Part of Python for Beginners

Build REST APIs with Pydantic Validation - Python FastAPI Tutorial -#38

Sandy LaneSandy Lane

Video: Build REST APIs with Pydantic Validation - Python FastAPI Tutorial -#38 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Python FastAPI: Type-Hinted Web APIs

pip install fastapi uvicorn. @app.get("/path") decorates a function as an endpoint. Type hints generate validation, OpenAPI docs at /docs, and JSON serialization. Uses Pydantic for request bodies; returns dicts or Pydantic models.

FastAPI is the modern Python web framework for APIs. Built on Starlette (ASGI) and Pydantic, it's fast, type-safe, and self-documenting.

Hello world

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def root():
  return {"message": "Welcome to the API", "version": "1.0"}

Run it:

pip install fastapi uvicorn
uvicorn app:app --reload

Open http://localhost:8000/ — JSON response. Open http://localhost:8000/docs — auto-generated interactive Swagger UI. Open /redoc — alternate doc style.

The auto-docs are huge — every endpoint, every parameter, every response, all interactive.

Path parameters

@app.get("/users/{user_id}")
def get_user(user_id: int):
  if user_id in users:
    return users[user_id]
  return {"error": "User not found"}

{user_id} in the path is captured. The function parameter user_id: int declares the type — FastAPI converts and validates automatically.

GET /users/1     → {"name": "Alice", ...}
GET /users/abc   → 422 Unprocessable Entity (not an int)

Query parameters

@app.get("/search")
def search(q: str, limit: int = 5):
  results = [u for u in users.values() if q.lower() in u["name"].lower()]
  return {"query": q, "limit": limit, "results": results[:limit]}

Function parameters not in the path become query parameters. limit: int = 5 means optional with default.

GET /search?q=ali           → uses default limit=5
GET /search?q=ali&limit=2   → custom limit
GET /search                 → 422 (missing required q)

Pydantic models for request bodies

from pydantic import BaseModel
from typing import Optional

class Item(BaseModel):
  name: str
  price: float
  description: Optional[str] = None

@app.post("/items", status_code=201)
def create_item(item: Item):
  items.append(item.model_dump())
  return {"message": "Item created", "item": item.model_dump()}

Item(BaseModel) is a Pydantic model — fields are typed and validated.

When a function parameter is a Pydantic model, FastAPI:

  1. Reads the JSON request body.
  2. Validates against the model.
  3. Constructs the model instance.
  4. Returns 422 if validation fails.
POST /items {"name": "Laptop", "price": 999.99}     → 201
POST /items {"name": "Broken"}                      → 422 missing 'price'
POST /items {"name": "Bad", "price": "free"}        → 422 wrong type

item.model_dump() converts the model to a dict (Pydantic v2; v1 uses .dict()).

Returning Pydantic models

class UserResponse(BaseModel):
  id: int
  name: str
  email: str

@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int) -> UserResponse:
  return UserResponse(id=user_id, name="Alice", email="alice@example.com")

response_model= filters the output through the model. Useful when your internal data has more fields than you want to expose.

HTTP errors

from fastapi import HTTPException

@app.get("/items/{item_id}")
def get_item(item_id: int):
  if item_id < 0 or item_id >= len(items):
    raise HTTPException(status_code=404, detail="Item not found")
  return items[item_id]

HTTPException returns a structured error response. The detail is included in the JSON.

For custom error handling, register exception handlers globally:

from fastapi.responses import JSONResponse

@app.exception_handler(ValueError)
async def value_error_handler(request, exc):
  return JSONResponse(status_code=400, content={"error": str(exc)})

All HTTP methods

@app.get("/items")
def list_items():
  return items

@app.post("/items")
def create_item(item: Item):
  ...

@app.put("/items/{id}")
def update_item(id: int, item: Item):
  ...

@app.delete("/items/{id}")
def delete_item(id: int):
  ...

@app.patch("/items/{id}")
def patch_item(id: int, partial: PartialItem):
  ...

@app.get, @app.post, etc. — one decorator per method. Same path can have multiple methods.

Async support

@app.get("/external")
async def fetch_external():
  async with httpx.AsyncClient() as client:
    response = await client.get("https://api.example.com")
  return response.json()

FastAPI is fully async. Use async def for endpoints that do I/O. The framework runs them on the event loop with high concurrency.

For sync endpoints (def without async), FastAPI runs them in a thread pool — still works, but async is more efficient when you have multiple I/O calls.

Dependency injection

from fastapi import Depends

def get_db():
  db = Database()
  try:
    yield db
  finally:
    db.close()

@app.get("/items")
def list_items(db = Depends(get_db)):
  return db.query("SELECT * FROM items")

Depends(...) runs the dependency function and passes the result. Generators with yield are auto-cleaned up. Pattern: DB connections, auth, common parameters.

Authentication

from fastapi import Depends, HTTPException, Header

def require_token(authorization: str = Header(None)):
  if authorization != "Bearer secret":
    raise HTTPException(status_code=401, detail="Unauthorized")

@app.get("/protected", dependencies=[Depends(require_token)])
def protected():
  return {"message": "secret data"}

Header() extracts a request header. dependencies=[] runs the check without injecting. For real auth, use OAuth2PasswordBearer and JWT — covered in FastAPI's docs.

Request/response models with extras

from pydantic import BaseModel, Field, EmailStr

class UserCreate(BaseModel):
  name: str = Field(..., min_length=1, max_length=100)
  email: EmailStr
  age: int = Field(..., ge=0, le=150)
  bio: Optional[str] = Field(None, max_length=500)

Pydantic constraints become validation. EmailStr requires pip install pydantic[email]. The auto-docs show all of this.

TestClient

from fastapi.testclient import TestClient

client = TestClient(app)

def test_root():
  response = client.get("/")
  assert response.status_code == 200
  assert response.json() == {"message": "Welcome..."}

def test_search():
  response = client.get("/search", params={"q": "alice"})
  assert response.status_code == 200
  assert "alice" in [r["name"].lower() for r in response.json()["results"]]

TestClient runs the app in-process — no server needed. Use it with pytest for fast API tests.

CORS

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
  CORSMiddleware,
  allow_origins=["http://localhost:3000"],
  allow_credentials=True,
  allow_methods=["*"],
  allow_headers=["*"],
)

For browser clients on a different origin, CORS is required. Configure once at app setup.

Deployment

Production:

# Use uvicorn (or hypercorn)
uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4

# Or behind gunicorn
gunicorn -w 4 -k uvicorn.workers.UvicornWorker app:app

For real production: behind nginx (TLS, static files), with a process manager (systemd, supervisor), in containers (Docker), or on a platform (Fly.io, Railway, AWS App Runner).

A complete employee API

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr

app = FastAPI(title="Employee API", version="1.0")

class Employee(BaseModel):
  id: int | None = None
  name: str
  email: EmailStr
  department: str

class EmployeeUpdate(BaseModel):
  name: str | None = None
  email: EmailStr | None = None
  department: str | None = None

employees: dict[int, Employee] = {}
next_id = 1

@app.get("/employees", response_model=list[Employee])
def list_employees(department: str | None = None):
  result = list(employees.values())
  if department:
    result = [e for e in result if e.department == department]
  return result

@app.post("/employees", response_model=Employee, status_code=201)
def create_employee(employee: Employee):
  global next_id
  employee.id = next_id
  employees[next_id] = employee
  next_id += 1
  return employee

@app.get("/employees/{emp_id}", response_model=Employee)
def get_employee(emp_id: int):
  if emp_id not in employees:
    raise HTTPException(404, "Employee not found")
  return employees[emp_id]

@app.patch("/employees/{emp_id}", response_model=Employee)
def update_employee(emp_id: int, update: EmployeeUpdate):
  if emp_id not in employees:
    raise HTTPException(404, "Employee not found")
  emp = employees[emp_id]
  for k, v in update.model_dump(exclude_unset=True).items():
    setattr(emp, k, v)
  return emp

@app.delete("/employees/{emp_id}", status_code=204)
def delete_employee(emp_id: int):
  if emp_id not in employees:
    raise HTTPException(404, "Employee not found")
  del employees[emp_id]

CRUD with proper status codes. Filtering. Validation. Auto-docs at /docs.

Common stumbles

Body parameters in GET. GET requests typically don't have bodies. For complex queries, use POST or pass as query parameters.

Mutating Pydantic instances directly. Pydantic v2 makes models mutable by default, but for immutable behavior, use model_config = {"frozen": True}.

Forgetting response_model. Returning dict works but skips validation/filtering. Specify response_model for control.

Sync DB calls in async endpoints. Blocks the event loop. Use async DB drivers or await asyncio.to_thread(...).

Globals. State in module-level vars works for demos but doesn't scale. Use a database, or at least dependency injection.

Skipping --reload in dev. With uvicorn app:app --reload, the server restarts on file changes. Without it, you restart manually every time.

Production with --reload. Don't. Big perf hit. Only use during development.

What's next

Lesson 39: Flask. The other classic Python web framework — simpler, more flexible, excellent for small apps.

Recap

@app.get("/") decorates an endpoint. Path parameters become typed function args; the rest are query parameters. Pydantic models for request bodies — automatic validation, JSON serialization. HTTPException for errors. Depends(...) for dependency injection. Async-first; works with async def and def. Auto-generated /docs (Swagger) and /redoc from your type hints. Use TestClient for in-process tests. Deploy with uvicorn behind nginx.

Next lesson: Flask.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.