
Introduction
Skip this if you already know Flask well — FastAPI borrows most of its ideas. Stay if you want to see what Python APIs look like when the framework actually uses type hints.
The short version: you annotate a function parameter as int and FastAPI rejects non-integer input, documents the parameter in Swagger, and returns a proper 422 error. All before your code runs. The interesting parts are dependency injection and async, which is where FastAPI actually diverges from Flask instead of just being Flask-with-types.
Why FastAPI Over Flask or Django
Performance is the obvious answer but rarely the real reason to switch. FastAPI sits on Starlette and Pydantic, and handles async natively. Fine.
The actual draw is less code. In Flask, you validate query parameters manually. Forget one and you find out in production. FastAPI reads your type annotations and enforces them. It generates Swagger UI at /docs and ReDoc at /redoc with zero config. Docs stay in sync because they come from your code.
Django is unbeatable when you need an admin panel, an ORM, and built-in auth all in one box. Flask is still great for small synchronous apps where you do not want the ceremony. But if you are writing a new API in Python 3.10+ and you do not pick FastAPI, I think you are making the wrong call. The type hint integration alone saves hours of debugging that you would spend on manual validation in Flask.
Your First FastAPI Application
Install FastAPI with its standard extras (Python 3.10+):
pip install "fastapi[standard]"The standard extra pulls in Uvicorn, the fastapi CLI, python-multipart (needed for the login form later) and email-validator (needed for EmailStr). Keep the quotes: unquoted square brackets fail in zsh.
Create main.py:
from fastapi import FastAPI
app = FastAPI(
title="My Awesome API",
description="A production-ready API built with FastAPI",
version="1.0.0",
)
# A simple health check endpoint
@app.get("/")
def root():
return {"status": "healthy", "message": "Welcome to the API"}
@app.get("/items")
def list_items():
return [
{"id": 1, "name": "Laptop", "price": 999.99},
{"id": 2, "name": "Keyboard", "price": 79.99},
]Run the server with Uvicorn:
uvicorn main:app --reload
Hit http://localhost:8000/docs for Swagger UI. That is it. One file, one command, interactive documentation. (fastapi dev main.py does the same thing with reload enabled.)
Path Parameters, Query Parameters and Request Bodies
FastAPI distinguishes between these three based on your type annotations. Parameter matches a path variable? Path parameter. Not in the path? Query parameter. Typed as a Pydantic model? JSON body.
from fastapi import FastAPI, Query, Path
from pydantic import BaseModel
from typing import Optional
app = FastAPI()
# Path parameter -- the item_id is part of the URL
@app.get("/items/{item_id}")
def get_item(
item_id: int = Path(..., ge=1, description="The ID of the item to retrieve"),
):
return {"item_id": item_id, "name": f"Item {item_id}"}
# Query parameters -- passed as ?skip=0&limit=10 in the URL
@app.get("/products")
def list_products(
skip: int = Query(default=0, ge=0, description="Number of items to skip"),
limit: int = Query(default=10, le=100, description="Max items to return"),
category: Optional[str] = Query(default=None, description="Filter by category"),
):
return {"skip": skip, "limit": limit, "category": category}
# Request body -- parsed from the JSON payload
class ItemCreate(BaseModel):
name: str
price: float
description: Optional[str] = None
in_stock: bool = True
@app.post("/items", status_code=201)
def create_item(item: ItemCreate):
return {"message": "Item created", "item": item.model_dump()}
Path() and Query() add constraints. ge=1 means "greater than or equal to 1." Send /items/0 and you get a 422 with a clear explanation. No manual validation code.
Pydantic Models and Validation
The idea with Pydantic is one model definition doing three jobs: validating incoming data, serializing outgoing data, and generating API documentation. You define a base class with shared fields, extend it for create operations (which need a password), and extend it again for responses (which add server-generated fields like id and created_at but never expose the password). The @field_validator decorator adds custom rules — require uppercase letters and digits in passwords, for example. And model_config = {"from_attributes": True} tells Pydantic to read data from ORM model attributes, not just dictionaries. Without that flag, converting SQLAlchemy objects to API responses breaks.
from pydantic import BaseModel, Field, EmailStr, field_validator
from datetime import datetime
from typing import Optional
from enum import Enum
class UserRole(str, Enum):
admin = "admin"
editor = "editor"
viewer = "viewer"
# Base model with shared fields
class UserBase(BaseModel):
email: EmailStr
username: str = Field(
..., min_length=3, max_length=50,
pattern=r"^[a-zA-Z0-9_]+$",
description="Alphanumeric username, 3-50 characters",
)
full_name: Optional[str] = Field(default=None, max_length=100)
role: UserRole = UserRole.viewer
# Model for creating a user (includes password)
class UserCreate(UserBase):
password: str = Field(..., min_length=8, max_length=128)
@field_validator("password")
@classmethod
def validate_password_strength(cls, v):
if not any(c.isupper() for c in v):
raise ValueError("Password must contain an uppercase letter")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain a digit")
return v
# Model for responses (never expose password)
class UserResponse(UserBase):
id: int
is_active: bool = True
created_at: datetime
model_config = {"from_attributes": True}
Separate models for input and output. Always. UserCreate for what the client sends, UserResponse for what they get back. One model for both is how passwords end up in API responses.
Send
"abc12345"as a password and Pydantic rejects it before your endpoint runs. No manual validation. No try/catch. The framework handles the 422 response with a detailed error message pointing at the exact field.
Dependency Injection
This is where FastAPI stops being "Flask with type hints" and becomes its own thing. Define a function that provides something your endpoint needs. FastAPI calls it automatically. Dependencies can depend on other dependencies. The framework resolves the tree.
The first two main.py snippets were standalone demos. From here on the code is one small project, split into modules that import from each other:
app/
├── main.py # creates FastAPI(lifespan=lifespan), includes routers
├── database.py # engine, session maker, User model, lifespan
├── schemas.py # Pydantic models
├── dependencies.py # get_db, PaginationParams
├── users.py # GET /users
└── auth.py # POST /users, POST /auth/token, GET /users/mefrom dataclasses import dataclass
from fastapi import Query
from database import async_session_maker
# A reusable pagination dependency
@dataclass
class PaginationParams:
skip: int = Query(default=0, ge=0)
limit: int = Query(default=20, ge=1, le=100)
# Database session dependency
async def get_db():
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raiseAny router can now use both dependencies:
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import User
from dependencies import PaginationParams, get_db
from schemas import UserResponse
router = APIRouter()
# Now use both dependencies in an endpoint
@router.get("/users", response_model=list[UserResponse])
async def list_users(
pagination: PaginationParams = Depends(),
db: AsyncSession = Depends(get_db),
):
query = select(User).offset(pagination.skip).limit(pagination.limit)
result = await db.execute(query)
return result.scalars().all()get_db imports async_session_maker from database.py, shown in the next section. This composability is why large FastAPI projects stay manageable. A get_current_user dependency might require both get_db and verify_token. FastAPI resolves the whole tree. Every endpoint that needs pagination adds one line. Sessions always close properly because get_db is a generator — FastAPI handles the cleanup after yield, even on exceptions.
Async Endpoints and Database Integration
Fair warning: getting SQLAlchemy 2.0 async working correctly takes patience. The documentation has improved but you will still end up reading GitHub issues. The payoff is that a single worker can keep serving other requests while each one waits on the database, which suits I/O-bound endpoints.
pip install "sqlalchemy[asyncio]" asyncpgDatabase engine with connection pooling, a model definition, and the application lifespan:
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, Boolean, DateTime
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/mydb"
# Connection pooling is critical for production performance.
# pool_size controls steady-state connections; max_overflow
# allows burst capacity beyond that limit.
engine = create_async_engine(
DATABASE_URL,
pool_size=20, # keep 20 connections ready
max_overflow=10, # allow 10 extra under load
pool_pre_ping=True, # verify connections are alive
pool_recycle=3600, # recycle connections after 1 hour
echo=False,
)
async_session_maker = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
def utc_now() -> datetime:
# datetime.utcnow() is deprecated since Python 3.12
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now
)
# Application lifespan -- create tables on startup.
# Pass it to FastAPI(lifespan=lifespan) in main.py.
@asynccontextmanager
async def lifespan(app):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
await engine.dispose()Connection pooling matters because opening a database connection means a network round trip plus authentication; a pool pays that cost once and reuses the connection. pool_size=20 keeps 20 connections ready. max_overflow=10 allows bursting to 30 during spikes.
expire_on_commit=False is the async gotcha that gets everyone. Without it, SQLAlchemy expires objects after commit. Accessing an expired attribute triggers a synchronous lazy load, which raises an error in an async context. If you are coming from sync SQLAlchemy, this will confuse you for at least an hour. Probably more.
The lifespan context manager replaces the deprecated @app.on_event pattern. Startup before yield, cleanup after. It only runs if you hand it to the app with FastAPI(lifespan=lifespan), which main.py does below. datetime.now(timezone.utc) replaces datetime.utcnow(), deprecated since Python 3.12.
Authentication with OAuth2 and JWT
FastAPI has built-in OAuth2 support. Standard stuff.
pip install pyjwt "pwdlib[argon2]"The FastAPI docs now use PyJWT for tokens and pwdlib (Argon2) for password hashing. Older tutorials use python-jose and passlib; passlib has not had a release since 1.7.4 in 2020 and breaks with current bcrypt releases.
The auth module wires together sign-up with password hashing, token creation, verification, and a login endpoint:
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import User
from dependencies import get_db
from schemas import UserCreate, UserResponse
# Generate with: openssl rand -hex 32 (load from an env variable in production)
SECRET_KEY = "change-me-to-a-long-random-secret"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
password_hash = PasswordHash.recommended() # Argon2
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
router = APIRouter()
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: AsyncSession = Depends(get_db),
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
sub = payload.get("sub")
if sub is None:
raise credentials_exception
user_id = int(sub) # "sub" is a string in the token
except (InvalidTokenError, ValueError):
raise credentials_exception
user = await db.get(User, user_id)
if user is None:
raise credentials_exception
return user
# Sign-up endpoint -- store only the password hash
@router.post("/users", response_model=UserResponse, status_code=201)
async def create_user(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
existing = await db.execute(
select(User).where(
(User.username == user_in.username) | (User.email == user_in.email)
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail="Username or email already taken")
user = User(
email=user_in.email,
username=user_in.username,
hashed_password=password_hash.hash(user_in.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
# Login endpoint
@router.post("/auth/token")
async def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(User).where(User.username == form_data.username)
)
user = result.scalar_one_or_none()
if not user or not password_hash.verify(form_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
access_token = create_access_token(
data={"sub": str(user.id)}, # JWT "sub" must be a string
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
return {"access_token": access_token, "token_type": "bearer"}
# Protected endpoint -- requires valid JWT
@router.get("/users/me", response_model=UserResponse)
async def read_current_user(
current_user: Annotated[User, Depends(get_current_user)],
):
return current_userFinally, main.py creates the app with the lifespan and mounts both routers:
from fastapi import FastAPI
import auth
import users
from database import lifespan
app = FastAPI(title="My Awesome API", lifespan=lifespan)
app.include_router(auth.router, tags=["auth"])
app.include_router(users.router, tags=["users"])OAuth2PasswordBearer tells FastAPI to expect a Bearer token. The tokenUrl parameter gives Swagger UI a working "Authorize" button — you can test protected endpoints without Postman. On a protected endpoint, FastAPI calls get_current_user first, decodes the JWT, looks the user up. Expired token, tampered payload, deleted user — 401. Note that the JWT sub claim must be a string: PyJWT rejects a token whose sub is an integer, so the login endpoint stores str(user.id) and get_current_user converts it back with int().
For production: load SECRET_KEY from an environment variable. Add refresh tokens if sessions need to outlive your access token lifetime. And rate-limit the login endpoint. slowapi works well for this.
Where FastAPI Falls Short
Do not make every endpoint async def. CPU-bound work in an async handler blocks the event loop. Regular def handlers run in a thread pool automatically, which is what you want for anything that is not I/O-bound.
The async story is still messy with ORMs. SQLAlchemy async works but the error messages when you hit a synchronous lazy load in an async context are baffling, and Alembic migration support for async engines requires workarounds that feel hacky. That is the one area where Flask's simplicity still wins — sync SQLAlchemy just works, no gotchas, no GitHub issues. Set up Alembic for migrations early. Use httpx's AsyncClient for testing.