Course outline

FastAPI / Advanced: lifecycle and reliable service design / Lesson 13

Lifespan and yield cleanup

Assign application and request resources to explicit lifetimes and verify cleanup timing around response streaming.

About 5 min readContent reviewed
On this page

Lesson goals

  • Explain the assumptions and boundaries of lifespan and yield cleanup.
  • Interpret both complete examples and justify their observed outputs.
  • Diagnose the listed failures and apply the worked solutions while keeping identity, state, and lifecycle ownership explicit.

Application resources need an application owner

A reusable HTTP client, model, or connection pool can have an application lifetime rather than being recreated for every request. FastAPI lifespan provides an explicit async context for startup acquisition and shutdown cleanup. The first example creates only synthetic state and records entry and exit around TestClient. Prefer the documented lifespan pattern for new code instead of mixing independent legacy startup hooks and assuming their ordering. If acquisition fails, the application must not announce readiness as though the resource exists. Cleanup should be bounded and observable, with a defined owner for every successfully acquired resource.

python Complete application-lifespan check
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
events = []
@asynccontextmanager
async def lifespan(app):
    events.append("startup")
    app.state.catalog = {"count": 2}
    try:
        yield
    finally:
        events.append("shutdown")
app = FastAPI(lifespan=lifespan)
@app.get("/catalog")
def catalog(): return app.state.catalog
with TestClient(app) as client:
    print(client.get("/catalog").json(), events.copy())
print(events)
Expected output
{'count': 2} ['startup']
['startup', 'shutdown']

Startup precedes the request and shutdown follows the client context. Only a synthetic dictionary and event list are owned.

Keep worker and process scope explicit

Application state belongs to the created application and executing process. Multiple workers do not automatically share a dictionary, lock, pool, or loaded model. Startup may therefore occur independently in each worker according to the serving model. A global in-memory cache is not a distributed store and should not be used as the only durable idempotency record. Keep configured credentials and external resources outside teaching examples. Test app construction and lifespan without touching project services, and document how a real deployment obtains, refreshes, and closes its approved resources under the selected server and platform.

A yielding dependency owns request cleanup

A dependency with yield can acquire a resource, provide it to the operation, and release it in finally. Exceptions and cancellation must still leave cleanup reachable. Its scope determines when release occurs relative to the response, and the pinned FastAPI behavior should be tested rather than inferred from an old example. The second complete app compares request-scoped cleanup with function-scoped cleanup using synthetic open flags and streaming. It performs no file, database, or network I/O. The point is resource availability during response generation, not an instruction that one scope is always the right choice.

python Complete streaming cleanup-scope check
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
async def resource():
    state = {"open": True}
    try:
        yield state
    finally:
        state["open"] = False
async def body(state):
    yield "open" if state["open"] else "closed"
app = FastAPI()
@app.get("/request-scope")
async def request_scope(state: Annotated[dict, Depends(resource)]):
    return StreamingResponse(body(state))
@app.get("/function-scope")
async def function_scope(state: Annotated[dict, Depends(resource, scope="function")]):
    return StreamingResponse(body(state))
with TestClient(app) as client:
    print(client.get("/request-scope").text)
    print(client.get("/function-scope").text)
Expected output
open
closed

Request scope leaves the synthetic resource open while streaming; function scope closes it before the stream uses it. This deliberately exposes timing and is not a real resource-access implementation.

Match lifetime to response use

If a streaming body reads from a yielded resource, closing it before the body is consumed can break the response. Conversely, keeping a scarce transaction open for a long stream may be unnecessary or harmful when the handler already copied the needed data. Function scope can release resources before sending the response under its documented contract, while request scope can preserve them through response handling. Commit timing is a separate persistence decision: do not postpone a required commit until after a success response has started. Background work should receive its own owned inputs and resources rather than capturing a request session whose lifetime has ended.

Verify teardown on success and failure

Use TestClient as a context manager to run lifespan and assert startup, usable state, and shutdown. Async ASGI tests need explicit lifespan handling when resources are required. Test exceptions during acquisition, handler execution, streaming, and cleanup with safe fixtures, and preserve the original error when cleanup also fails. Neither example starts a server or uses configured resources. The observed order is specific to the pinned teaching baseline, which must be rechecked on upgrades. Resource safety comes from a clear lifetime and tested ownership rather than merely adding yield or a finally block without understanding who still needs the value.

Common errors and fixes

A stream reads an already closed request resource

Why it happens
Cleanup scope ended before response consumption.
How to fix it
Match scope or copy required data before release.
Prevent it next time
Test body consumption and cleanup ordering.

Global state is assumed shared by all workers

Why it happens
Application memory was confused with distributed persistence.
How to fix it
Use an appropriate external durable contract when required.
Prevent it next time
Document per-process initialization and ownership.

Tests skip startup but expect initialized state

Why it happens
Client transport and lifespan were conflated.
How to fix it
Run the intended lifespan fixture explicitly.
Prevent it next time
Assert acquisition and teardown as separate outcomes.

Check your understanding

1. A handler copies all needed data before returning a stream. Must its database session stay open for the entire stream?

Show worked solution

Not necessarily. If the body no longer uses that session, earlier release may protect scarce capacity. Choose and test the correct scope under the pinned framework behavior, while completing any required transaction before reporting success.

2. Why should a background task not capture a request-owned database session?

Show worked solution

The task can outlive the request resource contract and encounter a closed or unsafe session. Pass stable identifiers and create appropriately owned resources inside the task or durable worker. Test that ownership independently from the requestโ€™s cleanup.

Key takeaways

  • A reusable HTTP client, model, or connection pool can have an application lifetime rather than being recreated for every request.
  • A dependency with yield can acquire a resource, provide it to the operation, and release it in finally.
  • Use TestClient as a context manager to run lifespan and assert startup, usable state, and shutdown.

Official references