</>CodeWithKarani

FastAPI yield Dependencies: How Cleanup Works and Where It Silently Breaks

Karani GeoffreyKarani Geoffrey8 min read

You have a FastAPI dependency that opens a database session, yields it, and closes it in a finally. It has worked for a year. Then you bump FastAPI a couple of minor versions, and something subtle breaks: a background task that used the session now finds it closed, or a middleware that committed the transaction based on the response status quietly stops committing. No exception, no clear signal. Just resources that used to be alive at a certain moment and now are not.

This is the kind of bug that gets "fixed" by pinning to an old version and never looking again, because the actual change is undocumented in any blog post and the GitHub thread is the maintainers themselves working out the correct semantics. The real story is specific and worth understanding: FastAPI 0.106.0 deliberately changed when the code after yield runs, and that single change of ordering is behind almost every "yield dependency broke after upgrade" report.

Once you understand the ordering, the fixes are obvious and you never need to pin blindly again. Let me walk through how yield teardown actually works, exactly what changed, and how to prove your cleanup runs when you think it does.

In FastAPI 0.106.0 the exit code of a yield dependency (everything after yield) moved to run before the response is sent, instead of after. Consequences:

  • You can now raise HTTPException and other exceptions after yield, and exception handlers will see them. Before 0.106 you could not.
  • You can no longer use a yield-dependency's resource inside a background task, because the resource is torn down before the task runs.
  • Middleware and exception handlers can no longer act on resources created in a yield dependency, because those resources are already closed by the time middleware runs.

Do not pin blindly. Move commit/rollback logic into the dependency itself, and stop sharing yield resources with background tasks.

The symptom: "Context managers in Depends are broken after 0.106"

The exact phrasing people search for, from the GitHub issue, is this:

Context managers in Depends are broken after 0.106

The concrete report is a database session dependency whose commit used to happen after middleware ran, and now happens before. Before the upgrade the sequence was create session -> open -> commit -> close. After, it became create session -> open -> close -> commit, so a middleware that decided whether to commit based on the response status found the session already closed. Nothing raised. The transaction behaviour just silently changed.

How FastAPI wires up yield teardown

A yield dependency is a generator. FastAPI treats the part before yield as setup and the part after as teardown, and it manages the teardown using contextlib.AsyncExitStack. When a request comes in, FastAPI enters each yield dependency into an exit stack in order. When the request is done, it unwinds the stack, running each dependency's post-yield code in reverse order (last entered, first cleaned up), exactly like nested with blocks.

from sqlalchemy.orm import Session

async def get_db():
    db = SessionLocal()
    try:
        yield db          # setup done, hand the session to the route
    finally:
        db.close()        # teardown: run when the exit stack unwinds

The question that everything hinges on is: when does that exit stack unwind relative to the response being sent and to middleware running? That is precisely what 0.106.0 changed.

When does the code after yield run? Before 0.106 route runs response sent middleware teardown (after yield) 0.106 and later route runs teardown (after yield) response sent background task After 0.106 the resource is closed BEFORE middleware and background tasks. That is the whole bug.
The teardown did not disappear. It moved earlier, so anything downstream of the response no longer sees the resource.

What actually changed in 0.106.0, and why

Before 0.106.0, the exit code in a yield dependency ran after the response was sent. That meant a database connection stayed open while the response travelled across the network to the client, holding a pooled resource for no reason. It also meant you could not raise a useful exception after yield, because exception handlers had already run by then.

FastAPI 0.106.0 moved the teardown to run before the response is sent. The stated motivation was to stop holding resources during the network round-trip and to allow dependencies to raise HTTPException and other exceptions after yield that exception handlers can actually catch. Both are genuine improvements. The cost is that patterns relying on the old timing broke:

PatternBefore 0.1060.106 and later
Raise HTTPException after yieldnot possible (handlers already ran)supported
Use yield resource in a background taskworked (teardown ran later)not supported (already torn down)
Commit/rollback in middleware by response statusworked (session still open)broken (session closed)
Resource held during response network transityes (wasteful)no (released earlier)

The fix: put lifecycle logic where the resource lives

Step 1: Move commit/rollback into the dependency, not middleware

If you were committing in middleware based on the response, stop. The dependency is the right place because it is the thing that owns the session's lifetime. Decide commit-or-rollback inside the generator, using the fact that an exception propagating out of the route will reach the dependency:

async def get_db():
    db = SessionLocal()
    try:
        yield db
        db.commit()          # no exception: commit
    except Exception:
        db.rollback()        # route raised: roll back
        raise
    finally:
        db.close()

This is timing-independent. It does not care when the response is sent or what middleware does, because commit and rollback are decided by whether the route raised, which the generator sees directly. This is the pattern that survives future ordering changes.

Step 2: Stop sharing yield resources with background tasks

If a background task uses the same session the request used, it is relying on a resource that 0.106 tears down before the task runs. Give the background task its own resource with its own lifecycle instead:

from fastapi import BackgroundTasks, Depends

def send_receipt(order_id: int):
    # open a fresh session INSIDE the task, do not reuse the request's
    with SessionLocal() as db:
        order = db.get(Order, order_id)
        # ... work with a session that is alive here

@app.post("/orders")
async def create_order(bg: BackgroundTasks, db: Session = Depends(get_db)):
    order = Order(...)
    db.add(order); db.commit()
    bg.add_task(send_receipt, order.id)   # pass an id, not the session
    return {"id": order.id}

Pass an identifier the task can re-load, not the live object bound to a session that will be closed. This is also just better design; a background task outliving the request should not borrow the request's connection.

Step 3: Use raise-after-yield deliberately if you need it

The upside of the change is that you can now validate during teardown and raise a proper HTTP error. If a dependency needs to reject the request based on something it only learns at cleanup, that now works:

from fastapi import HTTPException

async def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
    # after 0.106 you may raise here and handlers will catch it

Verification: prove your cleanup actually runs

Do not trust that teardown fires; test it. The reliable way is to make the teardown record that it ran and assert on that in a pytest test, including the cancellation case where a client disconnects mid-request:

import pytest
from fastapi.testclient import TestClient

cleanup_calls = []

async def get_db():
    cleanup_calls.append("open")
    try:
        yield "session"
    finally:
        cleanup_calls.append("closed")

def test_teardown_runs():
    cleanup_calls.clear()
    client = TestClient(app)
    r = client.get("/thing")
    assert r.status_code == 200
    assert cleanup_calls == ["open", "closed"]   # teardown fired

def test_teardown_runs_on_error():
    cleanup_calls.clear()
    client = TestClient(app)
    client.get("/thing-that-raises")             # route raises
    assert "closed" in cleanup_calls             # still cleaned up

The second test is the one that matters: cleanup on the error path is exactly what silently breaks. If closed is absent after a raising route, your finally is not running and you are leaking connections under every error. This mirrors the discipline I use for the pytest-asyncio event loop issues: assert the lifecycle, do not assume it.

Nested yield dependencies and teardown order

If dependency B depends on dependency A, FastAPI enters A then B, and unwinds in reverse: B's teardown runs before A's. That is correct and usually what you want (the thing built last is torn down first). The gotcha is assuming A is still usable inside B's teardown, or vice versa. If B's cleanup needs A's resource, remember A is still open at that point (A tears down after B), but if A's cleanup needs something B produced, that is gone. Order your dependencies so the one that owns the outer resource is depended on, not depending.

What people get wrong

"Just pin FastAPI below 0.106 and move on." This freezes you out of every security fix and feature since 2023 to avoid understanding a five-line reordering. The commit-in-the-dependency pattern above works on current versions and is strictly better design. Pinning is a decision to carry the bug forever.

"The cleanup silently stopped running, so yield dependencies are unreliable." The cleanup runs; it just runs earlier than before. Nothing about the generator teardown is flaky. What broke was code that depended on the old, later timing.

"Do the commit in middleware so all routes share it." This is the exact anti-pattern that broke. Middleware runs after teardown now, so the session it wants is closed. Lifecycle belongs with the resource, in the dependency.

"I'll reuse the request's DB session in the background task to save a connection." That connection is gone by the time the task runs. Open a fresh session in the task. Reusing it was always fragile and is now simply broken.

When it is still broken

  • Check your exact FastAPI and Starlette versions. pip show fastapi starlette. The ordering change is FastAPI-side, but Starlette's middleware and background-task handling interacts with it, so record both when you reproduce and when you upgrade.
  • Reproduce under cancellation. A client that disconnects mid-request triggers a different teardown path. If cleanup only fails under load, test the cancelled-request case explicitly, not just the happy path.
  • Look for connections leaking, not errors. The failure is often silent: the pool slowly exhausts. Watch your SQLAlchemy pool checkout count over time, not just for exceptions.
  • Streaming responses change the timing again. With a StreamingResponse, the response is not "done" when the route returns, which interacts with yield teardown differently. If you stream, verify cleanup runs after the stream finishes, not when the route function returns.

The lesson that outlasts any version: put a resource's lifecycle where the resource is created, decide its fate from what the generator can see directly, and test the cleanup on the error path. Do that and no future reordering of when teardown fires can silently break you, because you never relied on the timing in the first place.

Frequently asked questions

What broke in FastAPI 0.106 for yield dependencies?
FastAPI 0.106.0 changed when the code after yield runs. Previously it ran after the response was sent; now it runs before. That means resources created in a yield dependency are torn down before middleware and background tasks execute, so any code that relied on the resource still being open at that later point breaks silently, with no exception raised.
Why does my background task find the database session already closed?
Because since FastAPI 0.106 the yield dependency's teardown runs before the response is sent, and background tasks run after. So the session the request used is already closed by the time the task executes. The fix is to open a fresh session inside the background task and pass it an identifier to re-load, not the live object bound to the request's session.
Should I just pin FastAPI below 0.106 to avoid this?
No. Pinning freezes you out of years of security fixes and features to avoid a documented reordering. Instead move commit and rollback logic into the dependency itself, decided by whether the route raised, and stop sharing yield resources with background tasks. That pattern is timing-independent and survives future changes.
How do I test that a FastAPI dependency's cleanup actually runs?
Have the teardown append to a list and assert on it in a pytest test, both on the success path and on a route that raises. The error-path test is the important one: if the 'closed' marker is missing after a raising route, your finally block is not running and you are leaking connections on every error.
#FastAPI#Dependencies#SQLAlchemy#AsyncExitStack#Python
Keep reading

Related articles