Your first FastAPI app
FastAPI takes the models from the last module and puts them on the network. You write a plain function with type hints, decorate it with a path, and the framework does the rest: it parses the request, validates the input with Pydantic, calls your function, serialises the return value, and publishes an OpenAPI document with an interactive page at /docs. The type hints are not decoration; they are the specification the framework runs on.
This lesson builds the smallest useful version of Relay: a health check, a route that returns one ticket, and a route that creates one. Later lessons replace the in-memory dictionary with PostgreSQL and add auth, but the shape of the code stays the same.
Create the project
uv init relay && cd relay
uv add "fastapi[standard]" pydantic
The [standard] extra pulls in uvicorn (the ASGI server) and the fastapi command-line tool. Now the app itself.
# main.py
from datetime import datetime, UTC
from typing import Literal
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="Relay", version="0.1.0")
class TicketIn(BaseModel):
customer_id: int
subject: str
body: str
priority: Literal["low", "normal", "high"] = "normal"
class Ticket(TicketIn):
id: int
status: Literal["open", "pending", "closed"] = "open"
created_at: datetime
tickets: dict[int, Ticket] = {}
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/tickets/{ticket_id}")
async def get_ticket(ticket_id: int) -> Ticket:
if ticket_id not in tickets:
raise HTTPException(status_code=404, detail="ticket not found")
return tickets[ticket_id]
@app.post("/tickets", status_code=201)
async def create_ticket(ticket_in: TicketIn) -> Ticket:
ticket = Ticket(id=len(tickets) + 1, created_at=datetime.now(UTC),
**ticket_in.model_dump())
tickets[ticket.id] = ticket
return ticket
Run it with uv run fastapi dev main.py. The dev server finds app in main.py, listens on port 8000, and restarts when a file changes. Open http://127.0.0.1:8000/docs and you will see three routes with their schemas, and a form that lets you call each one.
Read the routes again with the framework in mind. ticket_id: int in a function whose path contains {ticket_id} becomes a typed path parameter; /tickets/abc returns a 422 before your code runs. ticket_in: TicketIn is a Pydantic model, so FastAPI reads it from the JSON body. The return annotation -> Ticket becomes the response model: the return value is validated and serialised with it, and it is what /docs shows as the response schema. status_code=201 sets the status for the successful case, and HTTPException is how a handler returns an error response deliberately.
What happens to a request
uvicornaccepts the TCP connection, parses HTTP, and hands FastAPI an ASGI event.- FastAPI matches the method and path against the registered routes.
- Path, query, and body values are extracted and validated against the function's signature. Failures become a 422 with Pydantic's error list.
- Your function runs, with plain Python values as arguments.
- The return value is validated against the response model and serialised to JSON.
Because step three happens before your function, a handler never sees a malformed request. Because step five happens after, a handler never has to call json.dumps.
Engineering note. Declare the return type on every route from the first day. It costs nothing, it documents the endpoint, and it stops a stray database field or a
SecretStrfrom leaking into a response when someone refactors the storage layer six months later.
async def or def
Both work. A route declared async def runs on the event loop, so anything it awaits (a database query, an HTTP call, a model API) lets other requests proceed while it waits. A route declared with plain def is run in a thread pool, so a blocking library inside it does not stall the loop.
| Handler | Runs on | Use when |
|---|---|---|
async def |
The event loop | Everything you call is await-able |
def |
A worker thread | You must call a blocking library |
async def with a blocking call inside |
The event loop, blocked | Never; this freezes every other request |
Relay is async def throughout, because SQLAlchemy async, httpx, redis.asyncio, and the model SDKs all offer awaitable clients. The one rule is the third row: do not put time.sleep, requests.get, or a synchronous database driver inside an async def route.
Ways to run it
| Command | Reload | Use |
|---|---|---|
fastapi dev main.py |
Yes | Local development |
fastapi run main.py |
No | Containers and production |
uvicorn main:app --reload --port 8000 |
Yes | When you want uvicorn's flags directly |
All three run the same app object. The fastapi command adds discovery (it finds app for you) and sensible defaults; uvicorn is what runs underneath and is what the Docker lesson uses.
Where it goes wrong
python main.pydoes nothing. There is noif __name__ == "__main__"server start in the file, on purpose; the server is a separate program that imports your app.- A blocking call in an
async defroute. Onerequests.getinside a handler stalls every concurrent request for the duration of that call. Usehttpx.AsyncClient, or declare the route withdef. - Returning something Pydantic cannot serialise. A raw SQLAlchemy row or a
setin the return value fails at step five with a 500. Return a model or a plain dict. Address already in use. A previous dev server is still running; find it withlsof -i :8000(ornetstat -ano | findstr 8000on Windows) or pick another port with--port.- Installing bare
fastapi. Without[standard]there is nofastapicommand and no server.uv add "fastapi[standard]"with the quotes.
Try it
Extend main.py with GET /tickets returning list[Ticket], and DELETE /tickets/{ticket_id} returning status 204 with no body (status_code=204 and no return annotation). Create three tickets through /docs, list them, delete one, and confirm that fetching it afterwards returns the 404 JSON body {"detail": "ticket not found"}. Then send /tickets/abc and read the 422 response carefully; it is the same structure as Pydantic's errors().
The next lesson looks closely at where each argument comes from: path, query string, or body, and how FastAPI decides.
Continue with the complete track
Keep your progress and unlock the surrounding lessons, exercises, and complete learning path.
Unlock the complete track