A fixture that acquires one resource and releases it is the most common thing in any pytest suite, and yield handles it perfectly. The trouble starts when setup acquires several things in sequence and the third step fails: with a single yield, the teardown code never runs, and the first two resources leak. That is the situation request.addfinalizer exists for, and knowing when to reach for it is what separates fixtures that clean up reliably from fixtures that leak a container or a temporary directory every time setup hiccups.
Both mechanisms are well supported and neither is deprecated. The choice is about the shape of the setup, not about style, and a suite that uses each where it fits ends up with fixtures that are both readable and robust.
Prerequisites
pytest >= 8.0; both mechanisms are long-standing and stable.- Familiarity with fixture scope and the setup and teardown phases from mastering pytest fixtures.
Solution
Use yield when there is one resource and one acquisition step. Use addfinalizer when setup has several steps, registering each cleanup the moment its resource exists.
import pytest
@pytest.fixture
def temp_bucket(storage):
# One resource, one step: yield is clearest.
bucket = storage.create_bucket("test-bucket")
yield bucket
storage.delete_bucket(bucket) # runs whether the test passed or failed
@pytest.fixture
def seeded_environment(request, storage, queue, database):
# Several resources acquired in sequence. Each cleanup is registered the
# moment its resource exists, so a failure in a LATER step still releases
# everything acquired so far.
bucket = storage.create_bucket("seed")
request.addfinalizer(lambda: storage.delete_bucket(bucket))
subscription = queue.subscribe("events") # may fail
request.addfinalizer(subscription.cancel)
schema = database.create_schema("seed_schema") # may also fail
request.addfinalizer(lambda: database.drop_schema(schema))
return {"bucket": bucket, "subscription": subscription, "schema": schema}
If database.create_schema raises, pytest reports a setup error — and then runs the two finalizers already registered, releasing the subscription and deleting the bucket. With a single yield fixture containing all three steps, the same failure would leave both leaked, because the code after yield is never reached.
Why this works
A yield fixture is a generator: pytest runs it up to the yield, runs the test, then resumes it to run the rest. If the generator raises before yield, there is nothing to resume, so the post-yield code is simply never executed. That is correct from pytest's point of view — the fixture never finished setting up — and it is exactly why partially-acquired resources leak.
request.addfinalizer registers a callable on the fixture's request immediately. pytest runs every registered finalizer during teardown, in reverse registration order, regardless of whether the fixture function returned normally or raised. Registering each cleanup right after its resource exists therefore guarantees that whatever was acquired is released, however far setup got.
Edge cases and failure modes
- A
yieldfixture with its owntry/finallyaround several steps. This works, but thefinallymust cope with resources that were never acquired — usually by initialising them toNoneand checking.addfinalizerexpresses the same thing without the bookkeeping. - Finalizers that capture loop variables.
for name in names: request.addfinalizer(lambda: drop(name))releases the last name repeatedly. Bind with a default argument orfunctools.partial. - A teardown that raises. pytest reports a teardown error for the test and continues with the remaining finalizers. Do not swallow the exception; a failing cleanup is a real problem.
- Mixing
yieldandaddfinalizerin one fixture. It is allowed, and the finalizers run after the post-yieldcode. It is rarely clearer than choosing one. - Async fixtures.
addfinalizertakes a synchronous callable. For async cleanup inpytest-asynciofixtures, useyieldwithtry/finally, orcontextlib.AsyncExitStack, which gives the same register-as-you-go semantics.
ExitStack: the same idea without pytest
contextlib.ExitStack offers register-as-you-go cleanup as a plain Python object, which makes it a useful middle ground: the fixture keeps the readability of yield while each resource's cleanup is registered as soon as it exists.
import contextlib
import pytest
@pytest.fixture
def seeded_environment(storage, queue, database):
with contextlib.ExitStack() as stack:
bucket = storage.create_bucket("seed")
stack.callback(storage.delete_bucket, bucket)
subscription = queue.subscribe("events")
stack.callback(subscription.cancel)
schema = database.create_schema("seed_schema")
stack.callback(database.drop_schema, schema)
yield {"bucket": bucket, "subscription": subscription, "schema": schema}
# Leaving the with-block unwinds every registered callback in reverse order.
If create_schema raises inside the with block, ExitStack.__exit__ still runs the two callbacks already registered. The fixture reads top to bottom like the yield version and cleans up like the addfinalizer version, and the same pattern works for async fixtures with AsyncExitStack and push_async_callback.
Teardown order across fixtures
Within one fixture the order is the reverse of registration. Across fixtures, pytest tears down in the reverse of the order it set them up, and that order is decided by the dependency graph: a fixture that requests another is set up after it and torn down before it. Getting this right matters whenever one resource depends on another — a connection on a server, a subscription on a broker, a schema on a database.
The rule that follows is simple and worth stating plainly. If resource B cannot be cleaned up without resource A still existing, the fixture that provides B must request the fixture that provides A. Declaring the dependency explicitly is what guarantees B's teardown runs while A is still alive. Two fixtures that happen to be set up in the right order because the test listed them in that order have no such guarantee, and the order can change the moment another test requests them differently.
A common trap illustrates it. A client fixture and a server fixture, both requested by a test but with no dependency between them, are set up in the order the test lists them and torn down in reverse. List client first and the server tears down before the client — which then fails to close its connection cleanly and reports a teardown error that looks like a bug in the client. Making client request server fixes it permanently, because now the graph, not the test's argument order, decides the sequence.
The same principle extends across scopes. A function-scoped fixture that depends on a session-scoped one is always torn down first, because function teardown happens at the end of each test while session teardown happens once at the very end of the run. That ordering is fixed by pytest and needs no declaration — but it does mean a session-scoped resource must never depend on anything narrower, which is exactly the rule ScopeMismatch enforces. When a teardown error appears only on the last test of a session, a session fixture reaching for something a narrower scope already released is the first thing to check, and --setup-show will display the offending order directly.
Checking teardown actually happens
Teardown bugs are silent by nature: a leaked resource does not fail the test that leaked it. pytest --setup-show makes setup and teardown visible, printing each fixture's setup and teardown in order with its scope, which is the fastest way to confirm that a fixture's cleanup runs when and where you expect.
For resources outside the process — buckets, schemas, containers — the stronger check is a session-scoped fixture that records what exists at the start of the run and asserts nothing extra exists at the end. It turns a slow accumulation of leaked resources, usually noticed weeks later as a quota error, into a failure on the run that introduced the leak. Combined with deliberately failing a setup step once and confirming the partial resources were released, it gives real confidence that the fixture behaves correctly in the case that matters most — which is also the case least likely to be exercised by an ordinary green run.
Frequently Asked Questions
Does code after yield run if the test fails?
Yes. Teardown after yield runs whether the test passed, failed or raised. It does not run if the fixture itself raised before reaching yield, because pytest never considered the fixture set up — which is the case addfinalizer handles better.
In what order do finalizers run? In reverse order of registration, and fixtures tear down in reverse order of setup. That mirrors how resources are usually nested: the last thing acquired is the first thing released.
What happens if teardown raises?
pytest reports it as an error in the teardown phase, separate from the test's own outcome, and continues tearing down other fixtures. With several addfinalizer callbacks, one raising does not prevent the others from running.
Related
- Mastering pytest Fixtures — scopes and the dependency graph these teardowns run within.
- Parametrizing Fixtures with params and ids — teardown runs once per parameter.
- Testing Async Generators and Context Managers — the async equivalent of these cleanup rules.
- Fixing ScopeMismatch Errors in pytest — the other scoping mistake that shows up at teardown.
← Back to Mastering pytest Fixtures