FastAPI Basics: Type-Hinted APIs
1 · The lesson
readFlask 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: intyou'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.jsonschema. 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
pip install 'fastapi[standard]'
The [standard] extra pulls in the modern fastapi CLI, uvicorn, and httpx for testing. You would install these anyway.
# main.py from fastapi import FastAPI app = FastAPI() @app.get("/") def home(): return {"message": "Hello, FastAPI!"}
Run it:
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.
@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:
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.
@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:
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.
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:
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:
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:
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:
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:
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:
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.
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:
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
@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
@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
fetch(`https://api.example.com/v1/users/${id}`) // duplicated in 40 placesRegenerate 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.
# 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, usecategory: 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
# 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]'andfastapi dev main.pyis 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 withAnnotated[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=...andHTTPExceptionare the success and error paths. Use names fromfastapi.status.- Inject
Responseor return aResponsesubclass for cookies, custom headers, or non-JSON bodies. /docs,/redoc,/openapi.jsonare 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.