Skip to content

Managing Async Resources with FastAPI Yield Dependencies

FastAPI dependencies that yield are the idiomatic way to give a request a resource and clean it up afterwards: a database session, a transaction, a lock, a temporary file. The important question is when the cleanup runs relative to the response, because it decides whether a streaming response can still use the session and whether an exception in cleanup can still change the status code. The answer has changed across FastAPI versions, so it is worth verifying on yours. On FastAPI 0.142, tested with the ASGI transport: for a normal response the order was open → handler → close; for a StreamingResponse, the dependency stayed open until after the last chunk had been produced; and with Depends(..., scope="function"), it closed before the first chunk. This guide shows how to use each behaviour deliberately.

Prerequisites

1. Write a yield dependency for a per-request resource

The code before yield sets up, the yielded value is injected, and the code after runs when the request is done with it. Use try/finally so cleanup runs on errors too:

from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession

app = FastAPI(lifespan=lifespan)          # the engine and sessionmaker live in the lifespan


async def get_session():
    async with app.state.Session() as session:      # one AsyncSession per request
        try:
            yield session
            await session.commit()                    # only if the handler did not raise
        except Exception:
            await session.rollback()
            raise


@app.post("/orders")
async def create_order(payload: OrderIn, session: AsyncSession = Depends(get_session)):
    session.add(Order(**payload.model_dump()))
    return {"ok": True}

The engine and pool are application resources and belong in the lifespan; the session is a request resource and belongs in the dependency. Creating a pool in a dependency creates one per request — a connection leak. The session-per-request pattern in detail is in SQLAlchemy asyncio session-per-request patterns.

Verify: count open database connections under load; it stays at the pool size, not one per request.

A yield dependency around a normal response A sequence of 4 messages between 3 participants. A yield dependency around a normal response FastAPI yield dependency handler run up to yield: open session call handler with session return result resume after yield: commit, close Verified on FastAPI 0.142: open, handler, close.

2. Know what happens with streaming responses

A StreamingResponse produces its body after the handler returns. If the dependency closed when the handler returned, a generator that reads from the session would find it closed mid-stream. On FastAPI 0.142 the default keeps the dependency open until the stream finishes:

from fastapi.responses import StreamingResponse


@app.get("/export")
async def export(session: AsyncSession = Depends(get_session)):
    async def rows():
        result = await session.stream(select(Order))
        async for row in result:
            yield (row.Order.to_csv() + "\n").encode()
    return StreamingResponse(rows(), media_type="text/csv")

Verified order: open → chunk 0 → chunk 1 → chunk 2 → close. The session and its connection are held for the whole download, which is what the generator needs — and also means a slow client holds a pooled connection for as long as it takes to read. For large exports, cap concurrency on this endpoint or stream from a dedicated pool, as discussed in streaming responses with Starlette and FastAPI.

Verify: a streaming endpoint that reads from the session completes without "session is closed" errors, and pool usage reflects one connection per active stream.

3. Close early with scope="function" when the stream does not need it

When a streaming response does not need the resource — the data is fetched up front, or the stream comes from elsewhere — holding the session for the whole stream wastes a connection. Declare the dependency with function scope:

@app.get("/report/{report_id}")
async def report(report_id: int, session: AsyncSession = Depends(get_session, scope="function")):
    meta = await session.get(Report, report_id)           # use the session in the handler...
    return StreamingResponse(object_store.stream(meta.key))   # ...not in the stream

Verified with FastAPI 0.142: with scope="function" the order was open → close → chunk 0 → chunk 1 — the dependency exited when the handler returned, before any of the body was sent. That frees the connection immediately, and it makes it an error for the stream to touch the session, which is the point.

Verify: under concurrent slow downloads, pool usage stays low for function-scoped endpoints.

When the dependency closes, by response type and scope A grid of 3 rows by 3 columns. When the dependency closes, by response type and scope response default scope scope="function" normal (dict, JSONResponse) after the handler after the handler StreamingResponse after the last chunk before the first chunk use the resource in the stream works fails: already closed Verified with FastAPI 0.142 through httpx's ASGI transport.

4. Handle errors in and after the handler

Exceptions raised by the handler propagate into the dependency at the yield, which is what lets the except block roll back. Two rules keep this correct:

async def get_session():
    async with app.state.Session() as session:
        try:
            yield session
        except Exception:
            await session.rollback()
            raise                              # re-raise, or FastAPI's error handling is bypassed
        else:
            await session.commit()

Always re-raise. Swallowing the exception in the dependency hides the error from FastAPI's exception handlers and can turn a failure into a misleading success response. And remember what the cleanup can and cannot affect: for a normal response, cleanup runs after the handler but the status code was decided by the handler's return value; a commit failure in cleanup surfaces as an error the client may or may not see depending on whether the response has started. For operations where the client must know the commit succeeded, commit inside the handler and return after it:

@app.post("/payments")
async def pay(req: PaymentIn, session: AsyncSession = Depends(get_session)):
    payment = Payment(**req.model_dump())
    session.add(payment)
    await session.commit()                     # failure here becomes a proper 5xx
    return {"id": payment.id}

Verify: a forced commit failure produces a 5xx response, not a 200 followed by a logged error.

5. Keep cancellation in mind

If the client disconnects, the ASGI server cancels the request task, and the dependency's cleanup runs while the task is being cancelled. Awaits in cleanup can then be cancelled themselves, leaving a session half-closed. Shield the critical part and bound it:

async def get_session():
    session = app.state.Session()
    try:
        yield session
    finally:
        try:
            async with asyncio.timeout(5):
                await asyncio.shield(session.close())    # finish closing even if cancelled
        except TimeoutError:
            log.warning("session close timed out")

asyncio.shield keeps the close running if a second cancellation arrives during cleanup; the timeout keeps a stuck close from holding the task forever. The cleanup patterns for cancelled code are in preventing CancelledError leaks in cleanup, and detecting disconnects proactively is covered in detecting client disconnects in ASGI handlers.

Verify: abort requests mid-handler in a load test; the pool never accumulates connections stuck in transactions (idle in transaction in pg_stat_activity).

Where should this resource be created? A decision on How long does the resource live with 3 outcomes. Where should this resource be created? How long does the resource live? the whole app lifespan pools, clients a request, used while streaming yield dep, default scope closes after the stream a request, not used by the stream yield dep, scope=function closes before the body Match the resource's lifetime to what actually uses it.

Verification

Yield dependencies are used correctly when:

  • Pools and clients live in the lifespan; dependencies only create per-request objects.
  • Streaming endpoints that read from the resource keep it open (default scope) and others close early (scope="function").
  • Exceptions are re-raised after rollback, and commits that must be confirmed happen in the handler.
  • Cleanup survives cancellation, with shielded, bounded closes.

Diagnostic Hook: export pool checked-out connections alongside active streaming responses per endpoint. Checked-out connections tracking the number of open downloads means default-scope dependencies are holding sessions through streams; if those streams do not need the session, switch the endpoint to scope="function". Connections in idle in transaction point at cleanup that did not finish.

Pitfalls & edge cases

  • Creating pools in dependencies. One pool per request is a connection leak.
  • Swallowing exceptions in the dependency. FastAPI's error handling never sees them.
  • Assuming cleanup timing across versions. It has changed; verify on yours with a small test like the one here.
  • Slow streaming clients holding sessions. Default scope keeps the connection for the whole download.

Frequently Asked Questions

When does a FastAPI dependency with yield run its cleanup?

On FastAPI 0.142, after the handler for normal responses, and after the last chunk for StreamingResponse by default. With Depends(..., scope="function") it runs when the handler returns, before any streamed body. Verify on your version, as this has changed over time.

Can I use a database session inside a StreamingResponse in FastAPI?

Yes with the default dependency scope on current FastAPI, which keeps the session open until the stream finishes. Be aware that each active stream then holds a pooled connection.

Where should I create the database engine in FastAPI?

In the application lifespan, once per process. Use yield dependencies only for per-request objects such as a session or a transaction.

Should a yield dependency commit the transaction?

It can commit when the handler succeeds, but if the client must know the commit succeeded, commit in the handler before returning so a failure becomes an error response.