FastAPI reads the type hints you were going to write anyway and uses them to validate incoming requests, serialise outgoing responses, and generate an OpenAPI document with interactive documentation attached. One annotation does all three jobs. That is the whole pitch, and it is worth being precise about what it does and does not mean before spending eighteen lessons on it.
What you actually get
Here is a complete endpoint. Nothing has been elided:
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class Quote(BaseModel):
nights: int = Field(ge=1, le=365)
nightly_rate: float = Field(gt=0)
@app.post("/quote")
def quote(payload: Quote) -> dict:
return {"total": payload.nights * payload.nightly_rate}That is a working JSON API. Send it something valid and it answers:
curl -s -X POST localhost:8000/quote \
-H 'Content-Type: application/json' \
-d '{"nights": 3, "nightly_rate": 129.50}'{"total":388.5}Send it something wrong and you did not have to write the error:
curl -s -X POST localhost:8000/quote \
-H 'Content-Type: application/json' \
-d '{"nights": 0, "nightly_rate": 129.50}'{"detail":[{"type":"greater_than_equal","loc":["body","nights"],
"msg":"Input should be greater than or equal to 1","input":0,
"ctx":{"ge":1}}]}A 422, naming the field, the rule it broke, and the value it received. The ge=1 that
produced it is also the documentation, and also the type the editor autocompletes against. This is
the idea the whole framework is built on: declare the shape once, and let it be enforcement,
documentation and typing simultaneously.
The type hints it depends on
Everything above rests on annotations, so it is worth being sure of the four forms that appear constantly. If these are already familiar, skip ahead.
from typing import Annotated
# 1. Plain types. FastAPI converts and validates against these.
def f(listing_id: int, city: str, rate: float, live: bool): ...
# 2. Collections, parameterised. Python 3.9+ uses the builtins directly —
# typing.List and typing.Dict are the old spelling and no longer needed.
def g(tags: list[str], counts: dict[str, int]): ...
# 3. Optional, spelled with `|`. Python 3.10+ replaces Optional[str].
# ⚠️ `| None` makes it nullable; the `= None` is what makes it OPTIONAL.
def h(state: str | None = None): ...
# 4. Annotated: a type PLUS metadata. The type still reads first, and the
# extra argument is what FastAPI inspects for rules.
def i(q: Annotated[str, Query(max_length=200)] = None): ...The third one catches people. state: str | None with no default is a
required parameter that is allowed to be null — the caller must send it, and may
send null. Adding = None is what makes it possible to omit. The
distinction is invisible in Python and very visible in a 422.
The fourth is the form to standardise on. Annotated keeps the type where a reader
expects it and hangs the framework's metadata off the side, which means the same function is still
readable — and still callable — outside FastAPI. The older style put
Query(...) in the default slot, which works but quietly makes the default value a
Query object to anyone calling the function directly. Lesson 2 goes into this
properly; StayHub uses Annotated throughout.
None of this is checked at runtime by Python itself. Annotations are inert metadata; FastAPI reads them deliberately and acts on them. That is why a type hint here does something, while the same hint in an ordinary function does nothing at all.
What it does not do
Tutorials tend to stop at the paragraph above, which leaves a misleading impression. FastAPI is a web layer. It has no opinion about, and no help for:
- Your database. There is no ORM, no models, no migrations. SQLAlchemy and Alembic are separate choices you wire up yourself — lesson 6.
- Project structure. The docs show one file. What happens at forty endpoints is left to you, and it is the question people get wrong most often — lesson 4.
- Background work.
BackgroundTasksexists, but it is not a queue and does not pretend to be — lesson 11. - Admin interfaces, templating, sessions, CSRF. Django ships these. FastAPI does not.
It is also worth saying plainly: FastAPI is not fast because it is async. The
name and the benchmarks encourage that reading, and it leads directly to the most common
performance mistake in the framework — writing async def everywhere and then
making a blocking call inside it. Lesson 12 measures what async actually buys, including a case in
this very codebase where the concurrent version is two and a half times slower.
Why not Flask, or Django?
All three are reasonable. They solve different problems, and the honest comparison is narrower than the marketing on any of them.
Here is the same endpoint in Flask, written to the same standard — validating input and returning a useful error:
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post("/quote")
def quote():
data = request.get_json(silent=True) or {}
nights = data.get("nights")
if not isinstance(nights, int) or not 1 <= nights <= 365:
return jsonify(error="nights must be an integer between 1 and 365"), 422
rate = data.get("nightly_rate")
if not isinstance(rate, (int, float)) or rate <= 0:
return jsonify(error="nightly_rate must be greater than 0"), 422
return jsonify(total=nights * rate)Fifteen lines against seven, and the fifteen are the ones that rot. The rules now live in imperative checks rather than in a declaration, so nothing can read them — no documentation is generated, no editor knows the shape, and the next field added is another four lines somebody has to remember to write. Flask has extensions that close this gap; the point is that FastAPI's version is the type hint you would have written regardless.
Where each one earns its place:
| FastAPI | Flask | Django | |
|---|---|---|---|
| Best at | JSON APIs | small apps, total control | full web apps |
| Validation | built in, from type hints | an extension, by hand | forms / DRF serializers |
| API docs | generated, always current | an extension | an extension (DRF) |
| ORM | none — bring your own | none — bring your own | included, excellent |
| Admin UI | none | none | included, the killer feature |
| Async | native | bolted on | partial |
| Learning curve | low, if you know type hints | lowest | highest |
The short version: if the thing you are building serves HTML to browsers and needs an admin interface, Django will save you months and this is the wrong framework. If it serves JSON to a frontend or to other services, FastAPI is very hard to beat. StayHub — the app this track uses — is the second kind: two React apps talk to it, and it never renders a page.
About the speed claims
FastAPI's benchmarks are real but frequently misread. What they measure is framework overhead — the microseconds between a request arriving and your function being called. FastAPI is fast there because Starlette is fast and pydantic v2's validation core is compiled Rust.
That overhead is almost never what makes an API slow. A single unindexed query, or one N+1 in a list endpoint, costs more than the framework will spend all day. Choosing FastAPI for throughput and then writing the data layer carelessly is the standard way to end up with a slow application built on a fast framework.
Lesson 6 covers the N+1 problem, and lesson 12 puts an actual number on the async question.
Installing it
python3 -m venv .venv
source .venv/bin/activate
pip install "fastapi==0.115.5" "uvicorn[standard]==0.32.1"Two packages, and the second one is the server. FastAPI itself does not listen on a port — it builds an ASGI application object, and something else runs it. Uvicorn is that something.
The [standard] extra pulls in uvloop and httptools, which
are the parts that make it quick. Installing plain uvicorn works and is measurably
slower, for no saving worth having.
Save the code above as main.py and run it:
uvicorn main:app --reload --port 8000main:app is module:variable — the file, then the name of the
FastAPI() object inside it. Getting this wrong is most people's first error, and the
message is clear enough: Could not import module "main".
Building something slightly real
One endpoint does not show much. Here is a small API with the four things almost every endpoint is made of — a path parameter, query parameters, a request body, and a chosen status code. Each gets a lesson of its own; this is to show what the shape looks like before the detail.
from datetime import date
from typing import Annotated
from fastapi import FastAPI, Query, status
from pydantic import BaseModel, Field
app = FastAPI(title="Tiny Listings")
LISTINGS = {
1: {"title": "Sunlit Loft in the Mission", "city": "San Francisco", "rate": 189.0},
2: {"title": "Quiet Studio near Zilker Park", "city": "Austin", "rate": 122.0},
}
class BookingRequest(BaseModel):
check_in: date
check_out: date
guests: int = Field(default=1, ge=1, le=16)
@app.get("/listings")
def list_listings(
city: str | None = None,
max_rate: Annotated[float | None, Query(gt=0, alias="maxRate")] = None,
):
"""Query parameters, because they are optional filters on a collection."""
results = LISTINGS.values()
if city:
results = [r for r in results if r["city"].lower() == city.lower()]
if max_rate:
results = [r for r in results if r["rate"] <= max_rate]
return list(results)
@app.get("/listings/{listing_id}")
def get_listing(listing_id: int):
"""A path parameter, because it identifies WHICH listing."""
return LISTINGS[listing_id]
@app.post("/listings/{listing_id}/bookings", status_code=status.HTTP_201_CREATED)
def create_booking(listing_id: int, payload: BookingRequest):
"""A body, because it carries the data being created."""
nights = (payload.check_out - payload.check_in).days
return {"listing": LISTINGS[listing_id]["title"], "nights": nights}Three things there are worth noticing now.
listing_id: int does real work. A path segment is a string; that
annotation converts it and rejects what will not convert:
curl -s localhost:8000/listings/abc | head -c 120{"detail":[{"type":"int_parsing","loc":["path","listing_id"],
"msg":"Input should be a valid integer, unable to parse string as an integer"}]}check_in: date parses the string for you. Send
"2027-03-01" and the function receives a real datetime.date, which is why
subtracting the two just works:
curl -s -X POST localhost:8000/listings/1/bookings -H 'Content-Type: application/json' -d '{"check_in": "2027-03-01", "check_out": "2027-03-04", "guests": 2}'{"listing":"Sunlit Loft in the Mission","nights":3}FastAPI decided what each parameter is without being told. A name that
matches a {placeholder} in the path is a path parameter; a pydantic model is the body;
anything else is a query parameter. That inference is convenient and occasionally surprising, which
is why lesson 2 spends time on making it explicit with Annotated.
What /docs is really reading
Open http://localhost:8000/docs and there is a Swagger UI listing your endpoint,
with the constraints filled in and a working "Try it out" button. Nothing generated that page from
comments or a config file. It is rendering this, which FastAPI serves at
/openapi.json:
{
"components": {
"schemas": {
"Quote": {
"type": "object",
"required": ["nights", "nightly_rate"],
"properties": {
"nights": {"type": "integer", "maximum": 365, "minimum": 1},
"nightly_rate": {"type": "number", "exclusiveMinimum": 0}
}
}
}
}
}That minimum: 1 is the same ge=1 from the model. The OpenAPI document
is derived, not maintained, which is what stops it going stale — the usual failure of
hand-written API docs.
It is also machine-readable, so it generates clients. Point any OpenAPI generator at that URL and you get a typed SDK for free. That single fact is worth more on a real project than the interactive page most people think is the feature.
There are two renderings of the same document. /docs is Swagger UI, which is
interactive — you can send requests from it. /redoc is ReDoc, which is read-only
and considerably nicer to hand to somebody who just needs to understand the API. Both are free and
both stay current.
Shaping it is worth ten minutes, and mostly happens for free. This is a real StayHub endpoint, unedited:
router = APIRouter(prefix="/bookings", tags=["bookings"])
...
@router.post("/quote", response_model=PriceBreakdown)
def quote(payload: QuoteRequest, db: DbSession) -> PriceBreakdown:
"""What would this stay cost? Creates nothing.
Runs the same pricing code the booking runs, so a quote is always honoured.
"""
_, breakdown = BookingService(db).quote(payload)
return breakdowntags=["bookings"] on the router puts every endpoint under it in one "bookings"
group. The docstring's first line becomes the endpoint's description and the rest renders as
markdown underneath it. response_model documents the response shape, which is how a
generated client knows the return type. Not one of those lines was written for the documentation
— they are all doing another job first.
Where the generated text is not good enough, summary= overrides the heading and
description= the body, both on the decorator. Reach for them when the docstring is
aimed at maintainers rather than callers.
One endpoint in StayHub opts out entirely — the Stripe webhook is declared with
include_in_schema=False, because it is called by Stripe and nobody else, and listing
it in public documentation invites people to try.
How a request actually travels
Understanding this early saves a lot of confusion later, because several things that look like bugs are really questions about ordering.
First, what ASGI is. WSGI — the interface Flask and Django were built on — is a function that takes a request and returns a response. One request occupies one worker for its whole life, and there is no vocabulary in the protocol for anything that arrives later. ASGI replaces that with a coroutine over three arguments:
async def app(scope, receive, send):
"""Every ASGI application, including yours, is ultimately this shape."""
assert scope["type"] == "http"
await send({"type": "http.response.start", "status": 200,
"headers": [(b"content-type", b"text/plain")]})
await send({"type": "http.response.body", "body": b"hello"})scope is the connection's metadata, receive pulls incoming events and
send pushes outgoing ones. Because the response goes out as a series of events rather
than a single return value, streaming and long-lived connections stop being special cases. You will
not write this by hand — but every middleware layer below is one of these wrapping another,
which is why the ordering rules work the way they do.
Uvicorn parses the HTTP request into an ASGI scope and hands it to your app object.
Your app is not one thing but a stack of layers wrapped around a router:
uvicorn
└─ ServerErrorMiddleware catches anything that escapes everything else
└─ your middleware CORS, logging, request ids … (outermost = added LAST)
└─ ExceptionMiddleware runs your @app.exception_handler(...) handlers
└─ router path matching
└─ dependencies Depends(...) resolved here
└─ your functionTwo consequences that bite real projects, both covered later:
app.add_middleware()inserts at the front, so the last one you add is the outermost layer — the reverse of how the calls read. Lesson 13.- A handler registered for bare
Exceptiondoes not live with the other handlers. It becomesServerErrorMiddleware's handler, outside everything, which has consequences nobody expects. Lesson 8.
The other thing worth knowing now: when your endpoint is a plain def, FastAPI runs
it in a worker thread so it cannot block the event loop. When it is async def, it runs
directly on the loop and blocking it is entirely your problem. That one-word choice gets its own
lesson.
The application every example comes from
Code samples in this track are not invented. They are taken from StayHub, a working short-let booking API — the write side of an Airbnb-style app, with listings, availability, server-side pricing, bookings that cannot double-book, Stripe payments and a staff console. It runs, and it has a hundred passing tests.
That matters more than it sounds. An invented example can be quietly wrong forever. Code lifted from a running application with a test suite is wrong loudly, and the snippets in these posts are checked against their source files automatically.
Its shape, because a few examples later will only make sense against it:
┌── writes ──> FastAPI ──> Postgres
React apps ───┤ │
├── reads ──> Hasura ─────────┘
└── search ──> FastAPI ──> ElasticsearchEvery create, update and delete goes through FastAPI — which is what makes server-side
pricing and the availability rules impossible to bypass. Most reads come from a GraphQL layer, with
one deliberate exception: GET /api/v1/search, because the point of maintaining a
search index is to answer that question without touching the database.
Here is its application entry point — the real one, minus its comments:
app = FastAPI(
title=settings.app_name,
version="1.0.0",
description=(
"StayHub — the WRITE side of an Airbnb-style app.\n\n"
"Every create, update and delete lives here. Reads come from Hasura GraphQL "
"(http://localhost:8081) with ONE deliberate exception: `GET /api/v1/search`, which "
"queries Elasticsearch. Both accept the same JWT this API issues."
),
lifespan=lifespan,
docs_url="/docs",
)And its health check, which is a small example of a habit worth forming — it reports each dependency separately, because "is it up?" is not the question anyone has at 3am:
curl -s localhost:8000/health{"status":"ok","database":true,"elasticsearch":true}Starting up and shutting down
Most applications need something to happen once at boot — open a connection pool, warm a
cache, check that a dependency is reachable — and something to happen on the way out. That is
the lifespan argument, and StayHub's is a good example because of what it refuses to
do:
@asynccontextmanager
async def lifespan(_: FastAPI):
if es_available():
try:
ensure_index(get_es())
logger.info("Elasticsearch index ready")
except Exception:
logger.exception("Could not prepare the search index — search will be degraded")
else:
logger.warning(
"Elasticsearch is not reachable at %s — search will return 503 until it is",
settings.elasticsearch_url,
)
yieldEverything before yield runs at startup, everything after it at shutdown. The
shape is an async context manager, so cleanup and setup sit in one function instead of two
decorated ones.
The judgement in it is worth copying. Preparing the search index is wrapped in a
try that only logs. If Elasticsearch is slow to start — and it always is, it is
a JVM — a hard failure here means the entire API is down because search is not
ready, when everything except search would have worked fine. A boot-time dependency check should
generally degrade, not refuse.
When it does not work
Four errors account for most first sessions.
Could not import module "main" — you are not in the
directory containing main.py, or you passed a filename. It is
main:app, not main.py:app.
Error loading ASGI app. Attribute "app" not found — the
variable is called something else. The part after the colon is the name of the
FastAPI() object, not a convention.
A 422 you did not expect on a POST — nine times in ten the request had no
Content-Type: application/json, so the body was never parsed as JSON. The error names
["body"] as the location, which is the tell.
The browser reports a CORS error from your React app — FastAPI serves no
CORS headers by default. Lesson 13 covers the middleware, and one trap in particular: allowing
credentials forbids the "*" origin, and the browser's message about it is useless.
Versions
Every command and every snippet in this track was run against these, on one machine, on one day. FastAPI moves quickly and pydantic v1 examples are still all over the internet; if something here does not behave as described, check this list first.
python 3.12.4
fastapi 0.115.5
starlette 0.41.3
pydantic 2.10.3
pydantic-settings 2.6.1
SQLAlchemy 2.0.36
alembic 1.14.0
uvicorn 0.32.1
psycopg 3.2.3
postgres 16.15The lessons, in order
The first three cover the framework itself. Four to nine build a real application around it. Ten to thirteen are the things production asks for that tutorials skip. The last four ship it.
- What it is and why it exists — you are here.
- Routes, parameters and status codes — turning a request into arguments.
- Pydantic models and validation — the type system doing the work.
- Project structure — what happens after the single file stops working.
- Dependency injection — the feature the framework is built on.
- SQLAlchemy, sessions and migrations — a real data layer.
- Designing the REST API — resources, PATCH, pagination.
- One error shape for the whole API.
- Authentication and authorization — JWTs, and who may call what.
- File uploads without the holes.
- Background tasks and their limits.
- async def or def, and how to tell — with measurements.
- Middleware, ordering and CORS.
- Testing the whole stack.
- Logging, health checks and request tracing.
- Containerising it properly.
- Getting it into production.
- Interview questions.
Next: routes and parameters — how a URL, a query string and a JSON body become typed Python arguments, and the handful of declarations that cover almost everything you will need.