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

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 yield runs before the path operation. The yielded value is injected into the path operation and into any dependency that declares this one.

  • def and async def generators both work, exactly as with plain dependencies. A def generator runs (both its setup and its teardown) in the threadpool; an async def generator runs on the event loop. This was observed by checking asyncio.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 alias SessionDep can be reused in every route.

  • Yielding the resource is not the same as returning a value: with return there 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:

  • finally always runs, with or without an exception: use it for the unconditional cleanup (close()).

  • except SomeError observes that exception: use it for rollbacks and logging.

  • Code after yield inside try (not in finally) 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.

sequenceDiagram autonumber participant C as Client participant F as FastAPI participant R as Dependency scope=request participant U as Dependency scope=function participant P as Path function participant B as Background tasks C->>F: HTTP request F->>R: run code up to yield alt exception before yield R-->>C: error response (handlers run) end F->>U: run code up to yield F->>P: call path function with yielded values alt path function raises (HTTPException or other) P-->>U: exception thrown in at yield U-->>R: may re-raise or convert R-->>C: error response (handlers run) else path function returns P-->>F: return value F->>U: run code after yield Note over U: scope=function closes here, before the response F-->>C: send response (start and body) Note over C,F: response is sent, status cannot change anymore F->>B: run background tasks B-->>F: tasks finished F->>R: run code after yield Note over R: scope=request closes here, after the response and tasks end

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)
/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

"request" (default for yield dependencies)

Before the path function.

After the response has been sent (and after background tasks). The dependency wraps the whole request and response cycle.

"function"

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 is StreamingResponse or a streamed yield response 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 on FastAPI(…​)), 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 with over a library object such as httpx.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; HTTPException can be raised from the teardown.

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 scope="function" (0.121.0).

With the default scope the background task runs first and the resource is still open; with scope="function" it is closed.

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}")
Observed output (the two columns that differ are the interesting ones)
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)
/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 after yield).

  • Swallowing exceptions. An except without raise hides the error from handlers and logs and produces a bare 500. Re-raise, or raise HTTPException.

  • 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: DependencyScopeError at 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 def generator’s teardown runs on the event loop; a blocking close() there stalls every request. Use a def generator (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 Security accepts scope. Only Depends does; Security has scopes (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 HTTPException can be raised after the yield; resources yielded are no longer usable in background tasks (a breaking change).

  • 0.110.0. An exception caught with except in 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 StreamingResponse and background tasks, with corner-case fixes. HTTPException raised 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 with DependencyScopeError (verified on 0.142.2).

  • Style. Use Annotated and reusable aliases such as SessionDep, SQLModel or SQLAlchemy sessions, and async/def generators. The release history is collected on What’s Changed & Migration.