Building APIs with FastAPI
Introduction
FastAPI is a modern Python framework for building your own APIs — the server side of what the previous chapter called with requests. Its main advantage over writing raw HTTP handling yourself is automatic validation and documentation: you describe your data with plain Python type hints and Pydantic models, and FastAPI generates request parsing, validation, error responses, and interactive docs from that description.
All examples below go in a file called main.py, run with uvicorn:
pip install fastapi "uvicorn[standard]"
uvicorn main:app --reload
The --reload flag restarts the server automatically whenever you save main.py.
By the end of this chapter you will be able to:
- Build a REST API with path parameters, query parameters, and request bodies.
- Validate incoming data automatically using Pydantic models.
- Return proper HTTP status codes and error responses using
HTTPException. - Read FastAPI's automatically generated Swagger UI documentation.
Concepts
Routes and parameters
@app.get('/path'),@app.post,@app.put,@app.deleteregister a route for an HTTP method.- A function parameter that appears in the path (e.g.
{item_id}) is a path parameter; FastAPI converts and validates it against the type hint (item_id: int). - A function parameter that does not appear in the path is treated as a query parameter, with any default value making it optional.
Request bodies with Pydantic
- Define a
BaseModelsubclass to describe the shape of a JSON request body — FastAPI validates incoming JSON against it automatically and returns a422 Unprocessable Entitywith field-level errors if it doesn't match. Field(...)adds constraints (min_length,max_length,ge,le) beyond a bare type hint.@field_validatorruns custom validation logic that a type/constraint alone can't express.
Errors and status codes
- Raise
HTTPException(status_code=..., detail=...)to return a structured error response for a specific case (e.g.404when a resource doesn't exist). - Set the success status code for a whole route with
status_code=201in the route decorator (e.g. for aPOSTthat creates something).
Interactive docs
- FastAPI serves Swagger UI at
/docs, ReDoc at/redoc, and the raw OpenAPI schema at/openapi.json— all generated from your route definitions and models, no extra code required.
Examples
A first app with a path parameter:
# main.py
from fastapi import FastAPI
app = FastAPI(title='Task Manager API')
@app.get('/')
def read_root():
return {'message': 'Task Manager API is running'}
@app.get('/items/{item_id}')
def read_item(item_id: int):
return {'item_id': item_id}
GET / -> {"message": "Task Manager API is running"}
GET /items/42 -> {"item_id": 42}
GET /items/abc -> 422 Unprocessable Entity (FastAPI validates item_id is an int)
Query parameters and a Pydantic request body:
from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional
app = FastAPI(title='Task Manager API')
class Task(BaseModel):
title: str
done: bool = False
priority: Optional[int] = None
@app.get('/search')
def search_tasks(keyword: str, limit: int = 10):
return {'keyword': keyword, 'limit': limit}
@app.post('/tasks')
def create_task(task: Task):
return {'received': task}
Full CRUD with an in-memory store:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title='Task Manager API')
tasks: dict[int, dict] = {}
next_id = 1
class Task(BaseModel):
title: str
done: bool = False
@app.get('/tasks')
def list_tasks():
return tasks
@app.post('/tasks', status_code=201)
def create_task(task: Task):
global next_id
tasks[next_id] = {**task.model_dump(), 'id': next_id}
next_id += 1
return tasks[next_id - 1]
@app.put('/tasks/{task_id}')
def update_task(task_id: int, task: Task):
if task_id not in tasks:
raise HTTPException(status_code=404, detail='Task not found')
tasks[task_id] = {**task.model_dump(), 'id': task_id}
return tasks[task_id]
@app.delete('/tasks/{task_id}', status_code=204)
def delete_task(task_id: int):
if task_id not in tasks:
raise HTTPException(status_code=404, detail='Task not found')
del tasks[task_id]
Validation constraints:
from pydantic import BaseModel, Field, field_validator
class Task(BaseModel):
title: str = Field(min_length=1, max_length=100)
priority: int = Field(default=3, ge=1, le=5)
@field_validator('title')
@classmethod
def title_not_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError('title cannot be blank')
return v
POST /tasks {'title': '', 'priority': 9}
-> 422 with error details for both 'title' (blank) and 'priority' (must be <= 5)
Calling your own API with requests (client.py):
import requests
BASE_URL = 'http://127.0.0.1:8000'
create = requests.post(f'{BASE_URL}/tasks', json={'title': 'Write report'})
task_id = create.json()['id']
update = requests.put(f'{BASE_URL}/tasks/{task_id}', json={'title': 'Write report', 'done': True})
delete = requests.delete(f'{BASE_URL}/tasks/{task_id}')
print('Deleted, status:', delete.status_code)
Challenges
1. Health and greeting routes
- Add a route
GET /healththat returns{'status': 'ok'}. - Add a route
GET /greet/{name}that returns{'message': f'Hello, {name}!'}. - Visit
/items/abcon the first example above and note how FastAPI responds without you writing any validation code.
💡 Show Answer
@app.get('/health')
def health():
return {'status': 'ok'}
@app.get('/greet/{name}')
def greet(name: str):
return {'message': f'Hello, {name}!'}
/items/abc returns 422 Unprocessable Entity with a body explaining item_id must be a valid integer — FastAPI derives this purely from the item_id: int type hint.
2. Optional query parameters
- Add an optional query parameter
done_only: bool = Falseto/search. - Send a POST to
/taskswith an invalid body (e.g.titleas a number) and read the422error detail.
💡 Show Answer
@app.get('/search')
def search_tasks(keyword: str, limit: int = 10, done_only: bool = False):
return {'keyword': keyword, 'limit': limit, 'done_only': done_only}
POST /tasks with {'title': 123} returns 422, with detail listing title and the message "Input should be a valid string" — Pydantic rejects the wrong type before your function body even runs.
3. Get a single task, and a filter on the list
- Add a
GET /tasks/{task_id}route that raises a404if the task doesn't exist. - Add a query parameter
done: Optional[bool] = NonetoGET /tasksthat filters the list only when supplied.
💡 Show Answer
from typing import Optional
@app.get('/tasks/{task_id}')
def get_task(task_id: int):
if task_id not in tasks:
raise HTTPException(status_code=404, detail='Task not found')
return tasks[task_id]
@app.get('/tasks')
def list_tasks(done: Optional[bool] = None):
if done is None:
return tasks
return {tid: t for tid, t in tasks.items() if t['done'] == done}
4. Stronger validation
- Add the
Fieldandfield_validatorconstraints from the Examples section to yourTaskmodel. - Send a request that violates two constraints at once and read both errors in the response body.
- Add a custom
HTTPExceptionwithstatus_code=400for the case where a duplicate title is submitted.
💡 Show Answer
@app.post('/tasks', status_code=201)
def create_task(task: Task):
if any(t['title'] == task.title for t in tasks.values()):
raise HTTPException(status_code=400, detail='A task with this title already exists')
global next_id
tasks[next_id] = {**task.model_dump(), 'id': next_id}
next_id += 1
return tasks[next_id - 1]
POST /tasks {'title': '', 'priority': 9} returns a 422 body with two entries in detail, one for title (fails min_length=1 and the custom validator) and one for priority (fails le=5) — Pydantic reports every failing field in a single response rather than stopping at the first.
5. Explore the interactive docs
- With the server running, open
/docsand use "Try it out" to callPOST /tasksdirectly from the browser. - Add a docstring and a
summary=argument to one of your routes and see where it appears in/docs. - Open
/openapi.jsonand find the schema generated for yourTaskmodel.
💡 Show Answer
@app.post('/tasks', status_code=201, summary='Create a new task')
def create_task(task: Task):
"""Creates a task and assigns it the next available id."""
...
The summary appears as the route's one-line label in the /docs list; the docstring appears as the longer description underneath when you expand that route. In /openapi.json, look under components.schemas.Task for the generated JSON Schema (properties, required) matching your Pydantic model field-for-field.
6. A client that exercises every route
- With the server running in one terminal, write
client.pyusingrequeststo create, list, update, and delete a task, printing each step's result. - Add a call that intentionally requests a deleted task and confirm your client handles the
404gracefully.
💡 Show Answer
import requests
BASE_URL = 'http://127.0.0.1:8000'
create = requests.post(f'{BASE_URL}/tasks', json={'title': 'Write report'})
print('Created:', create.status_code, create.json())
task_id = create.json()['id']
print('All tasks:', requests.get(f'{BASE_URL}/tasks').json())
update = requests.put(f'{BASE_URL}/tasks/{task_id}', json={'title': 'Write report', 'done': True})
print('Updated:', update.json())
delete = requests.delete(f'{BASE_URL}/tasks/{task_id}')
print('Deleted, status:', delete.status_code)
again = requests.get(f'{BASE_URL}/tasks/{task_id}')
if again.status_code == 404:
print('Confirmed gone:', again.json()['detail'])