Pytest & CI

Indirect Parametrization with pytest Fixtures

@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

Solution

The fixture reads its value from request.param, and the decorator names the fixture rather than a plain argument:

Python
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:

Python
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.

Where the parameter goes with and without indirect A left-to-right comparison: without indirect, the parametrize value is passed straight into the test function; with indirect, the same value is set on request.param, the fixture builds an object from it, and the test receives the constructed object. Where the parameter goes with and without indirect parametrize value a plain string request.param set per test item fixture builds setup and teardown test receives the built object Without indirect=True the test would receive the raw string and the fixture would never run.
The middle two stages are what indirect adds: the value becomes fixture input rather than test input.

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.

The sequence for one indirect parameter A sequence diagram with three lanes: the collector, the fixture and the test. The collector creates one item per parameter and sets request.param, the fixture builds the object and yields it, the test runs against the object, and the fixture finalizer tears it down before the next parameter. The sequence for one indirect parameter collector fixture test set request.param build the object yield the object test finishes teardown, next param Each parametrize value produces a separate test item with its own fixture lifecycle.
Setup and teardown bracket each parameter individually, which a value passed directly to the test body cannot do.

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.

Bash
$ 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:

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

Reading --setup-show for an indirect fixture A table mapping three setup-show patterns to what they mean: one setup per parameter with the parameter in brackets is correct, a single module-scoped setup means the object is shared, and a setup with no bracketed parameter means indirect was not applied. Reading --setup-show for an indirect fixture Criterion Means Action SETUP F name[param] per case correct indirection nothing to do one SETUP M for all cases object is shared narrow the fixture scope SETUP with no [param] indirect not applied check the argument name no SETUP line at all fixture never ran name mismatch in indirect
The bracketed parameter in the setup line is the proof that the value was routed through the fixture rather than into the test.

Edge cases and failure modes

  • request.param does not exist when the fixture is used without parametrization. A fixture written for indirect use raises AttributeError if another test requests it plainly. Guard with getattr(request, "param", DEFAULT) when the fixture must serve both.
  • The argument name must match the fixture name exactly. indirect=["client"] with a fixture called api_client silently treats the value as direct — there is no error, and the test receives the raw string.
  • Indirect and ids interact. Because the test never sees the raw value, the default id is derived from it anyway, which is usually what you want; pass ids= 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.

Python
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.

Indirect parametrization versus a parametrized fixture A table comparing indirect parametrization and a parametrized fixture across where the values are declared, who runs the extra cases, and the kind of axis each suits. Indirect parametrization versus a parametrized fixture Criterion Values declared Applies to indirect=True at the call site one test fixture(params=) on the fixture every consumer both together case and environment the product of the two
Per-case data belongs at the call site; an environment axis belongs on the fixture — mixing them is what makes a suite slow.

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.

← Back to Advanced Parametrization Techniques