FastAPI Tutorial: Build REST APIs with Python (2026)

FastAPI has become the go-to framework for building high-performance REST APIs in Python. It combines async-first design, automatic OpenAPI documentation, and Pydantic-powered validation into a developer experience that outshines Flask for new API projects and challenges Django REST Framework for throughput-sensitive workloads. This guide walks you through everything — from your first endpoint to JWT authentication, async database access, and production deployment.

FastAPI vs Flask vs Django REST Framework

Choosing between FastAPI, Flask, and Django REST Framework (DRF) depends on your use case. Here is how they compare in 2026:

  • FastAPI: Best for async, high-throughput APIs. Automatic docs, type safety via Pydantic, native async/await. No ORM bundled — you choose SQLAlchemy, Tortoise, or others.
  • Flask: Minimalist, mature, sync-first. Great for simple APIs but requires many extensions (Flask-RESTful, Marshmallow, etc.). Async support via Quart or Flask 2.x async views is available but feels bolted on.
  • Django REST Framework: Full-stack batteries-included. Best when you need Django's ORM, admin, and auth ecosystem. More opinionated and heavier than FastAPI for pure API services.

Benchmarks consistently show FastAPI matching or beating Node.js Express for throughput on async I/O-bound workloads. For new API-only projects, FastAPI is the clear choice in 2026.

Installation and Project Setup

# Install FastAPI with all optional dependencies
pip install "fastapi[standard]"

# Or install manually with uvicorn
pip install fastapi uvicorn[standard] pydantic

# For async SQLAlchemy and JWT support
pip install sqlalchemy[asyncio] asyncpg python-jose[cryptography] passlib[bcrypt]

Create a basic project structure:

myapi/
├── main.py
├── models.py
├── schemas.py
├── database.py
├── auth.py
└── routers/
    ├── users.py
    └── items.py

Your minimal main.py:

from fastapi import FastAPI

app = FastAPI(
    title="My API",
    description="Production-ready FastAPI application",
    version="1.0.0",
)

@app.get("/health")
async def health_check():
    return {"status": "ok"}

# Run with: uvicorn main:app --reload

Pydantic Models for Request and Response

FastAPI uses Pydantic V2 for all data validation. Define schemas in schemas.py:

from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime

class UserCreate(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    email: EmailStr
    password: str = Field(..., min_length=8)

class UserResponse(BaseModel):
    id: int
    username: str
    email: EmailStr
    created_at: datetime
    is_active: bool = True

    model_config = {"from_attributes": True}  # replaces orm_mode in Pydantic V2

class ItemCreate(BaseModel):
    title: str = Field(..., max_length=200)
    description: Optional[str] = None
    price: float = Field(..., gt=0, description="Price must be positive")

class ItemResponse(ItemCreate):
    id: int
    owner_id: int
Tip: Always separate your input schemas (UserCreate) from your output schemas (UserResponse). This prevents accidentally leaking sensitive fields like passwords in API responses.

Path Parameters, Query Parameters, and Request Bodies

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

app = FastAPI()

# Path parameter with validation
@app.get("/users/{user_id}")
async def get_user(
    user_id: int = Path(..., gt=0, description="The user ID"),
):
    # Raises 422 automatically if user_id is not a positive integer
    return {"user_id": user_id}

# Query parameters with defaults and validation
@app.get("/items/")
async def list_items(
    skip: int = Query(0, ge=0),
    limit: int = Query(10, ge=1, le=100),
    search: Optional[str] = Query(None, min_length=2),
    sort_by: str = Query("created_at", enum=["name", "price", "created_at"]),
):
    return {
        "skip": skip,
        "limit": limit,
        "search": search,
        "sort_by": sort_by,
    }

# Request body (POST)
@app.post("/items/", response_model=ItemResponse, status_code=201)
async def create_item(item: ItemCreate, db: AsyncSession = Depends(get_db)):
    db_item = Item(**item.model_dump())
    db.add(db_item)
    await db.commit()
    await db.refresh(db_item)
    return db_item

Async Endpoints and Dependency Injection

FastAPI's dependency injection system is one of its killer features. It handles teardown automatically and composes cleanly:

from fastapi import Depends, HTTPException, status
from typing import AsyncGenerator

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

# Authentication dependency
async def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: AsyncSession = Depends(get_db),
) -> User:
    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])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception

    user = await db.get(User, username)
    if user is None:
        raise credentials_exception
    return user

# Protected endpoint using dependencies
@app.get("/users/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
    return current_user
Note: Mark endpoints async def whenever they perform I/O (database queries, HTTP calls, file operations). Use plain def for CPU-bound work — FastAPI automatically runs sync endpoints in a thread pool so they don't block the event loop.

JWT Authentication with python-jose

from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm

SECRET_KEY = "your-secret-key-store-in-env"  # use os.getenv in production
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
    to_encode["exp"] = expire
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

@app.post("/token")
async def login(
    form_data: OAuth2PasswordRequestForm = Depends(),
    db: AsyncSession = Depends(get_db),
):
    user = await authenticate_user(db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(status_code=400, detail="Incorrect username or password")
    access_token = create_access_token(
        data={"sub": user.username},
        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
    )
    return {"access_token": access_token, "token_type": "bearer"}

Async Database with SQLAlchemy 2.0

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, Boolean, DateTime, func

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

engine = create_async_engine(DATABASE_URL, echo=True, pool_size=10, max_overflow=20)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
    email: Mapped[str] = mapped_column(String(200), unique=True)
    hashed_password: Mapped[str] = mapped_column(String(200))
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

# Create tables on startup
@app.on_event("startup")
async def startup():
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

# CRUD operations
from sqlalchemy import select

async def get_users(db: AsyncSession, skip: int = 0, limit: int = 100):
    result = await db.execute(
        select(User).where(User.is_active == True).offset(skip).limit(limit)
    )
    return result.scalars().all()

Background Tasks

FastAPI's BackgroundTasks runs work after the response is sent — perfect for sending emails, logging, or triggering webhooks without making the client wait:

from fastapi import BackgroundTasks
import smtplib
from email.message import EmailMessage

def send_welcome_email(email: str, username: str):
    """Runs in the background after the response is returned."""
    msg = EmailMessage()
    msg["Subject"] = "Welcome to MyAPI"
    msg["From"] = "noreply@myapi.com"
    msg["To"] = email
    msg.set_content(f"Hi {username}, welcome aboard!")
    with smtplib.SMTP("localhost") as smtp:
        smtp.send_message(msg)

@app.post("/users/", response_model=UserResponse, status_code=201)
async def create_user(
    user: UserCreate,
    background_tasks: BackgroundTasks,
    db: AsyncSession = Depends(get_db),
):
    db_user = User(
        username=user.username,
        email=user.email,
        hashed_password=hash_password(user.password),
    )
    db.add(db_user)
    await db.commit()
    await db.refresh(db_user)
    # Schedule email — does NOT block the response
    background_tasks.add_task(send_welcome_email, db_user.email, db_user.username)
    return db_user
Tip: For heavy background work (video processing, bulk email campaigns), use Celery with Redis instead of FastAPI's built-in BackgroundTasks. See our Celery guide for a complete walkthrough.

Deployment with Uvicorn and Gunicorn

For development, use uvicorn with reload. For production, use gunicorn as the process manager with uvicorn workers:

# Development
uvicorn main:app --reload --host 0.0.0.0 --port 8000

# Production — 4 workers, each with its own event loop
gunicorn main:app \
  --workers 4 \
  --worker-class uvicorn.workers.UvicornWorker \
  --bind 0.0.0.0:8000 \
  --timeout 120 \
  --access-logfile -

Docker example:

# Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "main:app", "--workers", "4", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--bind", "0.0.0.0:8000"]
Worker count rule of thumb: For async workloads, use 2 × CPU cores + 1 workers. Since each uvicorn worker has its own async event loop, a 2-core machine handles 5 workers well.

Frequently Asked Questions

Is FastAPI production-ready in 2026?
Absolutely. FastAPI is used in production by companies like Microsoft, Uber, Netflix, and Explosion AI. It has a stable API, active development, and a large ecosystem.
When should I use async def vs def in FastAPI?
Use async def for endpoints that perform I/O (database queries, external HTTP calls). Use def for CPU-bound work — FastAPI runs sync endpoints in a thread pool automatically, so they won't block the event loop.
How does FastAPI compare to Flask for performance?
FastAPI with async endpoints typically achieves 2-5x higher throughput than sync Flask on I/O-bound workloads. For purely CPU-bound work without I/O, the difference is minimal.
Can FastAPI handle WebSockets?
Yes. FastAPI has first-class WebSocket support via @app.websocket("/ws"). You can combine REST and WebSocket endpoints in the same application.
Where is the automatic API documentation?
FastAPI generates Swagger UI at /docs and ReDoc at /redoc automatically from your code. No extra configuration needed. Disable in production if desired: FastAPI(docs_url=None, redoc_url=None).