Skip to content
← All articles

parametrize: Tables of Cases Instead of Loops of Asserts

A loop over ten inputs reports one failure and hides nine. pytest.param with ids and marks, stacked decorators and their exact order, indirect parametrisation, and what pytest 9's strict ids change.

This is the most common test in Python:

def test_slugify() -> None:
    cases = [
        ("Hello World", "hello-world"),
        ("  padded  ", "padded"),
        ("Ünïcode", "unicode"),
        ("a--b", "a-b"),
        ("", ""),
    ]
    for raw, expected in cases:
        assert slugify(raw) == expected

And this is what it tells you when three of those five are broken:

E   AssertionError: assert 'hello world' == 'hello-world'
1 failed

One failure. The loop stopped at the first assert, so cases three, four and five were never evaluated. You fix the separator bug, re-run, discover the unicode bug, fix it, re-run, discover the collapse bug. Three round trips through a suite that already knew all three answers on the first run.

The parametrised version:

@pytest.mark.parametrize(
    ("raw", "expected"),
    [
        ("Hello World", "hello-world"),
        ("  padded  ", "padded"),
        ("Ünïcode", "unicode"),
        ("a--b", "a-b"),
        ("", ""),
    ],
)
def test_slugify(raw: str, expected: str) -> None:
    assert slugify(raw) == expected

Five independent test items. All five run. Three fail, and each names its own input. One round trip.

That is the mechanical argument, and it is the smaller half. The larger half is that the table is now documentation. A reader who wants to know what slugify does with an empty string does not read the implementation; they read row five. Adding a case is adding a line. The contract and its examples live in the same place, which is the only arrangement in which they stay in sync.

Argument names and the one/several asymmetry

argnames can be a comma-separated string or a sequence of strings. Both are common; a tuple reads better once you have more than two.

The rule that trips everyone is the difference between one name and several:

@pytest.mark.parametrize("xs", [[1, 2], [3]])       # xs is [1,2], then [3]
@pytest.mark.parametrize("a,b", [[1, 2], [3, 4]])   # a=1,b=2 then a=3,b=4

With one name, each element of argvalues is that argument’s value, whole — the list is not unpacked. With several, each element must be a sequence of matching arity and its items line up positionally. So adding a second argument name to an existing table changes the meaning of every row that was already there, which is exactly the kind of edit that looks safe.

pytest.param: ids and marks

A raw table gives you generated ids: test_slugify[Hello World-hello-world]. Readable for strings, useless for anything else — a dataclass or a dict becomes case0, case1, and now your CI failure says test_pricing[case3] and you are counting rows by hand.

pytest.param fixes both problems:

@pytest.mark.parametrize(
    ("plan", "seats", "expected"),
    [
        pytest.param(FREE, 1, Decimal("0"), id="free-single-seat"),
        pytest.param(TEAM, 5, Decimal("50"), id="team-under-minimum"),
        pytest.param(
            TEAM, 0, Decimal("0"),
            id="team-zero-seats",
            marks=pytest.mark.xfail(strict=True, reason="TICKET-88"),
        ),
    ],
)
def test_price(plan: Plan, seats: int, expected: Decimal) -> None:
    assert price(plan, seats) == expected

id= gives you a name you can paste back into the shell to re-run one case. marks= attaches a marker to that row alone — xfail for a known bug, skipif for a platform-specific case — which is how you record “this one case is broken” without commenting out a line and losing it forever.

Note strict=True on the xfail. Without it, the day the bug is fixed the row starts passing and reports XPASS, and the marker stays there indefinitely, silently exempting a case from checking.

💡Generated ids come from the argument values. Why does that make a datetime or a Decimal in the table a problem, and what breaks specifically? click to reveal

Because pytest only auto-generates readable ids for a small set of types — strings, numbers, booleans, None — and falls back to argname0, argname1 for everything else. A table of five Decimal rows becomes expected0 through expected4.

Three concrete costs. The failure message no longer says which case failed in any human sense, so you count rows in the source to find it. pytest -k and pytest 'tests/test_x.py::test_price[expected3]' become positional references that break the moment someone inserts a row — a rerun command in a ticket goes stale silently and now points at a different case. And in a diff, reordering the table produces no change in the test names, so a review cannot see that case 3 is now case 4.

There is a second-order problem for anything with a non-deterministic repr. If a value’s generated id depends on something like a memory address or a hash-ordered set, the id can change between runs, and then test selection by id is not stable at all.

The fix is always the same: give every non-trivial row an explicit id=. It costs one keyword per row and it makes the ids a name rather than a derivation, which is what you want when a name is going into a CI report someone reads at 3am.

Stacking: the exact order

Two decorators produce the cartesian product, and the order is documented and worth memorising because it shows up in every report you read:

@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_foo(x: int, y: int) -> None: ...

runs x=0/y=2, x=1/y=2, x=0/y=3, x=1/y=3. The lower decorator — the one closest to the function, applied first — varies slowest. Parameters are exhausted in the order of the decorators, from the bottom up.

This is genuinely useful when the outer axis is expensive. Put the slow parametrised fixture on the bottom decorator and every test for one backend runs before the suite moves to the next, rather than thrashing between them.

It is also how a table quietly becomes 240 test items. Three stacked decorators of 4, 5 and 12 values is a number nobody wrote down, and it usually means one of those axes should have been a separate test.

indirect=True and parametrised fixtures

Sometimes the parameter is not a value the test uses directly — it is a knob on a fixture. Two spellings.

Parametrised fixture — sweep every test that uses it:

@pytest.fixture(params=["sqlite", "postgres"])
def database(request: pytest.FixtureRequest) -> Iterator[Database]:
    with make_database(request.param) as db:
        yield db

Every test requesting database now runs twice, and the fixture is set up and torn down separately for each — the exception to the one-instance-per-scope rule.

indirect=True — the same mechanism, but chosen per test:

@pytest.mark.parametrize("database", ["postgres"], indirect=True)
def test_uses_a_window_function(database: Database) -> None:
    ...

The value goes to the fixture as request.param instead of to the test as an argument. This is how one test opts into a specific backend while the rest of the file sweeps both.

One typing note: request.param is Any. There is no way for pytest to know what a fixture was parametrised with, so narrow it immediately — assign to an annotated local and let mypy check from there rather than letting Any propagate through the fixture body.

💡pytest 9's unified strict includes strict_parametrization_ids, which turns duplicate or ambiguous ids into an error. What was actually going wrong before? click to reveal

Ids were being silently disambiguated, and silent disambiguation of a name is how two things become indistinguishable.

If a table generated the same id twice — easy with pytest.param(..., id="edge-case") copy-pasted, or with two values whose reprs collide — pytest appended an index and moved on. You now have edge-case0 and edge-case1, in an order determined by table position. Neither name means anything, and pytest -k edge-case selects both.

The sharper failure is a rerun command in a bug report. Someone pastes pytest 'test_x.py::test_y[edge-case1]' into a ticket. A row is added above it. edge-case1 is now a different case. The ticket’s reproduction command still works, still passes, and is now testing something else — the worst possible outcome, because it looks like the bug is fixed.

Making it an error costs you one edit at the moment you introduce the collision, when you know which two cases you meant. It is the same trade as strict_markers: a name that does not resolve to exactly one thing should be a failure, not a rename.

When not to reach for it

A table is the right shape when the cases differ only in data. When they differ in setup, or in what they assert, forcing them into one parametrised function produces a test body full of if — at which point you have written a small interpreter and the table is no longer documentation of anything.

The tell is a branch on the parameter inside the test. If it is there, you wanted two tests.