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

FastAPI Basics: Type-Hinted APIs

1 · The lesson

read

Flask gave you routing and left the rest to you. FastAPI also gives you routing, but it does more. It reads your type hints, validates every incoming request against them, serialises every response, and generates interactive API documentation. The documentation stays in sync with the code because it is generated from 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 type in a generated TypeScript client. All of this comes from one annotation.

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

This lesson covers the syntax, the Pydantic library that FastAPI is built on, and the six or so patterns you'll use on almost every endpoint.


1. Why FastAPI

Three frameworks are used most for Python web work: Flask, Django and FastAPI. The case for FastAPI:

  • Async-first. Built on Starlette + asyncio. For I/O-bound work, a single worker can handle thousands of concurrent connections. Flask needs many threads or processes to do the same.
  • Type hints become validation. The same user_id: int you'd write for any function is parsed, validated, and documented automatically.
  • OpenAPI with no extra work. Interactive Swagger docs at /docs, ReDoc at /redoc, and a machine-readable /openapi.json schema. All three are generated automatically and always match your code.
  • Usually the fastest of the three for I/O-bound work. Benchmark numbers vary, but FastAPI is usually ahead.

The trade-off: Pydantic takes time to learn, and the automatic type-hint handling stops applying when you bypass it (for example, when you return a Response object directly). It is worth it for any project bigger than a single webhook.


2. Hello, FastAPI

bash
pip install 'fastapi[standard]'

The [standard] extra pulls in the modern fastapi CLI, uvicorn, and httpx for testing. You would install these 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 reloads the app when a file changes, picks a sensible host and port, and prints links to the docs. For production you'll use fastapi run, which does not auto-reload and can run several worker processes.

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


3. The Automatic Documentation

Open http://127.0.0.1:8000/docs in a browser. You get Swagger UI: an interactive page that lists every endpoint, every parameter and every response shape. It has a "Try it out" button that sends real requests to 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 FastAPI's most important 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 get out of date, because they are generated from the code.


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      returns {"user_id": 42, "type": "int"}
# GET /users/abc     returns 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, FastAPI rejects "abc" with a structured error before your function runs, so 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       returns 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/                                returns {"skip":0,"limit":10,"q":null}
# GET /items/?skip=20&limit=5                returns {"skip":20,"limit":5,"q":null}
# GET /items/?q=widget                       returns {"skip":0,"limit":10,"q":"widget"}
# GET /items/?limit=abc                      returns 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 style. It keeps the type separate from the extra rules, and it combines well with other metadata. The older Query(default, ...) form still works, but the FastAPI docs now use Annotated.


6. Request Bodies: Enter Pydantic

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')

If you send {"name": "widget", "price": 9.99}, it works: is_offer defaults to False and tags defaults to []. If you send {"name": "", "price": -1}, you get a 422 with a precise error for each field. If you send {"name": "widget"}, you get a 422 because price is required.

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

Models can contain other models:

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 come from one place: the model definition.


7. Response Models: Don't Expose Internal Fields

Returning a database record directly is convenient, but 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, so the password 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 filters `saved` through UserOut, so 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 are in neither model. They stay in your DB layer and are never sent to the client.

response_model_exclude_unset=True, response_model_exclude_none=True, and response_model_by_alias=True give you per-route control over the shape of the serialised response. This is useful for PATCH requests, 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 errors (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, which is 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 use, 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 usually enough. When you need more 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')

To change headers or cookies while still returning a dict, inject Response and modify it. 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 removed before the static handler sees the request. html=True serves index.html for directory paths like /. It does not send unknown routes such as /settings to index.html. For a Single Page App with client-side routes, add a catch-all route that returns index.html.

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 that the catch-all doesn't hide /api/....


11. The OpenAPI Advantage

Open /openapi.json and 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. If you add a route and regenerate the client, the frontend's type checker shows you what changed. If you rename a field in a response model and regenerate, the frontend fails to compile at every place that used the old name.

This is the workflow FastAPI was designed for. With FastAPI, you don't have to write client types or API docs by hand and then keep them up to date.


Common Mistakes

1. Forgetting type hints

python
@app.get("/users/{user_id}")
def get_user(user_id):                  # no type hint, so FastAPI treats it as str
    return {"id": user_id, "type": type(user_id).__name__}
# GET /users/42 returns {"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 type in the OpenAPI schema, no typed Swagger field. You lose the main benefit of FastAPI. Always write the type hint.

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 read 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. They are easier to test and reuse, and they don't tie you to one framework. 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 and set response_model=UserOut.

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. The endpoint URL, method, body shape and 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 ?. If you write user_id: int = 0 for a parameter named in the path, FastAPI raises no error, but it ignores the default: the parameter is still required, and a request to /users/ without an ID gets a 404. A default on a path parameter has no effect, so leave it out.


🎯 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/ returns 201 and the created product with its new id
# TODO 5: GET /products/{product_id} raises HTTPException(404) if missing
Hint 1: Literal for enum-like fields For a fixed set of allowed strings, use category: Literal["electronics", "books", "clothing"]. Pydantic checks that the value is one of them, and the OpenAPI schema shows it as an enum. For simple cases, this is cleaner than a free-form str with a custom validator.
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


# An in-memory "DB" is fine for a lesson, but never use one in 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 it with fastapi dev main.py and try it from the Swagger UI at /docs. This structure is a good template for a small production 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 update and delete endpoints, move 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, the OpenAPI schema and Swagger UI. Without hints, you lose these features.
  • 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. They are typed, validated and documented from one source.
  • response_model=... passes the return value through a schema. This is 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 generated automatically, always in sync, and the basis for typed client generation.
  • The path function only connects the request to your code. 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.

Found this useful? Share it with a friend who’s learning Python.