@pytest.mark.slwo
def test_full_pipeline() -> None:
...
Read it twice. slwo, not slow.
By default pytest accepts that. Unknown markers are registered on the fly with a warning nobody reads in a 4,000-line CI log, and the test’s behaviour silently inverts: pytest -m "not slow" no longer excludes it, so the five-minute integration test now runs on every commit. Or the other way around — pytest -m slow in the nightly job stops selecting it, and the only test that exercises your payment provider has not run in four months.
Neither failure produces an error. Both produce a green build. That is the entire argument for strict configuration: the cost of a typo should be a failure, not a change in meaning.
What pytest 9 changed
pytest’s configuration has been readable from pyproject.toml for years, but through a compatibility shim: the [tool.pytest.ini_options] table, whose name says exactly what it was — ini options that happen to live in a TOML file. Every value was a string or a list of strings, because that is what an ini file can express, and strict_markers = true was really the string "true" being coerced.
pytest 9 introduces a native [tool.pytest] table with real TOML types. Booleans are booleans, integers are integers, and a mistyped value is a configuration error rather than a truthy string.
It also recognises a standalone pytest.toml, which takes precedence over pyproject.toml — and does so even when it is empty. That last detail catches people. An empty pytest.toml is not “no configuration found, keep looking”; it is a complete configuration that says nothing, and your pyproject.toml settings are then ignored entirely. If you create the file, you own the whole configuration.
The third change is the one to act on. The old flag zoo — strict_config, strict_markers, strict_parametrization_ids, strict_xfail — is subsumed by a single strict. One switch, and each of the four sloppinesses becomes an error:
-
strict_config— an unknown key in your pytest config is a failure, not a silently-ignored line. This is what catchesaddopt = [...]andtestpath = [...]. -
strict_markers— an unregistered marker is a failure. This isslwo. -
strict_parametrization_ids— duplicate or ambiguous test ids are a failure rather than being silently disambiguated intocase0,case1, which is how two different parametrize cases end up indistinguishable in a report. -
strict_xfail— anxfailtest that passes is a failure. Without it, a bug you fixed six months ago is still marked as expected-to-fail, and the marker will stay there until someone reads the file.
Alongside this, PytestRemovedIn9Warning is now an error rather than a warning, so deprecated behaviour blocks the run instead of accumulating; and overlapping path arguments are deduplicated, so pytest tests tests/unit no longer collects the unit tests twice.
💡strict_xfail turns an unexpectedly-passing xfail into a failure. That sounds like it punishes you for fixing a bug. Why is it the right default?
click to reveal
Because xfail is a claim about the present, and an unmaintained claim about the present is worse than no claim at all.
Think about what an xfail marker means to a reader: we know this does not work, here is the ticket, do not be alarmed. Now suppose the underlying bug gets fixed as a side effect of something else — an upstream dependency bump, a refactor, a different code path being taken. The test starts passing. Without strict xfail, pytest reports XPASS and moves on, and the marker stays.
Three things then go wrong. The suite is no longer asserting the behaviour, because an xfail test’s result is not checked — if the fix regresses next month, the test goes back to failing and is still reported as expected. You have a test that can never fail, which is the same as not having it. Second, the marker is now a lie: a reader sees “known broken” for something that works, and either wastes time on a fixed bug or starts distrusting every other marker in the file. Third, the ticket referenced in the marker never gets closed.
Strict xfail makes the transition loud exactly once, at the moment it becomes true, when the person best placed to act on it is the person who just changed something. The fix takes ten seconds: delete the marker, close the ticket. The alternative is a file of expired claims that nobody dares to touch.
The escape hatch, for the genuinely flaky case, is @pytest.mark.xfail(strict=False) per marker — explicit, local, and visible in review, which is exactly where that decision belongs.
The baseline
Start every project with this, and add to it rather than negotiating it later:
[tool.pytest]
strict = true
addopts = ["-ra", "--strict-markers"]
testpaths = ["tests"]
filterwarnings = ["error"]
markers = [
"slow: takes more than a second; excluded from the default run",
"integration: talks to a real external service",
]
Each line earns its place.
strict = true is the whole discussion above.
-ra prints a short summary of everything that was not a plain pass — every skip, xfail, xpass and error, with its reason. Without it, a suite can drift into skipping a third of its tests and report a cheerful green line. -ra is the single highest-value pytest flag and almost nobody sets it.
testpaths means a bare pytest collects the same set as CI does, from any working directory. It also stops pytest from wandering into build/, .venv/ and node_modules/ looking for tests.
filterwarnings = ["error"] turns every warning into a failure. This is the setting people flinch at, and it is the one that pays. A DeprecationWarning from a library is a message telling you exactly what will break on the next major version, delivered months in advance, addressed to a log nobody reads. Promoting it to an error means you handle it on a quiet Tuesday instead of during an upgrade. When a specific warning is genuinely not yours to fix, add a targeted ignore:: entry with a comment naming the upstream issue — a list of four such entries is a maintenance record; a blanket -W ignore is a decision to find out later.
markers is the registry that makes strict able to catch slwo. Note the descriptions: they show up in pytest --markers, which is where a new contributor discovers that integration exists.
💡Your team turns on filterwarnings = ["error"] and 140 tests immediately fail, almost all of them from a DeprecationWarning in a third-party library you cannot upgrade this quarter. What is the move?
click to reveal
Not -W ignore, and not reverting.
The right shape is a filter list where the general rule is error and every exception is specific, dated and attributed:
filterwarnings = [
"error",
# numpy 2.x removes this in 2.4; blocked on pandas>=3 — TICKET-4471
"ignore:`np.float_` is deprecated:DeprecationWarning",
]
Three properties matter here. It is narrow — matched by message prefix and category, so a different deprecation from the same library still fails. It is attributed — the comment says who is blocked on what, so the next person can tell whether it is still true. And it is finite — a list of six entries invites cleanup; a global ignore invites nothing.
You can also scope it to where the problem actually is, with @pytest.mark.filterwarnings("ignore:...") on the affected tests, which keeps the rest of the suite strict and makes the blast radius obvious in the diff.
The thing to resist is the middle option that feels reasonable: "ignore::DeprecationWarning". It looks targeted — it names a category — but it silences every deprecation from every library including your own, which is the exact signal you turned the setting on to receive. You would be left with the cost of the migration and none of the benefit.
Migrating
If you are on [tool.pytest.ini_options] today, nothing breaks; the shim is still read. The move to [tool.pytest] is worth doing when you touch the file anyway, and the mechanical part is small — quoted booleans become real booleans, quoted numbers become numbers.
What is worth doing first, regardless of version, is turning on strict markers and -ra and reading what falls out. In most repos of any age it is a short and slightly embarrassing list: two markers nobody registered, one xfail that has been passing since 2024, and a dozen skips with no reason attached. Every one of those is a test that has been reporting something other than what its author intended, and none of them were ever going to surface on their own.