Build a REST API with Python FastAPI: From Zero to Production-Ready

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

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 TestClient makes 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.

Share: