Yield Dependencies and Cleanup Order in FastAPI

Key takeaways:

  • Teardown is strictly LIFO: the dependency that set up last cleans up first, like nested with blocks.
  • Every open yield dependency gets its finally block when the endpoint raises — that is what makes rollback-on-error reliable.
  • Wrapping the yield in try/except lets a dependency see the endpoint's exception, which is how commit-or-rollback is written.
  • When a dependency raises during setup, its own teardown is skipped but everything already open still unwinds.
  • On FastAPI 0.139.2, background tasks run before yield teardown. This ordering is version-specific; do not build on it.

This page is the lifecycle half of dependency injection strategies. It answers, with real logs rather than assertion, exactly when your session closes.

The Problem This Solves

You have a session dependency that commits on success and rolls back on failure. Under load you see connections held longer than expected, or a rollback that did not happen, or a background task that explodes with session is closed. All three are questions about ordering, and ordering is not something you can reason about from the FastAPI docs alone — the interaction between the dependency stack, the response, and the task queue is subtle enough that it deserves a measurement.

LIFO teardown of nested yield dependencies Setup runs outer then middle then inner, then the endpoint body executes, then teardown runs inner then middle then outer, in reverse order. outer middle inner endpoint body setup order outer, middle, inner teardown order inner, middle, outer
Each yield dependency is a scope. Scopes close in the reverse of the order they opened.

Why It Happens: an AsyncExitStack per Request

A yield dependency is not special machinery. FastAPI wraps the generator in an async context manager and pushes it onto an AsyncExitStack that lives for the duration of the request. Setup is __aenter__; teardown is __aexit__.

AsyncExitStack unwinds in reverse push order — that is its entire contract — which is where LIFO comes from. It is also where the exception behaviour comes from: when the stack is closed with an exception in flight, that exception is thrown into each suspended generator at its yield point. A generator that wraps the yield in try/except therefore sees a real Python exception object and can act on it, and a generator that only uses finally still gets its cleanup.

Because it is a stack and not a graph walk, sub-dependency relationships are preserved automatically: a provider cannot be torn down while something that was built on top of it is still tearing down.

Proving It: a Real Ordering Log

Reasoning about this is fine; measuring it is better. This app appends to a module-level list at every interesting point and exposes a /events endpoint that reports what the previous request logged. Because the log is written by real execution and read by a second real request, the sequences below are observations, not claims.

"""yield dependencies: LIFO teardown, teardown on error, and ordering vs background tasks."""
from collections.abc import AsyncGenerator
from typing import Annotated

from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request

app = FastAPI()

EVENTS: list[str] = []


@app.middleware("http")
async def reset_events(request: Request, call_next):
    if request.url.path != "/events":
        EVENTS.clear()                 # /events reads the log the previous request left behind.
    response = await call_next(request)
    if request.url.path != "/events":
        EVENTS.append("middleware: got response from the route")
    return response


async def outer() -> AsyncGenerator[str, None]:
    EVENTS.append("outer: setup")
    try:
        yield "outer"
    finally:
        EVENTS.append("outer: teardown")


async def middle(o: Annotated[str, Depends(outer)]) -> AsyncGenerator[str, None]:
    EVENTS.append("middle: setup")
    try:
        yield "middle"
    finally:
        EVENTS.append("middle: teardown")


async def inner(m: Annotated[str, Depends(middle)]) -> AsyncGenerator[str, None]:
    EVENTS.append("inner: setup")
    try:
        yield "inner"
    finally:
        EVENTS.append("inner: teardown")


async def observing_dep() -> AsyncGenerator[str, None]:
    """Sees whether the endpoint raised, exactly like a session that must roll back."""
    EVENTS.append("observing_dep: setup")
    try:
        yield "session"
    except HTTPException as exc:
        EVENTS.append(f"observing_dep: saw HTTPException {exc.status_code} -> rollback")
        raise
    else:
        EVENTS.append("observing_dep: no exception -> commit")
    finally:
        EVENTS.append("observing_dep: closed")


@app.get("/ok")
async def ok(i: Annotated[str, Depends(inner)]):
    EVENTS.append("endpoint: body ran")
    return {"events_so_far": list(EVENTS)}


@app.get("/raises")
async def raises(
    i: Annotated[str, Depends(inner)],
    s: Annotated[str, Depends(observing_dep)],
):
    EVENTS.append("endpoint: about to raise")
    raise HTTPException(status_code=409, detail="conflict")

The happy path is LIFO

$ GET /ok
200 OK
{
  "events_so_far": [
    "outer: setup",
    "middle: setup",
    "inner: setup",
    "endpoint: body ran"
  ]
}

$ GET /events
200 OK
{
  "events": [
    "outer: setup",
    "middle: setup",
    "inner: setup",
    "endpoint: body ran",
    "middleware: got response from the route",
    "inner: teardown",
    "middle: teardown",
    "outer: teardown"
  ]
}

Setup is outer → middle → inner. Teardown is inner → middle → outer. LIFO, confirmed.

There is a second, less obvious fact in that transcript. middleware: got response from the route appears before any teardown. On the success path the teardown runs after the route has handed its response object outward. If your middleware measures request duration by timing call_next, it is not counting the time your session takes to commit and close.

On error, teardown moves earlier — and sees the exception

$ GET /raises
409 Conflict
{
  "detail": "conflict"
}

$ GET /events
200 OK
{
  "events": [
    "outer: setup",
    "middle: setup",
    "inner: setup",
    "observing_dep: setup",
    "endpoint: about to raise",
    "observing_dep: saw HTTPException 409 -> rollback",
    "observing_dep: closed",
    "inner: teardown",
    "middle: teardown",
    "outer: teardown",
    "middleware: got response from the route"
  ]
}

Three things worth noticing.

observing_dep caught the real HTTPException raised by the endpoint. This is not a flag or an inspection of request.state — the exception is genuinely thrown into the generator at its yield, which is why the commit-or-rollback pattern works.

The teardown order is still LIFO, and observing_dep — set up last — tears down first.

The whole unwind now happens before the middleware sees a response, the mirror image of the success case. The exception has to travel back through the dependency stack before an error response can be produced at all.

A dependency that raises during setup

$ GET /setup-raises
503 Service Unavailable
{
  "detail": "dependency unavailable"
}

$ GET /events
200 OK
{
  "events": [
    "failing_dep: setup",
    "raises_during_setup: raising",
    "failing_dep: teardown",
    "middleware: got response from the route"
  ]
}

failing_dep had already yielded, so it is on the exit stack and gets torn down. raises_during_setup raised before producing a value, so there is nothing to unwind for it, and the endpoint body never runs. This is the correct behaviour for a dependency that gates the request — a failed auth check leaves no half-open resources behind.

Background tasks run before teardown on 0.139.2

$ GET /with-background
200 OK
{
  "scheduled": true
}

$ GET /events
200 OK
{
  "events": [
    "task_dep: setup",
    "endpoint: task scheduled",
    "middleware: got response from the route",
    "background task: ran",
    "task_dep: teardown"
  ]
}

On FastAPI 0.139.2, the background task ran before the yield dependency tore down. A session injected into the endpoint is therefore still open while the task executes.

Do not build on this. The relative position of yield teardown and background tasks has moved between FastAPI releases, and code that quietly relies on a still-open session is code that breaks on an upgrade with an error far away from the cause. The durable pattern is for the task to acquire its own resources:

async def send_receipt(order_id: int) -> None:
    # The task owns its session; it does not borrow the request's.
    async with session_factory() as session:
        order = await session.get(Order, order_id)
        await mailer.send(order.email, render_receipt(order))


@app.post("/orders/")
async def create_order(order: OrderIn, tasks: BackgroundTasks, db: SessionDep):
    saved = await repository.create(db, order)
    tasks.add_task(send_receipt, saved.id)   # Pass an ID, never a live ORM object.
    return saved

Passing an ID rather than a session or an ORM instance is the whole trick, and it is the same discipline described in when BackgroundTasks silently fails.

The Production Pattern

Putting the observed behaviour to work, a session dependency should look like this:

from collections.abc import AsyncGenerator

from sqlalchemy.ext.asyncio import AsyncSession


async def get_session() -> AsyncGenerator[AsyncSession, None]:
    async with session_factory() as session:
        try:
            yield session
            await session.commit()       # Reached only if the endpoint returned.
        except Exception:
            await session.rollback()     # The endpoint's exception arrives here.
            raise                        # Re-raise: the error response still owes the client a body.

The raise is not optional. Swallowing the exception leaves FastAPI with a dependency that completed normally but an endpoint that never produced a return value, and the resulting behaviour is much harder to debug than the original error. The same reasoning applies to the global exception handlers sitting further out: they can only shape an error they actually receive.

Verification

Reproduce the ordering in your own app with a fixture that records:

@pytest.fixture
def order_log(app, monkeypatch):
    log: list[str] = []

    async def traced_session():
        log.append("open")
        try:
            yield FakeSession()
        finally:
            log.append("close")

    app.dependency_overrides[get_session] = traced_session
    yield log
    app.dependency_overrides.clear()


def test_session_closes_after_error(client, order_log):
    client.get("/orders/does-not-exist")
    assert order_log == ["open", "close"]     # closed even though the route 404'd

The override mechanism used here has its own sharp edges, covered in overriding dependencies in tests.

Trade-Offs and When Not To

A yield dependency holds its resource for the whole request. If the handler does a database read and then a slow external HTTP call, a session-scoped yield dependency keeps a pooled connection checked out across the network wait. Under concurrency that is how pools get exhausted; the diagnosis is in fixing asyncpg pool exhaustion. Narrowing the scope means acquiring the session inside the handler for the section that needs it, at the cost of losing the automatic teardown.

Teardown is not a place for slow work. On the success path it runs after the route has produced its response but before the connection is released; a two-second cleanup is two seconds of held resources.

Caching and teardown are coupled. A yield dependency resolved twice in one request — which happens if the same provider is reached through two distinct callable objects, or with use_cache=False — sets up twice and tears down twice. If that provider opens a transaction, you now have two. The mechanics of when that happens are in dependency caching and use_cache.

Exceptions raised in teardown are the worst kind. They surface after the endpoint has notionally succeeded and can mask the original error. Keep the code after yield boring: close, release, log. Nothing that can fail in an interesting way.

FAQ

In what order do yield dependencies clean up? In reverse order of setup, last in first out, exactly like nested context managers. If outer sets up, then middle, then inner, teardown runs inner, then middle, then outer. This guarantees a dependency is still usable while anything that depends on it is tearing down.

Does the teardown run when the endpoint raises? Yes. The exception propagates back through the generator stack, so every open yield dependency gets its finally block. A dependency that wraps its yield in try/except can also observe the exception and roll back a transaction before re-raising.

Can I catch the endpoint's exception inside a yield dependency? You can observe it with try/except around the yield, which is how session rollback is implemented. Swallowing it without re-raising is a mistake: FastAPI has already committed to an error response path and suppressing the exception there produces confusing behaviour.

Do yield dependencies stay open for background tasks? On FastAPI 0.139.2 the measured order is that background tasks run before yield teardown, so a session is still open. This ordering has changed across FastAPI releases, so treat it as version-specific and pass tasks their own resources rather than depending on it.

What happens if a dependency raises before it yields? Its own teardown never runs because the generator never suspended, but every dependency that already set up before it does tear down. The request short-circuits and the endpoint never executes.

Why does teardown appear after my middleware timer on success but before it on failure? On the success path the response object travels outward first and the exit stack closes behind it. On the error path the exception must unwind the dependency stack before any response exists to send. Both orderings are visible in the transcripts above.