FastAPI builds web APIs from ordinary typed Python functions. It validates requests using your type hints and Pydantic models, returns clear 422 errors for bad input, and generates interactive documentation at /docs automatically.
Pydantic models define the shape and rules of your data (Field(min_length=2), EmailStr, ranges). Use separate models for input (StudentIn) and output (StudentOut) so internal fields never leak.
Dependencies (Depends) inject shared things — a database session, the current user — into routes. Raise HTTPException for errors like 404. FastAPI's TestClient lets you test the API without starting a server.
pip install "fastapi[standard]"
fastapi dev main.py # then open http://127.0.0.1:8000/docsfrom typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from pydantic import BaseModel, Field
app = FastAPI(title="Results API")
class StudentIn(BaseModel):
name: str = Field(min_length=2, max_length=80)
form: int = Field(ge=1, le=6)
scores: list[int] = Field(default_factory=list)
class StudentOut(StudentIn):
id: int
average: float
class Store:
def __init__(self) -> None:
self.students: dict[int, StudentIn] = {}
self.next_id = 1
store = Store()
def get_store() -> Store:
return store
StoreDep = Annotated[Store, Depends(get_store)]
def to_out(student_id: int, s: StudentIn) -> StudentOut:
avg = sum(s.scores) / len(s.scores) if s.scores else 0.0
return StudentOut(id=student_id, average=round(avg, 1), **s.model_dump())
@app.post("/students", status_code=status.HTTP_201_CREATED)
def create_student(body: StudentIn, db: StoreDep) -> StudentOut:
student_id = db.next_id
db.students[student_id] = body
db.next_id += 1
return to_out(student_id, body)
@app.get("/students/{student_id}")
def get_student(student_id: int, db: StoreDep) -> StudentOut:
student = db.students.get(student_id)
if student is None:
raise HTTPException(status_code=404, detail="Student not found")
return to_out(student_id, student)from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create_and_get() -> None:
res = client.post("/students", json={"name": "Amina", "form": 4, "scores": [80, 90]})
assert res.status_code == 201
assert res.json()["average"] == 85.0
assert client.get(f"/students/{res.json()['id']}").status_code == 200
def test_validation_and_404() -> None:
assert client.post("/students", json={"name": "A", "form": 9}).status_code == 422
assert client.get("/students/999").status_code == 404Key points
- Type hints + Pydantic = automatic validation and docs.
- Separate input and output models; never return internal fields by accident.
- Use
Dependsfor shared resources andTestClientfor fast API tests.
Exercise
Add GET /students?form=4 (filter with a query parameter), PATCH /students/{id} that accepts a partial update, and DELETE /students/{id}. Write tests for each, including the error cases.
Show solution
Try the exercise yourself first — then compare your approach with this one.
The list endpoint takes an optional form query parameter. PATCH accepts a StudentPatch model where every field is optional; model_dump(exclude_unset=True) keeps only the fields the client actually sent, so the rest stay unchanged. DELETE returns 204 with no body.
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Response, status
from pydantic import BaseModel, Field
app = FastAPI(title="Results API")
class StudentIn(BaseModel):
name: str = Field(min_length=2, max_length=80)
form: int = Field(ge=1, le=6)
scores: list[int] = Field(default_factory=list)
class StudentPatch(BaseModel):
name: str | None = Field(default=None, min_length=2, max_length=80)
form: int | None = Field(default=None, ge=1, le=6)
scores: list[int] | None = None
class StudentOut(StudentIn):
id: int
class Store:
def __init__(self) -> None:
self.students: dict[int, StudentIn] = {}
self.next_id = 1
store = Store()
StoreDep = Annotated[Store, Depends(lambda: store)]
def get_or_404(db: Store, student_id: int) -> StudentIn:
student = db.students.get(student_id)
if student is None:
raise HTTPException(status_code=404, detail="Student not found")
return student
@app.post("/students", status_code=status.HTTP_201_CREATED)
def create_student(body: StudentIn, db: StoreDep) -> StudentOut:
student_id = db.next_id
db.students[student_id] = body
db.next_id += 1
return StudentOut(id=student_id, **body.model_dump())
@app.get("/students")
def list_students(db: StoreDep, form: int | None = None) -> list[StudentOut]:
return [
StudentOut(id=i, **s.model_dump())
for i, s in db.students.items()
if form is None or s.form == form
]
@app.patch("/students/{student_id}")
def update_student(student_id: int, body: StudentPatch, db: StoreDep) -> StudentOut:
current = get_or_404(db, student_id)
updated = current.model_copy(update=body.model_dump(exclude_unset=True))
db.students[student_id] = updated
return StudentOut(id=student_id, **updated.model_dump())
@app.delete("/students/{student_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_student(student_id: int, db: StoreDep) -> Response:
get_or_404(db, student_id)
del db.students[student_id]
return Response(status_code=status.HTTP_204_NO_CONTENT)from fastapi.testclient import TestClient
from main import app, store
client = TestClient(app)
def setup_function() -> None:
store.students.clear()
store.next_id = 1
client.post("/students", json={"name": "Amina", "form": 4})
client.post("/students", json={"name": "Juma", "form": 3})
def test_filter_by_form() -> None:
names = [s["name"] for s in client.get("/students", params={"form": 4}).json()]
assert names == ["Amina"]
def test_partial_update_keeps_other_fields() -> None:
res = client.patch("/students/2", json={"form": 4})
assert res.status_code == 200
assert res.json() == {"id": 2, "name": "Juma", "form": 4, "scores": []}
def test_update_validation_and_missing() -> None:
assert client.patch("/students/1", json={"form": 9}).status_code == 422
assert client.patch("/students/99", json={"form": 4}).status_code == 404
def test_delete() -> None:
assert client.delete("/students/1").status_code == 204
assert client.delete("/students/1").status_code == 404
assert len(client.get("/students").json()) == 1