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)
| Package | Current | Notes |
|---|---|---|
| FastAPI | 0.139.0 (2026-07-01) | Minimum Python raised to 3.10 (3.8 dropped from CI) |
| Pydantic | 2.13.0 (2026-04-13) | Ships an updated pydantic.v1 namespace at parity with 1.10.26, incl. Python 3.14 support |
| Starlette | tracks FastAPI's pin | Router 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.routesis 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). APIRoutergained.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:
- 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. - Threadpool exhaustion from too many
defendpoints - bounded to 40 concurrent, tune viaanyio'sCapacityLimiterinlifespan, or convert hot endpoints toasync defwith a real async driver.
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
| Situation | Do 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 timing | Do it explicitly in the endpoint/service; don't trust yield-dependency teardown ordering |
| Heavy/CPU-bound work | Real task queue (Taskiq, Celery, Arq) with workers, not BackgroundTasks - it shares the request process/event loop |
| Ambiguous union request bodies | Field(discriminator=...), never bare smart-union inference |
| Any endpoint doing blocking I/O with no async driver available | def, and explicitly size the threadpool via anyio.CapacityLimiter in lifespan - don't leave it at the default 40 unmeasured |
Testing
pytest+pytest-asyncio,httpx.AsyncClientfor async-endpoint tests,TestClientfor sync.app.dependency_overridesfor 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
- FastAPI Release Notes
- fastapi ยท PyPI (0.139.0, 2026-07-01)
- Pydantic Changelog (2.13.0, 2026-04-13)
- Yield dependency not cleaning up immediately after the request - fastapi#1719 / discussion #7348
- Yield dependency background-task-after-yield silently ignored - fastapi#6871
BaseHTTPMiddlewarebreaksBackgroundTaskonStreamingResponse- starlette#2801, starlette#1609- middleware breaks behavior of background task - fastapi#2215
- Case Study: Fixing FastAPI Event Loop Blocking in a High-Traffic API
- FastAPI Concurrency and async/await docs (threadpool default size)
- Smart union resolves to wrong member with
extra="ignore"/extra="allow"- pydantic#9095 arbitrarytypesalloweddeprecated, no 1:1 replacement - Pydantic Config docs