Pytest & CI

Marking Single Parametrized Cases with pytest.param

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

Solution

Python
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
Plain text
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
Five cases, three kinds of mark A parametrized list of five date inputs. Two plain cases run normally. One impossible date carries a strict xfail naming a bug. One timezone case carries a platform skipif. One Unicode-digit case carries a custom slow mark used for selection. Each mark affects only its own case. Each mark belongs to one case, with its reason iso-date ยท uk-date โ€” no mark, run normally impossible-date โ€” xfail(strict=True, "BUG-412") fails the build the day the bug is fixed, so the mark gets removed plus-14-offset โ€” skipif(win32, "tzdata lacks +14") skipped only where the condition holds; runs everywhere else math-digits โ€” mark.slow excluded by -m "not slow", selected by -m slow
The report shows every case, including the ones that are not passing, with the reason in the same line. Nothing about the gap is hidden.

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 = true in 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 xfail for bugs and skip for combinations that genuinely cannot run.
  • Unregistered custom marks. Under --strict-markers an unregistered slow mark 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.

Deciding how to handle a case that should not pass now A decision path. If the case fails because of a defect that will be fixed, use a strict xfail so the fix is detected. If the case cannot meaningfully run in some environment, use skipif with that condition. If the input is no longer a requirement at all, delete the case rather than marking it. Why is this case not passing? a defect we will fix bug, upstream issue cannot run here platform, engine, library no longer required behaviour intentionally dropped xfail(strict=True) runs; detects the fix skipif(condition) runs everywhere else delete the case honest about requirements
The commonest mistake is the middle box used for the left-hand situation: a skipped bug, which will never report that it has been fixed.

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.

Bash
# 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.

Strict versus non-strict xfail once a bug is fixed Two timelines after the bug behind an xfail is fixed. With a non-strict xfail, the case now passes and is reported as XPASS while the build stays green, so the stale mark persists indefinitely. With a strict xfail, the unexpected pass fails the build, prompting removal of the mark and keeping the known-gap register accurate. What happens the day the bug is fixed xfail (non-strict) case passes โ†’ XPASS โ†’ build green โ†’ mark stays for months the register now lists a bug that no longer exists xfail(strict=True) case passes โ†’ FAILED [XPASS(strict)] โ†’ someone removes the mark the register stays accurate without anyone remembering to prune it
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.

โ† Back to Advanced Parametrization Techniques