Instruction file imported from thite-amol/copilot-feature-deep-dive (
.github/instructions/fastapi.instructions.md). Copyright stays with the author.
FastAPI Best Practices
Project Structure
Organize code by domain, not by file type. Use an api/ layer to aggregate domain routers by version.
src/
├── api/ # API versioning layer — routing only, no logic
│ └── v1/
│ ├── __init__.py
│ └── router.py # Aggregates all v1 domain routers
├── {domain}/ # e.g., auth/, movie/, history/
│ ├── __init__.py
│ ├── router.py # API endpoints (thin — delegates to service)
│ ├── schemas.py # Pydantic request/response models
│ ├── models.py # SQLAlchemy Table models
│ ├── service.py # Business logic
│ ├── repository.py # Persistence — all SQL/ORM queries live here
│ ├── dependencies.py # Route dependencies
│ ├── config.py # Domain-specific settings (BaseSettings)
│ ├── constants.py # Constants and error codes
│ ├── exceptions.py # Domain-specific exceptions
│ └── utils.py # Helper functions
├── middleware/ # Cross-cutting middleware (timing, rate-limit, logging)
├── schemas.py # Shared base model: CustomModel(BaseModel)
├── config.py # Global configuration
├── exceptions.py # Global exceptions & global exception handler
├── database.py # Async database connection
└── main.py # FastAPI app initialization — mounts version routers
API versioning layout
- Each version lives in
src/api/v{N}/router.pyand creates a singleAPIRouter(prefix="/v{N}"). - The version router only aggregates domain routers — no endpoint definitions, no logic.
main.pyimports and mounts each version router:app.include_router(v1_router).- Domain folders (
src/auth/,src/movie/) stay at thesrc/level — they are not nested underapi/. - To add a new domain to v1, add one
include_routerline insrc/api/v1/router.py. - To introduce v2, create
src/api/v2/router.pyand mount it inmain.pyalongside v1.
# src/api/v1/router.py
from fastapi import APIRouter
from src.movie.router import router as movie_router
from src.auth.router import router as auth_router
v1_router = APIRouter(prefix="/v1")
v1_router.include_router(auth_router)
v1_router.include_router(movie_router)
- Use explicit module names when importing across domains:
from src.auth import constants as auth_constants - Every sub-directory that contains Python modules must have an
__init__.pyfile.
System-level endpoints
System/operational endpoints (health, readiness, liveness, metrics) are not domains. Mount them directly on app in src/main.py:
# src/main.py
@app.get("/health", response_model=HealthResponse, status_code=200, tags=["health"])
async def health_check() -> HealthResponse:
"""Return the current health status of the service."""
return HealthResponse(status="ok")
- Do not create a
src/health/domain folder for a health endpoint. - Shared response schemas for system endpoints (e.g.
HealthResponse) live insrc/schemas.pyalongsideCustomModel.
Pydantic
- Use built-in validators and
Fieldconstraints:min_length,max_length,pattern,ge,EmailStr, etc. - Create a shared
CustomModel(BaseModel)withmodel_config = ConfigDict(populate_by_name=True)for consistent serialization across the project. - Split
BaseSettingsby domain — never create a single monolithic settings class. Each domain owns its config in{domain}/config.py. ValueErrorraised inside afield_validatorbecomes a 422 Validation Error response — use this deliberately.- Extract shared fields into a private
_RequestBaseclass to avoid duplication; validators defined on the base are inherited automatically. - Prefix internal base classes with
_so they don't appear in OpenAPI schemas. - Override fields in subclasses only when constraints differ (e.g., required vs optional).
- In dependencies, type-hint with the base class instead of a union of subclasses.
- Field descriptions (
Field(description=...)): one concise sentence, max ~80 characters. Do not repeat constraints already visible in the schema. - Use
json_schema_extra={"example": ...}onFieldfor per-field examples. - Use
model_config = ConfigDict(json_schema_extra={"examples": [...]})on the class for full-model examples. - For Pydantic models, do not add an
Attributes:section mirroringFielddefinitions —Field(description=...)is the single source of truth. For regular (non-Pydantic) classes, use a Google-styleAttributes:section for public, non-property instance attributes. - Keep
field_validatordocstrings to one line — they are internal helpers, not public API.
Dependencies
- Use dependencies for validation and authorization logic, not just DI:
async def valid_post_id(post_id: UUID4) -> dict: post = await service.get_by_id(post_id) if not post: raise PostNotFound() return post - Chain dependencies to build layered authorization logic.
- Dependencies are cached per request — the same dependency called multiple times within one request executes only once.
- Prefer
asyncdependencies to avoid threadpool overhead for lightweight operations. - Cache singleton services with
functools.lru_cache(maxsize=1)or a module-level instance so expensive clients are not re-created on every request. - Use consistent path variable names across routes to enable dependency reuse.
REST Conventions
- Use consistent path variable names for dependency reuse (e.g.,
profile_idin bothGET /profiles/{profile_id}andGET /creators/{profile_id}).
API Versioning
- Prefix routers with a version:
router = APIRouter(prefix="/v1"). - Only introduce breaking changes in a new version.
- Mark deprecated versions explicitly and define a sunset timeline.
- Expose available versions from the root endpoint.
Middleware
- Add an
X-Process-Timeheader via timing middleware for observability. - Use rate-limiting middleware to protect endpoints from burst traffic; return 429 with a
Retry-Afterheader. - Make rate-limit configuration available via
BaseSettings(e.g.,RATE_LIMIT,RATE_WINDOW_SECONDS). - Log rate-limit events at
WARNINGlevel. - Keep middleware lightweight — no heavy allocations or blocking calls.
Error Handling
- Register a global exception handler that returns a sanitized 500 response and logs the full traceback server-side:
@app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: """Handle uncaught exceptions with a sanitized 500 response. Args: request: Incoming HTTP request. exc: Unhandled exception. Returns: Generic internal server error response. """ logger.error(f"Unhandled error: {exc}", exc_info=True) return JSONResponse(status_code=500, content={"detail": "Internal server error"}) - Define domain-specific exception classes extending
HTTPException:class PostNotFound(HTTPException): """Post with the given ID does not exist.""" def __init__(self): """Initialize with HTTP 404 status.""" super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found") - Never expose internal URLs, file paths, or raw exception messages in HTTP responses.
- Specific exception handlers take priority over the global handler — register them alongside it.
Health Check
- Expose an unauthenticated
GET /healthendpoint taggedInfrastructurefor load balancers and monitoring. - Optionally probe downstream dependencies and return
"degraded"if any are unreachable.
Security
- Inject auth as a router-level dependency so all routes under the router are protected by default.
- Validate tokens with timing-safe comparison (
secrets.compare_digest). - Hash secrets before storing (SHA-256 minimum).
- Allowlist external URLs/hosts to prevent SSRF.
- Use SQLAlchemy parameterised queries to prevent SQL injection — never build raw SQL with string concatenation.
- Configure retry policies with exponential backoff and timeouts for external service calls.
Database Naming Conventions
- Use
lower_case_snakeformat. - Singular table names:
post,user,post_like. - Group related tables with a shared prefix:
payment_account,payment_bill. - DateTime columns:
_atsuffix (e.g.,created_at). Date columns:_datesuffix (e.g.,birth_date). - Set explicit index names using SQLAlchemy naming conventions.
- Prefer database-level operations for complex joins, aggregation, and nested JSON responses.
API Documentation
- Hide
/docsand/redocin production by settingopenapi_url=None. - Router decorators should be minimal — FastAPI auto-generates most docs from Pydantic schemas:
- Use
summary=for the short sidebar label. - Write a Google-style docstring: summary line, then optional 2–3 sentence detail. See
docstring.instructions.md. - Only include non-200 responses in
responses=— the 200 response is auto-generated fromresponse_model. - Never pass
description=to the decorator. - Never include a
200key inresponses=.
- Use
- Put full response examples on the Pydantic model, not in the router's
responsesdict.
Migrations (Alembic)
- Keep migrations static and reversible.
- Use descriptive file names:
2022-08-24_post_content_idx.py. - Configure
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)sinalembic.ini.
Linting
All linting and formatting runs automatically via pre-commit run --all-files (the authoritative command).
For quick ad-hoc feedback during development:
ruff check --fix src tests
ruff format src tests
All ruff and mypy configuration lives in pyproject.toml. See [tool.ruff] and [tool.mypy] there — do not duplicate settings in separate config files.