March 26, 20269 min read

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.

python fastapi backend api
Ad 336x280

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:

  1. item_id: int -- FastAPI automatically validates that item_id is an integer. Send /items/abc and you get a 422 error with a clear message. No manual validation code.
  1. Open http://localhost:8000/docs in your browser. You get a full interactive API documentation page (Swagger UI) generated automatically from your code. You can test endpoints right there.
This is the FastAPI pitch: type hints drive validation and documentation. You write normal Python functions with type annotations, and FastAPI handles the rest.

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.

Nested models:
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 use async def vs def:
  • Use async def when your endpoint uses await (async database queries, HTTP calls, etc.)
  • Use def for 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+)
Choose Flask when:
  • 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
Choose Django when:
  • 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
FastAPI is the best choice for pure API development in Python right now. It's fast, the developer experience is excellent, and the automatic validation eliminates an entire class of bugs that every Flask and Django app has to handle manually.

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.

Ad 728x90