How to buildOctober 11, 202611 min read
Backend DevelopmentStep 7 of 13

FastAPI: a Python API with validation and documentation

In short

FastAPI is a Python framework for HTTP APIs in which parameter types and Pydantic models act as input validation, output filtering and API description at the same time: they produce the OpenAPI schema and the interactive docs at /docs. A route is a plain function decorated with @app.get or @app.post. Shared logic such as storage access or an API key check is plugged in with Depends, and tests are ordinary pytest functions using TestClient. This lesson builds a leads API with seven tests, verified on FastAPI 0.143 and Pydantic 2.14.

TutorialLevel: BeginnerFastAPIPractice: ~1.5–3 h

After this you will be able to

  • Create a virtual environment, install fastapi[standard] and start the API with fastapi dev
  • Describe Pydantic models with Field constraints and explain where a 422 response comes from and how to read its loc and type
  • Build CRUD endpoints with 201, 204, 401 and 404 responses and add an API key check with Depends
  • Cover the API with TestClient and pytest tests, overriding a dependency via app.dependency_overrides

Verified with

Python 3.12FastAPI 0.143Pydantic 2.14Starlette 1.7pytest 9.1httpx2 2.13

Checked against docs: October 11, 2026

Contents
  1. 01What FastAPI is and what we will build
  2. 02Environment: venv and dependencies
  3. 03Pydantic models: what the API accepts and returns
  4. 04Storage and dependencies: Depends
  5. 05Routes, status codes and errors
  6. 06Running the app and the /docs page
  7. 07Tests: TestClient and pytest
  8. 08Common mistakes and exercises

What FastAPI is and what we will build

How a backend works in general — server, database, API — is covered in “What is a backend”, and the request/response format in the HTTP and JSON lesson. This one is hands-on Python. FastAPI stands on two libraries: Starlette handles the web part (routing, requests, responses) and Pydantic handles data. You describe what an endpoint — a path plus an HTTP method — accepts and returns using ordinary Python type annotations. The framework then validates the input, serialises the response to JSON and documents the API.

The project is an API for website leads. The site form submits a lead without authentication. A manager uses protected endpoints to list leads, change their status and delete spam. Data lives in process memory, so the lesson doesn’t depend on a database and every test run gets a clean store.

POST /leads

Caller
website form
Success
201 Created
Errors
422 — invalid data

GET /leads?status=&limit=

Caller
manager, key required
Success
200 OK
Errors
401, 422

GET /leads/{lead_id}

Caller
manager
Success
200 OK
Errors
401, 404

PATCH /leads/{lead_id}

Caller
manager
Success
200 OK
Errors
401, 404, 422

DELETE /leads/{lead_id}

Caller
manager
Success
204 No Content
Errors
401, 404

GET /health

Caller
monitoring
Success
200 OK
Errors
—

Environment: venv and dependencies

Project dependencies go into a virtual environment — an isolated copy of the interpreter with its own packages. The Python docs describe it as disposable: it isn’t committed to Git and can be deleted and recreated at any time. The official FastAPI guide now installs with the uv package manager. For pip it says to create and activate a virtual environment and run pip install "fastapi[standard]", which is the path we follow here.

bash
mkdir leads-api
cd leads-api
python -m venv .venv
source .venv/bin/activate          # Windows PowerShell: .venv\Scripts\Activate.ps1
python -m pip install "fastapi[standard]" pytest httpx2
mkdir app tests                    # plus an empty app/__init__.py
Project setup: environment, packages, folders. The quotes around fastapi[standard] stop the shell from interpreting the square brackets
  • fastapi[standard] is FastAPI plus a set of optional packages: the uvicorn server, the fastapi command, email-validator for Pydantic, jinja2, python-multipart and httpx.
  • pytest is the test runner. Tests are plain functions with assert.
  • httpx2 is the HTTP client that TestClient is built on in Starlette 1.7. The Starlette docs call plain httpx supported but deprecated. Without httpx2 the tests still pass, but pytest prints a StarletteDeprecationWarning. We ran both variants.

So that pytest can import the app package from the tests folder, add a pyproject.toml at the root. pytest supports the [tool.pytest] table with native TOML types since version 9.0. Older versions use [tool.pytest.ini_options].

pyproject.toml
[tool.pytest]
pythonpath = ["."]
testpaths = ["tests"]
pythonpath adds the project root to the import path; testpaths tells pytest where the tests are

Final layout: app/__init__.py (empty), app/main.py, tests/test_leads.py, pyproject.toml. Add .venv to .gitignore.

Pydantic models: what the API accepts and returns

A Pydantic model is a BaseModel subclass with annotated fields. Pydantic guarantees that the fields of the resulting object match the declared types and raises ValidationError on invalid data. For request bodies FastAPI catches that error and answers with 422 and a list of problems. Constraints go into Field inside Annotated: string length, a regular expression, numeric bounds.

app/main.py
import os
import secrets
from typing import Annotated, Literal

from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
from pydantic import BaseModel, Field

app = FastAPI(title="Leads API", version="1.0.0")

Service = Literal["website", "mobile-app", "crm"]
LeadStatus = Literal["new", "in_progress", "done"]


class LeadIn(BaseModel):
    name: Annotated[str, Field(min_length=2, max_length=80)]
    phone: Annotated[str, Field(pattern=r"^\+?\d{10,15}$", examples=["+77011234567"])]
    service: Service
    comment: Annotated[str | None, Field(max_length=500)] = None


class Lead(LeadIn):
    id: int
    status: LeadStatus = "new"


class LeadStatusUpdate(BaseModel):
    status: LeadStatus
Part 1 of 3: imports, the app and the models. The three parts in sequence make up the whole file

Field(min_length=2, max_length=80)

What is checked
string length
Error type in the 422 response
string_too_short / string_too_long

Field(pattern=r"^\+?\d{10,15}$")

What is checked
the string matches the regular expression
Error type in the 422 response
string_pattern_mismatch

Literal["website", "mobile-app", "crm"]

What is checked
a value from a fixed list
Error type in the 422 response
literal_error

str | None = None

What is checked
optional field, defaults to None
Error type in the 422 response
—

Query(ge=1, le=100)

What is checked
a number between 1 and 100 (query parameter)
Error type in the 422 response
greater_than_equal / less_than_equal

Each model has one job. LeadIn is what the form sends, so a client can’t set id or status. Lead inherits every field of LeadIn and adds the server-side ones. LeadStatusUpdate is the PATCH body with a single field. This split is an official FastAPI pattern: the response model chapter shows a UserIn with a password next to a UserOut without one. The function’s return type works as a filter: FastAPI validates the response, serialises it with Pydantic and drops any field the model doesn’t declare. By default Pydantic ignores extra fields in incoming JSON: we sent "id": 99 and "status": "done" to POST /leads, and the lead was still created with id 1 and status new.

Storage and dependencies: Depends

A FastAPI dependency is a function the framework calls before the handler and whose result it passes into a parameter. The docs call this dependency injection: route code declares what it needs to work, and FastAPI provides it. A dependency accepts the same parameters a handler does: headers, query parameters, even other dependencies.

app/main.py
class LeadStore:
    """In-memory storage for the lesson and tests. A real project uses a database."""

    def __init__(self) -> None:
        self._items: dict[int, Lead] = {}
        self._next_id = 1

    def add(self, data: LeadIn) -> Lead:
        lead = Lead(id=self._next_id, **data.model_dump())
        self._items[lead.id] = lead
        self._next_id += 1
        return lead

    def list(self, status: LeadStatus | None, limit: int) -> list[Lead]:
        leads = [lead for lead in self._items.values() if status is None or lead.status == status]
        return leads[:limit]

    def get(self, lead_id: int) -> Lead | None:
        return self._items.get(lead_id)

    def save(self, lead: Lead) -> None:
        self._items[lead.id] = lead

    def delete(self, lead_id: int) -> bool:
        return self._items.pop(lead_id, None) is not None


store = LeadStore()


def get_store() -> LeadStore:
    return store


def require_api_key(x_api_key: Annotated[str | None, Header()] = None) -> None:
    expected = os.environ.get("LEADS_API_KEY")
    if not expected or x_api_key is None or not secrets.compare_digest(x_api_key, expected):
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or missing API key")


StoreDep = Annotated[LeadStore, Depends(get_store)]
admin_only = [Depends(require_api_key)]


def find_lead(lead_id: int, leads: LeadStore) -> Lead:
    lead = leads.get(lead_id)
    if lead is None:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lead not found")
    return lead
Part 2 of 3: the store, the dependencies and the shared lookup
  1. get_store returns the store

    Routes receive it through a StoreDep parameter and never touch the global store directly. Tests swap this dependency for a clean store without changing application code.

  2. StoreDep is a type alias

    Annotated[LeadStore, Depends(get_store)] is written once and reused. The FastAPI docs recommend the Annotated form (since 0.95) and show exactly this kind of alias.

  3. require_api_key checks a header

    FastAPI reads the x_api_key parameter declared with Header() from the x-api-key header: underscores are converted to hyphens and header names are case-insensitive. secrets.compare_digest compares strings in time that doesn’t depend on how long the matching prefix is.

  4. admin_only goes into the decorator

    A dependencies=[Depends(require_api_key)] list in the decorator runs the check without passing its result to the function. That’s the documented way to use dependencies that only allow or reject a request.

find_lead is a plain helper, not a dependency: it raises HTTPException with 404 when the lead doesn’t exist. HTTPException is a Python exception carrying data for the HTTP response. You raise it rather than return it, and FastAPI turns it into a response such as {"detail": "Lead not found"}.

Routes, status codes and errors

A route is a function with a decorator that names the method and path. FastAPI sorts the function parameters by itself: lead_id: int matches {lead_id} in the path, so it’s a path parameter; a Pydantic model is the request body; simple types not in the path are query parameters (the part of the URL after ?). The success status code goes into the decorator via status_code. The constants in fastapi.status hold the same numbers but read better.

app/main.py
@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}


@app.post("/leads", status_code=status.HTTP_201_CREATED)
def create_lead(data: LeadIn, leads: StoreDep) -> Lead:
    return leads.add(data)


@app.get("/leads", dependencies=admin_only)
def list_leads(
    leads: StoreDep,
    status_filter: Annotated[LeadStatus | None, Query(alias="status")] = None,
    limit: Annotated[int, Query(ge=1, le=100)] = 50,
) -> list[Lead]:
    return leads.list(status_filter, limit)


@app.get("/leads/{lead_id}", dependencies=admin_only)
def read_lead(lead_id: int, leads: StoreDep) -> Lead:
    return find_lead(lead_id, leads)


@app.patch("/leads/{lead_id}", dependencies=admin_only)
def update_lead_status(lead_id: int, update: LeadStatusUpdate, leads: StoreDep) -> Lead:
    lead = find_lead(lead_id, leads)
    updated = lead.model_copy(update=update.model_dump())
    leads.save(updated)
    return updated


@app.delete("/leads/{lead_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=admin_only)
def delete_lead(lead_id: int, leads: StoreDep) -> None:
    if not leads.delete(lead_id):
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lead not found")
Part 3 of 3: the routes
  • status_filter uses Query(alias="status"): the URL parameter is called status, but in Python that name is already taken by the imported fastapi.status module, so the variable has a different name.
  • PATCH takes the stored lead and uses model_copy(update=...) to create a copy with the new status — a technique from the partial updates chapter of the docs.
  • DELETE answers 204 No Content. Such a response must not have a body; FastAPI knows this and documents the response as having no body.
  • The functions use plain def. FastAPI runs such handlers in a thread pool so they don’t block the server. Use async def when the body contains await, for example with an async database driver. If in doubt, the docs suggest plain def.

200 OK

When in our API
successful GET and PATCH
Produced by
FastAPI by default

201 Created

When in our API
lead created
Produced by
status_code in the decorator

204 No Content

When in our API
lead deleted
Produced by
status_code in the decorator

401 Unauthorized

When in our API
missing or wrong key
Produced by
HTTPException in the dependency

404 Not Found

When in our API
no lead with that id
Produced by
HTTPException in find_lead

422 Unprocessable Content

When in our API
body or parameters failed validation
Produced by
FastAPI automatically

Running the app and the /docs page

fastapi dev starts a development server that reloads when you save a file. Set the admin key in an environment variable before starting; the value below is for local testing only.

terminal
$ export LEADS_API_KEY=dev-secret-change-me   # PowerShell: $env:LEADS_API_KEY = "dev-secret-change-me"
$ fastapi dev app/main.py

 ⚡️ Starting FastAPI in development mode

 🐍 Using import string: app.main:app

 🌐 Server started at http://127.0.0.1:8000
    Documentation at http://127.0.0.1:8000/docs

  Logs:

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [74464] using WatchFiles
INFO:     Started server process [42936]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
Real startup output (the line listing the watched folder is removed)

Now call the API from a second terminal. The output below is the server’s real responses: a successful create, three validation errors in one response, a rejection without a key, the list with a key and a 404.

terminal
$ curl -i -X POST http://127.0.0.1:8000/leads -H "Content-Type: application/json" -d '{"name": "Aigerim", "phone": "+77011234567", "service": "website"}'
HTTP/1.1 201 Created
date: Sun, 11 Oct 2026 23:13:28 GMT
server: uvicorn
content-length: 98
content-type: application/json

{"name":"Aigerim","phone":"+77011234567","service":"website","comment":null,"id":1,"status":"new"}

$ curl -s -X POST http://127.0.0.1:8000/leads -H "Content-Type: application/json" -d '{"name": "A", "phone": "call me", "service": "seo"}' | python -m json.tool
{
    "detail": [
        {
            "type": "string_too_short",
            "loc": [
                "body",
                "name"
            ],
            "msg": "String should have at least 2 characters",
            "input": "A",
            "ctx": {
                "min_length": 2
            }
        },
        {
            "type": "string_pattern_mismatch",
            "loc": [
                "body",
                "phone"
            ],
            "msg": "String should match pattern '^\\+?\\d{10,15}$'",
            "input": "call me",
            "ctx": {
                "pattern": "^\\+?\\d{10,15}$"
            }
        },
        {
            "type": "literal_error",
            "loc": [
                "body",
                "service"
            ],
            "msg": "Input should be 'website', 'mobile-app' or 'crm'",
            "input": "seo",
            "ctx": {
                "expected": "'website', 'mobile-app' or 'crm'"
            }
        }
    ]
}
$ curl -s http://127.0.0.1:8000/leads
{"detail":"Invalid or missing API key"}
$ curl -s http://127.0.0.1:8000/leads -H "X-API-Key: dev-secret-change-me"
[{"name":"Aigerim","phone":"+77011234567","service":"website","comment":null,"id":1,"status":"new"}]
$ curl -s http://127.0.0.1:8000/leads/42 -H "X-API-Key: dev-secret-change-me"
{"detail":"Lead not found"}
Real requests to the running server

Open http://127.0.0.1:8000/docs. This is Swagger UI, built from the schema at http://127.0.0.1:8000/openapi.json (OpenAPI 3.1). ReDoc at /redoc renders the same schema differently. Our schema has three paths and five models, including the built-in HTTPValidationError and ValidationError. The status and limit parameters and the x-api-key header of GET /leads got into the docs on their own, straight from the annotations.

  • In /docs, expand POST /leads, click “Try it out” and send a lead — 201 with an id
  • Send a phone number with letters — 422 with loc: ["body", "phone"]
  • Call GET /leads without the header — 401; with x-api-key from the environment variable — the list
  • Expand the Lead schema at the bottom of the page: the length limits and phone pattern come from Field
  • Open /openapi.json — a machine-readable contract a frontend or partner can generate a client from

Tests: TestClient and pytest

TestClient calls the application directly, without a running server or network. The FastAPI docs point out that test functions are plain def and client calls have no await, so pytest works without extra plugins. A pytest fixture is a function that prepares the environment for a test. Our client fixture does three things: sets the key with monkeypatch.setenv, replaces the get_store dependency with a fresh store via app.dependency_overrides, and resets the overrides after the test.

tests/test_leads.py
import pytest
from fastapi.testclient import TestClient

from app.main import LeadStore, app, get_store

API_KEY = "test-key"
ADMIN = {"X-API-Key": API_KEY}
VALID_LEAD = {"name": "Aigerim", "phone": "+77011234567", "service": "website"}


@pytest.fixture
def client(monkeypatch):
    monkeypatch.setenv("LEADS_API_KEY", API_KEY)
    fresh_store = LeadStore()
    app.dependency_overrides[get_store] = lambda: fresh_store
    yield TestClient(app)
    app.dependency_overrides = {}


def test_create_lead_returns_201_with_id(client):
    response = client.post("/leads", json=VALID_LEAD)
    assert response.status_code == 201
    assert response.json() == {**VALID_LEAD, "comment": None, "id": 1, "status": "new"}


def test_invalid_phone_returns_422(client):
    response = client.post("/leads", json={**VALID_LEAD, "phone": "call me"})
    assert response.status_code == 422
    error = response.json()["detail"][0]
    assert error["loc"] == ["body", "phone"]
    assert error["type"] == "string_pattern_mismatch"


def test_admin_routes_require_api_key(client):
    assert client.get("/leads").status_code == 401
    assert client.get("/leads", headers={"X-API-Key": "wrong"}).status_code == 401
    assert client.get("/leads", headers=ADMIN).status_code == 200


def test_missing_lead_returns_404(client):
    response = client.get("/leads/999", headers=ADMIN)
    assert response.status_code == 404
    assert response.json() == {"detail": "Lead not found"}


def test_update_status_and_filter(client):
    client.post("/leads", json=VALID_LEAD)
    client.post("/leads", json={**VALID_LEAD, "service": "crm"})

    response = client.patch("/leads/2", json={"status": "in_progress"}, headers=ADMIN)
    assert response.status_code == 200
    assert response.json()["status"] == "in_progress"

    in_progress = client.get("/leads", params={"status": "in_progress"}, headers=ADMIN).json()
    assert [lead["id"] for lead in in_progress] == [2]


def test_limit_is_validated(client):
    response = client.get("/leads", params={"limit": 0}, headers=ADMIN)
    assert response.status_code == 422


def test_delete_returns_204_and_removes_lead(client):
    client.post("/leads", json=VALID_LEAD)
    response = client.delete("/leads/1", headers=ADMIN)
    assert response.status_code == 204
    assert response.content == b""
    assert client.get("/leads/1", headers=ADMIN).status_code == 404
Seven tests: create, validation, access, 404, filtering, the limit bound, delete
terminal
$ pytest -v
============================= test session starts =============================
platform win32 -- Python 3.12.10, pytest-9.1.1, pluggy-1.6.0 -- …\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: …\leads-api
configfile: pyproject.toml
testpaths: tests
plugins: anyio-4.15.1
collecting ... collected 7 items

tests/test_leads.py::test_create_lead_returns_201_with_id PASSED         [ 14%]
tests/test_leads.py::test_invalid_phone_returns_422 PASSED               [ 28%]
tests/test_leads.py::test_admin_routes_require_api_key PASSED            [ 42%]
tests/test_leads.py::test_missing_lead_returns_404 PASSED                [ 57%]
tests/test_leads.py::test_update_status_and_filter PASSED                [ 71%]
tests/test_leads.py::test_limit_is_validated PASSED                      [ 85%]
tests/test_leads.py::test_delete_returns_204_and_removes_lead PASSED     [100%]

============================== 7 passed in 0.43s ==============================
Real output (paths shortened)
  • app.dependency_overrides is a plain dict mapping the original dependency to its replacement. The docs recommend resetting it by assigning an empty dict, otherwise an override from one test lives on into the next.
  • test_invalid_phone_returns_422 checks not only the code but also the error’s loc and type. If someone loosens the phone pattern, the test will notice.
  • The access test covers three cases: no key, a wrong key and the right key. Testing only the happy path misses the most dangerous bug — a protected endpoint left open.

Common mistakes and exercises

ModuleNotFoundError: No module named 'app' in pytest

Cause
the project root isn’t on the import path
Fix
pythonpath = ["."] in pyproject.toml, or run python -m pytest from the root

fastapi: command not found

Cause
the environment isn’t active or [standard] was skipped
Fix
activate .venv and install "fastapi[standard]"

StarletteDeprecationWarning about httpx in tests

Cause
TestClient is using the old httpx
Fix
pip install httpx2

A test passes alone but fails in the suite

Cause
state shared between tests
Fix
a fresh store in the fixture and resetting dependency_overrides

Every protected route returns 401

Cause
LEADS_API_KEY isn’t set in the server’s environment
Fix
set the variable before fastapi dev

A 500 instead of a 404, with ResponseValidationError in the log

Cause
return HTTPException(...) instead of raise: the exception came back as a plain object and failed response model validation
Fix
raise HTTPException(...)

Next step: replace the in-memory store with a database — ORMs and migrations have their own lesson. If you need a ready-made content admin as well as an API, read the Django introduction: models, migrations and an admin panel come included.

How this was verified4 · passed1 · partialchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.