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, handles async natively, benchmarks near Node.js Express. 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 and Uvicorn:
pip install fastapi uvicorn[standard]Create main.py:
fromfastapiimport 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("/")
defroot():
return {"status": "healthy", "message": "Welcome to the API"}
@app.get("/items")
deflist_items():
return [
{"id": 1, "name": "Laptop", "price": 999.99},
{"id": 2, "name": "Keyboard", "price": 79.99},
]Run the server with Uvicorn:
uvicorn main:app --reloadHit http://localhost:8000/docs for Swagger UI. That is it. Two files, one command, interactive documentation.
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.
fromfastapiimport FastAPI, Query, Path
frompydanticimport BaseModel
fromtypingimport Optional
app = FastAPI()
# Path parameter -- the item_id is part of the URL
@app.get("/items/{item_id}")
defget_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")
deflist_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 payloadclassItemCreate(BaseModel):
name: str
price: float
description: Optional[str] = None
in_stock: bool = True
@app.post("/items", status_code=201)
defcreate_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.
frompydanticimport BaseModel, Field, EmailStr, field_validator
fromdatetimeimport datetime
fromtypingimport Optional
fromenumimport Enum
classUserRole(str, Enum):
admin = "admin"
editor = "editor"
viewer = "viewer"# Base model with shared fieldsclassUserBase(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)classUserCreate(UserBase):
password: str = Field(..., min_length=8, max_length=128)
@field_validator("password")
@classmethod
defvalidate_password_strength(cls, v):
if notany(c.isupper() for c in v):
raise ValueError("Password must contain an uppercase letter")
if notany(c.isdigit() for c in v):
raise ValueError("Password must contain a digit")
return v
# Model for responses (never expose password)classUserResponse(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.
fromfastapiimport Depends, Query, HTTPException
fromsqlalchemy.ext.asyncioimport AsyncSession
fromdataclassesimport dataclass
# A reusable pagination dependency
@dataclass
classPaginationParams:
skip: int = Query(default=0, ge=0)
limit: int = Query(default=20, ge=1, le=100)
# Database session dependencyasync defget_db():
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise# Now use both in an endpoint
@app.get("/users")
async deflist_users(
pagination: PaginationParams = Depends(),
db: AsyncSession = Depends(get_db),
):
query = select(User).offset(pagination.skip).limit(pagination.limit)
result = await db.execute(query)
users = result.scalars().all()
return usersThis 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 performance payoff is real though -- async I/O on read-heavy endpoints can cut tail latency dramatically.
pip install sqlalchemy[asyncio] asyncpgDatabase engine with connection pooling, a model definition, and the application lifespan:
fromsqlalchemy.ext.asyncioimport (
create_async_engine, async_sessionmaker, AsyncSession
)
fromsqlalchemy.ormimport DeclarativeBase, Mapped, mapped_column
fromsqlalchemyimport String, Boolean, DateTime, select
fromdatetimeimport 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)
classBase(DeclarativeBase):
passclassUser(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(128))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow
)
# Application lifespan -- create tables on startupfromcontextlibimport asynccontextmanager
@asynccontextmanager
async deflifespan(app):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yieldawait engine.dispose()Connection pooling matters because creating a new database connection per request costs 50-100ms. 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.
Authentication with OAuth2 and JWT
FastAPI has built-in OAuth2 support. Standard stuff.
pip install python-jose[cryptography] passlib[bcrypt]The auth system wires together token creation, verification, and a login endpoint:
fromdatetimeimport datetime, timedelta
fromtypingimport Annotated
fromfastapiimport Depends, HTTPException, status
fromfastapi.securityimport OAuth2PasswordBearer, OAuth2PasswordRequestForm
fromjoseimport JWTError, jwt
frompasslib.contextimport CryptContext
fromsqlalchemyimport select
SECRET_KEY = "your-secret-key-store-in-env-variable"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token")
defcreate_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta ortimedelta(minutes=15))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
async defget_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])
user_id: int = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
# Login endpoint
@app.post("/auth/token")
async deflogin(
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 pwd_context.verify(form_data.password, user.hashed_password):
raiseHTTPException(status_code=401, detail="Invalid credentials")
access_token = create_access_token(
data={"sub": user.id},
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
return {"access_token": access_token, "token_type": "bearer"}
# Protected endpoint -- requires valid JWT
@app.get("/users/me")
async defread_current_user(
current_user: Annotated[User, Depends(get_current_user)],
):
return UserResponse.model_validate(current_user)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.
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.