Building Services with FastAPI¶
What this lesson gives you
FastAPI is the modern standard for Python web services — async-native, type-driven, with automatic validation and OpenAPI docs generated from your code. Building one from scratch in this lesson gives you the vocabulary to discuss any Python service architecture.
Estimated time: 22 min read · Part: HTTP, REST APIs & Databases
Learning Objectives
After this lesson you will be able to: (1) build a FastAPI service with typed route handlers; (2) define Pydantic request and response models with validation constraints; (3) use path parameters, query parameters, and request bodies; (4) implement dependency injection for shared resources; (5) explain the Python web framework landscape and when to reach for FastAPI vs Django vs Flask.
FastAPI Architecture¶
FastAPI builds web APIs directly from type hints and Pydantic models. You declare what a route expects and returns using Python types; FastAPI handles parsing, validation, serialization, error responses, and generates interactive OpenAPI documentation — all derived from the type annotations you would have written anyway. It runs on ASGI (Asynchronous Server Gateway Interface) via uvicorn, enabling async request handling that makes it among the fastest Python web frameworks.
The "validate at the boundary, trust inside" architecture is the key mental model. Every request body and path/query parameter passes through a Pydantic validator before your handler runs. Your business logic only ever receives valid, typed Python objects — it never needs to defensive-check input types or ranges. Invalid input is automatically rejected with a detailed 422 Unprocessable Entity response before it reaches your code.
from fastapi import FastAPI, HTTPException, Query, Path
from pydantic import BaseModel, Field
from datetime import datetime
app = FastAPI(
title="ACME Orders API",
description="Create and retrieve customer orders",
version="2.1.0",
)
# ── Pydantic models define the API contract ───────────────────────
class OrderIn(BaseModel):
customer_id: str = Field(min_length=1, max_length=50)
product_id: str = Field(min_length=1)
quantity: int = Field(ge=1, le=1000) # 1 ≤ quantity ≤ 1000
notes: str | None = None # optional field
class OrderOut(OrderIn):
id: str
status: str = "pending"
created_at: datetime
# In-memory store (replace with DB in production)
_orders: dict[str, OrderOut] = {}
# ── Route handlers ─────────────────────────────────────────────────
@app.post("/orders", response_model=OrderOut, status_code=201,
summary="Create a new order")
async def create_order(order: OrderIn) -> OrderOut:
"""Creates a pending order. Returns 422 if validation fails."""
new_id = f"ord-{len(_orders) + 1:04d}"
new = OrderOut(id=new_id, created_at=datetime.utcnow(), **order.model_dump())
_orders[new_id] = new
return new
@app.get("/orders/{order_id}", response_model=OrderOut,
summary="Get an order by ID")
async def get_order(
order_id: str = Path(description="The order identifier"),
) -> OrderOut:
if order_id not in _orders:
raise HTTPException(status_code=404, detail=f"Order {order_id!r} not found")
return _orders[order_id]
@app.get("/orders", response_model=list[OrderOut],
summary="List orders with optional filters")
async def list_orders(
customer_id: str | None = Query(default=None, description="Filter by customer"),
status: str | None = Query(default=None, description="Filter by status"),
limit: int = Query(default=20, ge=1, le=100),
) -> list[OrderOut]:
results = list(_orders.values())
if customer_id:
results = [o for o in results if o.customer_id == customer_id]
if status:
results = [o for o in results if o.status == status]
return results[:limit]
# Run: uv run uvicorn main:app --reload
# Docs: http://localhost:8000/docs (Swagger UI, auto-generated)
Validation at the Boundary
If a client sends quantity: -5, omits product_id, or sends a string where an integer is expected, FastAPI rejects the request with a detailed 422 response listing every violation — before your handler ever runs. The response includes the field name, location (body, path, query), and a human-readable error. This "validate at the boundary, trust inside" pattern means your business logic can assume all inputs are valid and typed — no defensive isinstance checks needed.
Dependency Injection¶
FastAPI has a built-in dependency injection system — route handlers can declare dependencies as parameters annotated with Depends(). This is how you share a database connection, authenticate a request, or extract a common header across many routes without duplicating code:
from fastapi import Depends, HTTPException, Header
from sqlalchemy.orm import Session
from database import SessionLocal
# ── Database session dependency ───────────────────────────────────
def get_db():
"""Yield a DB session per request; close it on completion."""
db = SessionLocal()
try:
yield db
finally:
db.close()
# ── Authentication dependency ─────────────────────────────────────
def require_auth(x_api_key: str = Header(alias="X-API-Key")) -> str:
"""Extract and validate the API key from the request header."""
valid_keys = {"secret-key-1", "secret-key-2"} # use a DB in production
if x_api_key not in valid_keys:
raise HTTPException(status_code=401, detail="Invalid API key")
return x_api_key
# ── Using dependencies in route handlers ─────────────────────────
@app.post("/orders", response_model=OrderOut, status_code=201)
async def create_order(
order: OrderIn,
db: Session = Depends(get_db), # injected DB session
_: str = Depends(require_auth), # authentication enforced
) -> OrderOut:
# db is a live session; _ is the validated API key (unused here)
db_order = Order(**order.model_dump())
db.add(db_order)
db.commit()
db.refresh(db_order)
return OrderOut.model_validate(db_order)
The Python Web Framework Landscape
FastAPI dominates new API and microservice work in 2026: async-native, type-driven, auto-docs. Django is batteries-included for full web applications: ORM, admin panel, auth, migrations out of the box — reach for it when you need a CMS, an admin dashboard, or a monolith. Flask is minimal and flexible — still used for small APIs and when you want full control over every component. uvicorn serves ASGI apps (FastAPI, Starlette); gunicorn manages multiple worker processes. In consulting, knowing which to recommend — a high-throughput microservice vs a content-heavy web app vs an internal admin tool — is exactly the judgment a solutions engineer provides.
Async Request Handling¶
FastAPI handlers declared with async def run on an asyncio event loop, allowing a single process to handle thousands of concurrent requests that spend time waiting on I/O (database queries, external API calls) without blocking. The pattern requires that I/O operations also be async — use httpx.AsyncClient for HTTP and async database drivers (asyncpg for PostgreSQL, databases library for SQLAlchemy). For CPU-bound work, run in a thread pool with asyncio.to_thread.
import httpx
import asyncio
from fastapi import FastAPI, BackgroundTasks
app = FastAPI()
# ── Async handler: non-blocking I/O ──────────────────────────────
@app.get("/enriched-orders/{order_id}")
async def get_enriched_order(order_id: str) -> dict:
"""Fetch order AND customer details in parallel — both non-blocking."""
async with httpx.AsyncClient(timeout=10.0) as client:
order_task = client.get(f"https://orders.internal/orders/{order_id}")
customer_task = client.get(f"https://crm.internal/customers/{order_id}")
order_resp, customer_resp = await asyncio.gather(order_task, customer_task)
order_resp.raise_for_status()
customer_resp.raise_for_status()
return {**order_resp.json(), "customer": customer_resp.json()}
# ── Background tasks: fire-and-forget ─────────────────────────────
def send_confirmation_email(order_id: str, email: str) -> None:
# Runs after the response is sent — doesn't delay the client
import smtplib # simplified for illustration
@app.post("/orders", status_code=201)
async def create_order_with_notification(
order: OrderIn,
background_tasks: BackgroundTasks,
) -> dict:
order_id = "ord-0001" # simplified
background_tasks.add_task(
send_confirmation_email,
order_id=order_id,
email="customer@example.com",
)
return {"id": order_id, "status": "pending"}
| FastAPI Feature | What it does | Interview talking point |
|---|---|---|
| Pydantic models | Validate request/response; generate JSON schema | "Validate at the boundary, trust inside" |
response_model= | Controls which fields are serialized in the response | Prevents accidentally leaking internal fields |
status_code= | Sets the default HTTP status on success | 201 for creation, 204 for no-body success |
Depends() | Dependency injection for DB sessions, auth, common headers | Clean separation of cross-cutting concerns |
| 422 response | Auto-generated on validation failure | Clients get field-level error detail for free |
/docs | Interactive Swagger UI auto-generated from type hints | Zero-cost API documentation |
BackgroundTasks | Fire-and-forget tasks after response is sent | Useful for emails, webhooks, cache invalidation |
Consulting Lens: When to Use FastAPI
FastAPI is the right choice when: you need a new HTTP API or microservice, the team knows Python, and you want OpenAPI docs generated automatically. It is not the right choice when: you need a full web application with an admin interface and ORM (Django), or you are extending an existing Flask codebase (stick with Flask). In a greenfield consulting engagement, defaulting to FastAPI + Pydantic + SQLAlchemy is a well-understood, well-hireable stack in 2026. When a client asks "why FastAPI over Django?", the answer is: we don't need the full MVC framework — we need a thin, fast API layer with type safety and automatic documentation.
Knowledge check
InterviewWhat does FastAPI's use of Pydantic models give you "for free" compared to writing a plain Flask route?
- Nothing; you must still hand-write all validation logic.
- Automatic request parsing and validation, serialization of responses, field-level error messages on invalid input, and generated OpenAPI/Swagger docs — all derived from the type hints you write anyway.
- A built-in production database with auto-migrations.
- Automatic horizontal scaling across multiple servers.
Answer
Automatic request parsing and validation, serialization of responses, field-level error messages on invalid input, and generated OpenAPI/Swagger docs — all derived from the type hints you write anyway.
In a plain Flask route you receive raw JSON, manually validate each field, manually serialize the response, and manually write docs. FastAPI derives all four from Pydantic model declarations. Invalid input is rejected with a structured 422 before your handler runs; responses are automatically serialized and filtered to the declared response_model ; and the OpenAPI schema is generated and served at /docs with no extra work. The type hints you write for correctness pay triple dividends.
Knowledge check
ConceptWhy might a FastAPI route handler be declared with async def instead of plain def?
- FastAPI requires all handlers to be async; sync functions are not supported.
- Async handlers can await I/O-bound operations (DB queries, external HTTP calls) without blocking the event loop, allowing a single process to handle many concurrent requests efficiently.
- Async handlers run faster even for CPU-bound computation.
- Sync and async handlers have identical performance; async is purely cosmetic.
Answer
Async handlers can await I/O-bound operations (DB queries, external HTTP calls) without blocking the event loop, allowing a single process to handle many concurrent requests efficiently.
FastAPI supports both sync and async handlers. When a handler is async, it runs on the event loop and can await I/O operations without blocking — meaning other requests are processed while one handler waits for a database response. This makes a single-process FastAPI application capable of handling hundreds or thousands of concurrent requests. Sync handlers are run in a thread pool to avoid blocking the loop. CPU-bound work should still be offloaded to a thread pool or process pool with asyncio.to_thread .
Exercise
Add a PATCH endpoint to the orders API that updates the status of an existing order. Requirements: (1) path parameter order_id; (2) request body: a Pydantic model OrderUpdate with a single field status constrained to values ["pending", "confirmed", "shipped", "cancelled"]; (3) returns the updated order or 404 if not found; (4) returns 409 if trying to update a cancelled order.
Show solution
from enum import Enum
from pydantic import BaseModel
from fastapi import HTTPException, Path
class OrderStatus(str, Enum):
pending = "pending"
confirmed = "confirmed"
shipped = "shipped"
cancelled = "cancelled"
class OrderUpdate(BaseModel):
status: OrderStatus
@app.patch("/orders/{order_id}", response_model=OrderOut)
async def update_order_status(
order_id: str = Path(description="Order to update"),
update: OrderUpdate = ...,
) -> OrderOut:
if order_id not in _orders:
raise HTTPException(status_code=404, detail=f"Order {order_id!r} not found")
existing = _orders[order_id]
if existing.status == "cancelled":
raise HTTPException(
status_code=409,
detail="Cannot update a cancelled order",
)
existing.status = update.status
_orders[order_id] = existing
return existing
Key Takeaways
- FastAPI builds APIs from type hints; Pydantic models are the request/response contract.
- Validation happens at the boundary — invalid input is rejected with a 422 before your handler runs.
- Dependency injection (
Depends()) cleanly shares DB sessions, authentication, and common logic. - Async handlers enable high concurrency for I/O-bound services without blocking.
- Auto-generated OpenAPI docs at
/docs— zero extra work. - FastAPI for APIs; Django for full web apps; Flask for minimal or legacy — match to the context.