Backend

FastAPI: Building High-Performance Python APIs

Mayur Dabhi
Mayur Dabhi
June 1, 2026
14 min read

You write a Flask endpoint, then spend the next twenty lines pulling fields out of request.json, checking types, returning 400s by hand, and updating a Swagger file that's already out of date. Then a frontend dev asks why price came back as a string. Most of that work is the same in every endpoint, and it's where the bugs live.

FastAPI moves that work into Python type hints. You declare what a request should look like, and the framework parses it, validates it, serializes the response, and generates the OpenAPI docs from the same declaration. This post covers project setup, routing, Pydantic schemas, dependency injection for DB sessions and JWT auth, testing with dependency overrides, and a Docker setup, plus the places where FastAPI isn't the right pick.

About Performance

FastAPI runs on Starlette (ASGI) and Pydantic v2, whose core is written in Rust. For I/O-bound APIs that means it handles concurrent requests far better than a WSGI app like Flask, because one worker can serve many requests while they wait on the database or network. For CPU-heavy endpoints the gap mostly disappears; Python is still Python.

Why Type Hints Change How You Write APIs

Older Python frameworks treat types as optional decoration. FastAPI was built around them from the start (Python 3.8+), so the annotations on your function actually drive behavior. That removes a lot of boilerplate:

Here's how it compares with the other two common choices:

Feature FastAPI Flask Django REST Framework
Performance (async) Very High (ASGI/Starlette) Medium (WSGI) Medium (WSGI)
Auto API Docs Built-in Swagger + ReDoc No (needs extensions) Partial (Browsable API)
Data Validation Pydantic (type-based, built-in) Manual or marshmallow Serializers (verbose)
Type Safety Native Python type hints Optional/manual Partial
Dependency Injection Built-in (Depends) Manual patterns Limited
Learning Curve Low-Medium Low Medium-High

Installation and Project Setup

You need two packages: fastapi itself and an ASGI server to run it. Most people use uvicorn for both development and production.

1

Install FastAPI and Uvicorn

The [standard] extra for uvicorn pulls in uvloop and httptools, which are faster than the pure Python defaults.

Terminal
# Core installation
pip install fastapi uvicorn[standard]

# Database support (async PostgreSQL)
pip install sqlalchemy[asyncio] asyncpg

# JWT authentication
pip install python-jose[cryptography] passlib[bcrypt]

# Environment variables
pip install python-dotenv
2

Organize Your Project Structure

Separate concerns into routers, models, schemas, and dependencies. A single main.py is fine for a prototype, but it gets painful after a dozen endpoints.

Project Layout
my-api/
├── app/
│   ├── main.py          # FastAPI app instance and middleware
│   ├── config.py        # Settings loaded from .env
│   ├── database.py      # Async engine and session factory
│   ├── models/          # SQLAlchemy ORM table definitions
│   │   └── user.py
│   ├── schemas/         # Pydantic request/response schemas
│   │   └── user.py
│   ├── routers/         # Route handlers grouped by domain
│   │   ├── users.py
│   │   └── items.py
│   └── dependencies/    # Shared deps: auth, db session
│       └── auth.py
├── tests/
│   └── test_users.py
├── requirements.txt
└── .env
3

Create the Application Entry Point

Configure the FastAPI() instance with a title, description, and version. These show up at the top of the generated docs.

app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import users, items

app = FastAPI(
    title="My API",
    description="A high-performance REST API built with FastAPI",
    version="1.0.0",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourdomain.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
app.include_router(items.router, prefix="/api/v1/items", tags=["items"])

@app.get("/health", tags=["health"])
async def health_check():
    return {"status": "healthy", "version": "1.0.0"}
4

Start the Development Server

Visit http://localhost:8000/docs once it's running and you'll get interactive docs where you can send real requests. Note that --reload is for development only; it watches files and restarts the process.

Terminal
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000

Building Routes and Path Operations

FastAPI's docs call routes path operations: a function with an HTTP method decorator on top. The framework reads the function's type annotations to decide how to parse the request and serialize the response. In practice, the type hints are the API contract.

When a request doesn't match those hints, FastAPI returns a 422 Unprocessable Entity with a detail array listing each failing field and its location (["body", "price"], ["query", "limit"]). Frontend devs often expect a 400 here, so it's worth agreeing on that early or adding a custom handler for RequestValidationError.

Client HTTP Request FastAPI Router Route Matching + Middleware Pydantic Validate & Parse Request Data Route Handler Your Business Logic (async def) JSON Response: serialized from the Pydantic model Auto-Generated Swagger / ReDoc Docs

FastAPI request lifecycle, from HTTP request to validated JSON response

Define routes using HTTP method decorators. FastAPI infers parameter sources from the function signature:

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}

@app.get("/items/{item_id}")
async def get_item(item_id: int):
    return {"item_id": item_id}

@app.post("/items", status_code=201)
async def create_item(name: str, price: float):
    return {"name": name, "price": price}

@app.delete("/items/{item_id}", status_code=204)
async def delete_item(item_id: int):
    return None

Parameters in the path template become path params; others become query params automatically:

from fastapi import FastAPI, Query, Path
from typing import Optional

app = FastAPI()

# Path parameter with built-in validation
@app.get("/users/{user_id}")
async def get_user(
    user_id: int = Path(..., gt=0, description="Must be positive")
):
    return {"user_id": user_id}

# Query parameters with defaults and constraints
@app.get("/items")
async def list_items(
    skip: int = Query(0, ge=0),
    limit: int = Query(10, le=100),
    search: Optional[str] = Query(None, min_length=3)
):
    return {"skip": skip, "limit": limit, "search": search}

# Enum parameter: FastAPI validates the value is one of the choices
from enum import Enum

class SortOrder(str, Enum):
    asc = "asc"
    desc = "desc"

@app.get("/posts")
async def list_posts(order: SortOrder = SortOrder.desc):
    return {"order": order}

A complete CRUD router for a Post resource using APIRouter:

from fastapi import APIRouter, HTTPException
from typing import List
from app.schemas.post import PostCreate, PostResponse, PostUpdate

router = APIRouter()

@router.get("/", response_model=List[PostResponse])
async def list_posts(skip: int = 0, limit: int = 10):
    return await post_service.get_all(skip=skip, limit=limit)

@router.post("/", response_model=PostResponse, status_code=201)
async def create_post(post: PostCreate):
    return await post_service.create(post)

@router.get("/{post_id}", response_model=PostResponse)
async def get_post(post_id: int):
    post = await post_service.get_by_id(post_id)
    if not post:
        raise HTTPException(status_code=404, detail="Post not found")
    return post

@router.put("/{post_id}", response_model=PostResponse)
async def update_post(post_id: int, data: PostUpdate):
    post = await post_service.update(post_id, data)
    if not post:
        raise HTTPException(status_code=404, detail="Post not found")
    return post

@router.delete("/{post_id}", status_code=204)
async def delete_post(post_id: int):
    deleted = await post_service.delete(post_id)
    if not deleted:
        raise HTTPException(status_code=404, detail="Post not found")

Async routes free up the event loop while they wait on I/O, so one process can juggle many requests. The catch: calling a blocking library (requests, time.sleep, a sync DB driver) inside async def stalls every request on that worker. If you can't avoid a blocking call, make the route a plain def instead.

import asyncio
import httpx
from fastapi import FastAPI

app = FastAPI()

# Non-blocking HTTP call
@app.get("/weather/{city}")
async def get_weather(city: str):
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"https://api.weather.example.com/{city}"
        )
        return response.json()

# Parallel async operations with gather
@app.get("/dashboard/{user_id}")
async def get_dashboard(user_id: int):
    stats, orders, notifications = await asyncio.gather(
        get_user_stats(user_id),
        get_recent_orders(user_id),
        get_notifications(user_id)
    )
    return {"stats": stats, "orders": orders, "notifications": notifications}

# Blocking work: use plain def, and FastAPI runs it in a thread pool
@app.get("/compute")
def heavy_computation(n: int):
    return {"result": sum(i * i for i in range(n))}

Pydantic Models: Type-Safe Data Validation

Every request body and response in FastAPI goes through Pydantic. You describe the shape of your data as a class inheriting from BaseModel, and Pydantic does the type coercion, validation, nesting, and JSON output. Current FastAPI uses Pydantic v2, whose core was rewritten in Rust and is a lot faster than v1. If you're upgrading an old project, watch for renamed methods: .dict() is now .model_dump() and orm_mode became from_attributes.

Separating Request and Response Schemas

Don't return your ORM model straight from a route, or sooner or later a password hash ends up in a JSON response. Separate schemas for input and output make that hard to do by accident:

app/schemas/user.py
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional
from datetime import datetime

class UserBase(BaseModel):
    email: EmailStr
    full_name: str
    is_active: bool = True

# CREATE: includes password field
class UserCreate(UserBase):
    password: str

    @field_validator("password")
    @classmethod
    def password_strength(cls, v: str) -> str:
        if len(v) < 8:
            raise ValueError("Password must be at least 8 characters")
        if not any(c.isdigit() for c in v):
            raise ValueError("Password must contain at least one digit")
        return v

# UPDATE: all fields optional for PATCH semantics
class UserUpdate(BaseModel):
    full_name: Optional[str] = None
    email: Optional[EmailStr] = None
    is_active: Optional[bool] = None

# RESPONSE: never expose password_hash
class UserResponse(UserBase):
    id: int
    created_at: datetime

    # Allows reading from SQLAlchemy ORM objects directly
    model_config = ConfigDict(from_attributes=True)

Advanced Validators and Nested Models

Cross-field Validation with model_validator
from pydantic import BaseModel, Field, model_validator
from typing import Literal
from decimal import Decimal

class OrderCreate(BaseModel):
    product_id: int = Field(..., gt=0)
    quantity: int = Field(..., ge=1, le=1000)
    unit_price: Decimal = Field(..., gt=0, decimal_places=2)
    discount_pct: float = Field(0.0, ge=0.0, le=100.0)
    shipping: Literal["standard", "express", "overnight"] = "standard"

    @model_validator(mode="after")
    def check_overnight_minimum(self) -> "OrderCreate":
        total = float(self.unit_price) * self.quantity
        discounted = total * (1 - self.discount_pct / 100)
        if self.shipping == "overnight" and discounted < 50:
            raise ValueError("Overnight shipping requires a $50+ order total")
        return self

# Nested models work out of the box
class AddressSchema(BaseModel):
    street: str
    city: str
    country: str = "US"

class UserWithAddress(UserResponse):
    address: Optional[AddressSchema] = None
    orders: list[OrderCreate] = []

Dependency Injection and Authentication

This is the part of FastAPI I'd miss most if I went back to Flask. You pass any callable to Depends(), FastAPI calls it before your handler runs, and the result arrives as a parameter. Database sessions, auth checks, and rate limits all live outside the route body, and you can swap them out in tests.

Database Session as a Dependency

app/dependencies/database.py
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from typing import AsyncGenerator

DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/mydb"

engine = create_async_engine(DATABASE_URL)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

JWT Bearer Token Authentication

app/dependencies/auth.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from app.dependencies.database import get_db

SECRET_KEY = "your-secret-key"   # Load from environment in production
ALGORITHM = "HS256"

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")

async def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: AsyncSession = Depends(get_db)
) -> User:
    credentials_error = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Invalid 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_error
    except JWTError:
        raise credentials_error

    user = await db.get(User, user_id)
    if user is None or not user.is_active:
        raise credentials_error
    return user

# Composable: build more specific deps on top of get_current_user
async def require_admin(current_user: User = Depends(get_current_user)) -> User:
    if not current_user.is_admin:
        raise HTTPException(status_code=403, detail="Admin access required")
    return current_user

Using them in a route is one parameter each. FastAPI resolves the whole chain, and within a single request a shared dependency like get_db is only called once and its result reused:

Protected Routes Using Dependencies
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

router = APIRouter()

# Any authenticated user
@router.get("/profile")
async def get_profile(current_user: User = Depends(get_current_user)):
    return {"id": current_user.id, "email": current_user.email}

# Admin-only endpoint
@router.get("/admin/users")
async def list_all_users(
    admin: User = Depends(require_admin),
    db: AsyncSession = Depends(get_db)
):
    result = await db.execute(select(User))
    return result.scalars().all()

# Multiple dependencies compose naturally
@router.post("/posts", status_code=201)
async def create_post(
    data: PostCreate,
    current_user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    post = Post(**data.model_dump(), author_id=current_user.id)
    db.add(post)
    await db.flush()
    return post
Production Security

Don't hardcode SECRET_KEY. Load it from environment variables via python-dotenv or a secrets manager. Set a short exp claim on JWT tokens, rotate refresh tokens, and consider disabling the auto-generated /docs endpoint in production with docs_url=None, redoc_url=None.

Testing and Deployment

TestClient sends real HTTP requests to your app without starting a server. Combined with dependency_overrides, you can point every route at a test database by changing one dictionary entry. One gotcha with in-memory SQLite: each new connection gets its own empty database, so if your tables seem to vanish between requests, use a file-based SQLite or a StaticPool.

Writing Tests with TestClient

tests/test_users.py
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.main import app
from app.dependencies.database import get_db

# Override DB with in-memory SQLite for tests
TEST_URL = "sqlite+aiosqlite:///:memory:"
test_engine = create_async_engine(TEST_URL)
TestSession = async_sessionmaker(test_engine)

async def override_get_db():
    async with TestSession() as session:
        yield session

app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)

def test_health_check():
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json()["status"] == "healthy"

def test_create_user():
    response = client.post("/api/v1/users/", json={
        "email": "test@example.com",
        "full_name": "Test User",
        "password": "securePass1"
    })
    assert response.status_code == 201
    data = response.json()
    assert data["email"] == "test@example.com"
    assert "password" not in data  # Schema never exposes password_hash

def test_unauthorized_profile():
    response = client.get("/api/v1/users/profile")
    assert response.status_code == 401

Dockerizing for Production

Dockerfile
FROM python:3.12-slim

WORKDIR /app

# Dependencies layer cached separately from source
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# 4 workers: rule of thumb is 2 * CPU_COUNT + 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
docker-compose.yml
version: "3.9"
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql+asyncpg://user:pass@db/mydb
      - SECRET_KEY=${SECRET_KEY}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: mydb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
      interval: 5s
      retries: 5

volumes:
  postgres_data:

FastAPI Production Checklist

Category Setting Recommendation
Server ASGI runner Gunicorn + UvicornWorker for process management
Security API docs Set docs_url=None, redoc_url=None in production
Security CORS origins Never use allow_origins=["*"] with credentials
Performance Workers 2 × CPU_COUNT + 1 uvicorn workers
Database Connection pool Set pool_size and max_overflow on the engine
Observability Logging Structured JSON logs with request correlation IDs

When I'd use FastAPI (and when I wouldn't)

  • Good fit: JSON APIs consumed by a separate frontend or mobile app, internal microservices, and anything that serves an ML model. You get typed validation and an OpenAPI spec you can generate clients from.
  • Think twice: if you need an admin panel, auth, migrations, and server-rendered pages out of the box, Django gives you all of that today. With FastAPI you assemble it yourself from SQLAlchemy, Alembic, and a handful of other libraries.
  • Async isn't free: if your database driver or SDKs are sync only, you'll either run everything as plain def or fight blocking calls inside async def. Check your dependencies before picking FastAPI for the speed.
  • CPU-heavy work: image processing or big report generation shouldn't run in a request at all. Push it to a task queue like Celery or RQ, whatever framework you use.

If you're already comfortable with Flask and your API is small and sync, there's no urgent reason to switch. For a new JSON API where the request shapes matter, FastAPI is the first thing I'd reach for in Python, mostly because the validation and docs stop drifting away from the code.

Python FastAPI API REST Async Pydantic Backend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.