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.
After this you will be able to
- Create a virtual environment, install
fastapi[standard]and start the API withfastapi dev - Describe Pydantic models with
Fieldconstraints and explain where a 422 response comes from and how to read itslocandtype - Build CRUD endpoints with 201, 204, 401 and 404 responses and add an API key check with
Depends - Cover the API with
TestClientand pytest tests, overriding a dependency viaapp.dependency_overrides
Know beforehand
Verified with
Python 3.12FastAPI 0.143Pydantic 2.14Starlette 1.7pytest 9.1httpx2 2.13
Checked against docs: October 11, 2026
Contents
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.
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__.pyfastapi[standard] stop the shell from interpreting the square bracketsfastapi[standard]is FastAPI plus a set of optional packages: theuvicornserver, thefastapicommand,email-validatorfor Pydantic,jinja2,python-multipartandhttpx.pytestis the test runner. Tests are plain functions withassert.httpx2is the HTTP client thatTestClientis built on in Starlette 1.7. The Starlette docs call plainhttpxsupported but deprecated. Withouthttpx2the tests still pass, but pytest prints aStarletteDeprecationWarning. 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].
[tool.pytest]
pythonpath = ["."]
testpaths = ["tests"]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.
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: LeadStatusField(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.
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 leadget_storereturns the storeRoutes receive it through a
StoreDepparameter and never touch the globalstoredirectly. Tests swap this dependency for a clean store without changing application code.StoreDepis a type aliasAnnotated[LeadStore, Depends(get_store)]is written once and reused. The FastAPI docs recommend theAnnotatedform (since 0.95) and show exactly this kind of alias.require_api_keychecks a headerFastAPI reads the
x_api_keyparameter declared withHeader()from thex-api-keyheader: underscores are converted to hyphens and header names are case-insensitive.secrets.compare_digestcompares strings in time that doesn’t depend on how long the matching prefix is.admin_onlygoes into the decoratorA
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.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")status_filterusesQuery(alias="status"): the URL parameter is calledstatus, but in Python that name is already taken by the importedfastapi.statusmodule, so the variable has a different name.PATCHtakes the stored lead and usesmodel_copy(update=...)to create a copy with the new status — a technique from the partial updates chapter of the docs.DELETEanswers 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. Useasync defwhen the body containsawait, for example with an async database driver. If in doubt, the docs suggest plaindef.
200 OK
- When in our API
- successful
GETandPATCH - Produced by
- FastAPI by default
201 Created
- When in our API
- lead created
- Produced by
status_codein the decorator
204 No Content
- When in our API
- lead deleted
- Produced by
status_codein the decorator
401 Unauthorized
- When in our API
- missing or wrong key
- Produced by
HTTPExceptionin the dependency
404 Not Found
- When in our API
- no lead with that
id - Produced by
HTTPExceptioninfind_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.
$ 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.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.
$ 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"}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, expandPOST /leads, click “Try it out” and send a lead — 201 with anid - Send a phone number with letters — 422 with
loc: ["body", "phone"] - Call
GET /leadswithout the header — 401; withx-api-keyfrom the environment variable — the list - Expand the
Leadschema at the bottom of the page: the length limits and phone pattern come fromField - 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.
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$ 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 ==============================app.dependency_overridesis 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_422checks not only the code but also the error’slocandtype. 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 = ["."]inpyproject.toml, or runpython -m pytestfrom the root
fastapi: command not found
- Cause
- the environment isn’t active or
[standard]was skipped - Fix
- activate
.venvand install"fastapi[standard]"
StarletteDeprecationWarning about httpx in tests
- Cause
TestClientis using the oldhttpx- 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_KEYisn’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 ofraise: 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.