FastAPI reads the type annotations you write anyway and uses them three times: to decide what to accept from a request, to decide what to return, and to write the OpenAPI documentation. Here the type of a parameter is not a comment: it is the endpoint's contract. That is why a five-route service can be written in an evening, and also why a wrong type stops being a silent bug and becomes a 422 response.
In this article you build a small customer-directory service, one step at a time: minimal server,
parameters, request body, errors, documentation, configuration and folder structure. Every listing
was actually run on 2026-11-12 with FastAPI 0.136.3, Pydantic
2.13.4, pydantic-settings 2.14.1 and email-validator
2.3.0 on CPython 3.12.13. The lines that start with # Output: were copied
from the interpreter's output. When I looked it up, PyPI listed 0.162.5 as the latest FastAPI
version, requiring Python 3.10 or higher: I did not try that one, and I make no claims about the
differences between the two versions.
A note on method: the listings use TestClient, which calls the application in the same
process, without opening ports and without starting a server. So the uvicorn and
fastapi dev commands you find below are the right ones, but I did not run
them: of the servers you will only see the response the application would have given.
The minimal server and what it generates on its own
One virtual environment per project, always: installing FastAPI system-wide means two projects
fight over the same version. The standard extra brings with it Uvicorn, the
httpx client that TestClient needs, the fastapi command line
and email-validator, which you will need shortly for EmailStr: I read it in
the installed package's metadata (Provides-Extra: standard), I am not taking it for
granted.
# Install commands: template to adapt, not run here
python -m venv .venv
source .venv/bin/activate # on Windows: .venv\Scripts\activate
pip install "fastapi[standard]" pydantic-settings
The smallest possible server has one instance and one route. The three constructor arguments
(title, description, version) do not change the behavior:
they end up in the info section of the OpenAPI schema, and that is where whoever
consumes your API will read them. The @app.get("/") decorator registers the function
under the GET method and the / path; the dictionary you return is serialized to JSON.
# main.py
from fastapi import FastAPI
app = FastAPI(
title="Customer directory",
description="Sample API built with FastAPI and Python type hints",
version="1.0.0",
)
@app.get("/")
def read_root():
return {"message": "Service running", "status": "running"}
# Output:
# fastapi 0.136.3
# 200 {'message': 'Service running', 'status': 'running'}
# 200 200
# Customer directory 1.0.0 ['/']
# 3.1.0
The third line of the output is the one that matters: /docs and /redoc
answer 200 without you having written a line of documentation, and the schema (3.1.0 is the
OpenAPI specification version this FastAPI emits) already contains your route and the three
metadata fields. To see it in the browser, start the server with fastapi dev
main.py, or with uvicorn main:app --reload (main is the file,
app the variable, --reload restarts on every change: it is a development
convenience, not a production one). Then open http://127.0.0.1:8000/docs. Both
interfaces, Swagger UI and ReDoc, are described on the
First Steps page of the documentation.
Path and query parameters: the type is the contract
FastAPI decides where to take each parameter from by looking at the function signature. If the name
appears in the path (/users/{user_id}) it is a path parameter; if it is a simple type
that does not appear in the path it is a query parameter; if it is a Pydantic model it is the body.
Without a default value the parameter is required, with a default value it is optional. The type
does the rest: int means «convert, and if you can't, reject».
For bounds (greater than, less than) you use Path() and Query(). Here I
write them inside Annotated, which leaves the default value free: in
limit the = 10 after the annotation is the default, and ge=1, le=100
is the constraint. The concrete case is paginating a list: a client asking for
limit=500 must not be able to make the database read five hundred rows at a time.
# params.py
from typing import Annotated
from fastapi import FastAPI, Path, Query
app = FastAPI()
@app.get("/users/{user_id}")
def get_user(user_id: int):
return {"user_id": user_id}
@app.get("/items/{item_id}")
def get_item(
item_id: Annotated[int, Path(title="Item code", ge=1, le=1000)],
):
return {"item_id": item_id}
@app.get("/search")
def search_users(
q: str, # required: no default
page: int = 1, # optional
limit: Annotated[int, Query(ge=1, le=100)] = 10, # optional, with bounds
active_only: bool = True, # "true"/"false" in the query string
role: str | None = None, # optional, None if absent
):
return {"q": q, "page": page, "limit": limit, "active_only": active_only, "role": role}
# Output:
# 200 /users/42
# {'user_id': 42}
# 422 /users/abc
# {'detail': [{'type': 'int_parsing', 'loc': ['path', 'user_id'], 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'abc'}]}
# 200 /items/5
# {'item_id': 5}
# 422 /items/0
# {'detail': [{'type': 'greater_than_equal', 'loc': ['path', 'item_id'], 'msg': 'Input should be greater than or equal to 1', 'input': '0', 'ctx': {'ge': 1}}]}
# 200 /search?q=mario&page=2&limit=20&active_only=false&role=admin
# {'q': 'mario', 'page': 2, 'limit': 20, 'active_only': False, 'role': 'admin'}
# 422 /search?q=mario&limit=500
# {'detail': [{'type': 'less_than_equal', 'loc': ['query', 'limit'], 'msg': 'Input should be less than or equal to 100', 'input': '500', 'ctx': {'le': 100}}]}
# 422 /search
# {'detail': [{'type': 'missing', 'loc': ['query', 'q'], 'msg': 'Field required', 'input': None}]}
Look at the three error cases. /users/abc fails with type int_parsing, and
the location ['path', 'user_id'] says unambiguously which part of the request holds
the problem. /items/0 violates ge=1 and the message carries the violated
limit in the ctx field. /search without q gives missing.
In all three cases your function's code has not even started: validation happened first, and that
is why inside the function you can trust the types. Note also that
active_only=false arrived as False, a real boolean and not the
string "false".
The format of those errors (type, loc, msg) is Pydantic's,
and it is the same one you will find again in the body. How to read them in detail, and how to write
validators richer than simple bounds, is the subject of
Pydantic v2: advanced validation.
The request body: Pydantic models and response_model
For the data of a POST, PUT or PATCH you declare a Pydantic model and use it as a parameter:
FastAPI reads the JSON, validates it against the model and hands you an already typed object. It is
worth using two distinct models, one for what the client sends (UserCreate)
and one for what the server returns (UserResponse): they are two different contracts, and
keeping them apart prevents an internal field from ending up in a response because «it's in the model
anyway».
The create_user function deliberately returns a dictionary with one extra field,
password_hash. The response_model=UserResponse parameter filters the
response through the declared model, so the field does not get out. The field_validator on
name is the domain rule that simple constraints cannot express («first and last
name»): it must be a classmethod, and it returns the value, cleaned up if needed. The
default role is viewer, the one with the fewest privileges: a wrong default here is a
hole, not a detail.
# users.py
from datetime import datetime, timezone
from enum import Enum
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr, Field, field_validator
app = FastAPI()
class UserRole(str, Enum):
admin = "admin"
editor = "editor"
viewer = "viewer"
class UserCreate(BaseModel):
name: str = Field(min_length=2, max_length=100, examples=["Mario Rossi"])
email: EmailStr
role: UserRole = UserRole.viewer # the least-privileged role
age: int | None = Field(default=None, ge=0, le=150)
@field_validator("name")
@classmethod
def name_must_contain_space(cls, v: str) -> str:
if " " not in v.strip():
raise ValueError("first and last name required")
return v.strip()
class UserResponse(BaseModel):
id: int
name: str
email: EmailStr
role: UserRole
created_at: datetime
@app.post("/users", response_model=UserResponse, status_code=201, tags=["users"])
def create_user(user: UserCreate):
return {
"id": 42, # in production the database assigns it
"name": user.name,
"email": user.email,
"role": user.role,
"password_hash": "$2b$12$must-not-leak", # internal field
"created_at": datetime(2026, 11, 12, 9, 30, tzinfo=timezone.utc),
}
# Output:
# 201 {'id': 42, 'name': 'Mario Rossi', 'email': 'mario@example.com', 'role': 'viewer', 'created_at': '2026-11-12T09:30:00Z'}
# 422
# ['body', 'name'] value_error - Value error, first and last name required
# ['body', 'email'] value_error - value is not a valid email address: An email address must have an @-sign.
# ['body', 'role'] enum - Input should be 'admin', 'editor' or 'viewer'
# ['body', 'age'] less_than_equal - Input should be less than or equal to 150
The first case shows the happy path: status 201 (status_code=201 in the decorator),
no password_hash in the JSON, and the date returned as an ISO 8601 string because
created_at is a datetime and Pydantic serializes it. The second shows a
property that saves time: the request had four defects, and the 422 reports
all four together. Whoever uses your API fixes everything in one round, not one per
attempt. A detail about EmailStr: without the email-validator package the
model definition fails immediately with «ImportError: email-validator is not installed, run
`pip install 'pydantic[email]'`» (I tried it by removing it from the path), so the error shows up
at startup and not in production. The standard extra includes it.
Errors: HTTPException, 422 and the generic handler
Expected errors, such as a customer that does not exist, are signalled with HTTPException:
the exception interrupts the function and FastAPI turns it into a response with the status and the
detail you specified. The status constants are equivalent to the
numbers, but HTTP_404_NOT_FOUND reads without consulting a table. Unexpected errors,
the ones you did not foresee, are another story, and here the behavior has two nuances that almost
no tutorial mentions.
# errors.py
import logging
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.responses import JSONResponse
app = FastAPI()
logger = logging.getLogger("api")
CUSTOMERS = {1: {"id": 1, "name": "Mario Rossi"}}
@app.get("/customers/{customer_id}")
def get_customer(customer_id: int):
customer = CUSTOMERS.get(customer_id)
if customer is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Customer {customer_id} not found",
)
return customer
@app.get("/fault")
def fault():
return 1 / 0
@app.exception_handler(Exception)
async def generic_error(request: Request, exc: Exception):
logger.error("Unhandled exception on %s", request.url.path, exc_info=exc)
return JSONResponse(status_code=500, content={"detail": "Internal error"})
# Output:
# 200 {'id': 1, 'name': 'Mario Rossi'}
# 404 {'detail': 'Customer 9 not found'}
# 500 {'detail': 'Internal error'}
# TestClient re-raises by default: ZeroDivisionError division by zero
# CORS on 404: https://app.example.it
# CORS on 500: None
The first nuance is in TestClient: by default it re-raises the
server's exceptions, even if you registered a handler, and I measured this (the fourth line of the
output is a ZeroDivisionError coming out of the client). If in tests you want to see the 500 response, build the client with
raise_server_exceptions=False, as I did in the first three calls. The second
nuance is the line CORS on 500: None. The Exception handler runs inside
ServerErrorMiddleware, the outermost layer of the Starlette stack (the
Exceptions page describes it), that is outside the CORS middleware.
Measured result, with a CORSMiddleware(allow_origins=[...]) added before startup and the Origin header in the request: on the 404 the Access-Control-Allow-Origin header is there, on the 500
it is not. A front end on another domain, facing an internal error, will not see your JSON: the browser
blocks it and a CORS error shows up in the console, hiding the real cause. If it happens to you, look at the
server log before suspecting the CORS configuration.
The handler, in any case, does two things: it records the full exception in the log (with
exc_info) and it answers with a generic message. The error detail must never
reach the client: stack traces and file paths are material for whoever wants to attack the service.
The structure of the error message, if you want a standard, is the subject of
REST in 2026.
OpenAPI documentation and what to switch off in production
The interfaces FastAPI exposes by default are three, and they depend on three independent constructor parameters:
/docs: Swagger UI, for trying calls from the browser; it is switched off withdocs_url=None./redoc: ReDoc, a more manual-like read; it is switched off withredoc_url=None./openapi.json: the machine-readable schema, from which clients are generated; it is switched off withopenapi_url=None.
The distinction that matters is in the last item. Switching off only the two graphical interfaces
leaves the complete schema reachable, and from the schema you can rebuild everything the graphical
pages showed. The documentation says that with openapi_url=None the two interfaces are
disabled as well, because they depend on the schema: and it is the parameter to set if you really want to close it.
# prod.py
import os
from fastapi import FastAPI
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
app = FastAPI(
docs_url="/docs" if DEBUG else None,
redoc_url="/redoc" if DEBUG else None,
openapi_url="/openapi.json" if DEBUG else None,
)
@app.get("/health")
def health():
return {"status": "ok"}
# Output:
# DEBUG=true [200, 200, 200, 200]
# DEBUG=false [404, 404, 404, 200]
# only docs_url/redoc_url set to None: [404, 404, 200]
The two DEBUG lines are the comparison: with the flag on, all three routes
answer; off, three 404s remain and only /health. The final line confirms it
on the most common mistake: with docs_url=None, redoc_url=None and nothing else, /openapi.json
answers 200. That said, an honest trade-off: hiding the documentation reduces what a
curious visitor finds at a glance, but it protects nothing. The routes answer all
the same to whoever knows their path. The protection is authentication; the switched-off document is just
extra hygiene, and in a public API meant for third parties, documentation left on is often exactly
what you want.
To enrich the documentation there is no need to rewrite the schema by hand: the constructor accepts
summary, contact and license_info (the
Metadata and Docs URLs page describes them), and the
decorator accepts tags and summary to group and title the routes.
# docs_meta.py
from fastapi import FastAPI
app = FastAPI(
title="Customer directory",
version="2.0.0",
summary="Directory management",
description="Long description, with **markdown**.",
contact={"name": "Federico Calo", "url": "https://federicocalo.dev"},
license_info={"name": "MIT", "url": "https://opensource.org/licenses/MIT"},
)
@app.get("/customers", tags=["customers"], summary="List of customers")
def list_customers():
return []
# Output:
# title -> Customer directory
# summary -> Directory management
# description -> Long description, with **markdown**.
# contact -> {'name': 'Federico Calo', 'url': 'https://federicocalo.dev/'}
# license -> {'name': 'MIT', 'url': 'https://opensource.org/licenses/MIT'}
# version -> 2.0.0
Each pair in the output is a key of the schema's info section: summary,
contact and license ended up there, and a client generated from the schema carries them along. Note that
the contact URL came back with a trailing slash: the schema normalizes URLs, and if you compare the
text in a test you will find out.
Configuration with pydantic-settings
The settings that change from one environment to another (database URL, keys, allowed
origins) are not written in the code: they are read from environment variables. The
BaseSettings class of the pydantic-settings package does this and also validates
the types, so a missing or malformed setting stops the startup instead of showing up at the first
request. Fields without a default value (database_url, secret_key)
are required; the others have a sensible default. The variable name matches the field's
name regardless of case, and the .env file is an additional source.
The get_settings function is decorated with lru_cache so the object is
built once, and not on every request, and it is injected into the routes with
Depends. The documentation devotes the section «Creating the Settings
only once with lru_cache» of the
Settings and Environment Variables page to this pattern;
in tests you can replace the dependency with app.dependency_overrides.
# app/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
app_name: str = "Customer directory"
app_version: str = "1.0.0"
debug: bool = False
database_url: str # required: without it, startup fails
secret_key: str # required
access_token_expire_minutes: int = 30
allowed_origins: list[str] = ["http://localhost:3000"]
@lru_cache
def get_settings() -> Settings:
return Settings()
# Output:
# required: database_url - missing
# required: secret_key - missing
# False 30 ['http://localhost:3000']
# ['https://federicocalo.dev', 'https://www.federicocalo.dev']
# SettingsError - error parsing value for field "allowed_origins" from source "EnvSettingsSource"
Three results to read. With no variables, the error lists all the missing
settings, not just the first. With the two required ones present, the other fields take the default
(False, 30, a list with the local origin). And there is the trap: a list
in an environment variable must be JSON. Writing
ALLOWED_ORIGINS=https://federicocalo.dev seems natural and gives a
SettingsError. The full message contains the indication of the field; I report
its first line. If in production you set the allowed origins by hand, remember the quotes and the
square brackets.
The structure of a project that grows
A single main.py is fine as long as the routes fit on one screen. As soon as there are ten, the
separation you need is simple: one APIRouter per domain (users,
items), a configuration module, and a main.py that assembles them with
include_router, assigning prefix and tag. The general pattern is the one on the
Bigger Applications page. Below
is the minimum that actually runs: three files, one router and a health route, with the documentation
switched off or on by the configuration flag.
# app/routers/users.py
from fastapi import APIRouter
router = APIRouter()
@router.get("/")
def list_users():
return [{"id": 1, "name": "Mario Rossi"}]
# app/main.py
from fastapi import Depends, FastAPI
from app.config import Settings, get_settings
from app.routers import users
settings = get_settings()
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
docs_url="/docs" if settings.debug else None,
redoc_url="/redoc" if settings.debug else None,
openapi_url="/openapi.json" if settings.debug else None,
)
app.include_router(users.router, prefix="/users", tags=["users"])
@app.get("/health", tags=["system"])
def health(s: Settings = Depends(get_settings)):
return {"status": "healthy", "version": s.app_version}
# Output:
# {'status': 'healthy', 'version': '1.0.0'} [{'id': 1, 'name': 'Mario Rossi'}] 200
The /health route is deliberately dumb: it returns the status and the version, it does not
touch the database or external services. It lets a load balancer or a system check know whether
the process is responding. If you make it depend on the database, a database outage makes processes that work
«unhealthy» too, and whoever checks them may decide to discard them exactly when they would be needed. To the structure you will add, when needed, a
services layer for business logic and one for data access: until you have two routes that
share the same logic, they are empty folders.
When is FastAPI not the answer?
If your application is mostly HTML pages with sessions, forms and an admin area, a framework that ships all of this out of the box saves work: FastAPI is meant for building APIs, and the parts it lacks you assemble yourself. If instead your service is an API with an explicit contract, such as an endpoint that a front end or another service calls, the contract in the types is exactly the advantage. The asynchronous model is a second choice to make consciously, and it has an article of its own: async/await in Python, event loops and coroutines.
How do you validate inputs safely?
FastAPI's validation guarantees that a field has the declared type and bounds; it does not guarantee that its content is harmless. A valid 50-character string can contain an SQL query, and that is a matter of how you build the query, not of how you declare the model. For that side see SQL injection and input validation.
What you can do now
You can start a FastAPI service and read the contract the annotations produce: path and query
parameters with their bounds, a body validated by one model, a response filtered by another.
You can recognize a 422 and say which part of the request holds the defect. You can handle errors
knowing that the generic handler does not go through CORS and that TestClient re-raises, you can
switch off the documentation entirely with openapi_url=None and know why it is not a defense, and
you can load configuration from environment variables with the right format for lists. The next
step, to understand why FastAPI scales on network waits, is the event loop.
Sources
- FastAPI - First Steps - the three routes of the generated documentation (
/docs,/redoc,/openapi.json), consulted on 2026-11-12 - FastAPI - Metadata and Docs URLs - the
contact,license_infoandopenapi_url=Noneparameters, consulted on 2026-11-12 - FastAPI - Handling Errors -
HTTPException, custom handlers and the 422 response, consulted on 2026-11-12 - FastAPI - Settings and Environment Variables -
BaseSettings, the dependency andlru_cache, consulted on 2026-11-12 - FastAPI - Bigger Applications -
APIRouterandinclude_router, consulted on 2026-11-12 - Starlette - Exceptions - the order of the middleware stack, with
ServerErrorMiddlewareon the outside, consulted on 2026-11-12
Commenti
Caricamento commenti...
Sign in to comment