Skip to main content
Advertisement

Production Tips

Project structure, exception handling, logging, and OpenAPI customization patterns used in real-world FastAPI projects.


Project Structure

project/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app entry point
│ ├── config.py # Environment settings (Pydantic Settings)
│ ├── dependencies.py # Common dependencies (DB session, current user)
│ ├── exceptions.py # Custom exceptions
│ ├── models/ # SQLAlchemy ORM models
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── item.py
│ ├── schemas/ # Pydantic request/response schemas
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── item.py
│ ├── crud/ # DB access layer
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── item.py
│ ├── routers/ # APIRouter collection
│ │ ├── __init__.py
│ │ ├── users.py
│ │ └── items.py
│ └── utils/ # Utility functions
│ └── security.py
├── tests/
│ ├── conftest.py
│ ├── test_users.py
│ └── test_items.py
├── alembic/ # DB migrations
├── pyproject.toml
└── .env

Environment Settings (Pydantic Settings)

# config.py
from pydantic_settings import BaseSettings
from functools import lru_cache


class Settings(BaseSettings):
app_name: str = "My API"
debug: bool = False
version: str = "1.0.0"

database_url: str = "sqlite+aiosqlite:///./app.db"

secret_key: str
algorithm: str = "HS256"
access_token_expire_minutes: int = 30

redis_url: str = "redis://localhost:6379"

model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
}


@lru_cache
def get_settings() -> Settings:
return Settings()


settings = get_settings()
print(settings.database_url)
# .env file
SECRET_KEY=your-super-secret-key-change-me
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/mydb
DEBUG=false

Global Exception Handling

# exceptions.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
import logging

logger = logging.getLogger(__name__)


class AppException(Exception):
def __init__(self, status_code: int, detail: str, code: str = "ERROR"):
self.status_code = status_code
self.detail = detail
self.code = code


class NotFoundError(AppException):
def __init__(self, resource: str, resource_id: int | str):
super().__init__(
status_code=404,
detail=f"{resource} {resource_id} not found",
code="NOT_FOUND",
)


class ConflictError(AppException):
def __init__(self, detail: str):
super().__init__(status_code=409, detail=detail, code="CONFLICT")


def setup_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
logger.warning(f"AppException: {exc.code} - {exc.detail}")
return JSONResponse(
status_code=exc.status_code,
content={"code": exc.code, "detail": exc.detail},
)

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = [
{
"field": ".".join(str(loc) for loc in error["loc"][1:]),
"message": error["msg"],
}
for error in exc.errors()
]
return JSONResponse(
status_code=422,
content={"code": "VALIDATION_ERROR", "errors": errors},
)

@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
logger.error(f"Unexpected error: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"code": "INTERNAL_ERROR", "detail": "An internal server error occurred"},
)

Structured Logging

# logging_config.py
import logging
import json
from datetime import datetime


class JSONFormatter(logging.Formatter):
"""JSON log format (for log aggregation systems)"""

def format(self, record: logging.LogRecord) -> str:
log_data = {
"timestamp": datetime.utcnow().isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info:
log_data["exception"] = self.formatException(record.exc_info)
return json.dumps(log_data)


def setup_logging(debug: bool = False):
level = logging.DEBUG if debug else logging.INFO

handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())

logging.basicConfig(level=level, handlers=[handler])

# Reduce noise from external libraries
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)

OpenAPI Customization

from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi


def custom_openapi(app: FastAPI):
if app.openapi_schema:
return app.openapi_schema

schema = get_openapi(
title="My Production API",
version="2.0.0",
description="""
## Authentication
All protected endpoints require `Authorization: Bearer <token>` header.

## Error Codes
- `NOT_FOUND`: Resource not found
- `CONFLICT`: Duplicate data
- `VALIDATION_ERROR`: Input validation failed
""",
routes=app.routes,
tags=[
{"name": "users", "description": "User management"},
{"name": "items", "description": "Item management"},
{"name": "auth", "description": "Authentication"},
],
)

# Add JWT security scheme
schema["components"]["securitySchemes"] = {
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
}
}

app.openapi_schema = schema
return schema


app = FastAPI(docs_url="/docs", redoc_url="/redoc")
app.openapi = lambda: custom_openapi(app)

Final main.py Assembly

from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import get_settings
from exceptions import setup_exception_handlers
from logging_config import setup_logging
from database import create_tables
from routers import users, items, auth

settings = get_settings()
setup_logging(debug=settings.debug)


@asynccontextmanager
async def lifespan(app: FastAPI):
await create_tables()
yield


app = FastAPI(
title=settings.app_name,
version=settings.version,
lifespan=lifespan,
docs_url="/docs" if settings.debug else None, # hide docs in production
)

app.add_middleware(
CORSMiddleware,
allow_origins=["https://myapp.com"],
allow_methods=["*"],
allow_headers=["*"],
)

setup_exception_handlers(app)

app.include_router(auth.router, prefix="/auth", tags=["auth"])
app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(items.router, prefix="/items", tags=["items"])


@app.get("/health")
def health_check():
return {"status": "ok", "version": settings.version}

Summary

PatternDescription
Layered structurerouters → crud → models
Pydantic Settings.env-based environment config
Global exception handlersConsistent error response format
JSON loggingIntegration with log aggregation
OpenAPI customizationBetter docs, security schema
lifespanApp startup/shutdown event management

In production, separation of concerns (routers, CRUD, schemas, models) and per-environment configuration are the keys to maintainability.

Advertisement