FastAPI in production: what actually matters
Five settings you won't find in any tutorial, and that separate an API that runs from an API you can operate.
FastAPI makes the first version easy. Too easy, maybe: you have a documented API in an hour, and you think you are done. What follows is what I add systematically before a service touches real users. Nothing spectacular. Everything is there because an incident put it there.
1. One shape for every error
An API that returns sometimes {"detail": "..."}, sometimes a Python traceback, sometimes HTML, is an API the frontend cannot handle. Every error, without exception, must come out in the same shape.
class AppError(Exception):
status_code = 400
code = "app_error"
def __init__(self, message: str) -> None:
self.message = message
@app.exception_handler(AppError)
async def handle_app_error(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.message, "code": exc.code},
)Every business error inherits from AppError and carries a stable code. The client branches on the code, never on the message — the message changes, the code does not. And unexpected errors go through a last handler that logs the traceback and returns a sober 500. The traceback never leaves the server.
2. One session per request, one explicit commit
The classic async trap: a SQLAlchemy session shared between requests, or opened and never closed. The dependency that fixes it is ten lines and not up for debate.
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raiseThe commit is at the end of the request, the rollback on any exception. A service never calls commit() itself: if it did, an error occurring afterwards would leave a half-committed transaction.
And pool_pre_ping=True on the engine. Without it, the first request after a network blip or a database restart fails on a dead connection. With it, the connection is tested and replaced. It costs a round trip; it saves an incident a week.
3. Timeouts everywhere
Every outbound call has a maximum delay: database, SMTP, third-party API. An email send waiting on a silent server blocks a worker; ten of them block the API.
And the timeout is a configuration value, not a constant in the code. In production you want to lower it without redeploying.
4. A health check that tells the truth
A /health returning {"status": "ok"} without checking anything only reassures. The probe must query what the API actually depends on — at minimum the database, with a SELECT 1 — and return a 503 if it does not answer.
That is what the host looks at to decide whether to route traffic. A lying probe sends users to a dead service.
5. Configuration validated at startup
A missing environment variable must fail startup, with a message saying which one. Not an error on the first request that uses it, an hour later, for a user.
class Settings(BaseSettings):
database_url: PostgresDsn
secret_key: str = Field(min_length=32)
allowed_origins: list[str]
model_config = SettingsConfigDict(env_file=".env")Pydantic does this for free. A secret key under 32 characters, a malformed DSN, a forgotten CORS origin: all refused before the first port opens.
What is not on this list
No cache, no compression, no worker-count tuning. Those are optimisations, and optimisations come with measurements. The five points above are not optimisations: they are the conditions for being able, one day, to measure anything at all.
Read next
A payment system starts with a ledger, not an API
Why double-entry bookkeeping is not an implementation detail when you move other people's money.
A deadline is negotiated on scope, never on quality
What gets sacrificed when you agree to ship faster, and why it is never the part you think.
The form nobody fills in
What six Cameroonian showcase sites taught me about the real customer journey — and why WhatsApp replaced the cart.