Search/Have AI Write FastAPI Services and Catch Common Mistakes in Review

Have AI Write FastAPI Services and Catch Common Mistakes in Review

Use when an AI agent builds or reviews a FastAPI service. Sets minimum versions, when to use async or sync routes, how to write Pydantic models and dependencies, database and test habits, and gives a table of common mistakes to flag in a diff with their fixes.

To use this AI rule
## Set Minimum Versions for Python, FastAPI, and Libraries Pin every dependency to the version below or newer; all guidance here assumes these baselines. An older version silently breaks the idioms in later sections — async APIs, `Annotated` dependencies, and v2 serialization. | Dependency | Minimum | Why this floor | |---|---|---| | Python | 3.11 | `StrEnum`, and the `X \| Y` union operator | | FastAPI | 0.115 | `Annotated[T, Depends(...)]` is the idiomatic form | | Pydantic | 2.7 | v1 APIs (`json_encoders`, `.dict()`) are removed | | pydantic-settings | 2.4 | A separate package since Pydantic v2 | | SQLAlchemy | 2.0 | The async API (`AsyncSession`, `async_sessionmaker`) | | Alembic | 1.13 | Async-aware migrations | | httpx | 0.27 | `ASGITransport` for in-process tests | | PyJWT | 2.9 | Maintained; replaces `python-jose` | | ruff | 0.6 | One tool for lint, format, and import sorting | ## Split the Project into Folders by Domain, Not File Type Organize the tree by domain (bounded context), not by file type. Give each domain its own package and keep a thin global layer for shared concerns. - Per-domain package: `router.py` (endpoints), `schemas.py` (Pydantic models), `models.py` (ORM models), `service.py` (business logic), `dependencies.py`, `config.py` (domain-scoped settings), `constants.py`, `exceptions.py`, `utils.py`. - Global top level: `config.py` (global settings), `models.py` (shared bases), `exceptions.py`, `database.py` (async engine plus session factory), `main.py` (app plus lifespan). - Cross-domain imports: always import the explicit module and alias it (for example, import a domain's `service` as `auth_service`). Never use a wildcard import, and never reach through a deep internal path — both couple domains and block refactoring. ## Choose async def or def Routes So the App Does Not Freeze Choose the route style from the work it does; the wrong choice either freezes the event loop or wastes threads. | Route does this | Use | |---|---| | Awaitable non-blocking I/O | `async def` | | Blocking I/O, no async client exists | `def` (sync, runs in the threadpool) | | A mix of both | `async def` plus `run_in_threadpool` for the blocking call | | CPU-bound work over ~50 ms | Offload to a worker process (Celery / Arq / RQ) | Never put a blocking call — `time.sleep`, `requests`, a sync DB driver, or plain `open()` — inside `async def`; it freezes every request on that worker. A sync `def` route blocks only one threadpool worker, so it is the safe home for unavoidable blocking calls. Inside `async def`, use awaitable equivalents (`asyncio.sleep`, `aiofiles`, an async driver) or wrap a sync library with `run_in_threadpool`. Caveats: the default threadpool holds about 40 threads, so saturating it slows every sync route, and threads cost more than coroutines — do not pick sync routes without a blocking reason. ## Validate Input with Pydantic and Split Settings by Domain - Prefer built-in validators and types over hand-rolled checks: `Field(min_length=, max_length=, pattern=, ge=)`, `EmailStr`, `AnyUrl`, and `StrEnum` for closed sets. - Do not pair a constraint with a contradicting default. `Field(ge=18, default=None)` is a bug: decide required (`Field(ge=18)`) or optional (`int | None = Field(default=None, ge=18)`). - Use modern serialization. `json_encoders` is deprecated in Pydantic v2 — replace it with `@field_serializer` for per-field rules, or annotate the type with `PlainSerializer`. - Split settings by domain. `pydantic-settings` is its own package; give each domain one `BaseSettings` subclass with its own `env_prefix` instead of one global settings object that every domain reads. ## Write Dependencies with Annotated and Let Them Check Data - Inject with `Annotated[T, Depends(fn)]`, the idiomatic form since FastAPI 0.95. Avoid the default-argument form (`param: T = Depends(fn)`); it is legacy and carries default-value gotchas. - Make dependencies validate, not just inject. A dependency can load a resource by id, raise a domain 404 when it is missing, and return the validated object so the route receives something already checked. - Chain dependencies to reuse logic. One dependency can depend on others — for example, an ownership check that depends on a load-by-id dependency plus a parsed-token dependency. - Rules: dependencies are cached per request, so the same `Depends(x)` used several times in one request runs once. Prefer `async def` dependencies; sync ones run in the threadpool, wasted overhead for small CPU-only checks. Reuse a dependency across endpoints by giving the shared path variable the same name. ## Handle JWT Auth and Async Database Access - JWT: use `PyJWT` (`import jwt`), not the unmaintained `python-jose`. Decode inside a guard on `InvalidTokenError` and raise a domain credentials error rather than letting the raw exception escape. - Sessions: prefer the SQLAlchemy 2.0 async API (`create_async_engine`, `async_sessionmaker`, `AsyncSession`) and hand sessions to routes through a `get_db` async-generator dependency. `encode/databases` is in maintenance mode — do not choose it for new projects. - Naming: use `lower_case_snake`; singular table names (`post`, `user`, `post_like`); group related tables with a shared prefix (`payment_account`, `payment_bill`); suffix `_at` for datetimes and `_date` for dates; and reuse the same foreign-key column name everywhere it appears. - Set one project-wide index and constraint naming convention on the `MetaData` so generated names stay predictable. - SQL-first, Pydantic-second: do joins, aggregation, and JSON shaping in SQL, and hydrate into Pydantic only for response validation, not for transformation. ## Choose Between Background Tasks and a Task Queue Like Celery Match the tool to the task's cost and reliability needs. | Use in-process background tasks when | Use a real queue (Celery / Arq / RQ) when | |---|---| | The task finishes in under a second | It runs for seconds to minutes | | Losing it silently is acceptable | You need retries, dead-letter, or visibility | | It is in-process (send an email, log a row) | It is CPU-heavy or needs its own pool | | No scheduling is required | You need cron, ETA, or rate limiting | In-process background tasks run after the response is sent, in the same worker process. If that worker dies the task is lost and there is no retry, so never use them for anything you would page an on-call engineer about. ## Test with httpx and a Real Database - Use an async HTTP client from day one: drive the app in-process with `httpx.AsyncClient` over `ASGITransport(app=app)`. Do not use `async_asgi_testclient` — it is unmaintained. - Override dependencies with the framework's built-in `app.dependency_overrides[dep] = fake` instead of monkeypatching internals, and clear the overrides after each test. - Use a real database for integration tests (an ephemeral schema or a throwaway container) and override only auth and external services. Mocking the database hides mock-versus-production divergence that eventually fires in production. ## Keep Migrations Reversible and Document Every API Endpoint - Migrations: keep every migration static and reversible, generate them from the async template, and use descriptive timestamped filenames (a date prefix plus a slug) so history reads in order. - API docs: hide the interactive docs outside chosen environments by setting the OpenAPI URL to `None` when the current environment is not in an allowlist. Document each endpoint fully — response model, status code, summary, description, tags, and one response entry per error status. - Linting: run the formatter and linter (for example, `ruff check --fix` then `ruff format`) and wire them into a pre-commit hook or CI. One modern tool replaces the separate formatter, import-sorter, and dead-code tools. ## Spot and Fix Common AI Mistakes in a FastAPI Diff When reviewing a diff, flag these agent mistakes; each row is a real failure mode with its fix. | Anti-pattern | Why it is wrong | Fix | |---|---|---| | `requests.get(...)` inside `async def` | Blocks the event loop | `httpx.AsyncClient`, or `await run_in_threadpool(...)` | | `time.sleep` / `open()` / sync DB driver in `async def` | Blocks the loop | Async equivalent (`asyncio.sleep`, `aiofiles`, async driver) | | `from jose import jwt` | Unmaintained library | `import jwt` (PyJWT) | | `async_asgi_testclient` | Unmaintained | `httpx.AsyncClient` plus `ASGITransport` | | `ConfigDict(json_encoders=...)` | Deprecated in Pydantic v2 | `@field_serializer` or `PlainSerializer` | | `Field(ge=18, default=None)` | Constraint contradicts the default | Pick required or optional, not both | | Default-argument `Depends(...)` | Legacy default-value gotchas | `Annotated[T, Depends(...)]` | | Catching bare `Exception` around a route body | Hides bugs, turns 500s into silent 200s | Catch the specific class, raise an explicit HTTP error | | Background tasks for pageable work | No retry, dies with the worker | Celery / Arq / RQ | | Sync ORM session inside `async def` | Blocks the loop, can deadlock the pool | `AsyncSession` | | Returning a model while also setting `response_model` to it | The model is built twice | Return a dict or row, or drop `response_model` | | Deep cross-domain imports | Tight coupling | Import the domain module and alias it | | One `BaseSettings` for the whole app | Every domain reads every variable | One `BaseSettings` per domain | | Mocking the database in integration tests | Mock-versus-production divergence | A real DB plus dependency overrides for auth and external services |