●2.1.278 — The auto mode classifier now runs server-side by default on the Claude API, Enterprise, Bedrock, Vertex and Foundry. You are not billed for the classifier, and /status gained an Auto mode server line●TASKOUT — The TaskOutput tool is gone. taskOutputMaxChars and TASK_MAX_OUTPUT_LENGTH no longer do anything, and background output is read with Read instead●10/07 — The old management-configuration key spellings are accepted until noon PT on October 7, seventeen days from now. After that, entries that still use them stop working until you rewrite them●BUNPANIC — Reports are coming in of the newest build crashing on launch alone. Earlier builds still run on the same machine, which points at the release rather than the environment●NEW — Deciding what belongs in Cowork and what belongs in Claude Code, using the approval boundary as the line●SONNET4.5 — A date in a deprecation table is a floor, not an end date. Sonnet 4.5 is still active and no deprecation notice has been posted●2.1.278 — The auto mode classifier now runs server-side by default on the Claude API, Enterprise, Bedrock, Vertex and Foundry. You are not billed for the classifier, and /status gained an Auto mode server line●TASKOUT — The TaskOutput tool is gone. taskOutputMaxChars and TASK_MAX_OUTPUT_LENGTH no longer do anything, and background output is read with Read instead●10/07 — The old management-configuration key spellings are accepted until noon PT on October 7, seventeen days from now. After that, entries that still use them stop working until you rewrite them●BUNPANIC — Reports are coming in of the newest build crashing on launch alone. Earlier builds still run on the same machine, which points at the release rather than the environment●NEW — Deciding what belongs in Cowork and what belongs in Claude Code, using the approval boundary as the line●SONNET4.5 — A date in a deprecation table is a floor, not an end date. Sonnet 4.5 is still active and no deprecation notice has been posted
Claude Code × Python FastAPI in Production — Architecture, pytest, and Docker Deployment
Building production-ready Python FastAPI servers with Claude Code as an AI pair programmer — Pydantic v2, pytest automation, Docker, and CI/CD, with working code at each step.
A week after adding Redis caching to one of my side-project APIs, the response-time graph looked exactly the same as before. No errors. Nothing unusual in the logs. The cache simply never hit once.
The cause was the cache key. The decorator built it from str(kwargs), and kwargs carried the injected database session — whose default repr contains a memory address. Every request produced a different key. Redis had become a write-only warehouse.
FastAPI is fast, type-safe, and pleasant to write async code in, and pairing it with Claude Code gets a production skeleton standing in remarkably little time. But code that looks like it works tends to settle in quiet places like that one.
What follows is the workflow for building a production FastAPI server with Claude Code, organized around implementation patterns — plus the defects this workflow tends to produce, each one run locally and measured rather than guessed at. As an indie developer shipping alone, I have found that generating faster and distrusting the output are not competing habits. They fund each other.
Written with one situation in mind: you know Python and FastAPI well enough, but stall just before production-grade architecture, testing, and deployment.
CLAUDE.md Design — Giving Your AI the Right Context
The quality of code Claude Code generates depends directly on how well it understands your project. CLAUDE.md is your technical specification document for Claude Code — the difference between getting generic boilerplate and production-quality code.
Writing an Effective CLAUDE.md
Place this file at the root of your project:
# FastAPI Production API — CLAUDE.md## Technology Stack- Python 3.12 / FastAPI 0.115+ / Uvicorn- SQLAlchemy 2.0 (async) + asyncpg (PostgreSQL)- Pydantic v2 (strict validation mode)- JWT authentication (python-jose + passlib bcrypt)- pytest-asyncio + httpx (async testing)- Docker + docker-compose## Coding Standards- All functions and classes must have full type hints- Use async/await consistently — never mix sync and async code- All endpoint response models must be defined with Pydantic schemas- Database sessions must be obtained via Depends() dependency injection- HTTPExceptions are centralized in app/core/exceptions.py## Testing Standards- Write pytest tests alongside every new feature- Unit tests: pure functions in app/core/- Integration tests: use real DB (in-memory SQLite for tests)- Coverage target: 80% minimum## Prohibited Patterns- Global mutable state- print() debugging (use logging module)- Hardcoded credentials (environment variables only)
With this context in place, Claude Code will automatically know to use async SQLAlchemy, write pytest-asyncio tests, and maintain type safety throughout — without needing to be reminded on every prompt.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Learn the exact prompting strategies to use Claude Code for accelerating FastAPI project design, coding, and testing
✦Master the full production workflow — pytest, Docker, GitHub Actions CI/CD — with working code you can use immediately
✦Discover how to combine Claude Code's multi-agent and Hooks features to automatically maintain backend API quality
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Step 1: Project Initialization — Generating the Scaffold
Start Claude Code and let it create the entire project foundation in one shot:
mkdir fastapi-app && cd fastapi-app# Place CLAUDE.md first (content above)claude
Use this prompt:
Following the CLAUDE.md specifications, generate the complete project scaffold
for a production FastAPI API.
Required files:
1. app/core/config.py — environment variable management using pydantic-settings
2. app/core/database.py — SQLAlchemy 2.0 async engine + session factory
3. app/core/security.py — JWT access token generation and verification
4. app/main.py — lifespan events, CORS middleware, health endpoint
5. requirements.txt — pinned versions
6. pyproject.toml — ruff + mypy configuration
Create all files now.
Generated Code Example
Here's the app/core/config.py Claude Code produces:
# app/core/config.pyfrom pydantic_settings import BaseSettings, SettingsConfigDictfrom functools import lru_cacheclass Settings(BaseSettings): """Application settings — auto-loaded from environment variables.""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, ) # Application app_name: str = "FastAPI Production API" app_version: str = "1.0.0" debug: bool = False # Database database_url: str = "postgresql+asyncpg://user:password@localhost/dbname" database_pool_size: int = 10 database_max_overflow: int = 20 # JWT secret_key: str # Required: use 256-bit random string in production algorithm: str = "HS256" access_token_expire_minutes: int = 30 # CORS allowed_origins: list[str] = ["http://localhost:3000"]@lru_cachedef get_settings() -> Settings: """Return singleton settings instance (for DI).""" return Settings()
The @lru_cache pattern ensures settings are loaded only once per process, which is important when using Depends(get_settings) across many request handlers. Claude Code applies these kinds of production best practices automatically when the CLAUDE.md sets clear expectations.
Step 2: Model and Schema Design — Pydantic v2 × SQLAlchemy 2.0
When designing models with Claude Code, the key is providing rich, specific requirements rather than vague descriptions:
Create app/models/user.py and app/schemas/user.py.
User model requirements:
- id: UUID (auto-generated)
- email: unique constraint, max 255 chars
- hashed_password: str
- is_active: bool (default True)
- is_superuser: bool (default False)
- created_at / updated_at: auto-managed timestamps
Three Pydantic schemas:
- UserCreate: for registration input (plain text password)
- UserRead: for API responses (password excluded)
- UserUpdate: for partial updates (all fields Optional)
Use SQLAlchemy 2.0 Mapped[T] annotation style throughout.
SQLAlchemy 2.0's Mapped[T] annotations provide full IDE autocomplete and pass mypy's strict type checking — a major quality-of-life improvement over the older column declaration style. Claude Code generates this modern syntax automatically when you specify the version in CLAUDE.md.
Step 3: API Endpoint Development
For endpoint generation, describe your requirements layer by layer:
Implement app/api/v1/users.py.
Endpoints:
- POST /users/ — new user registration (with duplicate email check)
- GET /users/me — get current authenticated user
- GET /users/{user_id} — get user details (superusers only)
- PATCH /users/{user_id} — update user information
- DELETE /users/{user_id} — soft delete (set is_active=False)
Requirements:
- All endpoints use async def
- DB operations use SQLAlchemy AsyncSession via Depends
- Authentication check via get_current_user dependency
- Return appropriate HTTPExceptions (400/401/403/404)
- All responses typed with UserRead schema
With response_model=UserRead, FastAPI automatically excludes hashed_password from the response — no manual filtering required.
Step 4: pytest Test Suite — Full Async API Testing
FastAPI + SQLAlchemy async testing requires some non-trivial setup. Claude Code handles this complexity gracefully when given the right prompt:
Create tests/conftest.py.
Requirements:
- Use in-memory SQLite (aiosqlite) as the test database
- Reset the database before each test for complete isolation
- Use pytest-asyncio with asyncio_mode = "auto"
- Test endpoints using httpx.AsyncClient
- Fixtures: normal user, superuser, JWT tokens
- Also create pytest.ini with asyncio_mode = auto
The generated tests/conftest.py:
# tests/conftest.pyimport pytestimport pytest_asynciofrom httpx import AsyncClient, ASGITransportfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_enginefrom sqlalchemy.pool import StaticPoolfrom app.main import appfrom app.core.database import Base, get_dbfrom app.core.security import get_password_hash, create_access_tokenfrom app.models.user import UserTEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"test_engine = create_async_engine( TEST_DATABASE_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool, # Required for in-memory sharing)@pytest_asyncio.fixture(autouse=True)async def setup_db(): """Create tables before each test; drop them after.""" async with test_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield async with test_engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all)@pytest_asyncio.fixtureasync def db_session(): async with AsyncSession(test_engine) as session: yield session@pytest_asyncio.fixtureasync def client(db_session: AsyncSession): """FastAPI test client with database overridden.""" app.dependency_overrides[get_db] = lambda: db_session async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac app.dependency_overrides.clear()@pytest_asyncio.fixtureasync def normal_user(db_session: AsyncSession) -> User: user = User( email="test@example.com", hashed_password=get_password_hash("testpassword123"), ) db_session.add(user) await db_session.commit() await db_session.refresh(user) return user@pytest_asyncio.fixturedef user_token(normal_user: User) -> str: return create_access_token(subject=str(normal_user.id))
The app.dependency_overrides pattern is the cornerstone of effective FastAPI testing. By swapping the production database dependency with an in-memory test fixture, tests remain completely isolated, fast, and reproducible.
Step 5: Advanced Error Handling and Middleware Architecture
Production APIs need centralized, consistent error handling. Claude Code excels at generating boilerplate-heavy but critical infrastructure code like this.
Custom Exception Hierarchy
Create app/core/exceptions.py with a custom exception hierarchy.
Requirements:
- Base AppException with status_code, detail, and error_code fields
- Subclasses: NotFoundError, RequestValidationFailure, AuthenticationError, PermissionDeniedError
- Do not reuse the name of any Python builtin or pydantic exception
- FastAPI exception handler that converts these to JSON responses
- Include request_id in every error response for tracing
Generated app/core/exceptions.py:
# app/core/exceptions.pyfrom fastapi import Requestfrom fastapi.responses import JSONResponseimport uuidclass AppException(Exception): """Base exception for all application errors.""" def __init__( self, status_code: int, detail: str, error_code: str = "INTERNAL_ERROR", ): self.status_code = status_code self.detail = detail self.error_code = error_code super().__init__(detail)class NotFoundError(AppException): def __init__(self, resource: str, resource_id: str): super().__init__( status_code=404, detail=f"{resource} with id '{resource_id}' was not found.", error_code="NOT_FOUND", )class RequestValidationFailure(AppException): # Deliberately not named ValidationError — that name belongs to pydantic def __init__(self, detail: str = "The request payload is invalid."): super().__init__(422, detail, "VALIDATION_FAILED")class AuthenticationError(AppException): def __init__(self, detail: str = "Authentication required."): super().__init__(401, detail, "AUTHENTICATION_REQUIRED")class PermissionDeniedError(AppException): # Deliberately not named PermissionError — that name is a builtin def __init__(self, detail: str = "Insufficient permissions."): super().__init__(403, detail, "PERMISSION_DENIED")# Register with FastAPI in main.pyasync def app_exception_handler(request: Request, exc: AppException) -> JSONResponse: """Convert AppException subclasses to consistent JSON error responses.""" return JSONResponse( status_code=exc.status_code, content={ "error": { "code": exc.error_code, "detail": exc.detail, # Reuse the id the logging middleware attached, so the error # response and the access log line share one identifier. "request_id": getattr(request.state, "request_id", str(uuid.uuid4())), } }, )
With this pattern, every error your API returns follows a consistent structure. Frontend clients can check error.code to handle specific error types programmatically, rather than parsing free-form error strings.
Two of those class names are chosen deliberately. The obvious names — ValidationError and PermissionError — collide with pydantic.ValidationError and with a Python builtin, and the collision is silent.
I checked this on Python 3.10.12. In a module that defines its own PermissionError, an except PermissionError around real filesystem code no longer catches the builtin:
class PermissionError(AppException): # shadows the builtin ...try: open("/tmp/ro_dir/f.txt", "w") # a genuine EACCESexcept PermissionError: print("caught")except Exception as e: print("slipped past ->", type(e).__name__, ":", e)
Output:
slipped past -> PermissionError : [Errno 13] Permission denied: '/tmp/ro_dir/f.txt'
Remove the custom class and the same except clause catches it immediately. The failure mode is that your handler misses real permission errors, and because the type name printed in logs is identical either way, nothing in your logs will point at the cause. Naming the classes in the prompt is the cheapest possible fix.
Request Logging Middleware
# app/core/middleware.pyimport timeimport uuidimport loggingfrom fastapi import Request, Responselogger = logging.getLogger(__name__)async def logging_middleware(request: Request, call_next) -> Response: """Log all requests with timing and correlation IDs.""" request_id = str(uuid.uuid4())[:8] start_time = time.perf_counter() # Attach request_id to request state for use in handlers request.state.request_id = request_id response = await call_next(request) duration_ms = (time.perf_counter() - start_time) * 1000 logger.info( "HTTP request", extra={ "request_id": request_id, "method": request.method, "path": request.url.path, "status_code": response.status_code, "duration_ms": round(duration_ms, 2), }, ) return response
Every request now produces a structured log entry with timing data. This makes debugging production issues dramatically easier because you can filter logs by request_id and see the full request lifecycle.
Step 6 (Extended): Async Background Tasks and Rate Limiting
Background Task Pattern
For operations that shouldn't block the request-response cycle — sending emails, generating reports, updating caches — use FastAPI's BackgroundTasks:
# app/api/v1/users.py (extended)from fastapi import BackgroundTasksfrom app.services.email import send_welcome_email@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)async def create_user( user_in: UserCreate, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db),) -> UserRead: """Register user and send welcome email in the background.""" # ... user creation logic ... db_user = User(email=user_in.email, hashed_password=hashed_pw) db.add(db_user) await db.commit() await db.refresh(db_user) # Enqueue welcome email — doesn't block the response background_tasks.add_task(send_welcome_email, email=db_user.email) return db_user
For more demanding workloads, ask Claude Code to generate a Celery + Redis configuration. The prompt pattern is: "Add Celery task queue support with Redis as the broker. Create a tasks.py with an example email-sending task, and show how to trigger it from a FastAPI endpoint."
Simple Rate Limiting with slowapi
Rate limiting is often overlooked until an API gets abused. Claude Code can add it quickly:
The request: Request parameter is mandatory even if the handler never touches it — slowapi locates the caller by that argument name. Dropping it fails loudly rather than silently: the decorator raises at import time with Exception: No "request" or "websocket" argument on function "<function login at ...>", so the process never starts. Verified with a 2/minute limit, the sequence was 200, 200, then 429 on the third call, with _rate_limit_exceeded_handler producing the 429.
One deployment caveat: get_remote_address reads the peer address. Behind a load balancer (Fly.io, Railway, most PaaS) every request appears to come from the same IP, so a per-IP limit turns into a global one. Either swap in a key_func that trusts X-Forwarded-For, or enforce the limit at the proxy.
With this configuration, every time Claude Code writes or edits a file, pytest runs automatically. When a test fails, Claude Code sees the output and attempts a fix — establishing a tight "generate → test → fix" loop that produces substantially better code than a single-pass generation approach.
Create a production-grade Dockerfile.
Requirements:
- Multi-stage build (builder + runtime stages)
- python:3.12-slim base image
- Run as non-root user (security best practice)
- .dockerignore to exclude unnecessary files
- Target image size under 200MB
- Health check configuration
This pipeline runs linting, type checking, and tests on every PR. Coverage must stay above 80% or deployment is blocked. It deploys automatically to Fly.io on every merge to main.
# FastAPI Code Review AgentYou are a FastAPI security and performance specialist reviewer.## Review priorities (in order)1. Security: SQL injection, auth bypass, privilege escalation risks2. N+1 queries: Loop-based SELECT statements (suggest selectinload/joinedload)3. Type safety: Gaps in Pydantic validation4. Async safety: Blocking sync calls (e.g., requests library instead of httpx)5. Error handling: Appropriate use of HTTPException## Output formatFor each issue:- Location: filename:line_number- Severity: CRITICAL / WARNING / INFO- Issue: What's wrong- Fix: Specific code example
Running this review agent as a subagent in a separate Claude Code session creates continuous parallel review — you develop in one session while the other watches for quality regressions.
Performance Optimization Patterns
Connection Pool Tuning
One of the most common production issues with FastAPI + SQLAlchemy async is connection pool exhaustion under load. Here's how to configure it correctly:
The expire_on_commit=False setting is particularly important in async contexts. Without it, accessing model attributes after a commit() triggers additional database queries, which can cause "MissingGreenlet" errors in async code.
Eager Loading to Prevent N+1 Queries
Ask Claude Code to audit your queries with this prompt: "Review all SQLAlchemy queries in the project and identify any that will cause N+1 problems when relationships are accessed. Add appropriate selectinload or joinedload options."
Example of what it will convert:
# BEFORE: N+1 problem — fetches each user's items in a separate queryusers = await db.execute(select(User))for user in users.scalars(): print(user.items) # Triggers a new SELECT per user!# AFTER: Single query with eager loadingfrom sqlalchemy.orm import selectinloadstmt = select(User).options(selectinload(User.items))result = await db.execute(stmt)users = result.scalars().all()# All items loaded in ONE additional query — no N+1
Response Caching with Redis — the Generated Version Never Hits
For read-heavy endpoints, Redis caching is the obvious next step, and this is the decorator that tends to come out of that prompt:
def cache(ttl_seconds: int = 300, key_prefix: str = ""): def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): cache_key = f"{key_prefix}:{func.__name__}:{str(kwargs)}" # problem one cached = await redis_client.get(cache_key) if cached: return json.loads(cached) result = await func(*args, **kwargs) await redis_client.setex(cache_key, ttl_seconds, json.dumps(result)) # problem two return result return wrapper return decorator
It reads correctly. Mounted on a real FastAPI app (Python 3.10.12, FastAPI 0.141.1) it fails twice over.
First, any endpoint with a response_model returns a 500:
TypeError: Object of type ItemRead is not JSON serializable
json.dumps cannot serialize a Pydantic model, so a textbook response_model=list[ItemRead] endpoint breaks on every request the moment caching is added.
Second, once you return plain dicts so serialization succeeds, the cache still never hits. Here are the keys generated by two requests to the same URL:
items:get_popular_items:{'db': <FakeSession object at 0x735ea09ee680>}
items:get_popular_items:{'db': <FakeSession object at 0x735ea086ceb0>}
kwargs carries the session injected by Depends(get_db), and the default repr of an object includes its memory address. Two requests produced two handler executions and two distinct cache entries — a 0% hit rate, with no error and no warning. The only visible symptom is Redis memory climbing.
The fix has two parts: name the arguments that belong in the key, and hand serialization to Pydantic.
# app/core/cache.pyimport jsonfrom functools import wrapsfrom pydantic import TypeAdapterdef cache(ttl_seconds: int = 300, key_prefix: str = "", model=None, cache_kwargs: tuple = ()): """Cache on the named kwargs only; serialize through Pydantic when a model is given.""" adapter = TypeAdapter(model) if model is not None else None def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): key_parts = [f"{k}={kwargs[k]!r}" for k in cache_kwargs if k in kwargs] cache_key = f"{key_prefix}:{func.__name__}:" + "&".join(key_parts) cached = await redis_client.get(cache_key) if cached is not None: return adapter.validate_json(cached) if adapter else json.loads(cached) result = await func(*args, **kwargs) payload = adapter.dump_json(result) if adapter else json.dumps(result) await redis_client.setex(cache_key, ttl_seconds, payload) return result return wrapper return decorator
Usage declares what the response actually depends on:
Measured again under the same conditions — six requests to /items/popular plus one to ?limit=5 — the handler ran twice, and exactly two keys existed: items:get_popular_items:limit=10 and items:get_popular_items:limit=5.
Naming cache_kwargs forces you to answer "what does this response actually vary on?" every time you add caching. That is the question people skip, and skipping it is how one user ends up served another user's data. If an endpoint's output depends on who is asking, the identifying argument must appear in cache_kwargs.
Pre-Deployment Production Checklist
Before deploying to Fly.io or Railway, verify these items:
Security
SECRET_KEY is a 256-bit random string (openssl rand -hex 32)
ALLOWED_ORIGINS contains only your production domain(s)
DEBUG=false in production
No known vulnerabilities in dependencies (pip audit)
Performance
Database connection pool size is appropriate for production load
Heavy operations are offloaded to BackgroundTasks or a task queue
No N+1 queries (verify with echo=True on the SQLAlchemy engine)
Observability
Structured logging is configured (structlog recommended)
/health endpoint checks database connectivity
Error tracking service (Sentry, Datadog, etc.) is configured
Deploy to Fly.io:
fly launch --name my-fastapi-app --region nrtfly secrets set SECRET_KEY="$(openssl rand -hex 32)"fly secrets set DATABASE_URL="postgresql+asyncpg://..."fly deployfly status && fly logs
Summary
From CLAUDE.md design through deployment, the workflow above holds together because each piece feeds the next.
Key takeaways:
CLAUDE.md is the foundation: Explicit technology stack, coding standards, and constraints dramatically improve code generation quality
Iterative prompting wins: Step-by-step instructions produce better results than trying to generate everything at once
Co-generate tests: Requesting endpoint and pytest tests together eliminates the "I'll write tests later" trap
Hooks create feedback loops: Auto-running tests on every file save turns Claude Code into a self-correcting developer
Claude Code is not merely a code generator — it participates in design decisions, debugging, and test writing.
It is also worth being precise about what this article measured rather than assumed:
The generated Redis cache decorator returned 500 on any response_model endpoint, and even with plain dicts produced two cache keys for two identical requests — a 0% hit rate
A custom PermissionError class silently stopped except PermissionError from catching real filesystem permission errors
Removing the request argument from a @limiter.limit handler raised at import time, not at request time
None of the three were visible by reading the code. Generation got faster; the reading did not get easier. If anything, the time saved is best spent measuring.
Start with one thing: print the cache key in your own project and hit the same URL twice. If the two keys differ, you already know what to fix.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.