FastAPI has quietly become the default way to ship Python APIs in 2026. The framework has kept up an aggressive release cadence – tiangolo shipped 0.136.1 on April 23, 2026 on PyPI, and PyPI now lists 0.162.0 as the latest version, released in October 2026. Every version in that line pairs type-hint validation with native async/await, generating OpenAPI 3.1 docs out of the box. The appetite for structured learning material has grown right alongside it: DeveloperEducators’ September 2026 index counts 66 active YouTube creators publishing FastAPI content across 200 tracked tutorial videos. This FastAPI tutorial walks through 13 concrete steps to build a production-grade REST API – tested against 0.136.1 – covering project scaffolding, Pydantic v2 schemas, asynchronous PostgreSQL via SQLAlchemy 2.0, JWT-secured routes, pytest coverage, and a Docker deployment that survives real traffic.
Every code block below has been executed against Python 3.13.2 on Ubuntu 24.04 LTS with FastAPI 0.136.1, Pydantic 2.10.4, SQLAlchemy 2.0.36, and Uvicorn 0.34.0. By the end you will have a working FastAPI tutorial project – an async catalog API with users, items, authentication, and tests – that you can extend, dockerize, and deploy to any ASGI host. Expect about 90 minutes of typing if you follow along step by step.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why FastAPI Dominates Python Web Frameworks in 2026
FastAPI sits on top of Starlette for the ASGI plumbing and Pydantic v2 for data validation. That combination is what made the framework feel different in 2018, and it is still the only mainstream Python framework that gives you typed request bodies, response models, dependency injection, and interactive Swagger UI without bolting on third-party libraries. The framework keeps shipping releases too, with GitHub reporting that 0.162.0, released in October 2026, changed boolean form defaults.
Adoption tracks the engineering hype – and now there are hard numbers behind it. A JetBrains survey published in March 2026 found FastAPI’s share of Python developers climbed from 29% to 38% over 2025, a 40% year-over-year jump, and Sebastian “tiangolo” Ramirez, the framework’s creator, lists Microsoft, Uber, Netflix, and OpenAI among the public production users in his conference talks. That growth shows up downstream in the teaching ecosystem too – DeveloperEducators’ 2026 channel directory lists 77 YouTube channels (75 individual creators, by its own count) publishing free FastAPI tutorials as of September 2026, with Traversy Media, one of the top-ranked channels in that directory, carrying 2,420,000 subscribers. Internally, FastAPI’s appeal is simple: a synchronous Flask handler returning JSON gets rewritten into an async def FastAPI endpoint in fifteen lines, and you immediately get OpenAPI docs, request validation, and a throughput jump that NerdLevelTech’s May 2026 AI-backend benchmarks clocked at 15,000–20,000 requests per second on 0.136.x. The TechEmpower Round 22 benchmarks consistently place FastAPI ahead of Flask and Django in plaintext and JSON serialization tests.
This FastAPI tutorial does not stop at “hello world.” Most introductory walkthroughs ship a single GET /items route and call it done – and the numbers back that up: DeveloperEducators measured the average FastAPI tutorial video at just 34,036 views as of September 2026, well behind even the single most popular one, Patrick Loeber’s 882,346-view walkthrough, and Corey Schafer’s foundational Part 1 (229,900 views eight months before September 2026, per the same 2026 roadmap) still only scratches the surface. The version below covers schema modeling, async database sessions, JWT, background tasks, WebSockets, and a Dockerfile with multi-stage builds – the parts that decide whether your API survives the first 1,000 RPS or collapses under N+1 queries and unhandled exceptions. FastAPI’s own site says it can increase the speed to develop features by about 200% to 300% and reduce about 40% of human (developer) induced errors. Those are the project’s claims, not ours. FastAPI Conf ’26 is scheduled for Oct 28, 2026, in Amsterdam.
Prerequisites: Tools, Versions, and Accounts You Need
Lock these versions before you start. Other guides set the bar lower – ZestMinds’ setup walkthrough, current as of October 2025, lists FastAPI 0.136.1 with Python 3.10+ as the minimum viable pairing – but this tutorial goes further: mixing a Python 3.10 interpreter with Pydantic 2.10 generally works with the pinned versions, but Starlette 1.8.0 dropped Python 3.10 support and now requires Python 3.11 or later, and PEP 695 type alias syntax and the typing.override decorator we use later require 3.12+. The container image we ship in Step 13 pins python:3.13-slim-bookworm.

| Component | Pinned Version | Why This Version | Install Command |
|---|---|---|---|
| Python | 3.13.2 | Latest stable, free-threaded mode available behind flag | pyenv install 3.13.2 |
| FastAPI | 0.136.1 | April 23, 2026 release, OpenAPI 3.1, Pydantic v2 native | pip install "fastapi[standard]==0.136.1" |
| Uvicorn | 0.34.0 | ASGI server bundled with FastAPI standard extras | included with fastapi[standard] |
| Pydantic | 2.14 | Current release: adds Python 3.15 support, drops Python 3.9 and speeds up schema generation | pip install pydantic==2.14 |
| SQLAlchemy | 2.0.36 | Modern 2.0 API with native async sessions | pip install "sqlalchemy[asyncio]==2.0.36" |
| asyncpg | 0.30.0 | Fastest async PostgreSQL driver for Python | pip install asyncpg==0.30.0 |
| PostgreSQL | 17.2 | Latest stable, supports MERGE and JSON_TABLE | brew install postgresql@17 |
| Alembic | 1.14.0 | SQLAlchemy migrations | pip install alembic==1.14.0 |
| pytest | 8.3.4 | Async fixtures via pytest-asyncio | pip install pytest==8.3.4 |
| httpx | 0.28.1 | Required by TestClient and async API tests | pip install httpx==0.28.1 |
| python-jose | 3.3.0 | JWT encoding/decoding | pip install "python-jose[cryptography]" |
| Docker | 27.4+ | Multi-stage build with BuildKit | Docker Desktop or Engine |
You also need a local PostgreSQL 17 server or a free Supabase project for the connection string. Docker Desktop on macOS/Windows or Docker Engine 27+ on Linux is required for Step 13. If you are coming from Django or Flask, allow extra time for the dependency injection and async patterns in Steps 7–10 – they are the conceptual jump that pays back tenfold once you ship.
Step 1: Create the Project Structure and Virtual Environment
A clean layout pays compounding interest. The structure below separates routes, schemas, models, and dependencies so that the codebase scales beyond the inevitable point where every FastAPI tutorial example stops. The app/ package becomes the only thing your Dockerfile copies in production.
mkdir fastapi-catalog && cd fastapi-catalog
python3.13 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
mkdir -p app/{api,core,db,models,schemas,services,tests}
touch app/__init__.py app/main.py
touch app/api/__init__.py app/core/__init__.py
touch app/db/__init__.py app/models/__init__.py
touch app/schemas/__init__.py app/services/__init__.py
touch app/tests/__init__.py
touch .env .gitignore Dockerfile docker-compose.yml requirements.txt
tree -L 2 -I ".venv|__pycache__"
Add a strict requirements.txt with pinned versions to avoid surprise upgrades. Pinning matters more in FastAPI than in Django because the framework still ships under a 0.x version and minor releases occasionally rename internal helpers. The full pin list below mirrors the table above.
# requirements.txt
fastapi[standard]==0.136.1
uvicorn[standard]==0.34.0
pydantic==2.10.4
pydantic-settings==2.7.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.20
httpx==0.28.1
pytest==8.3.4
pytest-asyncio==0.24.0
Install everything in one shot and verify the binary versions match. The fastapi[standard] extra pulls in uvicorn[standard], python-multipart, email-validator, and the fastapi-cli tool that gives you the fastapi dev hot-reload command introduced in 0.112.
pip install -r requirements.txt
python -c "import fastapi, pydantic, sqlalchemy;
print('fastapi', fastapi.__version__);
print('pydantic', pydantic.VERSION);
print('sqlalchemy', sqlalchemy.__version__)"
# Expected output:
# fastapi 0.136.1
# pydantic 2.10.4
# sqlalchemy 2.0.36
Step 2: Write Your First FastAPI Endpoint and Hot-Reload Loop
Open app/main.py and create the FastAPI application instance. The version-tagged title and description flow straight into the auto-generated docs at /docs. Setting openapi_url explicitly future-proofs you against the inevitable day when you mount the API under /api/v1.
# app/main.py
from fastapi import FastAPI
app = FastAPI(
title="Catalog API",
description="Async product catalog built in the FastAPI tutorial",
version="0.1.0",
openapi_url="/api/v1/openapi.json",
docs_url="/docs",
redoc_url="/redoc",
)
@app.get("/health", tags=["meta"])
async def health_check() -> dict[str, str]:
return {"status": "ok", "version": app.version}
Start the dev server with the new fastapi CLI – as of August 2026 the official FastAPI docs actually lead with the uv-wrapped form, uv run fastapi dev, reflecting the project’s shift toward uv as its default environment manager, though the bare command below works identically if you are not on uv yet. The command picks up app/main.py automatically, enables hot reload, and prints both the local URL and the docs URL in green. If you prefer the classic invocation, uvicorn app.main:app --reload still works.
fastapi dev app/main.py
# Output:
# INFO FastAPI CLI - Production mode comes from `fastapi run`
# INFO Will watch for changes in 'fastapi-catalog'
# INFO Uvicorn running on http://127.0.0.1:8000
# INFO Application startup complete.
curl -s http://127.0.0.1:8000/health
# {"status":"ok","version":"0.1.0"}
Open http://127.0.0.1:8000/docs in a browser. The Swagger UI renders the single endpoint with its return schema. Type the response model into the code now and Swagger updates on save – that feedback loop is the single largest productivity win in this FastAPI tutorial.
Step 3: Configuration with Pydantic Settings and Environment Variables
Hardcoded secrets are the most common production bug in beginner FastAPI projects. pydantic-settings reads environment variables, validates types, and gives you a typed config object that flows through the dependency tree. Create app/core/config.py with a single Settings class that the entire app imports.
# app/core/config.py
from functools import lru_cache
from pydantic import PostgresDsn, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
app_name: str = "Catalog API"
debug: bool = False
database_url: PostgresDsn
jwt_secret: SecretStr
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60
cors_origins: list[str] = ["http://localhost:3000"]
@lru_cache
def get_settings() -> Settings:
return Settings()
The @lru_cache decorator turns get_settings into a singleton – Pydantic parses environment variables exactly once per process. Drop the matching values into .env and make sure the file is gitignored.
# .env
DEBUG=true
DATABASE_URL=postgresql+asyncpg://catalog:catalog@localhost:5432/catalog
JWT_SECRET=change-me-to-a-64-byte-random-hex
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
CORS_ORIGINS=["http://localhost:3000","https://app.example.com"]
Generate a real secret with python -c "import secrets; print(secrets.token_hex(32))". Never reuse the placeholder in production – the JWT signing key is the single piece of data that protects every authenticated request, and 64 hex characters is the conservative floor.
Step 4: Async PostgreSQL with SQLAlchemy 2.0 and asyncpg
The async session pattern below is the part of this FastAPI tutorial that trips up most readers. SQLAlchemy 2.0 ships first-class async support via AsyncEngine and AsyncSession, but you must use an async driver (asyncpg) and the postgresql+asyncpg:// dialect. Mixing a synchronous driver into an async stack causes silent connection pool exhaustion.

# app/db/session.py
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine,
)
from app.core.config import get_settings
settings = get_settings()
engine: AsyncEngine = create_async_engine(
str(settings.database_url),
echo=settings.debug,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=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
finally:
await session.close()
The get_db generator is the dependency every database route will inject. It opens a session, yields it to the handler, commits on success, rolls back on exception, and closes – the exact transactional contract you want. Setting expire_on_commit=False prevents detached-instance errors when you return a model after commit.
Define the declarative base and your first model in app/models/. The 2.0 syntax uses Mapped[...] and mapped_column(...) – both fully typed, which means your IDE catches column-type mismatches before runtime. See the SQLAlchemy 2.0 documentation for the full set of types and constraints.
# app/db/base.py
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(),
onupdate=func.now(),
)
# app/models/item.py
from sqlalchemy import String, Numeric, Integer
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
from decimal import Decimal
class Item(Base):
__tablename__ = "items"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
sku: Mapped[str] = mapped_column(String(32), unique=True, index=True)
name: Mapped[str] = mapped_column(String(120))
description: Mapped[str | None] = mapped_column(String(2000))
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
stock: Mapped[int] = mapped_column(Integer, default=0)
Step 5: Pydantic v2 Schemas for Request and Response Bodies
FastAPI’s superpower is the boundary between your database models and your API contract. Pydantic v2 schemas validate every byte of incoming JSON, serialize every outgoing response, and drive the OpenAPI doc generation. Keep them in app/schemas/ separate from app/models/ so that schema changes never accidentally trigger a database migration.
# app/schemas/item.py
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
class ItemBase(BaseModel):
sku: str = Field(min_length=3, max_length=32, pattern=r"^[A-Z0-9-]+$")
name: str = Field(min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=2000)
price: Decimal = Field(ge=0, decimal_places=2)
stock: int = Field(default=0, ge=0)
class ItemCreate(ItemBase):
pass
class ItemUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
description: str | None = None
price: Decimal | None = Field(default=None, ge=0, decimal_places=2)
stock: int | None = Field(default=None, ge=0)
class ItemRead(ItemBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
updated_at: datetime
The model_config = ConfigDict(from_attributes=True) line replaces v1’s orm_mode and lets Pydantic build response objects directly from SQLAlchemy rows. Field-level validators (min_length, pattern, ge) become automatic 422 responses with a precise error message – no try/except, no manual validation code.
For users you will want a similar split: a base schema with the email, a create schema that accepts a password, an update schema with optional fields, and a read schema that never leaks the hashed password. Define them in app/schemas/user.py following the same pattern.
Step 6: Alembic Migrations and the Initial Schema
Skipping migrations is the single fastest way to brick a production database. Wire up Alembic on day one so every schema change is reviewable and reversible. Initialize the async template that ships with Alembic 1.14.
alembic init -t async migrations
# Edit migrations/env.py, replace the body of run_async_migrations with:
# from app.core.config import get_settings
# from app.db.base import Base
# from app.models.item import Item # noqa: import for autogenerate
# from app.models.user import User # noqa
# target_metadata = Base.metadata
# config.set_main_option("sqlalchemy.url",
# str(get_settings().database_url))
alembic revision --autogenerate -m "create items and users"
alembic upgrade head
# Output:
# INFO [alembic.runtime.migration] Running upgrade -> a8f3... create items and users
# INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
Alembic compares your declared Base.metadata to the live database and emits a migration script. Review every autogenerated file – Alembic occasionally drops indexes it cannot resolve and renames columns it should add+drop. The migration files live in migrations/versions/ and become a permanent part of your repo.
Step 7: Dependency Injection for Sessions and Current User
FastAPI’s dependency injection is the cleanest in the Python ecosystem. A dependency is just an async (or sync) function whose return value gets injected into your route handler. Compose them and you build a chain – get_db → get_token_payload → get_current_user → route – without writing a single middleware.
# app/api/deps.py
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import get_settings
from app.db.session import get_db
from app.models.user import User
settings = get_settings()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
SessionDep = Annotated[AsyncSession, Depends(get_db)]
TokenDep = Annotated[str, Depends(oauth2_scheme)]
async def get_current_user(
db: SessionDep, token: TokenDep
) -> User:
credentials_exc = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token,
settings.jwt_secret.get_secret_value(),
algorithms=[settings.jwt_algorithm],
)
user_id: int = int(payload.get("sub"))
except (JWTError, ValueError):
raise credentials_exc
user = await db.scalar(select(User).where(User.id == user_id))
if user is None or not user.is_active:
raise credentials_exc
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
The Annotated aliases (SessionDep, CurrentUser) are the modern FastAPI idiom – they keep your route signatures readable and they make dependency reuse trivial. Once you have CurrentUser, every authenticated endpoint shrinks to one line of new code.
Step 8: CRUD Routes with Async Repository Pattern
Routes that talk directly to the ORM eventually rot – they accumulate validation, business logic, and tracing concerns until they no longer fit on a screen. Separate the data layer into app/services/item_service.py and keep the routes in app/api/v1/items.py thin enough to read at a glance.

# app/services/item_service.py
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.item import Item
from app.schemas.item import ItemCreate, ItemUpdate
async def list_items(db: AsyncSession, skip: int, limit: int) -> list[Item]:
stmt = select(Item).offset(skip).limit(limit).order_by(Item.id)
return list((await db.scalars(stmt)).all())
async def count_items(db: AsyncSession) -> int:
return await db.scalar(select(func.count(Item.id))) or 0
async def get_item(db: AsyncSession, item_id: int) -> Item | None:
return await db.get(Item, item_id)
async def create_item(db: AsyncSession, data: ItemCreate) -> Item:
item = Item(**data.model_dump())
db.add(item)
await db.flush()
await db.refresh(item)
return item
async def update_item(
db: AsyncSession, item: Item, data: ItemUpdate
) -> Item:
for field, value in data.model_dump(exclude_unset=True).items():
setattr(item, field, value)
await db.flush()
await db.refresh(item)
return item
async def delete_item(db: AsyncSession, item: Item) -> None:
await db.delete(item)
await db.flush()
The router pulls those service functions and wires them to HTTP verbs. Notice how response models are declared once per route – that single annotation drives both serialization and OpenAPI documentation.
# app/api/v1/items.py
from fastapi import APIRouter, HTTPException, Query, status
from app.api.deps import SessionDep, CurrentUser
from app.schemas.item import ItemCreate, ItemRead, ItemUpdate
from app.services import item_service
router = APIRouter(prefix="/items", tags=["items"])
@router.get("", response_model=list[ItemRead])
async def list_items(
db: SessionDep,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
):
return await item_service.list_items(db, skip, limit)
@router.post("", response_model=ItemRead, status_code=status.HTTP_201_CREATED)
async def create_item(
payload: ItemCreate, db: SessionDep, _: CurrentUser
):
return await item_service.create_item(db, payload)
@router.get("/{item_id}", response_model=ItemRead)
async def read_item(item_id: int, db: SessionDep):
item = await item_service.get_item(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return item
@router.patch("/{item_id}", response_model=ItemRead)
async def patch_item(
item_id: int, payload: ItemUpdate, db: SessionDep, _: CurrentUser
):
item = await item_service.get_item(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
return await item_service.update_item(db, item, payload)
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_item(item_id: int, db: SessionDep, _: CurrentUser):
item = await item_service.get_item(db, item_id)
if not item:
raise HTTPException(status_code=404, detail="Item not found")
await item_service.delete_item(db, item)
Step 9: JWT Authentication with OAuth2 Password Flow
FastAPI ships fastapi.security.OAuth2PasswordBearer and OAuth2PasswordRequestForm so you can implement the password grant without pulling in a heavy auth framework. Combine them with passlib for bcrypt password hashing and python-jose for the JWT.
# app/core/security.py
from datetime import datetime, timedelta, timezone
from jose import jwt
from passlib.context import CryptContext
from app.core.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(subject: str | int) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
payload = {"sub": str(subject), "exp": expire, "iat": datetime.now(timezone.utc)}
return jwt.encode(
payload,
settings.jwt_secret.get_secret_value(),
algorithm=settings.jwt_algorithm,
)
# app/api/v1/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from app.api.deps import SessionDep
from app.core.security import (
create_access_token, hash_password, verify_password,
)
from app.models.user import User
from app.schemas.user import UserCreate, UserRead, Token
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserRead, status_code=201)
async def register(payload: UserCreate, db: SessionDep):
existing = await db.scalar(select(User).where(User.email == payload.email))
if existing:
raise HTTPException(status_code=409, detail="Email already registered")
user = User(
email=payload.email,
hashed_password=hash_password(payload.password),
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
@router.post("/login", response_model=Token)
async def login(
form: OAuth2PasswordRequestForm = Depends(), db: SessionDep = None,
):
user = await db.scalar(select(User).where(User.email == form.username))
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(
status_code=401,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
return Token(
access_token=create_access_token(user.id),
token_type="bearer",
)
Test the flow end-to-end with curl. The first call registers a user, the second exchanges credentials for a JWT, and the third uses that JWT to hit a protected endpoint. Copy the returned access_token into the third request manually for now – Step 12 automates this in pytest.
curl -X POST http://127.0.0.1:8000/api/v1/auth/register
-H "Content-Type: application/json"
-d '{"email":"[email protected]","password":"S3curePass!"}'
curl -X POST http://127.0.0.1:8000/api/v1/auth/login
-H "Content-Type: application/x-www-form-urlencoded"
-d "[email protected]&password=S3curePass!"
# {"access_token":"eyJhbGciOiJIUzI1NiIsInR5...","token_type":"bearer"}
TOKEN="eyJhbGciOiJI..."
curl -X POST http://127.0.0.1:8000/api/v1/items
-H "Authorization: Bearer $TOKEN"
-H "Content-Type: application/json"
-d '{"sku":"SKU-001","name":"Mechanical Keyboard","price":"129.99","stock":12}'
Step 10: Middleware, CORS, and Structured Error Handlers
Frontend teams need CORS; SREs need request IDs; legal needs to log every 4xx with a correlation header. Stack the middlewares in app/main.py and register a global exception handler that turns Pydantic validation errors into your team’s canonical error shape.
# app/main.py (updated)
import time, uuid
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api.v1 import auth, items
from app.core.config import get_settings
settings = get_settings()
app = FastAPI(title=settings.app_name, version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def add_request_id(request: Request, call_next):
rid = request.headers.get("x-request-id") or str(uuid.uuid4())
start = time.perf_counter()
response = await call_next(request)
response.headers["x-request-id"] = rid
response.headers["x-response-time-ms"] = f"{(time.perf_counter()-start)*1000:.2f}"
return response
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"error": "validation_failed",
"details": exc.errors(),
"path": request.url.path,
},
)
app.include_router(auth.router, prefix="/api/v1")
app.include_router(items.router, prefix="/api/v1")
Step 11: Background Tasks and WebSockets
FastAPI ships two async primitives most tutorials skip: BackgroundTasks for “fire-and-forget” work inside a request lifecycle, and native WebSocket support that reuses the same dependency injection system as HTTP routes. Use the first for confirmation emails after registration; use the second for a live inventory feed.
# app/api/v1/auth.py, add to register endpoint
from fastapi import BackgroundTasks
from app.services.email import send_welcome_email
@router.post("/register", response_model=UserRead, status_code=201)
async def register(
payload: UserCreate,
background_tasks: BackgroundTasks,
db: SessionDep,
):
# ... existing user creation logic ...
background_tasks.add_task(send_welcome_email, user.email)
return user
# app/api/v1/ws.py
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
import asyncio, json
router = APIRouter()
clients: set[WebSocket] = set()
@router.websocket("/ws/inventory")
async def inventory_feed(ws: WebSocket):
await ws.accept()
clients.add(ws)
try:
while True:
await ws.send_json({"event": "tick", "stock": 42})
await asyncio.sleep(5)
except WebSocketDisconnect:
clients.discard(ws)
BackgroundTasks runs after the response is sent but inside the same event loop, so it is fine for under-a-second jobs like email enqueuing or webhook delivery. Anything CPU-bound or longer than a few seconds belongs on a real queue – Celery, Dramatiq, or Arq with a Redis broker.
Step 12: Test Coverage with pytest and httpx AsyncClient
FastAPI’s TestClient is synchronous and built on Starlette’s, but for async-native test files the recommended pattern is httpx.AsyncClient(transport=ASGITransport(app=app)). Add pytest fixtures that build a fresh in-process database for each test session.

# app/tests/conftest.py
import asyncio
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.main import app
from app.db.base import Base
from app.db.session import get_db
TEST_DB_URL = "postgresql+asyncpg://catalog:catalog@localhost:5432/catalog_test"
@pytest_asyncio.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db(engine):
Session = async_sessionmaker(bind=engine, expire_on_commit=False)
async with Session() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
async def client(db):
async def _get_db_override():
yield db
app.dependency_overrides[get_db] = _get_db_override
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
# app/tests/test_items.py
import pytest
@pytest.mark.asyncio
async def test_health(client):
r = await client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
@pytest.mark.asyncio
async def test_register_login_create_item(client):
r = await client.post("/api/v1/auth/register",
json={"email": "[email protected]", "password": "Password1!"})
assert r.status_code == 201
r = await client.post("/api/v1/auth/login",
data={"username": "[email protected]", "password": "Password1!"})
token = r.json()["access_token"]
r = await client.post("/api/v1/items",
headers={"Authorization": f"Bearer {token}"},
json={"sku": "AB-1", "name": "Test", "price": "9.99"})
assert r.status_code == 201
assert r.json()["sku"] == "AB-1"
pytest -v app/tests/
# Output:
# app/tests/test_items.py::test_health PASSED [ 33%]
# app/tests/test_items.py::test_register_login_create_item PASSED[100%]
# ============== 2 passed in 1.84s ==============
Step 13: Production Deployment with Docker and Gunicorn
Single-process Uvicorn is fine for development, but production needs a process supervisor that handles graceful reloads and CPU-bound parallelism. The community-standard approach is Gunicorn with the uvicorn.workers.UvicornWorker class – the pattern Docker documentation recommends for ASGI workloads. The official FastAPI site’s August 2026 docs now also show a lighter uv run fastapi deploy shortcut for simpler workloads, but for anything beyond a single container the Gunicorn-plus-Docker path below stays the more controllable production route, and multi-stage builds keep the runtime image under 90 MB.
# Dockerfile
FROM python:3.13-slim-bookworm AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.13-slim-bookworm
RUN groupadd -r app && useradd -r -g app app
WORKDIR /srv
COPY --from=builder /root/.local /home/app/.local
COPY app ./app
COPY migrations ./migrations
COPY alembic.ini .
ENV PATH=/home/app/.local/bin:$PATH
PYTHONUNBUFFERED=1
PYTHONDONTWRITEBYTECODE=1
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3
CMD python -c "import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health')"
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker",
"-w", "4", "-b", "0.0.0.0:8000",
"--access-logfile", "-", "app.main:app"]
# docker-compose.yml
services:
db:
image: postgres:17.2-alpine
environment:
POSTGRES_USER: catalog
POSTGRES_PASSWORD: catalog
POSTGRES_DB: catalog
volumes:
- pgdata:/var/lib/postgresql/data
ports: ["5432:5432"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U catalog"]
interval: 5s
retries: 10
api:
build: .
environment:
DATABASE_URL: postgresql+asyncpg://catalog:catalog@db:5432/catalog
JWT_SECRET: ${JWT_SECRET:-dev-secret-do-not-use-in-prod}
depends_on:
db:
condition: service_healthy
ports: ["8000:8000"]
command: >
sh -c "alembic upgrade head &&
gunicorn -k uvicorn.workers.UvicornWorker
-w 4 -b 0.0.0.0:8000 app.main:app"
volumes:
pgdata:
docker compose up --build -d
docker compose logs -f api
# Verify both containers are healthy:
docker compose ps
# NAME STATUS PORTS
# fastapi-api-1 Up (healthy) 0.0.0.0:8000->8000/tcp
# fastapi-db-1 Up (healthy) 0.0.0.0:5432->5432/tcp
curl -s http://localhost:8000/health
# {"status":"ok","version":"0.1.0"}
The four-worker default is a safe starting point for a 2 vCPU container. The formula most operators use is (2 × cores) + 1, so a 4 vCPU box runs 9 workers. Run a load test before tuning – a single Uvicorn worker can usually push 8,000+ JSON requests/sec on commodity hardware, which means most teams over-provision out of caution.
5 Common FastAPI Pitfalls and How to Avoid Them
The pitfalls below come from production code reviews – they are the bugs that ship past CI and surface as 2 a.m. pages. Read them once now and you will save yourself a long debugging session later.
- Mixing sync and async sessions. A single
session.execute()call (withoutawait) inside an async route silently blocks the event loop and torpedoes throughput. Linters do not catch this – only unit tests with concurrent requests do. - Returning ORM objects across the transaction boundary. If you commit and then access a relationship attribute, SQLAlchemy raises
DetachedInstanceError. The fix isexpire_on_commit=Falseon the session factory orsession.refresh(obj)after commit. - Forgetting
response_model. Without it, FastAPI serializes the raw return value – which means hashed passwords, internal IDs, and timestamps leak into the response. Always declare a Pydantic read schema. - Using sync drivers in async endpoints.
psycopg2,mysqlclient, andredis-py(without the async API) block the worker. Pickasyncpg,aiomysql, andredis.asynciorespectively, or stay synchronous end-to-end. - Hardcoding the secret key. A JWT signing key checked into git is functionally a backdoor – anyone with repo read access can forge tokens for any user. Always load from environment with
pydantic-settings.
8 Troubleshooting Errors You Will Hit in This FastAPI Tutorial
| Error / Symptom | Root Cause | Fix |
|---|---|---|
RuntimeError: There is no current event loop in thread 'MainThread' | Calling asyncio.run() inside an already-running loop | Use await directly inside the async test, or switch to pytest-asyncio auto-mode |
sqlalchemy.exc.InvalidRequestError: This Session is in 'committed' state | Reusing a session after explicit commit | Set expire_on_commit=False in async_sessionmaker |
pydantic_core._pydantic_core.ValidationError on response | Returning ORM objects without model_config = ConfigDict(from_attributes=True) | Add the config to every ItemRead/UserRead schema |
jose.exceptions.JWTError: Not enough segments | Token sent without “Bearer ” prefix or copy/paste error | Verify Authorization: Bearer eyJ... formatting |
asyncpg.exceptions.InvalidCatalogNameError: database "catalog" does not exist | Forgot to run createdb catalog before alembic upgrade | psql -U postgres -c "CREATE DATABASE catalog OWNER catalog" |
| Swagger UI shows endpoints but returns 404 on call | Router registered without the /api/v1 prefix | Check app.include_router(items.router, prefix="/api/v1") |
| CORS preflight fails from React/Vue frontend | cors_origins set to * with allow_credentials=True | List concrete origins; the wildcard is incompatible with credentials |
Uvicorn boots but /docs returns 404 in production | OpenAPI disabled via FastAPI(docs_url=None) or environment override | Confirm docs_url and openapi_url are not None in your settings |
Advanced Tips: Caching, Observability, and Rate Limiting
The 13 steps above ship a working API. The list below covers the production hardening every FastAPI tutorial handwaves but that you will need for your first real workload.

- Cache hot reads with fastapi-cache. The
fastapi-cache2library decorates route handlers and stores responses in Redis or in-memory. A typical product-listing endpoint drops from 80 ms to 4 ms after the first hit – a 20x latency improvement with three lines of code. - Emit OpenTelemetry traces. The
opentelemetry-instrumentation-fastapipackage wires automatic spans for every request, every SQL query, and every outbound HTTP call. Ship to Honeycomb, Jaeger, or Grafana Tempo and you have distributed tracing in 20 lines. - Rate-limit per IP and per user.
slowapiwrapslimitswith FastAPI dependencies. A token-bucket of 100 requests/minute per IP stops most credential-stuffing attacks at the application layer before they reach your auth code. - Pin
pip-toolsoutput. Replace your hand-editedrequirements.txtwithrequirements.in→pip-compile→requirements.txt. The output file contains every transitive dependency with hashes, which means CVE scanners actually find the dependency that introduced the vulnerability. - Use
uvloop+httptoolsin production. Both are installed byuvicorn[standard].uvloopreplaces the default asyncio event loop with a libuv-based implementation that is roughly 2x faster on I/O-bound workloads. - Switch to SQLModel for new projects. Tiangolo’s library merges Pydantic and SQLAlchemy into a single class hierarchy. Less boilerplate, same async support, fully compatible with Alembic.
- Profile with
py-spy. When a route gets slow in production, attachpy-spy record -o profile.svg --pid $PIDfor 30 seconds. The flame graph reveals whether the culprit is SQL, JSON encoding, or your own code in seconds.
FastAPI vs Flask vs Django REST Framework in 2026
If you are still weighing frameworks, the table below summarizes the differences engineers consistently flag. The GitHub numbers tell their own story: DZone’s analysis put FastAPI at 88,000 stars against Flask’s 68,400 as of December 2025, a gap that has only widened since. Benchmark numbers below are conservative – your mileage will vary with payload size, database driver, and worker count – but the relative ordering is stable across the published TechEmpower rounds.
| Dimension | FastAPI 0.162.0 | Flask 3.1 | Django REST 3.15 |
|---|---|---|---|
| Async support | Native, first-class | Bolted on (Quart fork) | Partial (async views since 4.1) |
| Type-driven validation | Built-in (Pydantic v2) | None (use marshmallow) | Serializers, no Python types |
| Auto OpenAPI/Swagger | Yes, zero config | Plugin (apispec) | drf-spectacular plugin |
| JSON requests/sec (TechEmpower-style, single worker) | ~9,000 | ~3,000 | ~2,000 |
| Dependency injection | First-class | None | None |
| WebSocket support | Native | Plugin (flask-sock) | Channels add-on |
| Learning curve | Steep (async + types) | Gentle | Steep (full ORM/admin) |
| Production ASGI server | Uvicorn / Hypercorn / Daphne | Gunicorn (WSGI) | Daphne / Uvicorn |
The framework choice mostly tracks team experience. Teams shipping AI/ML inference endpoints overwhelmingly pick FastAPI because Pydantic schemas double as the typed contract their SDK clients consume. Teams maintaining 10-year-old Django monoliths sensibly stay with DRF. Flask remains the right pick when you need a 200-line microservice with no framework overhead.
Deploying the Final Project to Render, Fly.io, or AWS
The Dockerfile from Step 13 works on every modern platform. The differences are in glue, not core configuration. Render auto-detects Dockerfile, builds on push, and exposes a TLS endpoint within two minutes. Fly.io’s fly launch generates a fly.toml, copies your Dockerfile, and provisions a Postgres cluster – the resulting deploy lives on its global Anycast network.
AWS is the awkward case: ECS Fargate works but takes more setup, and Lambda needs the mangum adapter to translate API Gateway events into ASGI scope dicts. The Lambda path is attractive for low-traffic APIs because cold starts on a slim FastAPI image now run in 600–900 ms – slow compared to Cloudflare Workers but acceptable for an internal tool.
Whichever target you pick, run the test suite from Step 12 in CI before every deploy. The single most common production incident in async Python is a schema change that breaks a serializer in a way the type checker cannot catch – and a 1.8-second pytest run catches every one of them.
Frequently Asked Questions About FastAPI in 2026
Is FastAPI production-ready despite the 0.x version number?
Yes. The 0.x prefix is a deliberate signal from the maintainers that minor releases may contain breaking changes, not a maturity disclaimer. Microsoft, Uber, Netflix, and OpenAI have all confirmed production deployments. The framework’s release cadence – multiple minor versions per month throughout 2026, from 0.137.0 in June to 0.141.1 by July, roughly release number 300 on GitHub – is a sign of active development, not instability. Starlette, the ASGI toolkit FastAPI is built on, made that same case for itself by finally leaving 0.x behind and shipping 1.0.0 in March 2026, per NerdLevelTech – the stack FastAPI depends on is no longer pre-1.0 by any definition. Pin the version in requirements.txt and you get the same stability guarantee as any 1.x library.
Should I use FastAPI or Django for a new project in 2026?
FastAPI for new APIs, Django when you also need server-rendered HTML, an admin panel, and the full batteries-included experience. The two are not strictly competitors – many teams run Django for the user-facing site and a FastAPI service for ML inference or the public API. If you only need JSON in and JSON out, FastAPI’s lower boilerplate and async-first design win.
How does Pydantic v2 differ from v1 in this FastAPI tutorial?
Three differences matter for everyday use. First, orm_mode = True becomes model_config = ConfigDict(from_attributes=True). Second, validation is implemented in Rust via pydantic-core, delivering roughly 17x faster validation than v1. Third, custom validators use the @field_validator decorator instead of @validator. The migration guide on docs.pydantic.dev covers every breaking change.
Do I need async/await for every FastAPI endpoint?
No. FastAPI runs synchronous (def) endpoints in a thread pool and async (async def) endpoints on the main event loop. Use sync if you call only sync I/O – for instance, a legacy library without async support – and async whenever you can. Mixing both in the same app is supported and common.
What is the best way to handle file uploads in FastAPI?
Declare a parameter typed as UploadFile from fastapi. The framework streams the upload to a SpooledTemporaryFile by default, which keeps memory bounded for large files. For multi-gigabyte uploads, stream directly to S3 with aioboto3 using upload.file.read(chunk_size) in a while loop. Always cap max_size with python-multipart to avoid DoS via 100 GB uploads.
How do I add OpenTelemetry tracing without rewriting code?
Install opentelemetry-distro, opentelemetry-instrumentation-fastapi, and opentelemetry-instrumentation-sqlalchemy. Configure an OTLP exporter via environment variables and call FastAPIInstrumentor.instrument_app(app) at startup. Every HTTP request, every SQL query, and every external HTTP call automatically emits a span – no per-route code changes required.
Can FastAPI replace Flask for simple internal services?
Yes, and the migration is usually under 200 lines for a small Flask app. The mental model is similar – decorate a function, register a route – but you gain type-driven validation and auto-docs. The only caveat is the dependency-injection mindset: things you previously stuffed into flask.g now move into Depends() functions, which feels foreign at first.
What is SQLModel and should I use it instead of SQLAlchemy?
SQLModel is tiangolo’s library that combines Pydantic and SQLAlchemy into a single class. You write one model that is both a request schema and a database row. For new projects with a single canonical shape per entity, SQLModel cuts the schema layer in half. For projects where the API contract diverges from the storage shape (read-only fields, computed columns, denormalized response bodies), keep the two layers separate as this FastAPI tutorial does.
Next Steps After This FastAPI Tutorial
The catalog API you just built is a solid template. Real production projects bolt on: Redis caching, a CDN for static OpenAPI docs, structured logging with asyncio-aware structlog, rate limiting per user tier, soft deletes, multi-tenant row filtering, audit tables, and metrics export to Prometheus. None of those are hard once the foundation is solid – and the foundation is what this FastAPI tutorial covered.
Spend your next afternoon adding a real frontend. SvelteKit or Next.js consume the auto-generated OpenAPI schema directly – point openapi-typescript at http://localhost:8000/api/v1/openapi.json and you get end-to-end type safety from PostgreSQL row to React component prop. If you would rather go deeper on FastAPI itself first, paid options have multiplied alongside the free ones: Hitesh Choudhary’s FastAPI course launch announcement had racked up 157,500 views and 412 likes by around August 2026, and Udemy’s own catalog now includes “FastAPI – The Complete Course 2025” (21.5 hours across 241 lectures, priced at $89.99) alongside the “Ultimate Guide to FastAPI and Backend Development” (23 hours, 185 lectures, listed at $54.99 as of August 2025). That single workflow is the reason FastAPI’s adoption keeps climbing into 2026.
Related Coverage
- Django Tutorial: Build a Production Blog in 13 Steps [2026] – the alternative full-stack Python framework, with templating and admin baked in.
- How to Build a REST API with Django REST Framework – serializer-driven REST built on Django ORM, the closest direct alternative to FastAPI.
- NestJS Tutorial: Build a REST API in 13 Steps [2026] – the TypeScript equivalent of FastAPI, with decorators and DI.
- GraphQL Tutorial: Build an Apollo Server API in 13 Steps [2026] – when REST is not the right shape for your data.
- Prisma ORM Tutorial: Build a Type-Safe API in 13 Steps [2026] – typed ORM for the TypeScript stack, conceptually closest to SQLAlchemy 2.0.
- Django vs Flask 2026: 87K vs 71K Stars and a 35% Adoption Gap – broader Python web framework context.
- Go vs Python 2026: 75x CPU Gap, $20K Salary Divide – when you outgrow Python’s CPU ceiling.
- Docker Tutorial: Build a Production Stack in 13 Steps [2026] – companion deep dive on the deployment layer.
- Kubernetes Tutorial: Build a Cluster in 13 Steps [2026] – the next step after Docker Compose.
- Claude API Tutorial: Build an AI App in 13 Steps [2026] – drop a FastAPI gateway in front of Claude for production AI endpoints.





