Most of what goes wrong with fixtures traces back to one sentence in pytest’s documentation:
if any of those steps in the setup raise an exception, none of the teardown code will run
Read it with a real fixture in mind:
@pytest.fixture
def environment() -> Iterator[Env]:
db = create_database()
seed(db)
pool = open_pool(db)
worker = start_worker(pool)
yield Env(db, pool, worker)
worker.stop()
pool.close()
drop_database(db)
Four things are created before the yield. If start_worker raises, control never reaches yield, so none of the three cleanup lines run. You have leaked a database, a connection pool, and whatever seed wrote. The next test in the file inherits that world and fails somewhere unrelated, and the traceback you get points at the victim rather than the cause.
The fix is not a try/finally wrapped around the whole thing — that is four nested try blocks pretending to be one, and it still gets the ordering wrong on partial failure. The fix is structural:
@pytest.fixture
def database() -> Iterator[Database]:
db = create_database()
yield db
drop_database(db)
@pytest.fixture
def seeded(database: Database) -> Database:
seed(database)
return database
@pytest.fixture
def pool(seeded: Database) -> Iterator[Pool]:
p = open_pool(seeded)
yield p
p.close()
@pytest.fixture
def worker(pool: Pool) -> Iterator[Worker]:
w = start_worker(pool)
yield w
w.stop()
One state-changing action per fixture, bundled with its own cleanup. Now if start_worker raises, pytest has already completed database and pool, and it tears down exactly those, in reverse. Nothing leaks. The rule is not stylistic; it is the only structure in which the guarantee holds.
💡You have refactored into four fixtures and the ordering is now pytest's problem. What decides it, and what happens if pool also needs a settings fixture that database uses?
click to reveal
Fixtures form a directed acyclic graph, and pytest topologically sorts it. Setup runs dependencies-first; teardown runs in exactly reverse order of setup.
The shared-dependency case is the interesting one, and it is where the caching rule earns its keep: one instance per fixture per scope. If database requests settings and pool requests settings, both receive the same settings object, and it is set up once and torn down once — after both of its dependents are done, because it precedes both in setup order and therefore follows both in teardown.
That is what makes the diamond safe. Without caching you would get two settings, two of whatever it configured, and a teardown that runs twice on the same resource.
The exception worth knowing: a parametrised fixture is invoked more than once within a scope — once per parameter — because each parameter value is a distinct instance. @pytest.fixture(params=["sqlite", "postgres"]) gives you two runs of every dependent test, with two separate setups and teardowns. That is the mechanism, not a bug, and it is how you sweep a whole subtree of tests across backends without touching a single test function.
Scope
Five scopes: function (the default), class, module, package, session. Scope decides two things at once — how often the fixture runs, and how long the object it produced is allowed to live.
The instinct is to widen scope for speed. Starting a Postgres container per test is intolerable; per session it is fine. The instinct is right and the trap is immediate: a session-scoped fixture is shared mutable state across every test in the run. If any test writes to it, test order starts to matter, and you have manufactured exactly the flakiness the suite exists to detect.
The pattern that resolves this is to split expensive-and-immutable from cheap-and-mutable:
@pytest.fixture(scope="session")
def engine() -> Iterator[Engine]:
# expensive, created once, never mutated
eng = create_engine(start_container())
yield eng
eng.dispose()
@pytest.fixture
def session(engine: Engine) -> Iterator[Session]:
# cheap, per test, rolled back so nothing escapes
conn = engine.connect()
transaction = conn.begin()
yield Session(bind=conn)
transaction.rollback()
conn.close()
The container starts once. Every test gets a transaction that is thrown away. Speed from the wide scope, isolation from the narrow one.
One constraint the graph imposes: a fixture can only depend on one of equal or wider scope. A session-scoped fixture cannot request a function-scoped one, because it would outlive it. pytest raises a ScopeMismatch error rather than letting you find out at runtime.
Teardown: yield versus addfinalizer
yield is the readable form and should be the default — setup above, teardown below, both visible in one screen. Teardown code runs in reverse order of setup across the fixture graph, which is what makes the four-fixture refactor above correct.
request.addfinalizer exists for the case yield cannot express: cleanup that is registered conditionally, or registered several times, from inside the setup body.
@pytest.fixture
def workspace(request: pytest.FixtureRequest, tmp_path: Path) -> Path:
for name in ("in", "out", "tmp"):
directory = tmp_path / name
directory.mkdir()
request.addfinalizer(lambda d=directory: shutil.rmtree(d))
return tmp_path
Finalizers run first-in-last-out — the same discipline as yield, applied within a single fixture. The important property is that each one is registered immediately after the thing it cleans up succeeds, so a failure partway through still unwinds what was built. That is the same principle as splitting the fixture, at a finer grain.
Factory as fixture
When a test needs several of a thing, or needs to decide the thing’s contents itself, do not return an object. Return a function that makes them.
@pytest.fixture
def make_order(session: Session) -> Iterator[MakeOrder]:
created: list[Order] = []
def _make(*, customer: str, total: Decimal = Decimal("10.00")) -> Order:
order = Order(customer=customer, total=total)
session.add(order)
created.append(order)
return order
yield _make
for order in reversed(created):
session.delete(order)
The factory keeps a list of what it made and cleans up after itself, so a test that creates three orders gets three orders cleaned up without saying so. This is the shape that scales: it composes with other fixtures, it takes arguments, and it keeps the test readable — make_order(customer="ada") says what the test is about, where a fixture named order_for_ada_with_total_10 says what someone once needed.
The typing of that factory is genuinely hard and is covered separately; the short version is that Callable[..., Order] throws away every keyword and default, and a callback Protocol does not.
💡autouse=True makes a fixture apply to every test in its scope without being requested. When is that right, and what is the cost you are paying?
click to reveal
It is right for exactly one category: things that must be true for the test to be meaningful, that no test would ever want differently. Resetting a global registry between tests. Failing the run if a test makes a real network call. Freezing the timezone. All of these are properties of the environment, not inputs to any particular test.
The cost is that you have created an invisible dependency, and the symptom is specific: someone copies a test into a new file, or a script, or a debugger session, and it fails — because the thing that made it work was never written down in the test. The test signature said it needed nothing. It needed a database rollback.
So the heuristic is: if removing the autouse fixture would change what any test asserts, it should have been requested explicitly. If removing it would only change whether the test is isolated, autouse is doing its job.
Two practical mitigations when you do use it. Keep autouse fixtures in the narrowest conftest.py that needs them, so their reach is visible from the directory tree rather than global. And when a test needs to opt out, prefer an explicit marker the fixture checks (request.node.get_closest_marker("no_db")) over an override in a nested conftest, because the marker appears in the test file where the reader is.
The one-line summary
A fixture does one thing and undoes it. Everything else — scope, factories, finalizers — is an optimisation on top of that, and each one is safe only for as long as the first rule holds.