Skip to main content

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.delete register 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 BaseModel subclass to describe the shape of a JSON request body — FastAPI validates incoming JSON against it automatically and returns a 422 Unprocessable Entity with field-level errors if it doesn't match.
  • Field(...) adds constraints (min_length, max_length, ge, le) beyond a bare type hint.
  • @field_validator runs 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. 404 when a resource doesn't exist).
  • Set the success status code for a whole route with status_code=201 in the route decorator (e.g. for a POST that 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 /health that returns {'status': 'ok'}.
  • Add a route GET /greet/{name} that returns {'message': f'Hello, {name}!'}.
  • Visit /items/abc on 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 = False to /search.
  • Send a POST to /tasks with an invalid body (e.g. title as a number) and read the 422 error 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 a 404 if the task doesn't exist.
  • Add a query parameter done: Optional[bool] = None to GET /tasks that 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 Field and field_validator constraints from the Examples section to your Task model.
  • Send a request that violates two constraints at once and read both errors in the response body.
  • Add a custom HTTPException with status_code=400 for 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 /docs and use "Try it out" to call POST /tasks directly from the browser.
  • Add a docstring and a summary= argument to one of your routes and see where it appears in /docs.
  • Open /openapi.json and find the schema generated for your Task model.
💡 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.py using requests to 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 404 gracefully.
💡 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'])