Choosing Pytest Fixture Scopes Without Leaking State Between Tests
Pytest fixture scope is a speed-versus-isolation trade-off. Here's a decision rule for choosing function, module, or session scope, plus how to verify the behavior you actually get.
22 Aug 2026, 09:48 UTC

A test suite that takes twelve minutes because every test rebuilds the same database schema is annoying. A test suite that runs in ninety seconds but fails depending on execution order is worse. Both problems come from the same decision: what scope you give a parametrized fixture. Pytest makes that decision easy to write and easy to get subtly wrong.
The thesis here is simple: pick the widest scope your fixture's mutability allows, and no wider. Everything below is about making that judgment concrete.
What scope actually controls
Pytest offers four fixture scopes: function (the default), class, module, and session. The scope determines how often the fixture's setup and teardown code runs. A function-scoped fixture is created fresh for every test. A session-scoped fixture is created once per pytest invocation and shared by every test that requests it.
The trade-off is direct:
- Wider scope = less setup time, more shared state.
- Narrower scope = full isolation, repeated setup cost.
The deciding question is not "how expensive is setup?" but "does any test mutate what this fixture returns?" If the answer is yes, a wide scope turns that mutation into a hidden input for every later test.
Parametrized fixtures: one fixture, many variants
The params argument lets a single fixture produce several variants, and pytest generates one test instance per variant. Combined with indirect=True on @pytest.mark.parametrize, the test's parameter values are routed through the fixture, so the fixture logic decides what to build from each value.
Here is a concrete example. Suppose you are testing a configuration parser against several backends, and building each backend client takes a couple of seconds:
# conftest.py
import pytest
class FakeBackend:
def __init__(self, kind):
self.kind = kind
self.writes = [] # mutable per-test state
def write(self, record):
self.writes.append(record)
@pytest.fixture(params=["sqlite", "postgres"], scope="module")
def backend(request):
client = FakeBackend(request.param)
# expensive connect/migrate step would happen here
yield client
client.writes.clear()
# test_parser.py
def test_write_is_recorded(backend):
backend.write({"id": 1})
assert backend.writes == [{"id": 1}]
def test_writes_start_empty(backend):
assert backend.writes == [] # fails under module scope!This is the trap in miniature. With scope="module", both tests share one FakeBackend instance. test_write_is_recorded mutates writes, so test_writes_start_empty sees dirty state — but only if it runs second. Run the file with pytest -k empty and it passes. That ordering dependence is exactly the kind of failure that wastes an afternoon.
The fix, when tests mutate the fixture, is to narrow the scope:
@pytest.fixture(params=["sqlite", "postgres"], scope="function")
def backend(request):
...Now each test gets a fresh instance, and the parametrization still gives you both variants — four test instances total, all independent.
A practical decision rule
In practice, fixtures fall into three buckets:
| Fixture type | Mutated by tests? | Reasonable scope |
|---|---|---|
| Read-only reference data, parsed schemas, compiled regexes | No | session |
| Connections/clients where tests only read, or teardown reliably resets | No (with discipline) | module or session |
| Anything tests write to: temp dirs, database rows, in-memory lists | Yes | function |
The middle row is where judgment matters. A common pattern is a wide-scoped fixture for the expensive resource (say, a database container) paired with a narrow-scoped fixture that wraps it and guarantees cleanliness:
@pytest.fixture(scope="session")
def db_container():
# start once, reuse everywhere
...
@pytest.fixture(scope="function")
def db(db_container):
yield db_container.connect()
db_container.truncate_all_tables()You pay the container startup once and still get per-test isolation. This layering is usually a better answer than widening the scope of a mutable fixture and hoping tests behave.
Verify the scope does what you think
Don't trust the decorator — check the behavior. Two quick diagnostics:
First, confirm parametrization produced the variants you expect. Run this from your project root (no special permissions needed):
pytest --collect-only -qYou should see test IDs like test_write_is_recorded[sqlite] and test_write_is_recorded[postgres]. If a variant is missing, the usual culprit with indirect=True is a name mismatch: the string passed to @pytest.mark.parametrize must exactly match the fixture function's name. Pytest won't always fail loudly on this; it can silently skip the parametrization you intended.
Second, confirm setup frequency. Add a temporary print to the fixture:
@pytest.fixture(scope="module")
def backend(request):
print(f"\nSETUP backend[{request.param}]")
...Run pytest -s and count the SETUP lines. One per parameter per module means module scope is working; one per test means the scope isn't what you declared, or something is overriding it.
The limitation worth remembering
Wide scopes don't just risk state leaks — they also interact badly with pytest-xdist (parallel execution), where each worker gets its own session-scoped fixtures, and with dynamic parametrization via the pytest_generate_tests hook, where session-scoped parametrized fixtures can force every test in the session to be re-instantiated per parameter. None of this means avoid wide scopes; it means the speed you gain is paid for in constraints on how your suite can evolve.
Start with function scope. When a fixture shows up in your profiling as a real bottleneck, promote it one scope level at a time, and re-run the full suite twice in a row — ordering-dependent failures often only surface on the second pass through shared state. Your future self, debugging a flaky CI run, will thank you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.