PythonMastery
intermediate 22 min read · lesson 5 of 12 in Web Frameworks

FastAPI Basics: Type-Hinted APIs

1 · The lesson

read

Flask handed you routing and got out of the way. FastAPI does the same — and then quietly reads your type hints, validates every incoming request against them, serialises every response, and generates interactive API documentation that stays in sync with the code because it is the code. The same int you'd write for any function parameter becomes runtime validation, an OpenAPI schema entry, a Swagger UI form, and a generated TypeScript client — all from one annotation.

Run locally with pip install 'fastapi[standard]' and fastapi dev main.py. Expected output shown in comments.

This lesson is the syntax, the Pydantic foundation that powers it, and the half-dozen idioms you'll use on every endpoint you ever write.


1. Why FastAPI

Three frameworks dominate Python web work — Flask, Django, FastAPI. The pitch for FastAPI:

  • Async-first. Built on Starlette + asyncio. A single worker handles thousands of concurrent connections for I/O-bound work without the threadpool gymnastics Flask needs.
  • Type hints become validation. The same user_id: int you'd write for any function is parsed, validated, and documented automatically.
  • OpenAPI for free. Interactive Swagger docs at /docs, ReDoc at /redoc, and a machine-readable /openapi.json schema — auto-generated, always in sync with your code.
  • Fastest of the big three for I/O-bound work. The benchmarks vary, the order doesn't.

The trade-off — Pydantic adds a learning curve, and the magic that makes type hints "just work" leaks when you bypass it. Worth it on any project bigger than a webhook.


2. Hello, FastAPI

bash
pip install 'fastapi[standard]'

The [standard] extra pulls in the modern fastapi CLI, uvicorn, and httpx for testing — the things you'd install anyway.

python
# main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def home():
    return {"message": "Hello, FastAPI!"}

Run it:

bash
fastapi dev main.py
# INFO     Will watch for changes in these directories: ['/app']
# INFO     Uvicorn running on http://127.0.0.1:8000
# INFO     Application startup complete.

fastapi dev is the modern replacement for uvicorn main:app --reload. It enables auto-reload on file changes, picks a sensible host/port, and prints links to the docs. For production you'll use fastapi run (no reload, multiple workers).

Hit http://127.0.0.1:8000/ — you get {"message": "Hello, FastAPI!"}. The function returned a dict; FastAPI serialised it to JSON and set the Content-Type header. No jsonify, no make_response.


3. The Free Documentation

Open http://127.0.0.1:8000/docs in a browser. You get Swagger UI — an interactive page listing every endpoint, every parameter, every response shape, with a "Try it out" button that fires real requests against your running app. http://127.0.0.1:8000/redoc gives you a cleaner read-only view.

Both are generated from http://127.0.0.1:8000/openapi.json — a machine-readable OpenAPI 3.1 document that any client generator (openapi-typescript, openapi-python-client, the OpenAPI Generator project) can turn into a typed SDK for your API.

This is not a nice-to-have. It is the killer feature. Every endpoint you add appears in the docs automatically. Every parameter you type-hint shows up as a form field. Every Pydantic response model becomes a JSON-schema example. The docs cannot drift from the code, because the code is the spec.


4. Path Parameters

A URL segment in {braces} becomes a function parameter. Add a type hint and FastAPI parses, validates, and documents it.

python
@app.get("/users/{user_id}")
def get_user(user_id: int):
    return {"user_id": user_id, "type": type(user_id).__name__}

# GET /users/42      -> {"user_id": 42, "type": "int"}
# GET /users/abc     -> 422 Unprocessable Entity
#                       {"detail":[{"type":"int_parsing", ...}]}
+ setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')

Without the type hint you'd get a string. With it, "abc" is rejected at the framework boundary with a structured error — your function never sees the bad value. The same applies to float, bool, UUID, datetime, and any Pydantic model.

For constrained types — must be positive, must match a regex, must be in a range — use Path with Annotated:

python
from typing import Annotated
from fastapi import Path

@app.get("/users/{user_id}")
def get_user(user_id: Annotated[int, Path(ge=1, le=10_000)]):
    return {"user_id": user_id}

# GET /users/0       -> 422   {"detail":[{"type":"greater_than_equal", ...}]}
+ setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')

5. Query Parameters

Function parameters that aren't in the path become query-string parameters. Defaults make them optional.

python
@app.get("/items/")
def list_items(skip: int = 0, limit: int = 10, q: str | None = None):
    return {"skip": skip, "limit": limit, "q": q}

# GET /items/                                -> {"skip":0,"limit":10,"q":null}
# GET /items/?skip=20&limit=5                -> {"skip":20,"limit":5,"q":null}
# GET /items/?q=widget                       -> {"skip":0,"limit":10,"q":"widget"}
# GET /items/?limit=abc                      -> 422 validation error
+ setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')

Constrained queries use Query the same way path parameters use Path:

python
from fastapi import Query

@app.get("/items/")
def list_items(
    skip: Annotated[int, Query(ge=0)] = 0,
    limit: Annotated[int, Query(ge=1, le=100)] = 10,
    q: Annotated[str | None, Query(min_length=2, max_length=50)] = None,
):
    return {"skip": skip, "limit": limit, "q": q}
+ setup added so this can run · defines app, Annotated
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')
Annotated = _AutoMock('Annotated')

Annotated[T, Query(...)] is the modern idiom — it keeps the type clean and stacks cleanly with other metadata. The older Query(default, ...) form still works but the FastAPI docs have moved on.


6. Request Bodies — Pydantic Enters

For anything bigger than a query string — JSON payloads, nested objects, lists — you define a Pydantic model. The model is the schema; FastAPI parses the request body against it, validates every field, and you get a typed Python object inside your handler.

python
from pydantic import BaseModel, EmailStr, Field

class Item(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: float = Field(gt=0)
    is_offer: bool = False
    tags: list[str] = []

@app.post("/items/")
def create_item(item: Item):
    return {"received": item, "total_with_tax": item.price * 1.2}
+ setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')

Send {"name": "widget", "price": 9.99} — works, is_offer defaults to False, tags defaults to []. Send {"name": "", "price": -1} — 422 with a precise per-field error list. Send {"name": "widget"} — 422, price is required.

Field(...) adds constraints (min_length, max_length, gt, ge, lt, le, regex, default, examples). EmailStr and HttpUrl give you common validated string types — install with pip install 'pydantic[email]' for the email validator.

Nested models compose naturally:

python
class Address(BaseModel):
    street: str
    city: str
    postcode: str

class User(BaseModel):
    name: str
    email: EmailStr
    addresses: list[Address] = []

@app.post("/users/")
def create_user(user: User):
    return user
+ setup added so this can run · defines BaseModel, EmailStr, app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

BaseModel = _AutoMock('BaseModel')
EmailStr = _AutoMock('EmailStr')
app = _AutoMock('app')

The OpenAPI schema, Swagger UI, and validation logic all derive from one place — the model definition.


7. Response Models — Don't Leak Internals

Returning a database record directly is convenient and dangerous. The record has a password_hash, an internal_notes field, a customer_id you shouldn't expose. Use response_model to filter the response through a different Pydantic schema:

python
class UserIn(BaseModel):
    name: str
    email: EmailStr
    password: str

class UserOut(BaseModel):
    name: str
    email: EmailStr
    # no password field — it cannot leak

@app.post("/users/", response_model=UserOut, status_code=201)
def create_user(user: UserIn):
    saved = save_to_db(user)              # returns dict with id, password_hash, ...
    return saved
    # FastAPI projects `saved` through UserOut — extra fields are dropped
+ setup added so this can run · defines BaseModel, EmailStr, save_to_db, app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

BaseModel = _AutoMock('BaseModel')
EmailStr = _AutoMock('EmailStr')
def save_to_db(*_a, **_kw):
    print('-> save_to_db() called')
    return _AutoMock('save_to_db()')
app = _AutoMock('app')

Two models for one endpoint is the standard pattern: *In for what the client sends, *Out for what you return. Sensitive fields exist on neither end of the public API; they live in your DB layer and never escape.

response_model_exclude_unset=True, response_model_exclude_none=True, and response_model_by_alias=True give you per-route control over the serialised shape — useful for PATCH semantics where unset fields shouldn't appear in the response.


8. Status Codes and HTTPException

The default success status is 200. Override per-route:

python
from fastapi import status

@app.post("/items/", status_code=status.HTTP_201_CREATED)
def create_item(item: Item):
    return item
+ setup added so this can run · defines Item, app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Item = _AutoMock('Item')
app = _AutoMock('app')

For error paths — not found, forbidden, conflict — raise HTTPException:

python
from fastapi import HTTPException

@app.get("/items/{item_id}", response_model=Item)
def get_item(item_id: int):
    item = db.get(item_id)
    if item is None:
        raise HTTPException(
            status_code=404,
            detail="Item not found",
            headers={"X-Error-Code": "ITEM_404"},
        )
    return item
+ setup added so this can run · defines app, Item, db
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')
Item = _AutoMock('Item')
db = _AutoMock('db')

HTTPException produces a JSON body {"detail": "Item not found"} with the correct status. The headers kwarg adds response headers — useful for WWW-Authenticate on 401s, or rate-limit hints on 429s. For more elaborate error bodies, register a custom exception handler with @app.exception_handler(SomeException).

Common codes you'll reach for, from the fastapi.status module: HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, HTTP_422_UNPROCESSABLE_ENTITY, HTTP_429_TOO_MANY_REQUESTS, HTTP_500_INTERNAL_SERVER_ERROR.


9. The Response Object — Custom Headers, Cookies, Bytes

Returning a dict is enough 90% of the time. When you need control — custom headers on a success response, a cookie, raw bytes, an explicit Content-Type — declare a Response parameter or return one directly:

python
from fastapi import Response

@app.get("/cached")
def cached_endpoint(response: Response):
    response.headers["Cache-Control"] = "public, max-age=3600"
    response.set_cookie(key="visited", value="yes", max_age=86_400, httponly=True)
    return {"data": "..."}

@app.get("/binary")
def png():
    image_bytes = b"\x89PNG..."
    return Response(content=image_bytes, media_type="image/png")
+ setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')

Inject Response and mutate it for header/cookie tweaks while still returning a dict. Return a Response directly when the body isn't JSON. FileResponse, StreamingResponse, RedirectResponse, and HTMLResponse are specialised subclasses for common cases.


10. Static Files

For a frontend bundle, asset directory, or a tiny landing page, mount a static directory:

python
from fastapi.staticfiles import StaticFiles

app.mount("/static", StaticFiles(directory="static"), name="static")
# GET /static/logo.png      -> serves ./static/logo.png
# GET /static/app.js        -> serves ./static/app.js
+ setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')

mount attaches a sub-application — the path prefix is stripped before the static handler sees the request. For a Single Page App where unknown routes should fall back to index.html, pass html=True:

python
app.mount("/", StaticFiles(directory="dist", html=True), name="spa")
+ setup added so this can run · defines app, StaticFiles
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')
def StaticFiles(*_a, **_kw):
    print('-> StaticFiles() called')
    return _AutoMock('StaticFiles()')

Mount static files after your API routes so the catch-all doesn't shadow /api/....


11. The OpenAPI Advantage

Hit /openapi.json — you get a structured description of every route, parameter, body, response, and status code your app exposes. Feed it to a client generator and you get a typed SDK in any language. The TypeScript flow:

bash
npx openapi-typescript http://127.0.0.1:8000/openapi.json -o api.d.ts

Your frontend now has compile-time types for every endpoint. Add a route, regenerate, the frontend's typechecker tells you what changed. Rename a field in a response model, regenerate, the frontend stops compiling on every call site that used the old name.

This is the workflow FastAPI was designed for. Hand-writing client types — and worse, hand-writing API docs — is a maintenance tax FastAPI removes.


Common Mistakes

1. Forgetting type hints

python
@app.get("/users/{user_id}")
def get_user(user_id):                  # no hint — treated as str
    return {"id": user_id, "type": type(user_id).__name__}
# GET /users/42 -> {"id": "42", "type": "str"}
+ setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

app = _AutoMock('app')

Without the hint, FastAPI treats the parameter as a string, no validation, no OpenAPI typing, no Swagger field. You've used FastAPI like a slow Flask. The type hint is not optional — it's the whole point.

2. Putting business logic in the path function

python
@app.post("/orders/")
def create_order(order: Order):
    # 80 lines of validation, DB calls, email sending, inventory checks
    ...
+ setup added so this can run · defines Order, app
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

Order = _AutoMock('Order')
app = _AutoMock('app')

The path function should marshal the request, call a service, and return the result. Business logic belongs in plain Python functions or service classes that don't import FastAPI at all — easier to test, easier to reuse, no framework lock-in. Use dependency injection to wire services into routes.

3. Returning DB models directly

A SQLAlchemy User row contains password_hash, internal_notes, customer_segment, and the created_by_admin_id. Returning it leaks every field. Define a UserOut Pydantic model, set response_model=UserOut, sleep at night.

4. Hardcoding URLs in clients

typescript
fetch(`https://api.example.com/v1/users/${id}`)   // duplicated in 40 places

Regenerate a typed client from /openapi.json. Endpoint URL, method, body shape, response shape — all become functions you call. When you rename the route, the call sites break at compile time instead of in production.

5. Mixing path and query parameters incorrectly

Path parameters are required and appear in the URL path. Query parameters are optional (or have defaults) and appear after ?. Putting user_id: int = 0 for a parameter named in the path raises a validation error at startup — defaults on path params don't make sense.


🎯 Your Turn — Build a Products API

Build a /products API with three endpoints, using an in-memory dict as your "database":

  • GET /products?category=electronics — list products, optionally filtered by category.
  • POST /products/ — create a product from a Pydantic body (return 201).
  • GET /products/{product_id} — fetch one product, 404 if missing.

Use Pydantic models for input and output, with name (1-100 chars), price (gt 0), category (one of "electronics", "books", "clothing"). Return appropriate status codes and HTTPException(404) for missing items.

python
# main.py
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
from typing import Literal

app = FastAPI()

# TODO 1: Define ProductIn (no id) and ProductOut (with id) Pydantic models
# TODO 2: In-memory store: dict[int, ProductOut]; next_id counter
# TODO 3: GET /products with optional ?category= query
# TODO 4: POST /products/ -> 201, returns the created product with assigned id
# TODO 5: GET /products/{product_id} -> 404 via HTTPException if missing
Hint 1 — Literal for enum-like fields For a fixed set of allowed strings, use category: Literal["electronics", "books", "clothing"]. Pydantic enforces membership at validation time; the OpenAPI schema renders it as an enum. Cleaner than a free-form str with a custom validator for simple cases.
Hint 2 — Two models, one DB shape ProductIn is what the client sends (no id). ProductOut is what you store and return (with id). On POST, build a ProductOut from the ProductIn plus the next id: ProductOut(id=next_id, **product_in.model_dump()).
Show full solution
python
# main.py
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
from typing import Literal

app = FastAPI(title="Products API", version="1.0.0")

Category = Literal["electronics", "books", "clothing"]


class ProductIn(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: float = Field(gt=0)
    category: Category


class ProductOut(ProductIn):
    id: int


# In-memory "DB" — fine for a lesson, never for production
_db: dict[int, ProductOut] = {}
_next_id = 1


@app.get("/products", response_model=list[ProductOut])
def list_products(category: Category | None = None):
    items = list(_db.values())
    if category is not None:
        items = [p for p in items if p.category == category]
    return items


@app.post(
    "/products/",
    response_model=ProductOut,
    status_code=status.HTTP_201_CREATED,
)
def create_product(product: ProductIn):
    global _next_id
    new = ProductOut(id=_next_id, **product.model_dump())
    _db[_next_id] = new
    _next_id += 1
    return new


@app.get("/products/{product_id}", response_model=ProductOut)
def get_product(product_id: int):
    product = _db.get(product_id)
    if product is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Product {product_id} not found",
        )
    return product

Run with fastapi dev main.py and exercise it from the Swagger UI at /docs. The shape is the production template for a small resource API — one Pydantic input model per request shape, one output model per response shape, an explicit 404 path, status codes from the status module rather than magic numbers.

The next step in a real app: replace the dict with a database via a dependency, add an update/delete pair, lift the routes into an APIRouter, and stop using a module-level counter for IDs.


What You Learned

  • pip install 'fastapi[standard]' and fastapi dev main.py is the modern install + run combo.
  • Type hints drive everything — parsing, validation, OpenAPI schema, Swagger UI. Without hints you have a slow Flask.
  • Path parameters in {braces} map to typed function args. Constrain with Annotated[int, Path(ge=1)].
  • Query parameters are non-path args with defaults. Constrain with Annotated[..., Query(...)].
  • Pydantic models define request bodies and response shapes — typed, validated, documented from one source.
  • response_model=... projects the return value through a schema — the safe way to hide internal fields.
  • status_code=... and HTTPException are the success and error paths. Use names from fastapi.status.
  • Inject Response or return a Response subclass for cookies, custom headers, or non-JSON bodies.
  • /docs, /redoc, /openapi.json are free, always in sync, and the basis for typed client generation.
  • The path function is glue — keep business logic out of it.

Next: FastAPI Async — async def endpoints, the event loop in a real web server, background tasks, websockets, and the lifespan handler that replaces the old startup/shutdown events.