A parametrized test that reports test_charge[case3] has lost the only piece of information that mattered: which input failed. The default id generator renders ints, strings, booleans and enums well, but anything structured — a dict, a dataclass, a list of rows — becomes a positional placeholder, and the failure output stops being self-explanatory exactly when the input is complex enough that you need it to be.
Prerequisites
- pytest 7.0+;
pytest.paramand theidscallable form have been stable since 3.0, andpytest_make_parametrize_idsince 2.9. - The direct and indirect forms from advanced parametrization techniques.
Solution
There are three levels of control, and the right one depends on how many tests need it.
Per case, with pytest.param. The most explicit form, and the right choice when cases have names a reader would recognise. It also carries marks, so a single slow or expected-failure case can be tagged without splitting the list.
import pytest
@pytest.mark.parametrize(
"payload,expected_status",
[
pytest.param({"amount": 100, "currency": "EUR"}, 201, id="valid-eur"),
pytest.param({"amount": 0, "currency": "EUR"}, 422, id="zero-amount"),
pytest.param({"amount": 100, "currency": "XXX"}, 422, id="unknown-currency"),
pytest.param({"amount": 10 ** 12, "currency": "EUR"}, 422,
id="over-limit", marks=pytest.mark.slow),
],
)
def test_charge_validation(payload, expected_status):
assert post("/charges", json=payload).status_code == expected_status
The generated node ids are now test_charge_validation[valid-eur] and so on: greppable in a log, selectable on the command line, and meaningful in a coverage report.
Per parametrize call, with a callable. When the name can be derived from the value, a callable avoids repeating yourself. pytest calls it once per value (not per case), and returning None falls back to the default rendering for that value.
import pytest
def render(value):
if isinstance(value, dict):
return f"{value['currency']}-{value['amount']}" # dicts get a real name
return None # ints render themselves
@pytest.mark.parametrize(
"payload,expected_status",
[({"amount": 100, "currency": "EUR"}, 201),
({"amount": 0, "currency": "EUR"}, 422)],
ids=render,
)
def test_charge(payload, expected_status):
assert post("/charges", json=payload).status_code == expected_status
Project-wide, with a hook. pytest_make_parametrize_id is consulted for every value pytest cannot name by itself, across the whole suite. One implementation in the root conftest.py gives every test the same convention, which matters more than any individual name.
# conftest.py
from dataclasses import is_dataclass
def pytest_make_parametrize_id(config, val, argname):
if is_dataclass(val):
return f"{type(val).__name__}"
if isinstance(val, dict):
return "-".join(f"{k}={v}" for k, v in sorted(val.items()))[:40]
if isinstance(val, (list, tuple)):
return f"{argname}x{len(val)}" # rowsx3 rather than rows0
return None # let pytest handle scalars
Why this works
pytest builds each test id during collection, before any fixture runs. For every parametrized value it asks, in order: was an explicit id supplied, did the ids callable return a string, did pytest_make_parametrize_id return one, and finally can the value be rendered as a short ASCII literal? Only the last stage can fail, and its fallback is the positional argname0 form. Ids are also normalised — non-ASCII characters are escaped and duplicates get a numeric suffix — because the id has to be usable as part of a node id on the command line.
Selecting and deselecting individual cases
Readable ids pay for themselves the moment you need to run one case. The full node id works, with the parameter in brackets:
$ pytest "tests/test_charge.py::test_charge_validation[unknown-currency]"
$ pytest -k "unknown-currency" # substring match, no quoting pain
$ pytest -k "not over-limit" # skip the slow one locally
The -k form is worth preferring in shell scripts, because bracketed ids collide with glob expansion in some shells and require quoting that is easy to get wrong in CI configuration.
Ids also drive rerun and reporting tooling. --last-failed stores node ids, so stable, meaningful ids survive between runs while positional ones shift the moment a case is inserted in the middle of the list — inserting case1 renames every case after it, and --last-failed then re-runs the wrong tests. That fragility is the strongest practical argument for explicit ids on any list that changes over time.
Naming conventions that survive a growing suite
A naming rule is only useful if two engineers writing tests six months apart produce ids that read the same way. Three conventions do most of that work.
Name the case, not the data. id="zero-amount" describes the scenario; id="amount-0-currency-EUR" describes the payload. The first survives a change to the payload shape, the second has to be rewritten whenever a field is added — and it is that rewrite which breaks --last-failed and every dashboard keyed on node id.
Keep ids short and lowercase with hyphens. They appear inside brackets in node ids, in JUnit XML attributes, and in shell commands. Spaces and colons are legal but need quoting; capitals make -k matching case-sensitive in a way that surprises people. A ten-to-twenty-character kebab-case name is the sweet spot.
Encode the axis, not the index, in multi-parameter tests. With two parametrize decorators the ids compose as [outer-inner], so naming each axis separately gives readable combinations for free:
import pytest
@pytest.mark.parametrize("browser", ["chromium", "firefox"], ids=["chrome", "ff"])
@pytest.mark.parametrize("locale", ["en_GB", "de_DE"], ids=["gb", "de"])
def test_checkout_renders(browser, locale):
# ids: test_checkout_renders[gb-chrome], [gb-ff], [de-chrome], [de-ff]
assert render(browser, locale).status_code == 200
Four cases, each identifiable at a glance, and -k "ff and de" selects exactly one of them. Note the ordering: the last decorator applied is the outermost, so it appears first in the id — a detail worth checking with --collect-only rather than reasoning about, because it is the opposite of what most people expect.
For generated case lists — rows read from a CSV, scenarios discovered from a directory — build the ids in the same comprehension that builds the cases, so the two cannot drift apart:
import pathlib
import pytest
FIXTURES = sorted(pathlib.Path("tests/data").glob("*.json"))
@pytest.mark.parametrize(
"fixture_path",
FIXTURES,
ids=[p.stem for p in FIXTURES], # one id per case, derived from the file
)
def test_fixture_parses(fixture_path):
assert parse(fixture_path.read_text()) is not None
That pattern also makes a missing data file visible: the case disappears from the collected list rather than failing with a path error, and a --collect-only -q count assertion in CI catches the silent shrinkage before anyone trusts the green run.
Edge cases and failure modes
- Duplicate ids are silently suffixed. Two cases named
validbecomevalid0andvalid1, which reintroduces the problem you were solving. Keep ids unique within a parametrize call. - Non-ASCII characters are escaped. A unicode id renders as an escape sequence in the node id, making it hard to type on the command line. Transliterate names in the hook rather than shipping them raw.
- The
idslist form must match the case count exactly. A list of three names for four cases raises at collection — the callable form avoids this failure mode entirely. - Ids appear in JUnit XML and coverage reports. Renaming them changes historical test identity in dashboards that key on node id, so treat a mass rename as a deliberate migration rather than a cleanup.
- Indirect parameters are rendered from the raw value. With
indirect=Truethe test never sees the value, but the id still comes from it — see indirect parametrization with fixtures for the interaction.
One reporting consequence is worth planning for before a rename: ids are part of the node id, and node ids are the primary key of almost every downstream system. JUnit XML consumers, flaky-test dashboards, --last-failed state, and coverage contexts all key on them. Renaming ids across a large suite therefore resets history in those systems — dashboards show every test as new, and the flake record for a test that has been quarantined for a month disappears. Do the rename in one commit, note it in the changelog, and expect a week of blank trend lines rather than discovering it after the fact.
Frequently Asked Questions
How do I stop pytest generating ids like case0, case1?
Pass ids= with a list or a callable, or wrap each case in pytest.param(..., id="name"). pytest falls back to positional names only when it cannot render the value as a short ASCII string, which is why lists, dicts and objects get numbered.
Can I select a single parametrized case from the command line?
Yes. Quote the full node id including the bracketed parameter, for example pytest 'tests/test_api.py::test_get[staging-200]'. With -k you can match on the id substring instead, which avoids shell quoting problems.
How do I set ids for every test in the project at once?
Implement pytest_make_parametrize_id in a conftest.py. It is called for every parametrized value pytest cannot name itself, so returning a string from it gives the whole suite a consistent naming rule.
Related
- Advanced parametrization techniques — the generation mechanisms these ids label.
- Indirect parametrization with fixtures — where ids come from the raw value the test never receives.
- Pytest markers for conditional test execution —
pytest.paramalso carries marks, one case at a time. - Debugging flaky tests with pytest-rerunfailures — stable ids are what make a flake log attributable across runs.
← Back to Advanced Parametrization Techniques