Skip to content

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.

main.py
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:

deps.py
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.

async_handler.py
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.