05.01

Your first FastAPI app

Lesson 01 of 9 · 4:27
skillmaxing
Python with AI
Lesson 01
Your first FastAPI app
0:00 / 4:271x
Notes

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
Figure 1What happens to a requestValidation runs before your function; serialisation runs after it.

What happens to a request

  1. uvicorn accepts the TCP connection, parses HTTP, and hands FastAPI an ASGI event.
  2. FastAPI matches the method and path against the registered routes.
  3. Path, query, and body values are extracted and validated against the function's signature. Failures become a 422 with Pydantic's error list.
  4. Your function runs, with plain Python values as arguments.
  5. 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 SecretStr from 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.py does nothing. There is no if __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 def route. One requests.get inside a handler stalls every concurrent request for the duration of that call. Use httpx.AsyncClient, or declare the route with def.
  • Returning something Pydantic cannot serialise. A raw SQLAlchemy row or a set in 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 with lsof -i :8000 (or netstat -ano | findstr 8000 on Windows) or pick another port with --port.
  • Installing bare fastapi. Without [standard] there is no fastapi command 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.

Free preview

Continue with the complete track

Keep your progress and unlock the surrounding lessons, exercises, and complete learning path.

Unlock the complete track
124 online