Overview
I built my first FastAPI service after five years of Flask, expecting it to be a similar experience with slightly nicer type hints. It's not. FastAPI changes what a Python web framework can do — request validation, response serialization, and API documentation all fall out of the type annotations you were going to write anyway.
This is what it looks like in practice, and where the seams are.
Flask vs FastAPI, briefly
| Flask | FastAPI | |
|---|---|---|
| Request validation | Manual or Marshmallow | Automatic via Pydantic |
| Response serialization | Manual | Automatic via return type |
| OpenAPI docs | Separate plugin | Generated automatically |
| Async support | Limited (Quart, or threaded) | Native |
| Type hints | Optional | Central to the framework |
| Dependency injection | None built-in | First-class |
The dependency injection system is the underrated feature. It's how you handle authentication, Database sessions, configuration, and anything else that needs to be present before the handler runs. In Flask you'd use decorators or context locals; in FastAPI it's a function signature.
The skeleton
pip install fastapi uvicorn[standard] pydantic
# main.py
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="My API", version="1.0.0")
class Item(BaseModel):
name: str
price: float
tags: list[str] = []
@app.get("/")
async def root():
return {"status": "ok"}
@app.post("/items/", response_model=Item)
async def create_item(item: Item):
return item
uvicorn main:app --reload
Visit http://localhost:8000/docs. You have interactive API documentation with a try-it-out form for every endpoint, generated from the code. No annotations to maintain, no drf-spectacular or swagger-ui setup.
The response_model parameter tells FastAPI what shape the response should have. The returned object gets serialized to match — extra fields are dropped, missing required fields raise an error at the framework level, and the schema appears in the docs.
Pydantic models: validation for free
from pydantic import BaseModel, Field, EmailStr, field_validator
from datetime import datetime
class UserCreate(BaseModel):
email: EmailStr
name: str = Field(min_length=1, max_length=100)
age: int | None = Field(default=None, ge=0, le=150)
@field_validator("name")
@classmethod
def name_must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("name cannot be whitespace")
return v.strip()
class UserOut(BaseModel):
id: int
email: EmailStr
name: str
created_at: datetime
model_config = {"from_attributes": True}
Every constraint on the model is enforced before your handler runs. Invalid requests get a 422 response with a detailed error message identifying which field failed and why. You don't write validation code.
model_config = {"from_attributes": True} is what lets Pydantic build a UserOut from a SQLAlchemy model or any object with matching attributes. It replaces the old orm_mode = True in Pydantic v2.
Request and response models are different
Don't use the same model for both. UserCreate has a password field; UserOut doesn't. UserOut has an ID and created_at; UserCreate doesn't. Keeping them separate prevents leaking internal fields and makes the API contract explicit.
Dependency injection
from fastapi import Depends, HTTPException, Header
from sqlalchemy.orm import Session
from typing import Annotated
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(
authorization: Annotated[str | None, Header()] = None,
db: Session = Depends(get_db),
):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
token = authorization.removeprefix("Bearer ")
user = decode_and_fetch_user(token, db)
if not user:
raise HTTPException(status_code=401, detail="Invalid token")
return user
@app.get("/me")
async def get_me(user: User = Depends(get_current_user)):
return user
Three things to notice:
Dependencies chain. get_current_user depends on get_db. FastAPI resolves the graph, calls each dependency once per request, and injects the results. You never call get_db() manually.
Dependencies with yield get cleanup. The database session is closed after the response, in a finally block, even if the handler raised. This is the same pattern as Python context managers, applied automatically.
Dependencies are reusable. Any endpoint that needs the current user adds user: User = Depends(get_current_user) and gets the full resolution chain for free.
Pydantic v2 vs SQLAlchemy 2.0
The pattern for a database-backed endpoint:
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, select
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
name: Mapped[str] = mapped_column(String(100))
@app.post("/users/", response_model=UserOut, status_code=201)
async def create_user(payload: UserCreate, db: Session = Depends(get_db)):
user = User(email=payload.email, name=payload.name)
db.add(user)
db.commit()
db.refresh(user)
return user
@app.get("/users/{user_id}", response_model=UserOut)
async def get_user(user_id: int, db: Session = Depends(get_db)):
user = db.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
The SQLAlchemy model and the Pydantic model are separate. SQLAlchemy handles the database, Pydantic handles the API contract. from_attributes = True is what bridges them — FastAPI takes the SQLAlchemy object, reads the matching attributes, and builds the Pydantic response.
Async or sync?
FastAPI supports both async def and def handlers. The rule:
| Handler | Runs on | Use when |
|---|---|---|
async def | Event loop | You're using async I/O throughout |
def | Thread pool | You're using sync libraries |
Mixing is where things get interesting. If you declare async def but call a blocking function inside, the entire event loop stalls. FastAPI can't detect this — it looks identical to a legitimate async handler.
# Broken — blocks the event loop
@app.get("/bad")
async def bad():
time.sleep(1) # every other request waits
return {"status": "ok"}
# Fine — runs in a thread pool, doesn't block the loop
@app.get("/good")
def good():
time.sleep(1)
return {"status": "ok"}
# Best — actually async
@app.get("/best")
async def best():
await asyncio.sleep(1)
return {"status": "ok"}
The rule I use: if the handler touches async libraries (asyncpg, httpx, aioredis), use async def. If it touches sync libraries (psycopg2, requests, redis-py), use def and let FastAPI run it in a thread pool. Don't mix.
The thread pool has a default size of 40. If you have 100 concurrent requests to a sync handler, 60 of them wait for a thread. Adjust with --limit-concurrency on uvicorn or configure the anyio thread limiter.
Background tasks
from fastapi import BackgroundTasks
def send_welcome_email(email: str):
# slow operation, runs after the response
...
@app.post("/users/", response_model=UserOut, status_code=201)
async def create_user(
payload: UserCreate,
background: BackgroundTasks,
db: Session = Depends(get_db),
):
user = User(email=payload.email, name=payload.name)
db.add(user)
db.commit()
db.refresh(user)
background.add_task(send_welcome_email, user.email)
return user
Background tasks run after the response is sent, in the same process. They're fine for short operations like sending an email or writing an audit log. They're not a job queue — if the process restarts, pending tasks are lost. For anything durable, use Celery, RQ, or arq.
Error handling
from fastapi import Request
from fastapi.responses import JSONResponse
class DomainError(Exception):
def __init__(self, message: str, code: str):
self.message = message
self.code = code
@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError):
return JSONResponse(
status_code=400,
content={"error": exc.code, "message": exc.message},
)
Registering a handler for a custom exception type means your handlers can raise DomainError and it gets converted to a consistent JSON response. This is how you keep HTTP concerns out of the business logic.
For validation errors, FastAPI has a built-in handler that produces detailed 422 responses. If you want to customize the format:
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"error": "validation_failed",
"details": [
{
"field": ".".join(str(x) for x in err["loc"][1:]),
"message": err["msg"],
}
for err in exc.errors()
],
},
)
Deployment
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
Or with gunicorn managing uvicorn workers:
gunicorn main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--access-logfile - \
--error-logfile -
Four workers is roughly 2 × CPU cores + 1, minus one if the workers are async. For a truly async app, 2–4 workers per CPU is enough because each handles many concurrent requests.
What I don't love
- Pydantic v2 migration is real work. The v1 → v2 transition broke a lot of code, and the ecosystem took a year to catch up. If you're starting now, no issue.
- The async/sync distinction is a footgun. There's no warning when you accidentally block the event loop. The only way to catch it is profiling or knowing the code.
- Dependency injection errors are runtime. A missing dependency fails when a request hits the endpoint, not at startup. Tests should hit every endpoint at least once to catch these.
- Less flexibility than Flask for weird cases. Flask's simplicity means you can always drop to the WSGI layer. FastAPI's abstractions are harder to bypass when you need something unusual.
When to reach for it
FastAPI is the right choice when you're building an API with a defined contract, when type safety matters, and when you want the OpenAPI docs without maintaining them separately. It's the wrong choice when you need to do something very specific that fights the framework — in that case, Flask's simplicity wins.
For new Python APIs, I start with FastAPI by default. For a quick script with a web interface, Flask is still faster to spin up. Both are fine; the choice is about the shape of the project.
