Building APIs with FastAPI: Python's Modern Web Framework
A practical guide to building APIs with FastAPI. Covers project setup, route handling, request validation with Pydantic, database integration, authentication, error handling, and why FastAPI is becoming the default choice for Python APIs.
Flask dominated Python web development for a decade. It's simple, flexible, and has a massive ecosystem. But Flask was designed in 2010, before async Python, before type hints became standard, and before API-first development became the norm. FastAPI was designed for the world we actually live in.
FastAPI gives you automatic request validation, auto-generated API documentation, async support out of the box, and it's one of the fastest Python frameworks available. The tradeoff? It's more opinionated than Flask. But those opinions are good ones, and they eliminate entire categories of bugs.
Setting Up
pip install "fastapi[standard]"
This installs FastAPI plus Uvicorn (the ASGI server), Pydantic (validation), and other useful defaults. Create a file:
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello, World"}
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "query": q}
Run it:
fastapi dev main.py
That's it. You have a running API server. But two things happened that are worth noticing:
item_id: int-- FastAPI automatically validates thatitem_idis an integer. Send/items/abcand you get a 422 error with a clear message. No manual validation code.
- Open
http://localhost:8000/docsin your browser. You get a full interactive API documentation page (Swagger UI) generated automatically from your code. You can test endpoints right there.
Request Validation with Pydantic
Pydantic models define the shape of your data. FastAPI uses them to validate request bodies, query parameters, and response data.
from pydantic import BaseModel, EmailStr, Field
from datetime import datetime
class UserCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: EmailStr
age: int = Field(ge=0, le=150)
bio: str | None = None
class UserResponse(BaseModel):
id: int
name: str
email: str
created_at: datetime
@app.post("/users", response_model=UserResponse, status_code=201)
def create_user(user: UserCreate):
# user is already validated -- guaranteed to have:
# - name: non-empty string, max 100 chars
# - email: valid email format
# - age: integer between 0 and 150
# - bio: optional string or None
new_user = save_to_database(user)
return new_user
Send invalid data and you get a detailed error response:
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "name"],
"msg": "String should have at least 1 character",
"input": ""
}
]
}
No manual validation code. No if not request.json.get('email'). The model defines the contract, FastAPI enforces it, and your endpoint function receives clean, validated data.
class Address(BaseModel):
street: str
city: str
country: str
zip_code: str
class OrderItem(BaseModel):
product_id: int
quantity: int = Field(gt=0)
price: float = Field(gt=0)
class OrderCreate(BaseModel):
items: list[OrderItem] # Must have at least one item
shipping_address: Address
notes: str | None = None
@app.post("/orders")
def create_order(order: OrderCreate):
# order.items is a list of validated OrderItem objects
# order.shipping_address is a validated Address object
total = sum(item.price * item.quantity for item in order.items)
return {"total": total, "item_count": len(order.items)}
Path Parameters, Query Parameters, and Headers
FastAPI infers where parameters come from based on how you declare them:
from fastapi import Query, Header, Path
@app.get("/items/{item_id}")
def read_item(
# Path parameter (from the URL)
item_id: int = Path(ge=1, description="The ID of the item"),
# Query parameters (from ?key=value in the URL)
skip: int = Query(default=0, ge=0),
limit: int = Query(default=20, le=100),
search: str | None = Query(default=None, min_length=1),
# Header
x_api_key: str | None = Header(default=None),
):
return {
"item_id": item_id,
"skip": skip,
"limit": limit,
"search": search,
}
A request to /items/42?skip=10&limit=50&search=widget parses and validates everything automatically. Wrong types, out-of-range values, or missing required params all produce clean 422 errors.
Async Endpoints
FastAPI supports async natively. If your endpoint does I/O (database queries, HTTP calls, file reads), make it async:
import httpx
@app.get("/weather/{city}")
async def get_weather(city: str):
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.weather.com/v1/current",
params={"city": city, "key": API_KEY},
)
data = response.json()
return {"city": city, "temperature": data["temp"]}
The difference from Flask: in Flask, if one request is waiting for a database query, no other requests can be handled (unless you add Gunicorn workers). In FastAPI with async, while one request waits for I/O, the server handles other requests. This means better throughput with fewer resources.
When to useasync def vs def:
- Use
async defwhen your endpoint usesawait(async database queries, HTTP calls, etc.) - Use
deffor CPU-bound work or when calling synchronous libraries. FastAPI runs these in a thread pool automatically so they don't block the event loop.
Database Integration
FastAPI works with any database library. SQLAlchemy with async support is the most common choice.
pip install sqlalchemy aiosqlite # SQLite for development
# pip install asyncpg # PostgreSQL for production
# database.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
DATABASE_URL = "sqlite+aiosqlite:///./app.db"
engine = create_async_engine(DATABASE_URL)
async_session = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session() as session:
yield session
# models.py
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.sql import func
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
email = Column(String, unique=True, nullable=False, index=True)
created_at = Column(DateTime, server_default=func.now())
# main.py
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from models import User
app = FastAPI()
@app.post("/users", status_code=201)
async def create_user(user: UserCreate, db: AsyncSession = Depends(get_db)):
db_user = User(name=user.name, email=user.email)
db.add(db_user)
await db.commit()
await db.refresh(db_user)
return db_user
@app.get("/users/{user_id}")
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@app.get("/users")
async def list_users(
skip: int = 0,
limit: int = 20,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).offset(skip).limit(limit))
return result.scalars().all()
Dependency Injection
The Depends() function is one of FastAPI's most powerful features. It lets you declare dependencies that are automatically resolved and injected.
from fastapi import Depends, HTTPException, Header
# A dependency that extracts and verifies an API key
async def verify_api_key(x_api_key: str = Header()):
if x_api_key != "secret-key-123":
raise HTTPException(status_code=403, detail="Invalid API key")
return x_api_key
# A dependency that gets the current user
async def get_current_user(
token: str = Header(alias="authorization"),
db: AsyncSession = Depends(get_db),
):
user_id = decode_token(token)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="Invalid token")
return user
# Use dependencies in endpoints
@app.get("/profile")
async def get_profile(current_user: User = Depends(get_current_user)):
return current_user
@app.get("/admin/stats")
async def admin_stats(
current_user: User = Depends(get_current_user),
_: str = Depends(verify_api_key), # Must also have API key
):
return {"total_users": await count_users()}
Dependencies can depend on other dependencies. FastAPI resolves the full dependency tree automatically. get_current_user depends on get_db, and both are resolved before your endpoint function runs.
This replaces the ad-hoc patterns in Flask (decorators, g object, before_request hooks) with a single, consistent mechanism.
Error Handling
FastAPI uses HTTPException for expected errors:
from fastapi import HTTPException
@app.get("/items/{item_id}")
async def get_item(item_id: int, db: AsyncSession = Depends(get_db)):
item = await db.get(Item, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
if not item.is_active:
raise HTTPException(status_code=410, detail="Item has been removed")
return item
For custom error shapes, define exception handlers:
class AppError(Exception):
def __init__(self, code: str, message: str, status_code: int = 400):
self.code = code
self.message = message
self.status_code = status_code
@app.exception_handler(AppError)
async def app_error_handler(request, exc: AppError):
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": exc.code, "message": exc.message}},
)
# Usage
@app.post("/orders")
async def create_order(order: OrderCreate):
if not has_sufficient_inventory(order):
raise AppError(
code="INSUFFICIENT_INVENTORY",
message="Not enough stock for this order",
status_code=409,
)
# ... process order
Middleware
import time
from fastapi.middleware.cors import CORSMiddleware
# CORS (almost every API needs this)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Custom middleware
@app.middleware("http")
async def add_timing_header(request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
response.headers["X-Response-Time"] = f"{duration:.4f}s"
return response
Organizing a Larger Project
For anything beyond a small prototype, split your code into modules using APIRouter:
app/
main.py
database.py
models.py
dependencies.py
routers/
users.py
items.py
orders.py
schemas/
users.py
items.py
orders.py
# routers/users.py
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from dependencies import get_db, get_current_user
from schemas.users import UserCreate, UserResponse
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/", response_model=list[UserResponse])
async def list_users(db: AsyncSession = Depends(get_db)):
# ...
@router.post("/", response_model=UserResponse, status_code=201)
async def create_user(user: UserCreate, db: AsyncSession = Depends(get_db)):
# ...
@router.get("/me", response_model=UserResponse)
async def get_me(current_user = Depends(get_current_user)):
return current_user
# main.py
from fastapi import FastAPI
from routers import users, items, orders
app = FastAPI(title="My API", version="1.0.0")
app.include_router(users.router)
app.include_router(items.router)
app.include_router(orders.router)
Each router file handles its own set of endpoints. The main file just wires them together. Clean, modular, and easy to navigate.
Testing FastAPI
FastAPI includes a test client:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_read_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello, World"}
def test_create_user():
response = client.post("/users", json={
"name": "Jane",
"email": "jane@example.com",
"age": 30,
})
assert response.status_code == 201
data = response.json()
assert data["name"] == "Jane"
assert "id" in data
def test_create_user_invalid_email():
response = client.post("/users", json={
"name": "Jane",
"email": "not-an-email",
"age": 30,
})
assert response.status_code == 422
def test_get_nonexistent_user():
response = client.get("/users/99999")
assert response.status_code == 404
For async tests, use httpx with pytest-asyncio:
import pytest
from httpx import AsyncClient, ASGITransport
from main import app
@pytest.mark.asyncio
async def test_async_endpoint():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/items/1")
assert response.status_code == 200
FastAPI vs Flask: The Honest Comparison
Choose FastAPI when:- You're building an API (JSON in, JSON out)
- You want automatic validation and documentation
- You need async performance (many concurrent I/O operations)
- You're starting a new project and can use modern Python (3.10+)
- You're building a server-rendered web app (HTML templates)
- You need maximum flexibility and minimal opinions
- Your team already knows Flask well
- You're integrating with libraries that don't support async
- You need an admin panel, ORM, auth, and everything built-in
- You're building a full web application (not just an API)
- You want the "batteries included" approach
If you're learning Python and want to build real projects that include API development, CodeUp helps you develop the backend skills that make building with FastAPI feel natural.