Pytest & CI

Fixture Teardown: yield vs addfinalizer

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.

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

What gets cleaned up when step three of setup fails Two fixtures acquire the same three resources in sequence, and the third acquisition fails. The yield fixture never reaches its yield, so its teardown code never runs and the first two resources leak. The addfinalizer fixture registered cleanups after steps one and two, so both run in reverse order and nothing leaks. Failure in the middle of setup one yield fixture 1 · bucket created 2 · subscription opened 3 · create_schema raises yield never reached teardown code never runs bucket and subscription leak addfinalizer per step 1 · bucket created → finalizer A 2 · subscription → finalizer B 3 · create_schema raises pytest runs B, then A reverse order of registration nothing leaks
The difference only shows when setup fails partway, which is exactly when leaked resources are most costly — a flaky dependency on a CI runner, repeated across every retry.

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 yield fixture with its own try/finally around several steps. This works, but the finally must cope with resources that were never acquired — usually by initialising them to None and checking. addfinalizer expresses 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 or functools.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 yield and addfinalizer in one fixture. It is allowed, and the finalizers run after the post-yield code. It is rarely clearer than choosing one.
  • Async fixtures. addfinalizer takes a synchronous callable. For async cleanup in pytest-asyncio fixtures, use yield with try/finally, or contextlib.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.

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

Three ways to express register-as-you-go cleanup Three options compared. A single yield is clearest but only safe for one resource. request.addfinalizer registers each cleanup as it goes and works for any number of resources but only with synchronous callables. ExitStack combines the yield layout with per-resource registration and has an async counterpart. Choose by the number of resources and whether cleanup is async yield one resource, one step clearest to read leaks on partial setup with several steps addfinalizer any number of steps registered as acquired safe on partial setup sync callables only ExitStack + yield yield's layout per-resource callbacks safe on partial setup AsyncExitStack for async
For async fixtures the right-hand column is the only one of the three that gives register-as-you-go cleanup, which makes it the default choice there.

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.

Teardown order follows the dependency graph Two arrangements of a server and a client fixture. Without a declared dependency, teardown order follows the order the test happened to list them, and the server can be torn down before the client. When the client fixture requests the server fixture, the graph guarantees the client tears down first while the server is still alive. Declare the dependency; do not rely on argument order independent fixtures def test_x(client, server) setup: client, then server teardown: server, then client client closes against a dead server teardown error blamed on client client requests server def client(server): … setup: server, then client teardown: client, then server guaranteed by the graph argument order no longer matters
Teardown errors that appear or disappear when a test's argument list is reordered are always this: a dependency that exists in reality but not in the fixture graph.

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.

← Back to Mastering pytest Fixtures