FastAPI is one of the easiest frameworks to start with, and one of the easiest to make a mess of. Every tutorial shows you a single main.py with three routes and a @app.get("/") that returns {"hello": "world"}. That is great right up until you are six months in with auth, a database, background jobs, and a main.py that has quietly grown to eight hundred lines.
I have shipped a few FastAPI backends now, and most of what keeps them alive in production has nothing to do with the fancy async syntax. It is the boring structure around the routes. Here is how I lay a project out, how I handle auth, and the mistakes I made so you can skip them.
Structure that scales
The single-file app is a trap. It feels productive for a week, then every change means scrolling through one giant file. I split by responsibility instead:
app/
main.py # create the app, wire routers in
core/
config.py # settings
security.py # hashing, tokens
api/
deps.py # shared dependencies
routes/
auth.py
users.py
items.py
db/
session.py # engine + session factory
models.py # SQLAlchemy models
schemas/
user.py # Pydantic request/response models
services/
user_service.py # business logic, no FastAPI importsmain.py stays tiny. It builds the app and includes routers, nothing else:
from fastapi import FastAPI
from app.api.routes import auth, users, items
app = FastAPI(title="My API")
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"])The rule I hold to: routes handle HTTP, services handle logic. A route reads the request, calls a service, returns a response. When the business logic lives in services/ with no FastAPI imports, it is easy to test and easy to move. My mistake early on was stuffing real logic into the route functions, so the only way to test anything was to spin up the whole app and hit it.
Config belongs in one place
Reading os.environ["DATABASE_URL"] in five different files is how you end up with a service that crashes in production because one env var was misspelled and nothing validated it. Pydantic settings solves this: declare your config once, typed, and it is read and validated at startup.
# app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str
jwt_secret: str
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60
settings = Settings()Now from app.core.config import settings gives you typed, validated config everywhere, and a missing jwt_secret fails loudly on boot instead of silently at 2am.
Dependencies are the best part of FastAPI
FastAPI's dependency injection is the feature I would miss most if I switched frameworks. Anything a route needs (a database session, the current user, pagination params) becomes a small function you declare with Depends, and FastAPI wires it in.
Start with a database session that always closes itself:
# app/db/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)# app/api/deps.py
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from app.db.session import SessionLocal
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
DbSession = Annotated[Session, Depends(get_db)]That Annotated alias is worth adopting. Instead of repeating db: Session = Depends(get_db) in every route, you write db: DbSession. It reads better and there is one place to change.
Auth done right
Auth is where I see the most shortcuts, and they are the ones that hurt. Two rules: never store a raw password, and never parse the token by hand in every route.
Hash on the way in, sign a token on login:
# app/core/security.py
from datetime import datetime, timedelta, timezone
import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(raw: str) -> str:
return pwd_context.hash(raw)
def verify_password(raw: str, hashed: str) -> bool:
return pwd_context.verify(raw, hashed)
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
return jwt.encode(
{"sub": subject, "exp": expire},
settings.jwt_secret,
algorithm=settings.jwt_algorithm,
)Then the important part: turn "who is the logged-in user" into a single dependency, so protecting a route is one type annotation.
# app/api/deps.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
import jwt
from app.core.config import settings
from app.db.models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)], db: DbSession
) -> User:
creds_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
)
user_id = payload.get("sub")
except jwt.PyJWTError:
raise creds_error
user = db.get(User, user_id)
if user is None:
raise creds_error
return user
CurrentUser = Annotated[User, Depends(get_current_user)]Now any protected route is trivial, and there is exactly one place that knows how to validate a token:
@router.get("/me", response_model=UserOut)
def read_me(user: CurrentUser):
return userMy mistake here was pasting token-decoding logic into route after route before I understood dependencies. When I later needed to change how tokens worked, I had to touch a dozen files.
Never leak your database models
FastAPI will happily serialize your ORM object straight to JSON, including the password_hash column you forgot was on it. Always return through a Pydantic response model that lists only what the client should see:
# app/schemas/user.py
from pydantic import BaseModel, EmailStr
class UserOut(BaseModel):
id: int
email: EmailStr
# no password_hash, no internal flags
model_config = {"from_attributes": True}With response_model=UserOut on the route, FastAPI filters the output to those fields no matter what the ORM object holds. This is a security boundary, not just tidiness. Returning raw models is one of the easiest ways to leak data without noticing.
The async trap
This one bites almost everyone. In an async def route, any blocking call (a synchronous database driver, a requests call, a heavy pandas operation) blocks the entire event loop, which means it blocks every other request the process is handling.
# BAD: blocks the event loop for everyone
@router.get("/report")
async def report():
return build_report_with_pandas() # synchronous, slow
# GOOD: a plain `def` route runs in a threadpool
@router.get("/report")
def report():
return build_report_with_pandas()The counterintuitive fix: if your work is synchronous and blocking, make the route a normal def, not async def. FastAPI runs sync routes in a worker thread so they do not stall the loop. Only reach for async def when you are actually awaiting async libraries. Mixing a synchronous ORM into async routes was my slowest-to-diagnose performance bug.
Errors should look the same everywhere
Clients should get a consistent error shape, and you should never leak a stack trace. Define your own error and one handler:
from fastapi import Request
from fastapi.responses import JSONResponse
class AppError(Exception):
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
@app.exception_handler(AppError)
async def handle_app_error(request: Request, exc: AppError):
return JSONResponse(status_code=exc.status_code, content={"error": exc.message})Now services raise AppError(404, "Item not found") and every error the client sees has the same {"error": "..."} shape. Add logging in that handler and you also get one place that records every failure.
Slow work doesn't belong in the request
If a request sends an email, resizes an image, or calls a third-party API, the user should not wait for it. For light, fire-and-forget work, BackgroundTasks runs it after the response is sent:
@router.post("/signup")
def signup(payload: SignupIn, tasks: BackgroundTasks, db: DbSession):
user = create_user(db, payload)
tasks.add_task(send_welcome_email, user.email)
return {"ok": True}Just know its limits: BackgroundTasks runs in the same process and does not retry. The moment work is heavy, needs retries, or must survive a restart, move it to a real queue (arq, Celery, or RQ). Trying to run a long job in the request was the mistake that made an endpoint time out under load.
Shipping it
A few things that matter once it is actually deployed:
- Run it with multiple workers, not the dev server.
gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000. - Never run with
--reloadin production. It is a development convenience and it costs performance. - Use real migrations (Alembic). Do not let SQLAlchemy create tables on startup in prod.
- Put it behind a reverse proxy (Nginx, Caddy) and terminate TLS there.
- Every secret comes from the environment, never the repo.
FastAPI gives you a running API in an afternoon. Keeping it fast, safe, and maintainable in production is all the boring stuff around the routes, and that boring stuff is exactly what separates a demo from something you can actually run.
Cover photo by Christina @ wocintechchat.com M on Unsplash

