The Incident That Taught Me FastAPI
It was 2 AM on a Tuesday. Our Flask API was throwing 500 errors on roughly 15% of requests under load. I pulled up the logs, and the problem was obvious: no request validation, no type coercion, and zero visibility into what the client was actually sending us. After four hours of hotfixes, I promised myself I’d rebuild it properly.
That weekend, I migrated the core endpoints to FastAPI. Not because it was trendy — because it solved the exact problems I was dealing with: automatic validation, serialization, and built-in docs that clients could actually use without calling me at 2 AM again.
This is the tutorial I wish existed when I was staring at those logs. We’ll build a working API, cover the patterns that actually matter in production, and skip the parts that only exist in toy examples.
Core Concepts You Need Before Writing a Single Route
What FastAPI Actually Is
FastAPI is an ASGI web framework built on Starlette for request handling and Pydantic for data validation. The key distinction from Flask or Django REST Framework: validation happens before your route function runs. If the request body doesn’t match your schema, FastAPI returns a 422 Unprocessable Entity automatically — you never see the bad data.
In one API I migrated, this caught an entire class of bugs within the first week. Missing or malformed fields that used to silently slip past Flask and corrupt downstream state simply never made it past FastAPI’s validation layer.
Pydantic Models Are the Foundation
Every data structure in FastAPI starts as a Pydantic BaseModel. Define your fields with type hints, and Pydantic handles coercion and validation automatically. If a client sends "age": "25" as a string, Pydantic casts it to int. Send "age": "banana"? Rejected before your code runs.
Async by Default
FastAPI supports both sync and async route handlers. For I/O-bound operations — database calls, HTTP requests to third-party APIs — use async def. For CPU-bound or simple synchronous logic, regular def works fine (FastAPI runs it in a thread pool automatically).
Hands-On: Building a Task Management API
Step 1: Install Dependencies
Start with a clean virtual environment:
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install fastapi uvicorn pydantic
Uvicorn is the ASGI server. You’ll need it to run the app locally and in production.
Step 2: Create the App Structure
mkdir taskapi && cd taskapi
touch main.py models.py
Step 3: Define Your Data Models
Open models.py and define your request/response schemas:
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class TaskCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
priority: int = Field(default=1, ge=1, le=5) # 1=low, 5=critical
class TaskResponse(BaseModel):
id: int
title: str
description: Optional[str]
priority: int
created_at: datetime
completed: bool = False
class Config:
from_attributes = True # Allows ORM model conversion
The Field() constraints are enforced automatically. A priority of 6 gets rejected before your handler runs. This saved me hours of defensive coding.
Step 4: Build the API Routes
In main.py, wire up the routes:
from fastapi import FastAPI, HTTPException, status
from datetime import datetime, timezone
from models import TaskCreate, TaskResponse
from typing import List
app = FastAPI(
title="Task API",
description="Production-ready task management REST API",
version="1.0.0"
)
# In-memory store for this tutorial (replace with a real DB)
db: dict = {}
counter = 0
@app.post("/tasks", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task(task: TaskCreate):
global counter
counter += 1
record = {
"id": counter,
"title": task.title,
"description": task.description,
"priority": task.priority,
"created_at": datetime.now(timezone.utc),
"completed": False
}
db[counter] = record
return record
@app.get("/tasks", response_model=List[TaskResponse])
async def list_tasks():
return list(db.values())
@app.get("/tasks/{task_id}", response_model=TaskResponse)
async def get_task(task_id: int):
if task_id not in db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Task {task_id} not found"
)
return db[task_id]
@app.patch("/tasks/{task_id}/complete", response_model=TaskResponse)
async def complete_task(task_id: int):
if task_id not in db:
raise HTTPException(status_code=404, detail="Task not found")
db[task_id]["completed"] = True
return db[task_id]
@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_task(task_id: int):
if task_id not in db:
raise HTTPException(status_code=404, detail="Task not found")
del db[task_id]
Step 5: Run and Test
uvicorn main:app --reload --port 8000
Point your browser at http://localhost:8000/docs. FastAPI generates interactive Swagger UI automatically — no extra setup required. Your team (or that client who was calling you at 2 AM) can test every endpoint directly from the browser.
Test via curl:
# Create a task
curl -X POST http://localhost:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Fix the API bug", "priority": 5}'
# List all tasks
curl http://localhost:8000/tasks
# Complete a task
curl -X PATCH http://localhost:8000/tasks/1/complete
Step 6: Add Middleware for Production Readiness
Before deploying, add CORS headers and a basic request logger:
from fastapi.middleware.cors import CORSMiddleware
import logging
import time
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourdomain.com"], # Lock this down in production
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def log_requests(request, call_next):
start = time.time()
response = await call_next(request)
duration = round((time.time() - start) * 1000, 2)
logger.info(f"{request.method} {request.url.path} → {response.status_code} ({duration}ms)")
return response
That middleware was the visibility I was missing at 2 AM. Method, path, status code, duration — four fields that cover 80% of production debugging without touching application logic.
Step 7: Run in Production with Gunicorn
pip install gunicorn
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
The -w 4 flag spawns 4 worker processes. A common rule of thumb: 2 × CPU cores + 1. Each worker handles requests independently, so one hanging request doesn’t block the others.
What to Do Next
For a real deployment, here’s what to layer on top of this foundation:
- Replace the dict store with SQLAlchemy + PostgreSQL or SQLModel (FastAPI’s author built it specifically for this use case)
- Add authentication using FastAPI’s dependency injection with JWT tokens via
python-jose - Write tests with pytest + httpx — FastAPI’s
TestClientmakes this straightforward - Add rate limiting via slowapi (a FastAPI-compatible port of Flask-Limiter)
What you have now is a typed contract between client and server. Bad data gets rejected at the boundary, the docs write themselves, and every request leaves a paper trail. That’s the core. Add your database, wire in auth, and you have something you can put in front of real users.

