Module 1 · How FastAPI works

What FastAPI really is

Foundations 18 min read ASGI, Starlette, Pydantic, OpenAPI

FastAPI is a thin, clever layer on top of two other libraries. Starlette provides the web toolkit: routing, requests, responses, middleware, WebSockets. Pydantic provides data validation and serialization. FastAPI's own contribution is to read your function's type hints and wire those two together: parse and validate the request, inject dependencies, serialize the result, and describe all of it as an OpenAPI schema. Knowing where each layer starts and stops is what lets you debug FastAPI instead of guessing.

🏨

A hotel front desk

The building and corridors are Starlette: rooms (routes), doors (middleware), a lift (the ASGI server). The strict receptionist who checks every guest's ID and booking against a form is Pydantic. FastAPI is the hotel manager who wrote the booking forms from your descriptions of each room, so arriving guests are checked exactly against what the room expects, and the brochure (OpenAPI) always matches the rooms.

1. ASGI: the contract between server and app

Python web apps run behind a server (uvicorn, hypercorn, granian) that handles sockets and HTTP parsing. The old contract, WSGI, was one synchronous function call per request, so a worker was stuck until the response was done. ASGI is its asynchronous successor. The app is an async callable taking a scope (what this connection is) and two channels, receive and send. One process can therefore juggle thousands of connections, and long-lived ones such as WebSockets and streams fit naturally.

ASGI server uvicorn / hypercorn sockets, HTTP parsing, TLS, keep-alive, the event loop app(scope, receive, send) your FastAPI app is exactly this async callable scope = {"type": "http", "method": "GET", "path": …} await receive() → http.request (body) await send(http.response.start) await send(http.response.body) WSGI (Flask, classic Django): response = app(environ, start_response), one blocking call per request per worker.

No framework is needed to speak ASGI. Here is a complete ASGI application, driven by httpx's in-process ASGI transport so you can see every message:

import asyncio
import json

import httpx

async def app(scope, receive, send):
    """The whole ASGI contract: read the scope, receive the body, send start + body."""
    assert scope["type"] == "http"
    message = await receive()                                   # {"type": "http.request", "body": b"..."}
    payload = {"method": scope["method"], "path": scope["path"],
               "query": scope["query_string"].decode(), "body": message["body"].decode()}
    body = json.dumps(payload).encode()
    await send({"type": "http.response.start", "status": 200,
                "headers": [(b"content-type", b"application/json")]})
    await send({"type": "http.response.body", "body": body})

async def main():
    transport = httpx.ASGITransport(app=app)                     # calls app() in-process, no network
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        r = await client.post("/orders?dry_run=1", content=b'{"sku": "TEA-01"}')
        print(r.status_code, r.headers["content-type"])
        print(r.json())

asyncio.run(main())
200 application/json {'method': 'POST', 'path': '/orders', 'query': 'dry_run=1', 'body': '{"sku": "TEA-01"}'}

Everything above that contract (routing, parsing JSON, validation, error pages, OpenAPI) is what Starlette and FastAPI give you. In production, uvicorn main:app plays the role httpx played here.

2. The three layers, and what each one owns

LayerOwnsYou meet it as
Starlette (web toolkit)routing, Request/Response, middleware, WebSockets, background tasks, static files, the test client, lifespanfrom starlette… (FastAPI re-exports most of it: fastapi.Request, fastapi.responses)
Pydantic (data)parsing and validating input, serializing output, JSON SchemaBaseModel, Field, validators, pydantic-settings
FastAPI (glue)reading type hints, dependency injection, request → Pydantic → your function → response model, OpenAPI generation, security utilitiesFastAPI, APIRouter, Depends, Query, HTTPException

3. The same idea, with FastAPI

One function, two type hints, and FastAPI parses the path parameter as an int, validates the query parameter, returns 422 with a precise error when input is wrong, serializes the dict to JSON, and documents all of it:

from typing import Annotated

from fastapi import FastAPI, Query
from fastapi.testclient import TestClient

app = FastAPI(title="Shop")

@app.get("/orders/{order_id}")
def read_order(order_id: int, expand: Annotated[bool, Query()] = False):
    return {"order_id": order_id, "expanded": expand}

client = TestClient(app)
print(client.get("/orders/42?expand=true").json())             # "42" → 42, "true" → True
r = client.get("/orders/forty-two")
print(r.status_code, r.json()["detail"][0]["msg"])             # validated before your code runs

schema = client.get("/openapi.json").json()
params = schema["paths"]["/orders/{order_id}"]["get"]["parameters"]
print([(p["name"], p["in"], p["schema"]["type"]) for p in params])   # docs generated from the hints
{'order_id': 42, 'expanded': True} 422 Input should be a valid integer, unable to parse string as an integer [('order_id', 'path', 'integer'), ('expand', 'query', 'boolean')]

4. A FastAPI app is a Starlette app

from fastapi import FastAPI
from starlette.applications import Starlette

app = FastAPI()

@app.get("/health")
def health():
    return {"ok": True}

print(isinstance(app, Starlette))
for route in app.routes:                                   # yours, plus the built-in docs routes
    print(f"{type(route).__name__:10} {route.path:24} {sorted(getattr(route, 'methods', []) or [])}")
True Route /openapi.json ['GET', 'HEAD'] Route /docs ['GET', 'HEAD'] Route /docs/oauth2-redirect ['GET', 'HEAD'] Route /redoc ['GET', 'HEAD'] APIRoute /health ['GET']

Anything that works with Starlette works with FastAPI: its middleware, its Request object, its test client, even mounting a whole Starlette or ASGI app under a path. That is why this course keeps pointing out which layer a behaviour comes from. The fix is usually in that layer's documentation.

5. When FastAPI is, and is not, the right tool

Good fitThink twice
JSON APIs and microservices, especially with typed, validated contractsA content-heavy website with admin screens, forms and an ORM out of the box: Django gives you those batteries
I/O-bound work: databases, other APIs, LLM calls, streamingCPU-bound work per request (image processing, heavy maths): offload it to workers (lesson 11) whatever framework you use
Teams that want OpenAPI docs and generated clients for freeVery small scripts where a framework adds nothing
AI back ends: model gateways, RAG services, MCP servers (lesson 14's SSE)Teams unwilling to learn async. Lesson 11 shows why misused async is worse than none.
Where the speed comes from

"Fast" refers to two things. Development speed: type hints give you validation, docs and editor support in one place. Runtime speed: the ASGI model handles many concurrent I/O-bound requests per process, and since FastAPI 0.130 responses with a declared return type are serialized by Pydantic's Rust core. Your code still decides the rest: one blocking call inside async def can erase all of it (lesson 11).

Recap

  • ASGI: the app is async (scope, receive, send); the server (uvicorn) handles sockets and HTTP.
  • Starlette = web toolkit, Pydantic = data, FastAPI = type-hint glue + dependency injection + OpenAPI.
  • Type hints drive everything: parsing, validation, 422 errors, serialization and documentation.
  • A FastAPI app is a Starlette app, so debug in the layer that owns the behaviour.

Checkpoint

1 · Which layer is responsible for returning 422 when order_id is "forty-two"?
Starlette only matches the path pattern. FastAPI reads the int annotation and validates it with Pydantic, producing the 422.
2 · What does ASGI allow that WSGI does not?
WSGI is one blocking call per request. ASGI's awaitable channels let the event loop interleave many connections.