--- title: "FastAPI Best Practices" description: "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." language: python framework: fastapi category: backend_web_framework tags: - best-practices - dependency-injection - async - pydantic-v2 - lifespan - production - concurrency - middleware keywords: - fastapi 2026 version router refactor - fastapi dependency yield cleanup timing bug - fastapi backgroundtasks basehttpmiddleware streamingresponse - fastapi sync def threadpool starvation event loop blocking - pydantic v2 smart union arbitrary_types_allowed gotcha last_updated: "2026-07-08" difficulty: intermediate version: "FastAPI >= 0.139, Pydantic >= 2.13, Python >= 3.10" related: - ../django/best_practices.md - ../../testing.md - ../../../../core/architecture/clean_architecture.md - ../../../../core/testing/integration_testing.md - ../../../../core/security/secure_coding.md search_priority: high status: published --- # FastAPI Best Practices 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.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 `background_tasks.add_task(...)`, 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. 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. `arbitrary_types_allowed` 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.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 - [FastAPI Release Notes](https://fastapi.tiangolo.com/release-notes/) - [fastapi ยท PyPI (0.139.0, 2026-07-01)](https://pypi.org/project/fastapi/) - [Pydantic Changelog (2.13.0, 2026-04-13)](https://docs.pydantic.dev/latest/changelog/) - [Yield dependency not cleaning up immediately after the request - fastapi#1719](https://github.com/fastapi/fastapi/issues/1719) / [discussion #7348](https://github.com/fastapi/fastapi/discussions/7348) - [Yield dependency background-task-after-yield silently ignored - fastapi#6871](https://github.com/fastapi/fastapi/discussions/6871) - [`BaseHTTPMiddleware` breaks `BackgroundTask` on `StreamingResponse` - starlette#2801](https://github.com/Kludex/starlette/discussions/2801), [starlette#1609](https://github.com/Kludex/starlette/pull/1609) - [middleware breaks behavior of background task - fastapi#2215](https://github.com/fastapi/fastapi/issues/2215) - [Case Study: Fixing FastAPI Event Loop Blocking in a High-Traffic API](https://www.techbuddies.io/2026/01/10/case-study-fixing-fastapi-event-loop-blocking-in-a-high-traffic-api/) - [FastAPI Concurrency and async/await docs (threadpool default size)](https://fastapi.tiangolo.com/async/) - [Smart union resolves to wrong member with `extra="ignore"`/`extra="allow"` - pydantic#9095](https://github.com/pydantic/pydantic/issues/9095) - [`arbitrary_types_allowed` deprecated, no 1:1 replacement - Pydantic Config docs](https://docs.pydantic.dev/latest/api/config/)