A pytest fixture is not setup code that happens to return a value. It is a node in a dependency graph. A test requests root nodes by naming them as parameters; fixtures request other fixtures the same way. Pytest resolves the reachable graph, orders it, caches each node for its scope, runs the test, and tears resource nodes down in reverse order.
That model is more useful than "pytest calls functions for me." It explains why a fixture requested through three paths runs once, why a broad scope cannot depend on a narrow one, why teardown follows dependency order, and why one parametrized fixture can multiply an entire branch of tests.
The experiments below are small files intended to be run with python -m pytest. They target the documented APIs in pytest 9.1.1.
Version note. Python syntax in this article was checked with CPython 3.14.7. The pytest experiments were verified with pytest 9.1.1 loaded temporarily into that interpreter; pytest is not installed in the project's
.venv. Fixture behavior is a pytest contract, not a Python language guarantee. Plugin fixtures and output formatting vary by installed plugin set and pytest version.
Experiment 1: draw the graph from parameters
Save this as test_graph.py:
import pytest
@pytest.fixture
def customer():
return {"email": "ada@example.com"}
@pytest.fixture
def order(customer):
return {"customer": customer, "status": "draft"}
@pytest.fixture
def paid_order(order):
order["status"] = "paid"
return order
def test_paid_order_can_ship(paid_order):
assert paid_order["status"] == "paid"
Run it:
python -m pytest -q test_graph.py
The graph is test_paid_order_can_ship -> paid_order -> order -> customer. Pytest starts at the test, discovers dependencies recursively, then executes prerequisites before dependents. Parameter position is not a general ordering tool. The dependency edges are.
Good fixture names describe capabilities or states: paid_order, authenticated_client, empty_database. Names such as setup, data, and thing erase the graph's meaning. The test should expose the capabilities relevant to its claim without listing every transitive implementation detail.
pytest 9.1.1 guarantee. Fixture availability and execution are resolved from fixture requests. For fixtures of the same scope, an explicit dependency ensures the requested fixture executes first. Definition order and fixture names do not establish execution order.
Experiment 2: one node is cached within a request
Add a counter and request the same fixture along two paths:
import pytest
@pytest.fixture
def events():
return []
@pytest.fixture
def account(events):
events.append("create account")
return {"balance": 0}
@pytest.fixture
def funded_account(account):
account["balance"] = 50
return account
def test_shared_node(account, funded_account, events):
assert account is funded_account
assert events == ["create account"]
Although both the test and funded_account request account, pytest executes account once for this test. Its result and side effects are cached. That cache is why the identity assertion passes.
The default scope is function, so another test receives a fresh events list and account. "Function scope" means one fixture instance per test request, not one call per arrow. A parametrized fixture may be invoked more than once within a broader declared scope because pytest caches only one parameter instance at a time.
This identity sharing is useful, but mutation can obscure ownership. If funded_account silently mutates an account also asserted elsewhere, the graph carries that state transition. Name the stateful fixture accordingly and avoid tests that depend on incidental mutation order.
Experiment 3: scope controls lifetime and sharing
Scopes are function, class, module, package, and session. Observe two lifetimes:
import pytest
@pytest.fixture(scope="module")
def module_box():
return []
@pytest.fixture
def function_box():
return []
def test_first(module_box, function_box):
module_box.append("first")
function_box.append("first")
assert module_box == ["first"]
assert function_box == ["first"]
def test_second(module_box, function_box):
assert module_box == ["first"]
assert function_box == []
The module-scoped list crosses a test boundary; the function-scoped list does not. Scope is therefore an isolation decision, not just a performance switch. Widening it trades repeated setup for shared mutable state.
Use the narrowest scope that meets the resource's real lifetime. A session-scoped immutable configuration is natural. A session-scoped database with data mutated by every test may create ordering dependence, cleanup burden, and failures that appear only in the full suite. Measure setup cost before widening scope, then make reset semantics explicit.
Experiment 4: dependencies must not outlive their prerequisites
This graph cannot be built:
import pytest
@pytest.fixture
def request_id():
return object()
@pytest.fixture(scope="session")
def audit_log(request_id):
return [request_id]
def test_log(audit_log):
assert audit_log
Running it reports ScopeMismatch. A session-scoped node would retain a dependency whose function-scoped lifetime ends after one test. Pytest rejects that contradiction instead of choosing a surprising request ID.
The repair depends on the domain: make both nodes session-scoped, make audit_log function-scoped, or stop making one depend on the other. Do not widen request_id merely to silence the error. Scope should encode valid sharing.
Tool-version note.
ScopeMismatchwording and traceback layout are pytest output details. The valid dependency direction is the durable rule: a broader-scoped fixture cannot request a narrower-scoped fixture.
Experiment 5: yield defines ownership and teardown
A resource-owning fixture acquires one resource, yields it, and releases it:
import pytest
@pytest.fixture
def connection():
print("open connection")
resource = {"open": True}
yield resource
resource["open"] = False
print("close connection")
@pytest.fixture
def transaction(connection):
print("begin transaction")
yield connection
print("rollback transaction")
def test_query(transaction):
print("run test")
assert transaction["open"]
Run python -m pytest -q -s test_teardown.py. The meaningful order is:
open connection
begin transaction
run test
rollback transaction
close connection
Setup follows dependency order. Teardown resumes yielded fixtures in reverse order, so the transaction ends before its connection closes. This is a graph property: dependents must release their use before prerequisites disappear.
If the test fails, teardown still runs. If transaction raises before its yield, its post-yield code cannot run, but pytest still tears down connection, which completed setup. Keep each state-changing acquisition paired with its own cleanup so a later acquisition failure does not skip earlier cleanup.
Experiment 6: a giant fixture creates an unsafe transaction
This test is intentionally failing during setup:
import pytest
leaked = []
@pytest.fixture
def whole_world():
leaked.append("user-created")
raise RuntimeError("browser failed to start")
yield
leaked.remove("user-created")
def test_world(whole_world):
pass
def test_leak_is_visible():
assert leaked == ["user-created"]
The first test errors before reaching yield, so post-yield cleanup is never registered by that fixture. The second test demonstrates the leaked state. Real suites experience this as an undeleted user, container, temporary schema, or patched global.
Split independently reversible actions:
@pytest.fixture
def user(admin_client):
created = admin_client.create_user()
yield created
admin_client.delete_user(created)
@pytest.fixture
def browser():
driver = start_browser()
yield driver
driver.quit()
If browser creation fails after user succeeds, pytest can still finalize user. The official fixture guidance calls this safe fixture structure: one state-changing action per fixture, paired with its teardown. It is not about maximizing the number of tiny functions. Pure object assembly can remain together; independently failing external mutations need independent ownership.
request.addfinalizer() supports cases where cleanup must be registered immediately after a particular action. Once registered, the finalizer will run even if later fixture code fails, so register it only after the resource exists. Yield fixtures are clearer for the normal one-acquire/one-release shape.
Experiment 7: parametrization multiplies the reachable branch
Fixture parameters rerun every dependent test:
import pytest
@pytest.fixture(params=["sqlite", "postgres"], ids=["local", "server"])
def backend(request):
return request.param
@pytest.fixture
def repository(backend):
return {"backend": backend}
def test_save(repository):
assert repository["backend"] in {"sqlite", "postgres"}
def test_health(backend):
assert backend
python -m pytest --collect-only -q test_params.py shows four cases: two tests times two backend values. The parameter lives on backend, but it fans out through repository to anything downstream.
Add another independent fixture with three parameters and a test requesting both, and that test has six cases. This Cartesian product is correct when every combination is meaningful. It is waste when fixture parametrization is used as a hidden global test matrix.
Use fixture parameters when a reusable capability genuinely has multiple implementations. Use @pytest.mark.parametrize when values describe a particular test's examples. Give expensive or non-obvious cases stable ids; those IDs appear in failures, selection, and collection output.
Experiment 8: return a factory when cardinality belongs to the test
A fixture creates one value by default. If a test needs an arbitrary number, return a factory and let the fixture own cleanup:
import pytest
@pytest.fixture
def make_user():
created = []
def make_user(name):
user = {"name": name, "deleted": False}
created.append(user)
return user
yield make_user
for user in reversed(created):
user["deleted"] = True
def test_team_has_distinct_members(make_user):
ada = make_user("Ada")
grace = make_user("Grace")
assert ada is not grace
The capability is "make managed users," not "the user." The test controls cardinality and meaningful input while cleanup remains centralized. This is preferable to fixtures named user1, user2, and user3, or indirect parametrization used only to smuggle constructor arguments into setup.
Factories can become mini-frameworks. Keep their interface close to the domain operation, return ordinary values, and expose important differences in the test. If every call requires ten keyword arguments, a normal helper plus a resource-owning fixture may be easier to understand.
Experiment 9: autouse adds invisible edges
Autouse fixtures apply to every test in their visibility scope:
import os
import pytest
@pytest.fixture(autouse=True)
def clean_mode(monkeypatch):
monkeypatch.setenv("APP_MODE", "test")
def test_mode_is_set_without_requesting_it():
assert os.environ["APP_MODE"] == "test"
The test has no parameter, but its graph includes clean_mode -> monkeypatch. Autouse fixtures run before non-autouse fixtures in the same scope; fixtures requested by an autouse fixture become effectively autouse for the tests it affects.
This is appropriate for suite invariants such as forbidding real network access or restoring universally dangerous global state. It is poor for domain preconditions such as "a customer exists" or "the user is logged in." Hidden domain edges make a test's signature lie about what it needs.
Constrain autouse fixtures to the smallest class, module, or directory that shares the invariant. A fixture in conftest.py is available to tests in that directory and descendants; nested conftest.py files can add or override fixtures locally. Tests search outward through those scopes, then plugins. They do not import conftest.py directly.
Use these inspection commands when the graph is unclear:
python -m pytest --fixtures test_example.py
python -m pytest --setup-plan test_example.py
python -m pytest --collect-only -q
--fixtures answers what is visible, --setup-plan displays planned setup and teardown without running tests, and collection reveals parametrization fan-out.
Fixture design as dependency injection
Fixtures are dependency injection optimized for tests, but injection alone does not guarantee good design. A giant app fixture can expose everything while hiding every dependency. A fixture that returns a mock of the unit under test can move the behavior being tested into setup. A shared mutable session object can make the graph technically valid and behaviorally nondeterministic.
Prefer a graph with three recognizable node types:
- immutable configuration or pure value nodes;
- resource owners that acquire exactly one independently reversible resource;
- state builders that compose prerequisites into a named test state.
Keep the action under test in the test when practical. A paid_order fixture may establish a precondition for shipping; a fixture named ship_order that performs the action before every assertion can obscure cause and effect. The test should still read as arrange, act, assert even when arrangement is a graph.
Common failure modes
Scope was widened only for speed. Tests share mutation and become order-dependent. Profile first, then isolate mutable state or provide a proven reset boundary.
A fixture depends on incidental ordering. Definition order happened to work. Add a real dependency edge if one setup operation requires another, or make independent operations commute.
A giant yield fixture leaks on partial setup. Code raised before yield, so its cleanup did not run. Split state-changing acquisitions into resource-owning fixtures.
Autouse hides a precondition. A test passes because an unrequested fixture created domain state. Make the capability explicit or narrow autouse visibility.
Parametrization explodes collection. Independent fixture dimensions form products downstream. Keep only meaningful combinations and put local examples on tests.
A fixture was called directly. Fixture functions are managed by pytest and should be requested, not invoked as helpers. Extract an ordinary function if production or test code needs a callable operation.
conftest.py became a service locator. Hundreds of globally visible fixtures create collisions and make ownership impossible to find. Keep specialized fixtures near the tests that use them and move only broadly shared capabilities upward.
Practical decisions
- Start at function scope; widen only for a real lifetime or measured cost.
- Encode required setup order as dependencies, never source order.
- Pair each external mutation with cleanup in the same fixture.
- Use factories for multiple managed objects within one test.
- Reserve autouse for genuine invariants, and keep its visibility narrow.
- Use parametrized fixtures for implementations of a reusable capability, not every input table.
- Keep fixture return values simple and typed where that helps readers.
- Inspect graphs with
--setup-planbefore debugging guessed order. - Run isolation-sensitive tests both alone and in the full suite.
Exercises
- Draw the complete graph for Experiment 2, including the test node. Mark which requests share the cached object.
- Change
module_boxto session scope and run two test modules. Explain the new ownership boundary. - Repair the
ScopeMismatchexample in two semantically different ways and state what each shares. - Extend the teardown experiment with a second independent resource. Add only the dependency edges required for safe cleanup.
- Add three authentication modes to the parametrization experiment. Use collection output to calculate and then reduce the matrix.
- Replace an autouse domain setup in your own suite with an explicit fixture request. Compare test signatures and setup plans.
Keep this model
For each test, pytest builds the reachable fixture graph from names. Scope determines node lifetime and caching. Dependencies, scope, and autouse determine setup order. Yield boundaries attach cleanup to successful resource acquisition, and teardown unwinds the resolved graph.
When a fixture suite surprises you, do not ask which function pytest happened to call first. Ask which edge requires that order, which node owns each mutation, how long each cached value lives, and how many test cases parametrization creates. Those questions turn fixture behavior from framework magic into an inspectable mechanism.