Parametrized tests are where most of a suite's input coverage lives, and they are also where known problems most often go to be forgotten. Most parametrized cases should pass, but a realistic list usually contains a few that should not: an input that exposes a known bug, a combination unsupported on one platform, a case too slow for the fast suite. Removing those cases hides the knowledge; leaving them unmarked makes the test red. pytest.param attaches marks to one case without affecting its siblings, so the known gap stays in the list, visible in every report, with its reason attached.
The feature is small and its value is almost entirely in how it is used. A strict xfail with a reason that names a ticket is documentation that enforces itself; a non-strict xfail with no reason is a case that quietly stopped meaning anything. This guide covers the mechanics, the choice between xfail, skip and deleting the case, and the configuration that keeps a suite's register of known gaps accurate without anyone having to maintain it by hand. The same techniques apply whether the parameters come from a decorator, from fixture params, or from pytest_generate_tests, since all three accept pytest.param in the same way.
Prerequisites
pytest >= 8.0.- Registered custom markers with
--strict-markers, from pytest markers for conditional test execution. - The basics of
@pytest.mark.parametrizefrom advanced parametrization techniques.
Solution
import sys
import pytest
@pytest.mark.parametrize(
("raw", "expected"),
[
pytest.param("2026-09-18", (2026, 9, 18), id="iso-date"),
pytest.param("18/09/2026", (2026, 9, 18), id="uk-date"),
pytest.param(
"2026-02-30", None, id="impossible-date",
# Known bug: strict so the case FAILS the day the bug is fixed.
marks=pytest.mark.xfail(strict=True, reason="BUG-412: accepts Feb 30"),
),
pytest.param(
"2026-09-18T00:00:00+14:00", (2026, 9, 18), id="plus-14-offset",
marks=pytest.mark.skipif(sys.platform == "win32",
reason="tzdata lacks +14 on Windows runners"),
),
pytest.param(
"๐๐๐๐-๐๐ก-๐๐ ", (2026, 9, 18), id="math-digits",
marks=pytest.mark.slow, # selected by -m slow, excluded by -m "not slow"
),
],
)
def test_parse_date(raw, expected):
assert parse_date(raw) == expected
test_parse_date[iso-date] PASSED
test_parse_date[uk-date] PASSED
test_parse_date[impossible-date] XFAIL (BUG-412: accepts Feb 30)
test_parse_date[plus-14-offset] SKIPPED (tzdata lacks +14 on Windows runners)
test_parse_date[math-digits] PASSED
Why this works
pytest.param wraps a value together with an id and a set of marks. When parametrize generates items, it applies the wrapped marks to the item for that value alone, so the effect of an xfail, a skip or a custom mark is scoped exactly to one case. Everything else about the mark behaves as it would on a whole test: xfail outcomes are reported as XFAIL or XPASS, skipif evaluates its condition at collection, and custom marks participate in -m selection.
strict=True changes what an unexpected pass means. A non-strict xfail that passes is reported as XPASS and the run stays green; a strict one that passes is a failure. That turns the mark from a note into a tripwire: the moment the bug is fixed, the build fails until someone removes the mark, which keeps the list of known gaps accurate without anyone having to remember to prune it.
Edge cases and failure modes
- Non-strict xfail left in place. It becomes XPASS after the fix and nobody notices. Set
xfail_strict = truein configuration so every xfail is strict by default. - Reasons that say nothing.
reason="broken"explains nothing to the next reader. Name the ticket, the constraint or the upstream issue. - Skipping instead of xfailing a bug. A skipped case is never executed, so it cannot tell you when the bug is fixed. Use
xfailfor bugs andskipfor combinations that genuinely cannot run. - Unregistered custom marks. Under
--strict-markersan unregisteredslowmark fails collection, which is the desired behaviour. Register it in configuration. - Marks on the wrong level. A mark on the whole test applies to every case. When only one case is affected, the mark belongs on
pytest.param.
Choosing between xfail, skip and removal
Three responses to a case that should not pass right now look similar and mean very different things, and choosing correctly is most of the value of the feature.
xfail means "this is expected to fail because of a known defect, and we want to know when that changes". The case still runs, so a fix is detected automatically โ immediately with strict=True. It is the right mark for bugs, for upstream issues awaiting a release, and for features partially implemented behind a flag.
skip or skipif means "this cannot meaningfully run here". The case does not execute at all, so it can never report a change. It is the right mark for combinations that are genuinely unsupported โ a platform without a required library, a database engine without a feature โ and the wrong mark for bugs, because a skipped bug is a forgotten bug.
Removal means "this case is not a requirement". If an input is no longer something the code should handle, deleting it is honest and marking it is not. A long-lived xfail whose reason nobody can explain is often really a removal that nobody made.
Marks as a workflow, not just a status
Used deliberately, per-case marks support a workflow that is otherwise awkward: writing the test for a bug before fixing it. Add the failing input as a case with a strict xfail naming the ticket, merge that on its own, and the suite now documents the bug precisely and stays green. When the fix lands in a later change, the strict mark forces the same change to remove it, so the fix and the removal of the known-failure marker are reviewed together.
That sequence has two advantages over fixing and testing in one change. The failing case is in the suite from the moment the bug is understood, so nobody can accidentally make it worse in the meantime. And the fix's pull request is smaller and easier to review, because the test that proves it was already reviewed on its own. Teams that adopt it find their bug tickets acquire a reliable link to the exact test that reproduces them, which is valuable long after the bug is closed โ it is where the next person looking at similar behaviour will start.
Keeping the list of known gaps honest
Marked cases are a register of known problems, and like any register it decays unless someone reviews it. A few lines of tooling make that review cheap.
# Every xfail and skip reason in the suite, with where it lives.
grep -rn "xfail\|skipif\|pytest.mark.skip" tests/ | grep -o 'reason="[^"]*"' | sort | uniq -c | sort -rn
Running the suite with -rxXs prints a short summary of every xfailed, xpassed and skipped item at the end of the run, which is the other view worth checking โ it shows which marked cases actually ran and what happened to them.
Setting xfail_strict = true globally is the single most effective habit. It means an xfail can never silently turn into a pass: either the bug still exists and the case still fails, or it has been fixed and the build goes red until the mark is removed. The register stays accurate by construction rather than by diligence, which is the only kind of accuracy that survives a busy team.
A periodic look at the skip reasons is worth adding to that. Unlike xfails, skips never announce themselves, so a platform constraint that was lifted two releases ago will keep a case skipped until someone reads the list. Once a quarter, running the reason-count command above and asking of each skip whether its condition is still true takes ten minutes and usually removes two or three.
xfail_strict = true in configuration makes the lower row the default for every xfail in the suite.Frequently Asked Questions
Should xfail on a parametrized case be strict?
Yes, in almost every case. strict=True turns an unexpected pass into a failure, which is exactly the signal that a known bug has been fixed and the mark should be removed. A non-strict xfail that starts passing is reported as XPASS and is easy to miss for months.
Can a mark on one case depend on the environment?
Yes. pytest.mark.skipif and pytest.mark.xfail accept a condition, so pytest.param(value, marks=pytest.mark.skipif(sys.platform == "win32", reason="โฆ")) skips that one case only where the condition holds.
How do I select only the marked cases?
Custom marks on pytest.param participate in -m selection like any other mark. pytest -m slow runs only the cases, across all tests, that carry the slow mark โ useful for keeping a few expensive inputs out of the fast suite.
Related
- Advanced Parametrization Techniques โ the parametrization model these marks attach to.
- Generating Readable Test IDs โ ids that make marked cases easy to find in reports.
- pytest Markers for Conditional Test Execution โ registering and selecting custom marks.
- Debugging Flaky Tests with pytest-rerunfailures โ why flakiness is not a reason for xfail.
โ Back to Advanced Parametrization Techniques