Dependencies with yield
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on
2026-10-02. FastAPI supports Python 3.10 through 3.14; the code on this page was executed on Python 3.13, and the
version-dependent behaviour was additionally run on FastAPI 0.105.0, 0.106.0, 0.117.1, 0.118.0, 0.120.4 and 0.121.0.
A dependency with yield is a dependency that does some extra steps after the request is done: it opens a resource
(a database session, a file, an HTTP client), hands it to the path operation, and closes it afterwards. How
afterwards is defined, in particular relative to the response and to background tasks, changed twice (0.106.0 and
0.118.0) and was made configurable in 0.121.0. Getting that timing right is the whole subject of this page. The
basics of Depends are on Dependency Injection; here we only add
yield.
Setup and Teardown with yield
Write a dependency as a generator: the code before yield is the setup, the yielded value is what FastAPI injects,
and the code after yield is the teardown. Use yield exactly once per dependency. Any function that would be
valid with @contextlib.contextmanager or @contextlib.asynccontextmanager is valid; FastAPI uses those two
decorators internally, so a generator dependency and a context manager are the same idea (see
Files & Context Managers and
Iterators, Generators & Comprehensions
for the Python mechanics).
The following is the shape every example below builds on: a database session for the Bookshelf API. To keep the page runnable without a database server, the session is a small unit of work over an in-memory dictionary.
import logging
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
logger = logging.getLogger("bookshelf")
logging.basicConfig(level=logging.INFO)
BOOKS: dict[str, str] = {} # isbn -> title, stands in for the database
class BookIn(BaseModel):
isbn: str
title: str
class DuplicateIsbnError(Exception):
pass
class Session:
"""A tiny unit of work: changes become visible only on commit."""
def __init__(self) -> None:
self.pending: dict[str, str] = {}
def add(self, isbn: str, title: str) -> None:
if isbn in BOOKS or isbn in self.pending:
raise DuplicateIsbnError(isbn)
self.pending[isbn] = title
def commit(self) -> None:
BOOKS.update(self.pending)
logger.info("committed %d change(s)", len(self.pending))
def rollback(self) -> None:
self.pending.clear()
logger.info("rolled back")
def close(self) -> None:
logger.info("session closed")
def get_session():
session = Session()
try:
yield session
except DuplicateIsbnError as exc:
session.rollback()
raise HTTPException(status_code=409, detail=f"ISBN {exc} already exists")
except Exception:
session.rollback()
logger.exception("unexpected error, rolled back")
raise
finally:
session.close()
SessionDep = Annotated[Session, Depends(get_session, scope="function")]
app = FastAPI(title="Bookshelf API")
@app.post("/books", status_code=201)
def create_book(book: BookIn, session: SessionDep) -> BookIn:
session.add(book.isbn, book.title)
session.commit()
return book
Source: FastAPI: Dependencies with yield
Run it and create the same book twice (the sections below explain every line, including scope="function"):
fastapi dev bookshelf_yield.py
curl -i -X POST http://127.0.0.1:8000/books -H 'Content-Type: application/json' \
-d '{"isbn":"978-0441172719","title":"Dune"}'
curl -i -X POST http://127.0.0.1:8000/books -H 'Content-Type: application/json' \
-d '{"isbn":"978-0441172719","title":"Dune again"}'
{"isbn":"978-0441172719","title":"Dune"}
{"detail":"ISBN 978-0441172719 already exists"}
The first call answers 201 Created, the second 409 Conflict. The server log shows the order of the two requests'
teardown (taken from a real run):
INFO:bookshelf:committed 1 change(s)
INFO:bookshelf:session closed
INFO: 127.0.0.1:59276 - "POST /books HTTP/1.1" 201 Created
INFO:bookshelf:rolled back
INFO:bookshelf:session closed
INFO: 127.0.0.1:59278 - "POST /books HTTP/1.1" 409 Conflict
Points to take from the code:
-
Only the code up to and including
yieldruns before the path operation. The yielded value is injected into the path operation and into any dependency that declares this one. -
defandasync defgenerators both work, exactly as with plain dependencies. Adefgenerator runs (both its setup and its teardown) in the threadpool; anasync defgenerator runs on the event loop. This was observed by checkingasyncio.get_running_loop()in both halves of each kind. Rules for blocking calls are in Async & Concurrency. -
The generator is a dependency like any other: it can have parameters (path, query, other dependencies), it is declared with
Annotated[…, Depends(…)], and the aliasSessionDepcan be reused in every route. -
Yielding the resource is not the same as returning a value: with
returnthere is nothing after the value, so nothing to clean up.
What a Yield Dependency Looks Like in OpenAPI
Nothing. A dependency with yield contributes no schema of its own: only the parameters it declares (query, header,
cookie, path or body) and its security schemes reach /openapi.json. In the executed check, a route whose only
dependency was a session generator had no parameters entry at all. The yielded object is a Python value for your
code and is never serialized into the documentation. If the dependency declares a query parameter, that parameter
appears exactly as it would for a non-yield dependency; see
Dependency Injection for the full picture.
try, except and finally
Because the path operation runs while the generator is suspended at yield, an exception raised by the path
operation (or by any other dependency below it) is thrown into the generator at the `yield`. That gives the
dependency three tools:
-
finallyalways runs, with or without an exception: use it for the unconditional cleanup (close()). -
except SomeErrorobserves that exception: use it for rollbacks and logging. -
Code after
yieldinsidetry(not infinally) runs only when the path operation succeeded: that is where a "commit on success" belongs.
The following dependency commits on success, rolls back on any failure and always closes. The endpoint raises different exceptions depending on the ISBN so each branch is exercised:
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
events: list[str] = []
class Session:
def commit(self): events.append("commit")
def rollback(self): events.append("rollback")
def close(self): events.append("close")
class DuplicateIsbnError(Exception):
pass
def get_session():
session = Session()
try:
yield session
session.commit()
except DuplicateIsbnError as exc:
session.rollback()
raise HTTPException(status_code=409, detail=f"ISBN already exists: {exc}")
except Exception:
session.rollback()
raise
finally:
session.close()
app = FastAPI()
@app.post("/books/{isbn}", status_code=201)
def create(isbn: str, session: Annotated[Session, Depends(get_session)]):
if isbn == "dup":
raise DuplicateIsbnError(isbn)
if isbn == "boom":
raise RuntimeError("boom")
if isbn == "http":
raise HTTPException(status_code=404, detail="no such thing")
return {"isbn": isbn}
client = TestClient(app, raise_server_exceptions=False)
for p in ["ok", "dup", "boom", "http"]:
events.clear()
r = client.post(f"/books/{p}")
print(p, r.status_code, r.json() if r.status_code != 500 else r.text, events)
ok 201 {'isbn': 'ok'} ['commit', 'close']
dup 409 {'detail': 'ISBN already exists: dup'} ['rollback', 'close']
boom 500 Internal Server Error ['rollback', 'close']
http 404 {'detail': 'no such thing'} ['rollback', 'close']
Note that an HTTPException raised by the path operation is also thrown into the dependency (the http row: it
rolled back), then propagates and becomes the 404. Cleanup code that reacts to failures therefore sees HTTP
errors too, not only unexpected ones. Catch HTTPException explicitly when you want to treat it differently.
Always Re-raise What You Do Not Handle
If a dependency catches an exception with except and neither re-raises it nor raises a different one, FastAPI cannot
notice that anything went wrong, exactly as in plain Python. The client then gets a bare 500 Internal Server
Error and the server shows no traceback, because the original exception was swallowed:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
events: list[str] = []
class DuplicateIsbnError(Exception):
pass
def get_swallowing():
try:
yield "x"
except DuplicateIsbnError:
events.append("swallowed") # BUG: neither re-raised nor converted
app = FastAPI()
@app.get("/swallow")
def swallow(x: Annotated[str, Depends(get_swallowing)]):
raise DuplicateIsbnError("s")
client = TestClient(app, raise_server_exceptions=False)
r = client.get("/swallow")
print("swallow", r.status_code, r.text, events)
swallow 500 Internal Server Error ['swallowed']
The rule is: when you catch an exception in a dependency with yield, either re-raise the original with a bare
raise (which keeps the traceback and lets exception handlers and logs see it), or raise a new exception such as
HTTPException. The first dependency in this page does both: a known domain error becomes a 409, everything
else is rolled back and re-raised. Before 0.110.0 the swallowed exception was forwarded to the handlers
automatically; that was removed to fix unbounded memory use and to match plain Python, so code written for older
versions that relied on it must now re-raise explicitly.
Raising HTTPException in Exit Code
Raising HTTPException after the yield is allowed since 0.106.0 (2023-12-25); before that the exit code ran
after the response, too late to change it. It is the tool for converting a low-level error that only the dependency
understands (a unique constraint, a deadlock, a failed commit) into the correct HTTP status. General exception
design and handlers are on Status Codes &
Error Handling.
The 409 conversion in get_session works because the exception was thrown into the generator before the response
had started. That timing matters, and it is where the version history comes in.
Execution Order against the Response
By default (scope="request", the behaviour since 0.118.0) a yield dependency’s teardown runs after the response
has been sent. To prove the order instead of describing it, the following app logs every step, wrapping the ASGI
callable to log when the last response chunk is handed to the server:
from typing import Annotated
from fastapi import BackgroundTasks, Depends, FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
events: list[str] = []
def log(msg: str) -> None:
events.append(msg)
class Session:
def __init__(self) -> None:
self.closed = False
def close(self) -> None:
self.closed = True
log("session closed")
def get_session():
session = Session()
log("session opened")
try:
yield session
finally:
session.close()
def get_fn_session():
session = Session()
log("fn-session opened")
try:
yield session
finally:
session.close()
app = FastAPI()
def audit(session: Session, what: str) -> None:
log(f"background task runs, session closed={session.closed}")
@app.get("/books")
def list_books(
session: Annotated[Session, Depends(get_session)], tasks: BackgroundTasks
):
log("path function runs")
tasks.add_task(audit, session, "list")
return ["Dune"]
@app.get("/books/fn")
def list_books_fn(
session: Annotated[Session, Depends(get_fn_session, scope="function")],
tasks: BackgroundTasks,
):
log("path function runs")
tasks.add_task(audit, session, "list")
return ["Dune"]
# wrap the ASGI app to log when the response is sent
inner = app
async def traced(scope, receive, send):
async def tsend(message):
if message["type"] == "http.response.start":
log("response start sent")
if message["type"] == "http.response.body" and not message.get("more_body"):
log("response body complete sent")
await send(message)
await inner(scope, receive, tsend)
client = TestClient(traced)
for path in ["/books", "/books/fn"]:
events.clear()
r = client.get(path)
print(path, r.status_code, r.text.strip())
for e in events:
print(" ", e)
/books 200 ["Dune"]
session opened
path function runs
response start sent
response body complete sent
background task runs, session closed=False
session closed
/books/fn 200 ["Dune"]
fn-session opened
path function runs
session closed
response start sent
response body complete sent
background task runs, session closed=True
Read the two traces side by side:
-
Default (
/books): setup, path function, response sent, background tasks, then the teardown. The resource stays open until the whole request cycle, including background tasks, is over. -
scope="function"(/books/fn): setup, path function, teardown, then the response is sent and the background tasks run (and find the session already closed).
The Lifecycle as a Diagram
The sequence below combines the two traces: a request-scope dependency (get_session), a function-scope dependency
and the path function, the response and the background tasks. Time flows downwards.
An exception from the path function reaches the dependencies (innermost first) while the response has not started, so a dependency can still turn it into a different error response. After the response is sent no response can be changed; the next sections show what that means for errors raised in the teardown.
What You Cannot Do after the Response Is Sent
With the default scope, a teardown that raises HTTPException after the path function succeeded cannot change the
response, because the status line and body are already on the wire. A commit that fails in the teardown is the classic
trap. Compare the two scopes:
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
def commit_or_409():
yield "tx"
raise HTTPException(status_code=409, detail="commit failed")
app = FastAPI()
@app.get("/req")
def req(tx: Annotated[str, Depends(commit_or_409)]):
return {"ok": True}
@app.get("/fn")
def fn(tx: Annotated[str, Depends(commit_or_409, scope="function")]):
return {"ok": True}
c = TestClient(app, raise_server_exceptions=False)
for p in ["/req", "/fn"]:
r = c.get(p)
print(p, r.status_code, r.text)
Source: FastAPI: Early exit and scope
/req 200 {"ok":true}
/fn 409 {"detail":"commit failed"}
With the default scope the client received 200 even though the "commit" failed afterwards; the exception was
raised into the server (running the same app with the default TestClient re-raised RuntimeError: Caught
handled exception, but response already started.). With scope="function" the failure happened before the
response and became a real 409. This is why the first example of the page declares scope="function" and calls
session.commit() inside the path function: a failed commit must be able to change the status code.
scope="function" versus scope="request" (0.121.0)
Depends (and only Depends, not Security) accepts a scope parameter since 0.121.0 (2025-11-03). It is mainly
meaningful for dependencies with yield:
| Value | Setup runs | Teardown runs |
|---|---|---|
|
Before the path function. |
After the response has been sent (and after background tasks). The dependency wraps the whole request and response cycle. |
|
Before the path function. |
Right after the path function returns, before the response is sent. The dependency wraps only the path function. |
When to choose each:
-
Keep the default
"request"when the resource must live while the response is produced: the best example isStreamingResponseor a streamedyieldresponse whose generator uses the session (see Streaming JSON Lines & SSE), or when background tasks must see the resource. -
Choose
"function"to release the resource as soon as the handler finishes: a database connection that should go back to the pool before a slow response travels, or a commit/transaction that must be able to change the status code (previous section).
The scope of a dependency without yield is irrelevant in practice; if it is omitted on a yield dependency, it is
"request".
Streaming Shows the Difference
A response that is produced after the path function returns can only use a request-scoped resource. This is the
same app run for a StreamingResponse generator:
from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
events: list[str] = []
class Session:
def __init__(self) -> None:
self.closed = False
def close(self) -> None:
self.closed = True
events.append("session closed")
def get_session():
session = Session()
try:
yield session
finally:
session.close()
app = FastAPI()
def stream_for(session: Session) -> StreamingResponse:
def gen() -> Iterator[str]:
for i in range(2):
events.append(f"stream chunk {i}, session closed={session.closed}")
yield f"{i}\n"
return StreamingResponse(gen())
@app.get("/stream")
def stream(session: Annotated[Session, Depends(get_session)]):
return stream_for(session)
@app.get("/stream/fn")
def stream_fn(session: Annotated[Session, Depends(get_session, scope="function")]):
return stream_for(session)
c = TestClient(app)
for p in ["/stream", "/stream/fn"]:
events.clear()
c.get(p)
print(p, events)
/stream ['stream chunk 0, session closed=False', 'stream chunk 1, session closed=False', 'session closed']
/stream/fn ['session closed', 'stream chunk 0, session closed=True', 'stream chunk 1, session closed=True']
The default scope keeps the session usable for every chunk; scope="function" closes it before the first chunk is
produced, so a generator that touched the database would fail with a closed-session error.
The official guide gives a case where early exit is beneficial: a slow streamed response that does not itself use the
database, where the only database access is a verification in a dependency. There, closing the session early (with
scope="function", or by calling session.close() explicitly at the end of the verifying dependency) frees a
connection that would otherwise be held until the last byte is sent.
Scope and Sub-dependencies
A dependency has to be able to run its teardown while its own sub-dependencies are still open, because the teardown may use them. That gives one rule:
-
A
"request"dependency may depend only on"request"dependencies. -
A
"function"dependency may depend on"function"and on"request"dependencies.
Violating the rule is reported when the route is declared (at import time), not on the first request, as
fastapi.exceptions.DependencyScopeError:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.exceptions import DependencyScopeError
def child():
yield "child"
def parent(ch: Annotated[str, Depends(child, scope="function")]):
yield "parent"
app = FastAPI()
try:
@app.get("/rp")
def rp(x: Annotated[str, Depends(parent)]):
return x
except DependencyScopeError as exc:
print(type(exc).__name__, exc)
DependencyScopeError The dependency "parent" has a scope of "request", it cannot depend on dependencies with scope "function".
The valid direction (a function-scope parent over a request-scope child) is shown in the next section’s order trace.
Yield Sub-dependencies and Teardown Order
Dependencies with yield can form trees of any size and shape, and any or all nodes can use yield; plain
dependencies can sit in the same tree. FastAPI runs the teardowns in the correct order: reverse of setup, so a
dependency’s teardown can still use the objects its own dependencies produced. The same call below shows a chain of
three request-scope generators and a function-scope parent over a request-scope child:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
ev: list[str] = []
def a():
ev.append("a open")
try:
yield "a"
finally:
ev.append("a close")
def b(a: Annotated[str, Depends(a)]):
ev.append("b open")
try:
yield "b"
finally:
ev.append("b close")
def c(b: Annotated[str, Depends(b)]):
ev.append("c open")
try:
yield "c"
finally:
ev.append("c close")
app = FastAPI()
@app.get("/abc")
def abc(c: Annotated[str, Depends(c)]):
ev.append("path")
return c
def f_parent(a: Annotated[str, Depends(a)]):
ev.append("f open")
try:
yield "f"
finally:
ev.append("f close")
@app.get("/fp")
def fp(x: Annotated[str, Depends(f_parent, scope="function")]):
ev.append("path")
return x
client = TestClient(app)
for p in ["/abc", "/fp"]:
ev.clear()
r = client.get(p)
print(p, r.status_code, r.text, ev)
/abc 200 "c" ['a open', 'b open', 'c open', 'path', 'c close', 'b close', 'a close']
/fp 200 "f" ['a open', 'f open', 'path', 'f close', 'a close']
c depends on b depends on a: setup runs a, b, c, teardown runs c, b, a. In the second route, the
function-scope parent f_parent is closed right after the path function, and its request-scope child a is closed
last, with the request. In the Bookshelf API this is the tree you meet constantly: get_session (yield) under
get_current_user under get_current_active_user, as drawn on
Dependency Injection.
Caching, Decorator Dependencies and WebSockets
A yield dependency obeys the same rules as other dependencies:
-
Per-request cache. If the same dependency is declared several times in one request (for example through two sub-dependencies), it is set up and torn down once and the value is shared.
Depends(get_session, use_cache=False)creates a second, independent instance. -
Decorator dependencies. In
dependencies=[Depends(get_session)]on the path operation decorator (or on a router, or onFastAPI(…)), the yielded value is discarded but the setup and teardown still run. Use it for side-effect-only dependencies such as an audit trail or a rate-limit slot. -
WebSockets. Yield dependencies work in WebSocket routes too; the teardown runs when the connection ends.
import json
from typing import Annotated
from fastapi import Depends, FastAPI, WebSocket
from fastapi.testclient import TestClient
ev: list[str] = []
opened = 0
def get_session():
global opened
opened += 1
ev.append(f"open #{opened}")
try:
yield f"session-{opened}"
finally:
ev.append("close")
def current_user(s: Annotated[str, Depends(get_session)]):
return f"user via {s}"
def other(s: Annotated[str, Depends(get_session)]):
return s
def uncached(s: Annotated[str, Depends(get_session, use_cache=False)]):
return s
app = FastAPI()
@app.get("/cached")
def cached(u: Annotated[str, Depends(current_user)], s: Annotated[str, Depends(other)]):
return {"u": u, "s": s}
@app.get("/uncached")
def unc(a: Annotated[str, Depends(other)], b: Annotated[str, Depends(uncached)]):
return [a, b]
@app.get("/deco", dependencies=[Depends(get_session)])
def deco():
ev.append("path")
return "ok"
@app.websocket("/ws")
async def ws(websocket: WebSocket, s: Annotated[str, Depends(get_session)]):
await websocket.accept()
await websocket.send_text(s)
await websocket.close()
c = TestClient(app, raise_server_exceptions=False)
for p in ["/cached", "/uncached", "/deco"]:
ev.clear()
opened = 0
r = c.get(p)
print(p, r.status_code, r.text, ev)
ev.clear()
opened = 0
with c.websocket_connect("/ws") as w:
print("ws", w.receive_text())
print(ev)
spec = c.get("/openapi.json").json()
print(json.dumps(spec["paths"]["/cached"]["get"].get("parameters")))
/cached 200 {"u":"user via session-1","s":"session-1"} ['open #1', 'close']
/uncached 200 ["session-1","session-2"] ['open #1', 'open #2', 'close', 'close']
/deco 200 "ok" ['open #1', 'path', 'close']
ws session-1
['open #1', 'close']
null
The last line is the OpenAPI effect from earlier: there is no parameters entry for the session dependency. This is why the cached route above opened one
session for two declarations. The decorator and WebSocket pages of the dependency story are on
Dependency Injection and
WebSockets.
Context Managers inside Dependencies
A context manager is anything usable in a with statement. A yield dependency is the natural wrapper: put the
with or async with inside the generator and yield from within it. The block exits (and the context manager
cleans up) when the dependency’s teardown begins. Three common forms, all executed:
-
a class with
__enter__and__exit__; -
a function decorated with
@contextlib.contextmanager, wrapped by a thin dependency, for example a file handle; -
an
async withover a library object such ashttpx.AsyncClient.
import contextlib
import tempfile
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
from typing import Annotated
import httpx
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
events: list[str] = []
class Connection:
def execute(self, sql: str) -> str:
return f"ran {sql}"
class ConnectionManager:
def __enter__(self) -> Connection:
events.append("enter")
return Connection()
def __exit__(self, exc_type, exc, tb) -> None:
events.append(f"exit exc={exc_type.__name__ if exc_type else None}")
def get_connection():
with ConnectionManager() as conn:
yield conn
@contextlib.contextmanager
def opened(path: Path) -> Iterator[object]:
f = path.open("a", encoding="utf-8")
try:
yield f
finally:
f.close()
events.append("file closed")
tmp = Path(tempfile.mkdtemp()) / "audit.log"
def get_audit_file():
with opened(tmp) as f:
yield f
async def get_http_client() -> AsyncIterator[httpx.AsyncClient]:
async with httpx.AsyncClient(base_url="https://isbn.example.test") as client:
yield client
events.append("client closing")
app = FastAPI()
@app.get("/db")
def db(conn: Annotated[Connection, Depends(get_connection)]):
return conn.execute("SELECT 1")
@app.get("/audit")
def audit(f: Annotated[object, Depends(get_audit_file)]):
f.write("visited\n")
return {"closed": f.closed}
@app.get("/client")
async def client_ep(c: Annotated[httpx.AsyncClient, Depends(get_http_client)]):
return {"base_url": str(c.base_url), "closed": c.is_closed}
c = TestClient(app)
for p in ["/db", "/audit", "/client"]:
r = c.get(p)
print(p, r.json(), events)
print(tmp.read_text())
/db ran SELECT 1 ['enter', 'exit exc=None']
/audit {'closed': False} ['enter', 'exit exc=None', 'file closed']
/client {'base_url': 'https://isbn.example.test', 'closed': False} ['enter', 'exit exc=None', 'file closed', 'client closing']
visited
Do not decorate the dependency itself with @contextlib.contextmanager: FastAPI applies that machinery to your
generator internally and the official guide advises against doing it yourself. The
decorator is for helper context managers used by a dependency, as above. An exception thrown into the dependency
at yield is propagated through the with block, so exit receives it and may inspect or suppress it; if a
context manager suppresses the exception by returning a true value from __exit__, it is swallowed as in the
earlier "re-raise" example.
Background Tasks and Yield Dependencies
BackgroundTasks and yield dependencies interact, and this is the part that changed most:
| FastAPI version | Teardown relative to the response | What a background task sees |
|---|---|---|
before 0.106.0 |
After the response and after the background tasks. |
The dependency’s resource is still open. |
0.106.0 to 0.117.x (2023-12-25 to 2025-09-28) |
After the path function, before the response is sent; |
The resource is already closed (breaking change in 0.106.0). Resources had to be created inside the task. |
0.118.0 and later (2025-09-29) |
After the response is sent (default), or before it with |
With the default scope the background task runs first and the resource is still open; with |
I confirmed each row by running one identical script on FastAPI 0.105.0, 0.106.0, 0.117.1, 0.118.0, 0.120.4, 0.121.0
and 0.142.2. The script is a path operation with a yield dependency and a background task, a streaming route, a
teardown that raises HTTPException(409), and an except that swallows an exception:
# Runs unchanged on every version listed above
from typing import Annotated
import fastapi
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
events: list[str] = []
def get_session():
events.append("open")
state = {"closed": False}
try:
yield state
finally:
state["closed"] = True
events.append("close")
def commit_conflict():
yield
raise HTTPException(status_code=409, detail="commit failed")
def swallow():
try:
yield
except KeyError:
events.append("swallowed KeyError")
app = FastAPI()
def task(state: dict) -> None:
events.append(f"background task (session closed={state['closed']})")
@app.get("/tasks")
def tasks(state: Annotated[dict, Depends(get_session)], bg: BackgroundTasks):
bg.add_task(task, state)
return "ok"
@app.get("/stream")
def stream(state: Annotated[dict, Depends(get_session)]):
def gen():
events.append(f"stream body (session closed={state['closed']})")
yield "data"
return StreamingResponse(gen())
@app.get("/conflict")
def conflict(_: Annotated[None, Depends(commit_conflict)]):
return "ok"
@app.get("/swallow")
def swallow_ep(_: Annotated[None, Depends(swallow)]):
raise KeyError("x")
async def traced(scope, receive, send):
async def tsend(message):
if message["type"] == "http.response.body" and not message.get("more_body"):
events.append("response sent")
await send(message)
await app(scope, receive, tsend)
client = TestClient(traced, raise_server_exceptions=False)
print("fastapi", fastapi.__version__)
for path in ["/tasks", "/stream", "/conflict", "/swallow"]:
events.clear()
r = client.get(path)
print(f"{path:10} {r.status_code} {events}")
fastapi 0.105.0
/tasks 200 ['open', 'response sent', 'background task (session closed=False)', 'close']
/stream 200 ['open', 'stream body (session closed=False)', 'response sent', 'close']
/conflict 200 ['response sent']
fastapi 0.106.0
/tasks 200 ['open', 'close', 'response sent', 'background task (session closed=True)']
/stream 200 ['open', 'close', 'stream body (session closed=True)', 'response sent']
/conflict 409 ['response sent']
fastapi 0.117.1
/tasks 200 ['open', 'close', 'response sent', 'background task (session closed=True)']
/stream 200 ['open', 'close', 'stream body (session closed=True)', 'response sent']
/conflict 409 ['response sent']
fastapi 0.118.0
/tasks 200 ['open', 'response sent', 'background task (session closed=False)', 'close']
/stream 200 ['open', 'stream body (session closed=False)', 'response sent', 'close']
/conflict 200 ['response sent']
fastapi 0.142.2
/tasks 200 ['open', 'response sent', 'background task (session closed=False)', 'close']
/stream 200 ['open', 'stream body (session closed=False)', 'response sent', 'close']
/conflict 200 ['response sent']
(/swallow returned 500 with swallowed KeyError on every version; 0.120.4 and 0.121.0 matched 0.118.0.)
Even though in current versions a request-scoped session is still open while the background task runs, the official guidance, and the robust design, is the opposite: a background task should own its resources. It runs after the response, may run for a long time, and relying on the dependency’s teardown ordering ties the task’s correctness to an implementation detail that has already flipped once. Create the resource inside the task and pass identifiers, not live objects:
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Annotated
from fastapi import BackgroundTasks, Depends, FastAPI
from fastapi.testclient import TestClient
reviews: dict[int, dict] = {1: {"book": "Dune", "stars": 5}}
log: list[str] = []
class Session:
def __init__(self, name: str) -> None:
self.name = name
self.closed = False
def get_review(self, review_id: int) -> dict:
if self.closed:
raise RuntimeError(f"{self.name} is closed")
return reviews[review_id]
def close(self) -> None:
self.closed = True
log.append(f"{self.name} closed")
@contextmanager
def open_session(name: str) -> Iterator[Session]:
session = Session(name)
try:
yield session
finally:
session.close()
def get_session():
with open_session("request-session") as session:
yield session
def recalculate_rating(review_id: int) -> None:
# The task owns its resources: a fresh session, and only an ID crosses over.
with open_session("task-session") as session:
review = session.get_review(review_id)
log.append(f"recalculated {review['book']} with {review['stars']} stars")
app = FastAPI()
@app.post("/reviews/{review_id}/publish", status_code=202)
def publish(
review_id: int,
session: Annotated[Session, Depends(get_session)],
tasks: BackgroundTasks,
):
session.get_review(review_id)
tasks.add_task(recalculate_rating, review_id)
return {"queued": review_id}
client = TestClient(app)
print(client.post("/reviews/1/publish").json())
print(log)
{'queued': 1}
['recalculated Dune with 5 stars', 'task-session closed', 'request-session closed']
The task works the same whichever FastAPI version or scope is in force. Background tasks themselves (scheduling,
BackgroundTasks versus a task queue, errors in tasks) are covered on
Settings, Lifespan & Background Tasks.
Overriding Yield Dependencies in Tests
app.dependency_overrides replaces a dependency, including a yield one; the replacement can itself be a generator,
and its teardown runs as usual. The test-database setup (an in-memory SQLite engine instead of PostgreSQL) is the
standard use:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
closed = []
def get_session():
try:
yield "real-db"
finally:
closed.append("real")
def get_test_session():
try:
yield "test-db"
finally:
closed.append("test")
app = FastAPI()
@app.get("/who")
def who(s: Annotated[str, Depends(get_session)]):
return s
app.dependency_overrides[get_session] = get_test_session
print(TestClient(app).get("/who").json(), closed)
test-db ['test']
Only the replacement ran; the real generator was never started. More on overrides, TestClient and fixtures is on
Testing, and the SQLModel session dependency used in production is on
SQL Databases with SQLModel.
Misuse Errors
A yield dependency must yield exactly once. Both mistakes surface as a RuntimeError when the generator is driven,
and both executed:
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
def twice():
yield 1
yield 2
def never():
if False:
yield 1
app = FastAPI()
@app.get("/twice")
def tw(x: Annotated[int, Depends(twice)]):
return x
@app.get("/never")
def nv(x: Annotated[int, Depends(never)]):
return x
c = TestClient(app)
for p in ["/twice", "/never"]:
try:
print(p, c.get(p).status_code)
except Exception as e:
print(p, type(e).__name__, e)
Source: FastAPI: Dependencies with yield
/twice RuntimeError generator didn't stop
/never RuntimeError generator didn't yield
Yielding twice is reported only after the response is out (the client of /twice had already received 200, with
1), so it shows up in the server log, not as a failed request. A dependency that can finish without yielding (for
example, a code path that returns early) fails every request that takes that path.
Pitfalls
-
Committing in teardown with the default scope. The response is already sent, so a failed commit cannot change the status code and the client may believe the write succeeded. Commit in the path function (or in a
scope="function"dependency’s code afteryield). -
Swallowing exceptions. An
exceptwithoutraisehides the error from handlers and logs and produces a bare500. Re-raise, or raiseHTTPException. -
Using a resource in a background task and trusting the order. It was valid before 0.106.0, broken from 0.106.0 to 0.117.x, valid again from 0.118.0. Create resources inside the task and pass IDs.
-
scope="function"and streaming. The resource is closed before a streamed body is produced; a generator that uses it fails. Keep the default scope for those routes. -
Scope mixing. A request-scope dependency cannot depend on a function-scope one:
DependencyScopeErrorat import time. Use"function"only at the top of a chain. -
Raising after the response. Anything raised in a request-scope teardown after the response is sent cannot become an HTTP error; it reaches the server (Starlette complains that the response has already started).
-
Blocking in async teardown. An
async defgenerator’s teardown runs on the event loop; a blockingclose()there stalls every request. Use adefgenerator (threadpool) for blocking drivers or an async driver. -
Yielding more than once, or not at all. Both raise
RuntimeError. -
Decorating the dependency with
@contextlib.contextmanager. FastAPI does that itself; do not. -
Assuming
Securityacceptsscope. OnlyDependsdoes;Securityhasscopes(OAuth2 scopes), which is unrelated.
Where Older Guides Are Outdated, and What Changed Recently
-
Teardown after the response (before 0.106.0). Older guides and books show the teardown running after the response and after background tasks, and background tasks reusing the dependency’s session. That was true then.
-
0.106.0 (2023-12-25). The teardown moved before the response, so
HTTPExceptioncan be raised after theyield; resources yielded are no longer usable in background tasks (a breaking change). -
0.110.0. An exception caught with
exceptin a yield dependency and not re-raised is no longer forwarded to the handlers; re-raise explicitly. -
0.118.0 (2025-09-29). The teardown runs after the response is sent again, restoring use in
StreamingResponseand background tasks, with corner-case fixes.HTTPExceptionraised in a teardown after a successful path function no longer changes the response. -
0.121.0 (2025-11-03).
Depends(scope="function" | "request")lets you choose the previous (early) timing per dependency, and invalid combinations are rejected withDependencyScopeError(verified on 0.142.2). -
Style. Use
Annotatedand reusable aliases such asSessionDep, SQLModel or SQLAlchemy sessions, andasync/defgenerators. The release history is collected on What’s Changed & Migration.