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.
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.
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)
{'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.
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)
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.