02 · Yield Dependencies, Caching & Overrides¶
Some resources need cleaning up: a database session must be closed, a transaction
committed or rolled back, a lock released, a temporary file deleted. A dependency
written as a generator — with yield instead of return — gives you a place to do
setup before the endpoint and teardown after it, guaranteed, even when the endpoint
fails.
Setup, yield, teardown¶
To see the order of events without a real database, here's a fake session that logs what happens to it:
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
log: list[str] = []
app = FastAPI()
class FakeSession:
def __init__(self): self.pending = []
def add(self, x): self.pending.append(x)
def commit(self): log.append(f"commit {self.pending}")
def rollback(self): log.append("rollback"); self.pending.clear()
def close(self): log.append("close")
def get_session():
s = FakeSession()
log.append("open")
try:
yield s
s.commit()
except Exception as exc:
log.append(f"saw {type(exc).__name__}")
s.rollback()
raise
finally:
s.close()
Session = Annotated[FakeSession, Depends(get_session)]
@app.post("/ok")
def ok(s: Session):
s.add("book"); log.append("endpoint")
return {"ok": True}
@app.post("/conflict")
def conflict(s: Session):
s.add("dup"); log.append("endpoint")
raise HTTPException(409, "duplicate")
@app.post("/bug")
def bug(s: Session):
s.add("x"); log.append("endpoint")
return 1 / 0
The event logs:
/ok 200 ['open', 'endpoint', "commit ['book']", 'close']
/conflict 409 ['open', 'endpoint', 'saw HTTPException', 'rollback', 'close']
/bug 500 ['open', 'endpoint', 'saw ZeroDivisionError', 'rollback', 'close']
The structure is the one to memorise:
- code before
yield→ setup; yield value→ the value injected into the endpoint;- code after
yieldinsidetry→ runs only on success; except→ runs if the endpoint (or a later dependency) raised; the exception is thrown into the generator at theyield;finally→ always runs.
Every path closed the session. That guarantee is the whole point.
Always re-raise¶
What if a dependency catches an exception and doesn't re-raise it?
def swallow():
try:
yield "s"
except HTTPException:
log.append("swallowed")
@app.post("/swallow")
def sw(s: Annotated[str, Depends(swallow)]):
raise HTTPException(409, "dup")
The client got 500 Internal Server Error — not the 409 — and the server raised:
FastAPIError Response not awaited. There's a high chance that the application code is raising an exception and a dependency with yield has a block with a bare except, or a block with except Exception, and is not raising the exception again. ...
By swallowing the exception, the dependency erased the error that would have produced the
response, leaving FastAPI with nothing to send. An except block in a yield
dependency must end with raise, unless it raises a different exception on purpose.
When does teardown run? scope¶
This is the subtle part, and it changed across FastAPI releases. In the version used here
(0.143.0), Depends takes a scope argument:
scope="request"(the default for yield dependencies): teardown runs after the response has been sent.scope="function": teardown runs right after the endpoint function returns, before the response is sent.
The difference is visible with a streaming response, whose body is produced after the endpoint returns:
from fastapi.responses import StreamingResponse
@app.get("/stream-fn")
def stream_fn(s: Annotated[str, Depends(get_session_fn, scope="function")]):
def gen():
log.append("stream chunk 1"); yield b"a"
log.append("stream chunk 2"); yield b"b"
return StreamingResponse(gen())
/stream-fn 200 b'ab' ['open-fn', 'close-fn', 'stream chunk 1', 'stream chunk 2']
/stream-req 200 b'ab' ['open-req', 'stream chunk 1', 'stream chunk 2', 'close-req']
With function scope, the session was closed before the stream produced anything — if the generator had used it, it would have hit a closed session. With request scope, the session stayed open until the stream finished.
Worked example: the commit-after-response trap¶
Request scope has a cost. If the work after yield can fail — and a database commit
can fail (a constraint violation, a lost connection) — the client may already have its
answer:
def failing_commit_req():
yield "s"
log.append("committing (request scope)")
raise RuntimeError("commit failed")
def failing_commit_fn():
yield "s"
log.append("committing (function scope)")
raise RuntimeError("commit failed")
@app.post("/req")
def req(s: Annotated[str, Depends(failing_commit_req)]):
return {"saved": True}
@app.post("/fn")
def fn(s: Annotated[str, Depends(failing_commit_fn, scope="function")]):
return {"saved": True}
/req 200 {"saved":true} ['committing (request scope)']
/fn 500 Internal Server Error ['committing (function scope)']
With the default scope, the client was told {"saved": true} and then the commit
failed. The exception still surfaced on the server (the test client re-raised
RuntimeError: commit failed when exceptions weren't suppressed), but the client never
knew. That's a lie in your API.
Two safe patterns:
- Commit inside the endpoint (or service function) and use the dependency only to provide the session and roll back / close. A commit failure is then an ordinary exception that produces a 500 (or a 409 you map), before any response exists. Lesson 4 of this level uses this.
- Use
scope="function"for dependencies whose teardown can fail in a way the client must hear about — and don't combine them with streaming responses that need the resource.
Caching and overrides with yield dependencies¶
The per-request cache from lesson 1 applies: if three dependencies in one request all
need get_session, they share one session, opened once and closed once. That's what
makes "one transaction per request" work without passing the session around by hand.
Overrides work the same way, and an override can itself be a generator. A common test setup replaces the real session with one bound to a test database:
def override_session():
s = make_test_session()
try:
yield s
finally:
s.close()
app.dependency_overrides[get_session] = override_session
How It Actually Works¶
FastAPI wraps each generator dependency in a context manager — asynccontextmanager
for async def generators, and contextmanager run through
contextmanager_in_threadpool for plain def generators — and enters it on an
AsyncExitStack. The route handler in fastapi/routing.py (a modified copy of
Starlette's request_response) creates two nested stacks for every request, and the
nesting explains everything above:
async with AsyncExitStack() as request_stack:
scope["fastapi_inner_astack"] = request_stack
async with AsyncExitStack() as function_stack:
scope["fastapi_function_astack"] = function_stack
response = await f(request) # solve dependencies, call endpoint
await response(scope, receive, send) # send the response
response_awaited = True
if not response_awaited:
raise FastAPIError("Response not awaited. ...")
(Condensed from the 0.143.0 source.) When solving dependencies, a generator with
scope="function" is entered on function_stack; otherwise on request_stack.
function_stack closes as soon as the endpoint returns — before
await response(...) sends anything. request_stack closes only after the response has
been sent, which is why a failing commit there can't change the status code any more.
When the endpoint raises, the stacks unwind and each context manager's __exit__
throw()s the exception into your generator at the yield — that's how your except
block sees it. If your generator handles it and returns normally, the exception is
treated as handled, response_awaited is never set, and you get exactly the
Response not awaited error shown earlier.
FastAPI also refuses one combination at startup: a request-scoped dependency can't depend on a function-scoped one, since the inner resource would be closed before the outer one had finished with it.
Teardown order is the reverse of setup, like nested with blocks: a dependency that
depends on the session is torn down before the session itself.
Version history
The timing of yield-dependency teardown has changed more than once in FastAPI's
history, which is why older tutorials disagree. If you're on an older version
without scope, test the behaviour of your version with a logging dependency like
the one above before relying on it.
Common mistakes¶
- Swallowing exceptions in an
exceptblock, producingResponse not awaited500s. - Committing after
yieldwith the default scope, so a failed commit is reported to the client as success. - Using a function-scoped resource in a
StreamingResponse— it's already closed. - Doing slow cleanup in request scope and expecting it to be free. It runs after the
response, but the connection, the session and (for
defgenerators) a thread-pool slot stay in use until it finishes. yieldmore than once. A dependency generator must yield exactly once.- Returning instead of yielding and wondering why
finallyruns before the endpoint.
Exercise¶
- Write a
get_lockyield dependency that acquires athreading.Lockbeforeyieldand releases it infinally. Prove with a log that the lock is released even when the endpoint raisesHTTPException. - Reproduce the commit-after-response trap, then fix it both ways: move the commit into
the endpoint, and switch to
scope="function". Check the status codes. - Make two dependencies
get_sessionandget_repo(session = Depends(get_session)), both with logging teardown. Confirm that teardown runs in reverse order and that the session is opened only once when an endpoint uses both. - Try to use a function-scoped dependency inside a
StreamingResponsegenerator and describe what goes wrong.