Skip to main content

01 - FastAPI from Scratch: Setup, Type Hints and OpenAPI Self-Documentation

How FastAPI turns type hints into validation, 422 responses and OpenAPI docs, with run listings on parameters, body, errors and configuration.

What do you think about this article?

At a Glance

How FastAPI turns type hints into validation, 422 responses and OpenAPI docs, with run listings on parameters, body, errors and configuration.

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 with docs_url=None.
  • /redoc: ReDoc, a more manual-like read; it is switched off with redoc_url=None.
  • /openapi.json: the machine-readable schema, from which clients are generated; it is switched off with openapi_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

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects