@pytest.mark.parametrize hands its values straight to the test function, which is exactly wrong when the value needs to be built into something first — a database seeded with that dataset, a client configured for that backend, a temporary file containing that fixture data. Doing the construction inside the test body means the teardown lives there too, and every parametrized case repeats it. Indirect parametrization routes the value through a fixture instead, so each parameter gets a real setup and teardown cycle.
Prerequisites
- pytest 7.0+;
indirecthas behaved as described since 3.0. - Familiarity with fixtures and
requestfrom mastering pytest fixtures, and with the direct forms in advanced parametrization techniques.
Solution
The fixture reads its value from request.param, and the decorator names the fixture rather than a plain argument:
import pytest
@pytest.fixture
def api_client(request):
# request.param holds one value from the parametrize list, per test item.
backend = request.param
client = ApiClient(base_url=BACKENDS[backend], timeout=2)
client.connect() # real setup, once per parameter
yield client
client.close() # real teardown, once per parameter
@pytest.mark.parametrize("api_client", ["staging", "sandbox"], indirect=True)
def test_health_endpoint(api_client):
# The test receives the constructed client, never the string.
assert api_client.get("/health").status_code == 200
Without indirect=True, api_client would be the string "staging" and the fixture would never run. With it, pytest creates one test item per value, sets request.param for that item, and executes the fixture — including its teardown — around each one.
Partial indirection is the form most real suites need: some arguments are built by fixtures, others are plain data. Pass a list of names instead of True:
import pytest
@pytest.fixture
def seeded_db(request):
rows = request.param # only this argument is indirect
db = TestDatabase()
db.insert_many(rows)
yield db
db.truncate()
@pytest.mark.parametrize(
"seeded_db,expected_total",
[
([{"amount": 10}, {"amount": 5}], 15),
([], 0),
([{"amount": -3}], -3),
],
indirect=["seeded_db"], # expected_total is passed through as-is
)
def test_total(seeded_db, expected_total):
assert seeded_db.total() == expected_total
The alternative — a fixture with params=[...] — produces the same cross-product behaviour but binds the values to the fixture definition, so every test using that fixture inherits them. Indirect parametrization keeps the values at the call site, which is what you want when different tests need different data through the same construction logic.
Why this works
parametrize attaches a callspec to each generated test item, mapping argument names to values. During fixture resolution pytest checks whether each requested name is a fixture: if the name is marked indirect, the value is stashed on the fixture's request.param and the fixture function runs to produce the actual argument. If it is not, the value is bound directly to the test's parameter. Because the fixture executes per item, its finalizer runs per item too — which is the entire reason to reach for indirection rather than building the object in the test body.
Verifying the setup really runs per parameter
The whole reason for indirection is that the fixture's setup and teardown bracket each parameter. That is worth checking rather than assuming, because a fixture accidentally left at module scope produces the same passing tests with one shared object underneath.
$ pytest tests/test_api.py --setup-show -q
SETUP F api_client[staging]
tests/test_api.py::test_health_endpoint[staging] (fixtures used: api_client)
TEARDOWN F api_client[staging]
SETUP F api_client[sandbox]
tests/test_api.py::test_health_endpoint[sandbox] (fixtures used: api_client)
TEARDOWN F api_client[sandbox]
Two setups, two teardowns, and the parameter visible in brackets after the fixture name: that is a correctly indirect fixture. A single SETUP M api_client followed by two tests means the fixture is module-scoped and both parameters are sharing one object — which for a stateful client means the second test is running against state the first one left behind.
The same output also shows the ordering guarantee that makes indirection useful for expensive resources. Because teardown runs before the next parameter's setup, two parameters that bind the same port, database name or temporary path do not collide — something a fixture parametrized at module scope cannot promise.
For a quick structural check without running anything, --collect-only -q lists the generated items and their ids:
$ pytest tests/test_totals.py --collect-only -q
tests/test_totals.py::test_total[seeded_db0-15]
tests/test_totals.py::test_total[seeded_db1-0]
tests/test_totals.py::test_total[seeded_db2--3]
The seeded_db0 style id is the signal that a value went through a fixture rather than being rendered directly — pytest cannot show the contents of a list it never passed to the test, so it numbers them. When that hurts readability, supply explicit ids at the call site, which is the subject of generating readable test ids.
Edge cases and failure modes
request.paramdoes not exist when the fixture is used without parametrization. A fixture written for indirect use raisesAttributeErrorif another test requests it plainly. Guard withgetattr(request, "param", DEFAULT)when the fixture must serve both.- The argument name must match the fixture name exactly.
indirect=["client"]with a fixture calledapi_clientsilently treats the value as direct — there is no error, and the test receives the raw string. - Indirect and
idsinteract. Because the test never sees the raw value, the default id is derived from it anyway, which is usually what you want; passids=explicitly when the raw value is an unreadable structure. - Stacking indirect parametrize decorators multiplies fixture setups. Two indirect parameters with three values each produce nine items and nine full setup/teardown cycles, which is fine for in-memory objects and expensive for containers.
- Indirect values are computed at collection, fixtures at setup. A parameter list built from something that only exists at runtime — a database query, an environment probe — belongs in
pytest_generate_tests, not in a decorator; see advanced parametrization techniques.
Choosing between indirect and a parametrized fixture
Two mechanisms produce the same visible result — one test item per value, with fixture setup per value — and picking the wrong one shows up as duplication months later.
A parametrized fixture (@pytest.fixture(params=[...])) binds the values to the fixture. Every test that requests it runs once per value, automatically, whether or not that test cares. This is the right tool when the parameter is a property of the environment: run the whole suite against SQLite and Postgres, against both async backends, against two API versions.
import pytest
@pytest.fixture(params=["sqlite", "postgres"]) # every consumer runs twice
def store(request):
backend = request.param
store = make_store(backend)
yield store
store.close()
Indirect parametrization keeps the values at the call site, so one test can run against three datasets while its neighbour runs against one. This is the right tool when the parameter is a property of the case: the specific rows, payload or configuration that this test needs.
The failure mode of choosing wrongly is recognisable. A parametrized fixture used for case data forces every consumer through cases they do not care about, doubling suite runtime for no coverage; indirect parametrization used for an environment axis means every test file repeats the same list, and adding a third backend becomes a repository-wide edit.
When both apply — an environment axis and per-case data — they compose: the fixture supplies the backend, the indirect parameter supplies the dataset, and the generated items are the product of the two. Check that product before merging, because six cases per test across four backends is twenty-four items and a visibly slower suite.
Frequently Asked Questions
When do I need indirect=True instead of a plain parametrize? When the parameter must be processed by a fixture before the test sees it — building a client, seeding a database, or constructing an object whose setup and teardown belong in the fixture rather than in the test body.
Can I parametrize only some arguments indirectly?
Yes. Pass a list of argument names to indirect, such as indirect=["backend"], and pytest routes those names through their fixtures while the remaining arguments are passed to the test directly.
Why does request.param raise AttributeError?
Because the fixture was requested without a parameter. request.param only exists when the fixture is parametrized, so guard with getattr(request, "param", default) if the fixture must also work un-parametrized.
Does indirect parametrization work with fixtures defined in a plugin?
Yes — the mechanism only requires that the argument name resolves to a fixture, wherever it is defined. That makes indirection a good fit for shared infrastructure fixtures: the plugin owns the construction logic, and each test supplies the data it needs through the decorator. The one caveat is that the plugin's fixture must read request.param defensively, since other tests will request it without a parameter.
Related
- Advanced parametrization techniques — the direct forms,
pytest_generate_tests, and id control. - Mastering pytest fixtures — how
requestand fixture finalization work underneath this. - Fixing ScopeMismatch errors in pytest — what happens when an indirect fixture is widened past its dependencies.
- Generating readable test ids — keeping nine generated items identifiable in CI output.
← Back to Advanced Parametrization Techniques