- Understand what FastAPI is and how it compares to Flask and Django REST Framework
- Install FastAPI and run a development server with Uvicorn
- Define path operations with
@app.get,@app.post,@app.put,@app.delete,@app.patch - Use path parameters, query parameters, and default values
- Validate request bodies with Pydantic models
- Return typed responses with
response_modeland correct HTTP status codes - Organise routes into reusable
APIRoutermodules
1 · What is FastAPI?
FastAPI is a modern, high-performance web framework for building APIs with Python. It sits on top of
Starlette (the ASGI toolkit that gives it speed and async support) and
Pydantic (which provides data parsing and validation via type hints). Because it is
ASGI-native, FastAPI can handle WebSockets, background tasks, and thousands of concurrent connections with
the same familiar async def syntax.
One of FastAPI's biggest selling points is that it automatically generates interactive API
documentation from your code. Visit /docs for Swagger UI or /redoc for ReDoc —
both are driven by the OpenAPI schema FastAPI builds as it registers your routes.
Key differentiators at a glance:
- Async-first — path operations can be plain
deforasync def; FastAPI runs sync handlers in a thread pool so they never block the event loop. - Type-hint-driven validation — annotate a parameter
user_id: intand FastAPI validates, converts, and documents it automatically. - Zero-boilerplate serialisation — return a dict, a Pydantic model, or a list; FastAPI handles JSON encoding and response headers.
Comparison with Flask and Django REST Framework
| Feature | FastAPI | Flask | Django REST |
|---|---|---|---|
| ASGI / async native | ✅ | ❌ (WSGI) | Partial |
| Auto OpenAPI docs | ✅ | ❌ (plugin) | ❌ (plugin) |
| Pydantic validation | ✅ built-in | ❌ manual | ❌ serializers |
| Performance | ⚡ Very high | Medium | Medium |
| Learning curve | Low–Medium | Low | High |
| Best for | APIs, microservices | Simple apps/APIs | Full-stack Django |
2 · Installation & First App
Install FastAPI together with Uvicorn (the ASGI server):
pip install fastapi uvicorn[standard]terminal
The [standard] extra pulls in httptools and uvloop for
maximum performance, plus python-multipart for form data and
websockets for WebSocket support.
Create your first app in a file called main.py:
# main.py
from fastapi import FastAPI
app = FastAPI(
title="My API",
description="A sample FastAPI application",
version="1.0.0",
)
@app.get("/")
def root():
return {"message": "Hello, World!"}main.pyStart the development server:
uvicorn main:app --reload
# Serving at http://127.0.0.1:8000
# Docs at http://127.0.0.1:8000/docsterminal
The argument main:app tells Uvicorn to look in the module main for an object
called app. The --reload flag enables auto-restart whenever a source file
changes — essential during development, but remove it in production.
http://127.0.0.1:8000/docs to see the interactive Swagger UI.
All your endpoints are documented automatically from type hints — you can even send live requests
directly from the browser without any extra setup.
3 · Path Operations & HTTP Methods
A path operation is a combination of an HTTP method and a URL path. FastAPI registers
them through decorators on the app object:
@app.get("/path")— read a resource or list of resources@app.post("/path")— create a new resource@app.put("/path/{id}")— fully replace a resource@app.patch("/path/{id}")— partially update a resource@app.delete("/path/{id}")— remove a resource
from fastapi import FastAPI
app = FastAPI()
@app.get("/items") # GET — list or read
def list_items():
return [{"id": 1}, {"id": 2}]
@app.post("/items") # POST — create
def create_item():
return {"created": True}
@app.put("/items/{item_id}") # PUT — full replace
def replace_item(item_id: int):
return {"replaced": item_id}
@app.patch("/items/{item_id}") # PATCH — partial update
def update_item(item_id: int):
return {"updated": item_id}
@app.delete("/items/{item_id}") # DELETE — remove
def delete_item(item_id: int):
return {"deleted": item_id}main.py
Each function is called a path operation function. It can be a plain def or an
async def — FastAPI handles both correctly. The return value is automatically serialised
to JSON.
4 · Path Parameters
Path parameters are declared with curly braces in the route string ({user_id}) and as
typed function arguments. FastAPI converts the raw string from the URL to the declared Python type
and raises a validation error if the conversion fails.
from fastapi import FastAPI
app = FastAPI()
# Basic path parameter — type annotation does the validation
@app.get("/users/{user_id}")
def get_user(user_id: int): # FastAPI converts & validates automatically
return {"user_id": user_id}
# Multiple path parameters
@app.get("/users/{user_id}/posts/{post_id}")
def get_post(user_id: int, post_id: int):
return {"user_id": user_id, "post_id": post_id}
# Enum parameter — constrains accepted values
from enum import Enum
class ModelSize(str, Enum):
small = "small"
medium = "medium"
large = "large"
@app.get("/models/{size}")
def get_model(size: ModelSize):
params = {"small": "7B", "medium": "13B", "large": "70B"}
return {"size": size, "params": params[size]}main.py
Using a str, Enum subclass constrains the accepted values at both the validation layer
and in the generated OpenAPI schema — the Swagger UI will display a dropdown instead of a free-text
field.
GET /users/abc, FastAPI automatically
returns 422 Unprocessable Entity with a structured error body that describes exactly
which field failed and why — no manual validation code needed.
5 · Query Parameters
Any function parameter that is not declared in the route path is treated as a
query parameter (e.g. GET /items?skip=10&limit=5). FastAPI reads
the value from the URL query string, converts it to the annotated type, and applies the default if
the parameter is absent.
from fastapi import FastAPI
from typing import Optional
app = FastAPI()
# Query params are any function params that are NOT path params
@app.get("/items")
def list_items(
skip: int = 0, # default 0; GET /items?skip=10
limit: int = 10, # default 10; GET /items?limit=5
search: Optional[str] = None, # optional; GET /items?search=hello
):
return {"skip": skip, "limit": limit, "search": search}
# Required query param — no default value
@app.get("/search")
def search(q: str): # GET /search?q=python (required)
return {"query": q}
# Boolean query param — FastAPI accepts: true/false/1/0/on/off/yes/no
@app.get("/items/active")
def active_items(active: bool = True):
return {"active_filter": active}main.py
A parameter without a default (like q: str above) becomes required; omitting
it returns a 422 error. Parameters with defaults are optional. You can mix path and query parameters
freely in the same function.
6 · Request Bodies with Pydantic
When a client sends JSON in the request body (typically with POST, PUT, or
PATCH), declare a Pydantic model as a function parameter. FastAPI
reads the body, validates every field against the model, and passes a fully typed Python object to
your function.
from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Optional
app = FastAPI()
class Item(BaseModel):
name: str
description: Optional[str] = None
price: float = Field(gt=0, description="Must be positive")
in_stock: bool = True
class ItemUpdate(BaseModel):
name: Optional[str] = None
price: Optional[float] = Field(default=None, gt=0)
in_stock: Optional[bool] = None
# POST — body is the Item model
@app.post("/items")
def create_item(item: Item):
# item is fully validated and typed
return {"received": item.model_dump()}
# PATCH — partial update with ItemUpdate
@app.patch("/items/{item_id}")
def update_item(item_id: int, update: ItemUpdate):
return {"item_id": item_id, "changes": update.model_dump(exclude_none=True)}main.pyFastAPI uses a simple inference rule to classify each parameter:
- Declared in
{braces}in the route path → path parameter - Type is a Pydantic
BaseModel→ request body - Everything else → query parameter
An example JSON body that maps to the Item model above:
{
"name": "Widget",
"description": "A useful thing",
"price": 9.99,
"in_stock": true
}request body (JSON)Optional[T] = None for all fields in a PATCH /
update model. If a field is absent, that means the client does not want to change it. A model where
every field is required would force the client to resend data it does not intend to modify.
7 · Response Models & Status Codes
The response_model parameter on a path operation decorator tells FastAPI to
filter and validate the output through a Pydantic model before sending it to the
client. This is especially useful for stripping internal fields (passwords, tokens, internal IDs)
from the response even if they are present in the object you return.
from fastapi import FastAPI, status
from pydantic import BaseModel
from typing import Optional, List
app = FastAPI()
class ItemIn(BaseModel):
name: str
price: float
secret_token: str # should NOT appear in response
class ItemOut(BaseModel):
id: int
name: str
price: float
# secret_token excluded
# response_model filters and validates the output shape
@app.post(
"/items",
response_model=ItemOut,
status_code=status.HTTP_201_CREATED,
)
def create_item(item: ItemIn):
# secret_token is stripped by response_model
return ItemOut(id=42, name=item.name, price=item.price)
@app.get("/items", response_model=List[ItemOut])
def list_items():
return [
ItemOut(id=1, name="Widget", price=9.99),
ItemOut(id=2, name="Gadget", price=24.99),
]
# Common status codes via fastapi.status
# status.HTTP_200_OK 200
# status.HTTP_201_CREATED 201
# status.HTTP_204_NO_CONTENT 204
# status.HTTP_400_BAD_REQUEST 400
# status.HTTP_401_UNAUTHORIZED 401
# status.HTTP_403_FORBIDDEN 403
# status.HTTP_404_NOT_FOUND 404
# status.HTTP_422_UNPROCESSABLE_ENTITY 422main.pyCommon HTTP Status Codes
| Code | Constant | Meaning | Typical FastAPI use case |
|---|---|---|---|
| 200 | HTTP_200_OK |
OK | Default for GET, PUT, PATCH success |
| 201 | HTTP_201_CREATED |
Created | POST that creates a new resource |
| 204 | HTTP_204_NO_CONTENT |
No Content | DELETE that returns no body |
| 400 | HTTP_400_BAD_REQUEST |
Bad Request | Business-logic validation failure |
| 401 | HTTP_401_UNAUTHORIZED |
Unauthorized | Missing or invalid authentication credentials |
| 403 | HTTP_403_FORBIDDEN |
Forbidden | Authenticated but not authorised |
| 404 | HTTP_404_NOT_FOUND |
Not Found | Resource with given ID does not exist |
| 422 | HTTP_422_UNPROCESSABLE_ENTITY |
Unprocessable Entity | Automatic Pydantic / path-param validation failure |
Always import status codes from fastapi.status rather than hard-coding integers — it
makes intent clear, enables IDE autocompletion, and prevents typos.
APIRouter — Organising Routes
As your app grows, defining every route in main.py becomes unmanageable.
APIRouter lets you split routes into separate modules and mount them
with a prefix and shared tags.
# routers/items.py
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, List
router = APIRouter(
prefix="/items", # prepended to every route in this file
tags=["items"], # groups routes in /docs
)
class Item(BaseModel):
id: int
name: str
price: float
_db: list[Item] = [
Item(id=1, name="Widget", price=9.99),
Item(id=2, name="Gadget", price=24.99),
]
@router.get("/", response_model=List[Item])
def list_items():
return _db
@router.get("/{item_id}", response_model=Item)
def get_item(item_id: int):
for item in _db:
if item.id == item_id:
return item
return None
routers/items.py
# routers/users.py
from fastapi import APIRouter
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/")
def list_users():
return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
routers/users.py
# main.py — wire everything together
from fastapi import FastAPI
from routers import items, users
app = FastAPI(title="My API", version="1.0.0")
app.include_router(items.router)
app.include_router(users.router)
@app.get("/health", tags=["meta"])
def health():
return {"status": "ok"}
main.py
The resulting routes are:
| Method | Path | Tag |
|---|---|---|
| GET | /items/ | items |
| GET | /items/{item_id} | items |
| GET | /users/ | users |
| GET | /health | meta |
myapi/
├── main.py
├── routers/
│ ├── __init__.py
│ ├── items.py
│ └── users.py
├── models/
│ └── schemas.py ← Pydantic models
├── dependencies.py ← shared Depends() functions
└── pyproject.toml
HTTPException & Error Handling
Raise HTTPException to return a specific HTTP error response with
a JSON body, instead of returning None or crashing.
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from typing import Optional
app = FastAPI()
fake_db: dict[int, dict] = {
1: {"name": "Widget", "price": 9.99},
2: {"name": "Gadget", "price": 24.99},
}
@app.get("/items/{item_id}")
def get_item(item_id: int):
if item_id not in fake_db:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item {item_id} not found",
)
return fake_db[item_id]
# ── Custom exception handler ──
from fastapi import Request
from fastapi.responses import JSONResponse
class InsufficientStockError(Exception):
def __init__(self, item_id: int, available: int):
self.item_id = item_id
self.available = available
@app.exception_handler(InsufficientStockError)
async def stock_handler(request: Request, exc: InsufficientStockError):
return JSONResponse(
status_code=409,
content={
"error": "insufficient_stock",
"item_id": exc.item_id,
"available": exc.available,
},
)
error_handling.py
HTTPException — never return None for missing
resources. Returning None from a response_model-typed endpoint
causes a serialisation error; raising 404 gives the client a clear, standard response.
Async Path Operations
FastAPI supports both sync and async path operation functions.
Use async def when your handler does I/O (database queries, HTTP calls, file reads).
import asyncio
import httpx
from fastapi import FastAPI
app = FastAPI()
# Sync handler — fine for CPU-bound or blocking-free logic
@app.get("/sync")
def sync_route():
return {"type": "sync"}
# Async handler — use for I/O: DB, external APIs, file ops
@app.get("/async")
async def async_route():
async with httpx.AsyncClient() as client:
resp = await client.get("https://httpbin.org/get")
return resp.json()
# Concurrent I/O — gather multiple calls
@app.get("/combined")
async def combined():
async with httpx.AsyncClient() as client:
results = await asyncio.gather(
client.get("https://httpbin.org/get"),
client.get("https://httpbin.org/ip"),
)
return [r.json() for r in results]
async_routes.py
async def when calling any awaitable (databases via asyncpg/SQLAlchemy async, Redis, external HTTP). Use plain def for CPU-heavy logic — FastAPI runs sync functions in a thread pool automatically, so they don't block the event loop.
Best Practices
- Always use
response_model— it documents the output shape, strips unexpected fields, and validates what you return. - Separate input and output models — an
ItemIn(with apasswordfield) and anItemOut(without) prevents accidental data leaks. - Use
status.HTTP_*constants over bare integers —status.HTTP_201_CREATEDis self-documenting. - Organise with
APIRouterfrom day one — refactoring a 500-linemain.pylater is painful. - Raise
HTTPException, never return error dicts — standard error responses let clients handle errors reliably. - Use
async deffor I/O,deffor CPU — mixing them incorrectly degrades throughput. - Set
title,description,versiononFastAPI()— they populate the auto-generated docs. - Add
tagsto routers and endpoints — the Swagger UI groups routes by tag, making docs navigable.
Exercises
Exercise 1 — Books CRUD API
Build a complete in-memory CRUD API for a Book resource:
- Define
BookIn(name, author, year, price) andBookOut(same + id) Pydantic models. - Store books in a module-level
dict[int, BookOut]. GET /books— list all, with optional?author=query filter.POST /books→ 201 — create; auto-assign incrementingid.GET /books/{book_id}→ 200 or 404.PUT /books/{book_id}→ 200 or 404 — full replace.DELETE /books/{book_id}→ 204 or 404.
💡 Hint
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from typing import Optional
app = FastAPI()
class BookIn(BaseModel):
name: str
author: str
year: int
price: float
class BookOut(BookIn):
id: int
_books: dict[int, BookOut] = {}
_next_id = 1
@app.get("/books", response_model=list[BookOut])
def list_books(author: Optional[str] = None):
books = list(_books.values())
if author:
books = [b for b in books if b.author.lower() == author.lower()]
return books
@app.post("/books", response_model=BookOut, status_code=status.HTTP_201_CREATED)
def create_book(book: BookIn):
global _next_id
out = BookOut(id=_next_id, **book.model_dump())
_books[_next_id] = out
_next_id += 1
return out
@app.get("/books/{book_id}", response_model=BookOut)
def get_book(book_id: int):
if book_id not in _books:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Book not found")
return _books[book_id]
@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_book(book_id: int):
if book_id not in _books:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Book not found")
del _books[book_id]
Exercise 2 — Router Refactor & Tags
Split the Books API into a proper multi-module project:
- Create
routers/books.py— move all/booksroutes there usingAPIRouter(prefix="/books", tags=["books"]). - Create
routers/health.py— oneGET /healthroute returning{"status": "ok", "version": "1.0.0"}. - Wire both routers in
main.pywithapp.include_router(). - Start the server and verify
/docsshows two tagged groups: books and health.
💡 Hint — main.py
from fastapi import FastAPI
from routers import books, health
app = FastAPI(title="Books API", version="1.0.0")
app.include_router(books.router)
app.include_router(health.router)
Exercise 3 — Async External API Proxy
Build an async FastAPI route that proxies a public REST API:
- Install
httpx:pip install httpx. GET /pokemon/{name}— fetcheshttps://pokeapi.co/api/v2/pokemon/{name}and returns a trimmed response with justname,height,weight, and a list of ability names.- Use
async defandhttpx.AsyncClient. - Return 404 with a clear message if the Pokémon is not found (PokeAPI returns 404).
- Define a
PokemonOutPydantic model for the response shape.
💡 Hint
import httpx
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
app = FastAPI()
class PokemonOut(BaseModel):
name: str
height: int
weight: int
abilities: list[str]
@app.get("/pokemon/{name}", response_model=PokemonOut)
async def get_pokemon(name: str):
async with httpx.AsyncClient() as client:
resp = await client.get(f"https://pokeapi.co/api/v2/pokemon/{name.lower()}")
if resp.status_code == 404:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"Pokemon '{name}' not found")
data = resp.json()
return PokemonOut(
name=data["name"],
height=data["height"],
weight=data["weight"],
abilities=[a["ability"]["name"] for a in data["abilities"]],
)