A test guarded with @pytest.mark.skipif(os.environ["CI"] == "true", ...) can crash the entire collection with KeyError when the variable is absent, and an undeclared marker silently emits PytestUnknownMarkWarning that masks typos until a test runs everywhere it should have been skipped. Both stem from the same fact: pytest evaluates marker conditions during the collection phase, at module import time, long before any fixture or test body runs. This guide shows how to write import-safe skipif/xfail conditions, register markers to enforce hygiene, and inject markers from CI without editing test files.
The lifecycle ordering matters because it dictates which values a condition can legally read. A skipif expression is just Python evaluated at import; pytest.skip() inside a body runs after fixtures exist. Confusing the two is the root cause of nearly every marker bug below.
Prerequisites
pytest >= 8.0, Python3.9+.- Markers declared and strict mode enabled in
pyproject.toml:
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--strict-markers"
markers = [
"skip_platform: skip tests based on OS constraints",
"requires_db: skip tests when the database is unavailable",
"xfail_flaky: known intermittent failure",
]
Marker registration is part of the broader configuration discipline in Pytest Configuration Best Practices; how conditions are parsed during collection ties back to Advanced Pytest Architecture & Configuration.
Solution
Build conditions from deterministic, import-safe values and read environment variables with .get() so a missing key degrades gracefully instead of raising during collection:
import os
import sys
import platform
import pytest
# Evaluated once at import time from constants — never a network/DB call.
WINDOWS_ONLY = pytest.mark.skipif(
sys.platform != "win32",
reason="Requires Windows-specific registry APIs",
)
LINUX_PY310 = pytest.mark.skipif(
sys.platform != "linux" or sys.version_info < (3, 10),
reason="Needs Linux kernel features and Python 3.10+ match statement",
)
# os.environ["TEST_DB_URL"] would KeyError at collection if unset;
# .get() returns None, so the condition is simply truthy/falsy.
SKIP_IF_NO_DB = pytest.mark.skipif(
not os.environ.get("TEST_DB_URL"),
reason="TEST_DB_URL not configured; skipping integration tests",
)
@WINDOWS_ONLY
def test_windows_registry_access():
assert platform.system() == "Windows"
@SKIP_IF_NO_DB
def test_db_round_trip():
...
To gate a single parametrized case rather than the whole function, attach the marker to that case — function-level markers evaluate before parameters expand:
@pytest.mark.parametrize("payload", [
{"v": 1},
pytest.param({"v": 2}, marks=pytest.mark.xfail(reason="schema v2 not shipped")),
])
def test_payload(payload):
assert validate(payload)
For environment-aware filtering without touching test files, inject markers in pytest_collection_modifyitems — one of the collection hooks you can implement in a plugin or conftest — which runs after collection but before fixture setup. Place it in the root conftest.py so it applies across a whole conftest hierarchy:
# conftest.py
import os
import pytest
def pytest_collection_modifyitems(config, items):
"""Inject skip markers from CI env vars — keeps tests clean."""
fast_mode = os.environ.get("CI_FAST_MODE", "false").lower() == "true"
skip_slow = pytest.mark.skip(reason="slow tests skipped in fast CI stage")
for item in items:
if fast_mode and "slow" in item.keywords:
item.add_marker(skip_slow)
Confirm the resulting marker stack with pytest --collect-only -v before trusting it.
A marker is metadata until something reads it; these are the three readers and the order they run in.
Why this works
Marker conditions are plain Python expressions evaluated when the module is imported during collection, so anything they reference must already be resolvable — constants like sys.platform and sys.version_info always are, while os.environ["..."] is not. Using .get() converts a fatal KeyError into a benign falsy value. Deferring environment-driven decisions to pytest_collection_modifyitems moves them to a point where os.environ is safely readable and the decision is centralized, which keeps the same condition deterministic across every pytest-xdist worker.
Edge cases and failure modes
- CI string casing. CI systems inject
TRUE/FALSEas strings, both truthy in Python. Always normalize with.lower() == "true". xfail(strict=True)on flaky tests. A strict xfail reportsFAILEDwhen the test unexpectedly passes — correct for tracking known failures, but a trap for timing-flaky tests. Pair it with deterministic failure conditions, or use pytest-rerunfailures for genuine flakiness instead.- Referencing fixtures in conditions. Fixtures instantiate during setup, after collection, so
skipif/xfailcannot read fixture values — that raisesNameError. Callpytest.skip()orpytest.xfail()inside the test body when the decision needs fixture state. - Expensive condition functions. A condition calling
subprocess.run,socket.gethostbyname, or an ORM probe runs once per module at collection and inflatespytest --collect-only --durations=0. Precompute booleans at import (_IS_CI = os.environ.get("CI", "").lower() == "true"). - Worker-divergent conditions under parallelism. Conditions using
os.getpid()or mutable module globals evaluate differently per worker, causing inconsistent skips. Use only CI-provided environment variables or pre-filtered-klists.
Building a marker policy a team can follow
Markers rot faster than any other pytest feature, because adding one costs nothing and removing one requires knowing whether anything still selects it. A policy with three rules keeps the set small enough to remember.
Register every marker, and fail on unknown ones. --strict-markers turns a typo from a silently unselected test into an error at collection. Registration also gives each marker a one-line description that pytest --markers prints, which is the only documentation most people will read.
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--strict-markers -ra"
markers = [
"slow: takes more than two seconds; excluded from the pre-commit run",
"integration: needs Postgres and Redis on localhost",
"flaky: known-unstable, quarantined until the linked issue closes",
]
Separate selection markers from behaviour markers. slow and integration select; skipif and xfail change outcomes. Mixing them in one name — a integration marker that also skips when an env var is absent — makes the suite's behaviour depend on where the marker is defined rather than on how it is invoked. Keep the selection marker declarative and put the condition in a hook or a fixture where it can be read.
# conftest.py — one place decides what "integration" implies
import os, pytest
def pytest_collection_modifyitems(config, items):
if os.environ.get("RUN_INTEGRATION") == "1":
return # nothing to do
skip = pytest.mark.skip(reason="set RUN_INTEGRATION=1 to enable")
for item in items:
if "integration" in item.keywords:
item.add_marker(skip)
Give every quarantine marker an expiry. A flaky marker with an issue number in its reason is a task; one without is a permanent exemption nobody will revisit. A five-line test that fails when a quarantine is older than a sprint is cheap and keeps the list honest.
The selection syntax itself is worth teaching once, because it is more capable than most teams use. -m "slow and not integration" combines markers; -m "not (slow or flaky)" is the usual pre-commit filter; and -k selects on names rather than marks, so the two compose: pytest -m integration -k "billing and not refund".
One caveat that catches everyone: -m filters the collected items, so a test that is only marked inside pytest_collection_modifyitems cannot be selected by that marker on the same run — the filter has already run. If you need a computed marker to be selectable, compute it during collection with pytest_collection_modifyitems(config, items) running before the filter, or apply the marker statically and let the hook decide the outcome instead.
Frequently Asked Questions
Why does pytest.mark.skipif not evaluate correctly with parametrized tests?
Marker conditions evaluate during collection, before parameter expansion, so a marker on the function sees the unexpanded test. To gate a specific parameter set, attach the marker to that case with pytest.param(value, marks=pytest.mark.skipif(condition, reason="...")).
How can I dynamically skip tests by CI environment variable without editing test files?
Implement pytest_collection_modifyitems in the root conftest.py, read os.environ inside the hook, build pytest.mark.skipif objects, and attach them with item.add_marker(). This keeps test sources clean and centralizes CI logic.
What causes PytestUnknownMarkWarning and how do I suppress it safely?
Pytest warns when a marker is not declared in configuration. Register markers under [tool.pytest.ini_options] markers in pyproject.toml and add --strict-markers so typos become errors, enabling IDE autocompletion and consistent resolution.
How do I stop a marker from silently selecting nothing?
Run the selection in CI with --strict-markers and assert the count. pytest -m integration --collect-only -q | tail -1 prints the number of collected items, and a job step that fails when that number is zero catches both a renamed marker and a directory that stopped being collected. The failure mode this prevents is the worst kind: a green integration job that ran no integration tests at all, which looks identical to a healthy one in every dashboard.
Related
- Advanced Parametrization Techniques — attach
pytest.param(..., marks=...)to individual cases and stack IDs. - Building Custom Pytest Plugins — implement
pytest_collection_modifyitemsand other collection hooks that inject markers. - Managing Conftest Hierarchies — where a root-level marker-injection hook belongs so it applies suite-wide.
- Debugging Flaky Tests with pytest-rerunfailures — the right tool for genuine flakiness instead of
xfail. - pytest-xdist vs pytest-parallel Performance — why worker-divergent marker conditions cause inconsistent skips under parallelism.
← Back to Pytest Configuration Best Practices