languages/python/frameworks/fastapi/best_practices.md

FastAPI Best Practices

Version-specific FastAPI/Pydantic v2 gotchas: verified current releases, yield-dependency cleanup timing, BackgroundTasks/middleware footguns, threadpool starvation, and Pydantic v2 union-resolution traps - with sources.

Assumes fluency with Depends(), Pydantic models, and path operations. No introductory material - version-specific risk, named production footguns, and concrete decision rules only.

Version status (verified July 2026)

PackageCurrentNotes
FastAPI0.139.0 (2026-07-01)Minimum Python raised to 3.10 (3.8 dropped from CI)
Pydantic2.13.0 (2026-04-13)Ships an updated pydantic.v1 namespace at parity with 1.10.26, incl. Python 3.14 support
Starlettetracks FastAPI's pinRouter internals refactor (below) landed here first

FastAPI is still pre-1.0 (0.x). Pin an exact version in production; do not float fastapi>=0.x - minor releases have shipped router-internals-breaking changes within the last few months (below).

Router internals refactor - check before upgrading

Recent FastAPI releases changed APIRouter/APIRoute semantics: routes are no longer copied on include_router(), they're referenced. Practical effects:

  • router.routes is now explicitly an internal implementation detail - do not introspect or mutate it directly.
  • Adding routes to a router after it has been include_router()-ed now actually takes effect (previously a silent no-op because the parent held a copy).
  • APIRouter gained .matches() / .handle() for custom routing/dispatch logic.
  • app.frontend() static-file fallback now returns 404 for non-GET/HEAD methods with no match, instead of falling through.

If your app relied on the old copy-on-include behavior (e.g., building a router, including it, then mutating it expecting the parent app to be unaffected), audit it - behavior flipped.

Dependency-injection lifecycle gotchas (the ones that pass code review)

yield dependency cleanup does not run when you think it does

Cleanup code after yield in a dependency is documented to run "after the response is sent," but the well-known, still-open behavior (GitHub #1719, discussion #7348) is that under some ASGI server / connection-reuse conditions cleanup does not run immediately at request end - it can be deferred until the next request comes in. For a DB session dependency this means: don't assume session.close() / session.rollback() has already happened by the time your test or your metrics middleware checks connection-pool state right after a response. If you need deterministic cleanup timing (e.g., releasing a lock, closing a transaction before returning a value to a caller), do it explicitly inside the endpoint/service, not by relying on yield-dependency teardown ordering.

Background tasks added after yield in a dependency are silently dropped

If a yield-dependency's teardown code (the part after yield) calls backgroundtasks.addtask(...), the task is silently ignored - no error, no warning. BackgroundTasks must be populated during the "before yield" phase of the request, not the cleanup phase. This is easy to hit when refactoring "add a task at the end of a dependency" logic.

BaseHTTPMiddleware breaks BackgroundTask on StreamingResponse

This is Starlette issue #919 territory: when a StreamingResponse carrying a BackgroundTask passes through BaseHTTPMiddleware, the background task does not execute - BaseHTTPMiddleware's request/response wrapping consumes the response in a way that drops it. Two real fixes, not workarounds: (1) don't use class-based BaseHTTPMiddleware at all - write pure ASGI middleware (async def __call__(self, scope, receive, send)), which is also reported to run meaningfully faster (~40% under load) since it skips the wrapping overhead; or (2) if you must keep BaseHTTPMiddleware, attach the background task to a non-streaming Response/JSONResponse instead. Verify against your pinned Starlette version - this has been actively patched, but don't assume your version has the fix without checking the changelog.

def (sync) endpoints run in a thread pool sized 40 by default

A sync path operation is farmed out to Starlette's threadpool instead of blocking the event loop directly - correct in principle, but the default pool size (40) is a real production ceiling: under load, sync endpoints queue behind each other once 40 are in flight, and latency balloons while the event loop itself stays idle (a confusing symptom - CPU looks fine, requests just queue). Two independent failure modes to distinguish:

  1. Blocking call inside an async def (sync DB driver, requests, blocking file I/O) - this freezes the entire event loop, not just one request. Worse than threadpool exhaustion.
  2. Threadpool exhaustion from too many def endpoints - bounded to 40 concurrent, tune via anyio's CapacityLimiter in lifespan, or convert hot endpoints to async def with a real async driver.
  3. Default to async def for every endpoint; only use plain def when the body is a short, unavoidably-blocking call you've deliberately decided to threadpool-isolate.

Pydantic v2 gotcha: smart union + extra="ignore"/extra="allow" picks the wrong member

Pydantic v2's default union_mode is "smart" (not left-to-right like v1's non-smart mode). When union members are models with no required fields and extra="ignore"/extra="allow", smart mode can resolve to the wrong member - e.g. validating {"bar": "bar"} against Union[Foo, Bar] can construct Foo (silently dropping bar as an ignored extra field) instead of Bar, because both models "successfully" validate and smart mode's disambiguation heuristics don't strongly prefer the one with a matching field. If you rely on tagged/discriminated unions for API request bodies, use Field(discriminator=...) explicitly rather than trusting smart-union inference - it removes the ambiguity entirely instead of hoping the heuristic guesses right.

arbitrarytypesallowed is a v1 carryover with no 1:1 v2 replacement and is flagged for eventual removal - don't build new code that depends on it long-term; prefer a proper Pydantic-compatible type (Annotated + a validator, or a TypeAdapter) over opting an arbitrary class into validation via this flag.

Decision rules

SituationDo this
Need custom cross-cutting logic (auth, logging, tracing)Pure ASGI middleware, not BaseHTTPMiddleware, if it ever touches streaming responses or background tasks
Need deterministic resource cleanup timingDo it explicitly in the endpoint/service; don't trust yield-dependency teardown ordering
Heavy/CPU-bound workReal task queue (Taskiq, Celery, Arq) with workers, not BackgroundTasks - it shares the request process/event loop
Ambiguous union request bodiesField(discriminator=...), never bare smart-union inference
Any endpoint doing blocking I/O with no async driver availabledef, and explicitly size the threadpool via anyio.CapacityLimiter in lifespan - don't leave it at the default 40 unmeasured

Testing

  • pytest + pytest-asyncio, httpx.AsyncClient for async-endpoint tests, TestClient for sync.
  • app.dependency_overrides for swapping real infra (DB, external services) with fakes - this is FastAPI's actual DI testability payoff, use it rather than mocking imports.
  • Add a regression test for any yield-dependency you depend on for cleanup ordering - assert the side effect (e.g., row unlocked, connection returned to pool) rather than assuming teardown timing.

Sources