Unlock all weeks

Zero to FDE / Free Week 1 / Lesson 4

Free Week 1 · Lesson 4 of 5 · about 180 min

HTTP from first principles, then a tested FastAPI CRUD service

Roadmap task: Build a small REST API (FastAPI or Flask) with at least three endpoints

In this lesson: 10 ideas
  • The mental model: an API is a contract written in HTTP
  • Methods, safety and idempotency
  • Status codes that matter
  • Designing resources
  • FastAPI, Pydantic, uvicorn and ASGI
  • and 5 more
180 min
to read and practise
16 steps
one idea at a time
2 visuals
diagrams and hands-on
10 code
1 you can run here
5 checks
test yourself
Why this matters

Almost everything an FDE builds is reached over HTTP. The agent you deploy is called by the client's web app through an API; the agent itself calls a model provider's API, the client's CRM API and a payments API. When those calls fail, the status code, the headers and the exact JSON body are the evidence. If you cannot design a clean endpoint, choose the right status code, validate input before it touches the database, and prove it all works with tests, your integration becomes the thing everyone else has to work around. This lesson teaches HTTP properly first, then builds a small but production-shaped FastAPI service that you will reuse as the skeleton for your Week 1 deliverable and for every agent API later in the course.

The mental model: an API is a contract written in HTTP

HTTP is a request-response protocol made of plain text messages. A client sends a request: a method (what to do), a path (which thing), headers (metadata) and optionally a body. The server sends back a response: a status code (what happened), headers, and optionally a body. A REST API is a set of conventions on top of that: things are resources with URLs, and the standard methods act on them.

One request, one response
  1. Clientsends POST /tickets with a JSON body
  2. FastAPI routePydantic checks the body against your model
  3. Your codesaves the ticket in the database
  4. Response201 Created, with the new ticket as JSON

If the body is invalid, FastAPI stops at the second step and answers 422 before your code ever runs.

Text
POST /tickets HTTP/1.1
Host: localhost:8000
Content-Type: application/json
Content-Length: 55

{"title": "Invoice sync failing", "priority": "urgent"}
Text
HTTP/1.1 201 Created
content-type: application/json
location: /tickets/1

{"id":1,"title":"Invoice sync failing","body":"","priority":"urgent","status":"open","created_at":"2026-10-13T20:46:40.570319Z"}

Everything else in this lesson, including FastAPI, is a convenient way to produce and check messages like these.

Methods, safety and idempotency

MethodMeaningSafeIdempotent
GETread a resource or a listyesyes
POSTcreate a new resource (or trigger an action)nono
PUTreplace a resource entirelynoyes
PATCHchange some fields of a resourcenonot guaranteed
DELETEremove a resourcenoyes

Safe means the request does not change server state. Idempotent means sending the same request twice has the same effect on the server as sending it once. This is not trivia: networks drop responses, and clients, proxies and SDKs retry. Retrying an idempotent request is harmless; retrying a POST can create a duplicate order or charge a customer twice. That is why payment APIs accept an idempotency key header on POST, so the server can recognise a retry:

Python · runs as is
import itertools
import uuid

class ToyServer:
    """Just enough state to see what a retry does."""
    def __init__(self) -> None:
        self.tickets: dict[int, dict] = {}
        self._ids = itertools.count(1)
        self._seen: dict[str, int] = {}          # idempotency key -> ticket id

    def post(self, body: dict, key: str | None = None) -> tuple[int, int]:
        if key is not None and key in self._seen:
            return 201, self._seen[key]          # a replay: return the original result
        tid = next(self._ids)
        self.tickets[tid] = dict(body)
        if key is not None:
            self._seen[key] = tid
        return 201, tid

    def put(self, tid: int, body: dict) -> tuple[int, int]:
        self.tickets[tid] = dict(body)
        return 200, tid

    def delete(self, tid: int) -> tuple[int, None]:
        return (204, None) if self.tickets.pop(tid, None) is not None else (404, None)

s = ToyServer()
body = {"title": "Refund not received"}
s.post(body); s.post(body)                       # first response was "lost", client retried
print("POST twice, no key:  ", len(s.tickets), "tickets")   # 2: a duplicate

s = ToyServer()
key = str(uuid.uuid4())
s.post(body, key); s.post(body, key)
print("POST twice, same key:", len(s.tickets), "ticket")    # 1

s.put(1, {"title": "Refund received"}); s.put(1, {"title": "Refund received"})
print("PUT twice:           ", s.tickets)                    # same state as once
print("DELETE twice:        ", s.delete(1)[0], s.delete(1)[0], "->", s.tickets)
What will this print? Guess first, then check.Show output
POST twice, no key:   2 tickets
POST twice, same key: 1 ticket
PUT twice:            {1: {'title': 'Refund received'}}
DELETE twice:         204 404 -> {}

Note the last line: the second DELETE returns 404, a different status, but the server's state is the same as after one DELETE. Idempotency is about the effect, not the response.

Status codes that matter

The first digit is the category: 2xx success, 3xx redirect, 4xx the client got something wrong, 5xx the server did. The ones you will use and read constantly:

  • 200 OK (general success), 201 Created (after POST, ideally with a Location header pointing at the new resource), 204 No Content (success with no body, typical for DELETE).
  • 400 Bad Request (malformed), 401 Unauthorized (actually "unauthenticated": who are you?), 403 Forbidden (known identity, not allowed), 404 Not Found, 405 Method Not Allowed, 409 Conflict (clashes with current state, such as a duplicate email), 422 Unprocessable Content (well-formed but invalid; FastAPI uses it for validation errors), 429 Too Many Requests (rate limited; back off and retry later).
  • 500 Internal Server Error (an unhandled bug), 502 Bad Gateway and 504 Gateway Timeout (something upstream failed or was too slow), 503 Service Unavailable (temporarily cannot serve, such as a database being down).

The test of a good status code: a client should be able to decide what to do from the code alone. A 4xx says "fix your request, retrying will not help"; a 503 or 429 says "try again later"; a 500 says "file a bug". Returning 200 with {"error": "..."} in the body breaks every client, monitor and retry policy that relies on that.

Headers carry metadata both ways: Content-Type: application/json says what the body is, Authorization: Bearer ... carries credentials, Location points to a created resource, Retry-After says when to retry. JSON is the body format: objects, arrays, strings, numbers, booleans and null, with no comments, no trailing commas and no dates (send them as ISO 8601 strings).

An interactive exercise appears here when JavaScript is on.

Designing resources

  • Use plural nouns for collections and ids for items: /tickets, /tickets/42. The method is the verb, so avoid /getTickets or /tickets/42/delete.
  • Nest only for real ownership: /tickets/42/comments.
  • Filtering, sorting and pagination go in the query string: /tickets?status=open&limit=20&offset=40. Always cap limit.
  • Keep request and response shapes consistent, and keep error bodies in one format.
  • Plan for change: once a client depends on a field, renaming it breaks them. Many teams prefix routes with /v1.

FastAPI, Pydantic, uvicorn and ASGI

FastAPI is a Python web framework: you write functions, and decorators map them to method and path. Pydantic turns type-hinted classes into validators: incoming JSON is parsed and checked against the class, and anything invalid becomes a detailed 422 response before your function runs. FastAPI also uses those types to generate an OpenAPI description and interactive docs at /docs.

FastAPI does not listen on a network port itself. ASGI (Asynchronous Server Gateway Interface) is the standard contract between a Python web application and a web server: the server calls your app as async def app(scope, receive, send), passing details of each connection and functions to receive and send messages. Uvicorn is an ASGI server: it opens the socket, parses raw HTTP bytes, calls your FastAPI app, and writes the response back. ASGI is the successor to the older, synchronous WSGI standard (used by classic Flask and Django); it exists so one process can handle many concurrent connections, WebSockets and streaming responses, which you will need for streaming LLM output. One practical detail: endpoints declared with plain def run in a thread pool so they do not block the server; async def endpoints run on the event loop and must never call blocking code.

The complete service

A support-ticket API: create, list (with filter and pagination), read, update and delete, backed by SQLite. Set up a project and install:

Terminal
mkdir tickets-api && cd tickets-api
python3 -m venv .venv && source .venv/bin/activate
pip install "fastapi[standard]" pytest     # includes uvicorn and httpx
Python
# main.py  (tested with fastapi 0.143.0, pydantic 2.14.1, starlette 1.8.0, Python 3.13; October 2026)
import logging
import os
import sqlite3
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Annotated, Literal

from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field

logger = logging.getLogger("tickets")

SCHEMA = """
CREATE TABLE IF NOT EXISTS tickets (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT NOT NULL,
    body       TEXT NOT NULL DEFAULT '',
    priority   TEXT NOT NULL DEFAULT 'normal',
    status     TEXT NOT NULL DEFAULT 'open',
    created_at TEXT NOT NULL
)
"""

def db_path() -> str:
    return os.environ.get("TICKETS_DB", "tickets.db")

def connect(path: str) -> sqlite3.Connection:
    # check_same_thread=False: FastAPI may create the connection in one worker
    # thread and use it in another. Safe here because each request gets its own.
    conn = sqlite3.connect(path, check_same_thread=False)
    conn.row_factory = sqlite3.Row
    return conn

@asynccontextmanager
async def lifespan(app: FastAPI):
    conn = connect(db_path())          # runs once, at startup
    conn.execute(SCHEMA)
    conn.commit()
    conn.close()
    yield                              # the app serves requests here

app = FastAPI(title="Tickets API", lifespan=lifespan)

# ---------- the dependency: one connection per request ----------
def get_db():
    conn = connect(db_path())
    try:
        yield conn                     # injected into the endpoint
    finally:
        conn.close()                   # runs after the response, even on errors

DB = Annotated[sqlite3.Connection, Depends(get_db)]

# ---------- Pydantic models: the API's contract ----------
Priority = Literal["low", "normal", "urgent"]
Status = Literal["open", "closed"]

class TicketCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")       # unknown fields -> 422
    title: str = Field(min_length=3, max_length=200)
    body: str = Field(default="", max_length=5000)
    priority: Priority = "normal"

class TicketUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")
    title: str | None = Field(default=None, min_length=3, max_length=200)
    body: str | None = Field(default=None, max_length=5000)
    priority: Priority | None = None
    status: Status | None = None

class Ticket(BaseModel):
    id: int
    title: str
    body: str
    priority: Priority
    status: Status
    created_at: datetime

def fetch_ticket(db: sqlite3.Connection, ticket_id: int) -> Ticket:
    row = db.execute("SELECT * FROM tickets WHERE id = ?", (ticket_id,)).fetchone()
    if row is None:
        raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
    return Ticket(**dict(row))

# ---------- endpoints ----------
@app.get("/health")
def health() -> dict:
    return {"status": "ok"}

@app.post("/tickets", status_code=status.HTTP_201_CREATED)
def create_ticket(payload: TicketCreate, db: DB, response: Response) -> Ticket:
    now = datetime.now(timezone.utc).isoformat()
    cur = db.execute(
        "INSERT INTO tickets (title, body, priority, created_at) VALUES (?, ?, ?, ?)",
        (payload.title, payload.body, payload.priority, now),
    )
    db.commit()
    response.headers["Location"] = f"/tickets/{cur.lastrowid}"
    return fetch_ticket(db, cur.lastrowid)

@app.get("/tickets")
def list_tickets(
    db: DB,
    status_filter: Annotated[Status | None, Query(alias="status")] = None,
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
    offset: Annotated[int, Query(ge=0)] = 0,
) -> list[Ticket]:
    sql, params = "SELECT * FROM tickets", []
    if status_filter is not None:
        sql += " WHERE status = ?"
        params.append(status_filter)
    sql += " ORDER BY id LIMIT ? OFFSET ?"
    params += [limit, offset]
    return [Ticket(**dict(r)) for r in db.execute(sql, params).fetchall()]

@app.get("/tickets/{ticket_id}")
def get_ticket(ticket_id: int, db: DB) -> Ticket:
    return fetch_ticket(db, ticket_id)

@app.patch("/tickets/{ticket_id}")
def update_ticket(ticket_id: int, payload: TicketUpdate, db: DB) -> Ticket:
    fetch_ticket(db, ticket_id)                            # 404 if missing
    changes = payload.model_dump(exclude_unset=True, exclude_none=True)
    if changes:
        # Column names come from our own model (a fixed allowlist), never from
        # raw user input; the values still go in as ? parameters.
        assignments = ", ".join(f"{col} = ?" for col in changes)
        db.execute(f"UPDATE tickets SET {assignments} WHERE id = ?",
                   (*changes.values(), ticket_id))
        db.commit()
    return fetch_ticket(db, ticket_id)

@app.delete("/tickets/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_ticket(ticket_id: int, db: DB) -> None:
    cur = db.execute("DELETE FROM tickets WHERE id = ?", (ticket_id,))
    db.commit()
    if cur.rowcount == 0:
        raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")

# A database we cannot reach is our problem, not the client's: answer 503.
@app.exception_handler(sqlite3.OperationalError)
async def db_unavailable(request: Request, exc: sqlite3.OperationalError):
    # sqlite3 also raises OperationalError for SQL typos and missing tables,
    # so log the real error: a bug must not hide behind a 503.
    logger.error("database error on %s %s", request.method, request.url.path, exc_info=exc)
    return JSONResponse(status_code=503, content={"detail": "Database unavailable"})

Walking through the important parts

Three models, not one. TicketCreate is what a client may send, TicketUpdate is what may change, and Ticket is what the API returns. Keeping them separate means a client cannot set id or created_at, and extra="forbid" rejects misspelt fields instead of silently ignoring them. The return annotation -> Ticket makes FastAPI validate and filter the response too, so an internal column added later does not leak out by accident.

Validation is free and precise. Send a bad body and your function never runs:

Terminal
$ curl -s -X POST localhost:8000/tickets -H 'Content-Type: application/json' \
       -d '{"title": "ab", "priority": "asap"}' | jq
{
  "detail": [
    {
      "type": "string_too_short",
      "loc": ["body", "title"],
      "msg": "String should have at least 3 characters",
      "input": "ab",
      "ctx": {"min_length": 3}
    },
    {
      "type": "literal_error",
      "loc": ["body", "priority"],
      "msg": "Input should be 'low', 'normal' or 'urgent'",
      "input": "asap",
      "ctx": {"expected": "'low', 'normal' or 'urgent'"}
    }
  ]
}

(jq's output is compacted slightly here.) loc tells the client exactly which field failed, which is also what makes these errors easy for an LLM-driven client to self-correct later in the course. Query parameters are validated the same way: ?limit=500 gets a 422 saying the input should be less than or equal to 100.

Dependency injection. db: DB tells FastAPI: "before calling this endpoint, call get_db and pass me what it yields". The code before yield runs before your endpoint; the finally block runs after the response is sent, even if the endpoint raised. The endpoint never knows where the connection came from, which gives you three things: one place controls the connection's lifecycle, every endpoint gets the same treatment, and tests can replace the dependency entirely. The same pattern later supplies the current user, settings and API clients.

Status codes are explicit. POST returns 201 with a Location header, DELETE returns 204 with an empty body, missing ids raise HTTPException(404), and a database error becomes 503 through an exception handler rather than an anonymous 500. One caveat: sqlite3 uses the same OperationalError for "database is locked" or "unable to open database file" and for plain bugs such as no such column or a syntax error, which is why the handler logs the exception before answering. Unsupported methods get 405 automatically.

PATCH semantics. model_dump(exclude_unset=True) keeps only the fields the client actually sent, so {"status": "closed"} changes only the status. exclude_none=True additionally treats an explicit null as "leave it alone", since every column is NOT NULL. That is a design decision; write it in your API docs.

The lifespan function runs once at startup to create the table. Real projects use a migration tool (Alembic is the usual choice with SQLAlchemy) instead, but the shape is the same.

Run it and poke it

Terminal
fastapi dev main.py           # development server with auto-reload, http://127.0.0.1:8000
# equivalent, using the ASGI server directly:
uvicorn main:app --reload     # "main:app" means: from main import app

Open http://127.0.0.1:8000/docs to try every endpoint in the browser. Then use curl, as in the previous lesson:

Terminal
$ curl -s -i -X POST localhost:8000/tickets -H 'Content-Type: application/json' \
       -d '{"title": "Invoice sync failing", "priority": "urgent"}'
HTTP/1.1 201 Created
date: Tue, 13 Oct 2026 20:46:39 GMT
server: uvicorn
content-length: 128
content-type: application/json
location: /tickets/1

{"id":1,"title":"Invoice sync failing","body":"","priority":"urgent","status":"open","created_at":"2026-10-13T20:46:40.570319Z"}

$ curl -s -X PATCH localhost:8000/tickets/1 --json '{"status": "closed"}' | jq -c
{"id":1,"title":"Invoice sync failing","body":"","priority":"urgent","status":"closed","created_at":"2026-10-13T20:46:40.570319Z"}

$ curl -s -i localhost:8000/tickets/42
HTTP/1.1 404 Not Found
...
{"detail":"Ticket 42 not found"}

--json (curl 7.82 and later) is a shortcut that sends the body and sets both Content-Type and Accept to application/json; it implies POST, which is why the PATCH example also passes -X PATCH. For production, fastapi run main.py starts without auto-reload; never run --reload in production.

Tests with TestClient

TestClient calls your app in-process, without a real network, using the same HTTP semantics. Each test gets a fresh database in pytest's temporary folder, and one test uses app.dependency_overrides to simulate the database being down:

Python
# test_main.py  (run with: pytest -q)
import sqlite3

import pytest
from fastapi.testclient import TestClient

from main import app, get_db

@pytest.fixture
def client(tmp_path, monkeypatch):
    # Every test gets a brand-new database file in a temporary folder.
    monkeypatch.setenv("TICKETS_DB", str(tmp_path / "test.db"))
    with TestClient(app) as c:        # "with" runs the lifespan, creating the table
        yield c

def make_ticket(client, **overrides):
    body = {"title": "Invoice sync failing", "priority": "urgent", **overrides}
    r = client.post("/tickets", json=body)
    assert r.status_code == 201, r.text
    return r.json()

def test_create_returns_201_and_location(client):
    r = client.post("/tickets", json={"title": "GST report wrong"})
    assert r.status_code == 201
    data = r.json()
    assert data["priority"] == "normal" and data["status"] == "open"
    assert r.headers["location"] == f"/tickets/{data['id']}"

def test_get_missing_ticket_is_404(client):
    r = client.get("/tickets/999")
    assert r.status_code == 404
    assert r.json() == {"detail": "Ticket 999 not found"}

def test_validation_errors_are_422(client):
    r = client.post("/tickets", json={"title": "ab", "priority": "asap"})
    assert r.status_code == 422
    fields = {tuple(e["loc"]) for e in r.json()["detail"]}
    assert ("body", "title") in fields and ("body", "priority") in fields

def test_unknown_field_is_rejected(client):
    r = client.post("/tickets", json={"title": "Valid title", "owner": "ravi"})
    assert r.status_code == 422

def test_patch_changes_only_sent_fields(client):
    t = make_ticket(client, body="Tally export stuck")
    r = client.patch(f"/tickets/{t['id']}", json={"status": "closed"})
    assert r.status_code == 200
    assert r.json()["status"] == "closed"
    assert r.json()["body"] == "Tally export stuck"      # untouched

def test_list_filters_by_status(client):
    a = make_ticket(client)
    make_ticket(client, title="Second ticket")
    client.patch(f"/tickets/{a['id']}", json={"status": "closed"})
    r = client.get("/tickets", params={"status": "open"})
    assert [t["title"] for t in r.json()] == ["Second ticket"]

def test_delete_then_get_is_404(client):
    t = make_ticket(client)
    r = client.delete(f"/tickets/{t['id']}")
    assert r.status_code == 204 and r.content == b""
    assert client.get(f"/tickets/{t['id']}").status_code == 404
    assert client.delete(f"/tickets/{t['id']}").status_code == 404

def test_database_down_is_503(client):
    def broken_db():
        raise sqlite3.OperationalError("unable to open database file")
        yield  # never reached; makes this a generator like get_db
    app.dependency_overrides[get_db] = broken_db
    try:
        assert client.get("/tickets").status_code == 503
    finally:
        app.dependency_overrides.clear()
Terminal
$ pytest -q
........                                                                 [100%]
8 passed in 0.41s

Notice what the tests check: status codes, headers, error shapes and the edge cases (missing id, invalid input, unknown fields, partial update, double delete, database down), not only the happy path. If you see a deprecation warning from Starlette's test client about httpx and httpx2, it is harmless: recent Starlette releases prefer the httpx2 package for TestClient and fall back to httpx with a warning, so pip install httpx2 makes it go away.

How this shows up in FDE work

A D2C skincare brand wants their support agent to create tickets in their helpdesk. Their helpdesk team asks for "an API we can call and trust". You ship this exact shape: typed request models so their frontend gets precise 422s, idempotency keys on ticket creation because their mobile app retries on flaky 4G, 503 rather than 500 when the database is down so their retry logic backs off correctly, and a test suite their CI runs on every PR. When their engineer reports "your API returned 500", you reproduce it with curl in one minute, add a failing test, fix it, and show them the test passing. That is what "trust" means in practice.

Pitfalls
  • Returning 200 for everything and putting errors in the body. Clients, monitors and retry logic all key off the status code; they will treat failures as successes.
  • Using one Pydantic model for input and output. Clients can then set server-owned fields like id, and internal fields leak into responses.
  • Building SQL with f-strings from request values. That is SQL injection; values always go in as ? parameters (column names only ever from your own allowlist).
  • Calling blocking code (database drivers, requests, time.sleep) inside an async def endpoint. It freezes the event loop for every user; use plain def or a truly async library.
  • Testing only the happy path. The bugs live in 404s, 422s, empty lists and partial updates.
  • Unbounded list endpoints. One GET /tickets on a table with a million rows takes down the service; always cap limit.
Practice
Check yourself

Answer in your head or on paper first, then open the answer and be honest with yourself.

Why is it safe for a client to retry a timed-out DELETE but risky to retry a timed-out POST?

DELETE is idempotent: repeating it leaves the server in the same state as doing it once. POST is not, so a retry after a lost response can create a second resource; idempotency keys solve that.

What is the difference between 401, 403 and 422?

401 means the request is not authenticated (we do not know who you are). 403 means we know who you are and you are not allowed. 422 means the request was well-formed but its content failed validation.

What does uvicorn do that FastAPI does not?

Uvicorn is the ASGI server: it listens on the socket, parses HTTP bytes and calls the FastAPI application through the ASGI interface. FastAPI is the application framework that routes, validates and builds responses.

When exactly does the code after yield in get_db run, and why does that make it the right place to close the connection?

After the endpoint has finished and the response has been sent, and also if the endpoint raised, because it is in a finally block. Closing there guarantees every request releases its connection without each endpoint remembering to.

How does app.dependency_overrides make the 503 test possible without breaking a real database?

It swaps get_db for a function that raises the database error, only for the duration of the test, so you can exercise the failure path deterministically; clearing the overrides restores normal behaviour.

Use AI for this

AI assistants write FastAPI endpoints fluently, which is exactly why you should type this first service yourself: the value is in understanding each status code, model and dependency, and you only get that by making the mistakes. Once you have, AI is excellent for generating the boring parts (extra test cases, docstrings, OpenAPI examples) and for reviewing your API design against HTTP semantics. Always run what it gives you, and read every line of a generated test to make sure it asserts something meaningful.

Review my API design before I write code
You are a senior backend engineer reviewing a REST API design. Here is the resource
and the endpoints I plan to build:

Resource: [e.g. "notes, with title, body, tags, archived flag"]
Endpoints: [LIST METHOD + PATH + what each does + request/response fields]

Review it for:
1. Correct method choice (GET/POST/PUT/PATCH/DELETE) and REST naming.
2. The status code each endpoint should return on success and for each failure case.
3. Idempotency: which endpoints are retry-safe, and where I need an idempotency key.
4. Pagination, filtering and limits on list endpoints.
5. What should be validated in the request model (lengths, enums, required fields).
Answer as a table per endpoint, then list the three most important changes.
Do not write implementation code.
Find the missing tests
Here is my FastAPI app and my pytest file using TestClient
(FastAPI [VERSION], Pydantic v2). List the behaviours that are NOT tested yet,
grouped as: validation errors (422), not found (404), boundary values (limits, empty
lists, max lengths), partial updates, and failure paths (database errors).
For the five most valuable gaps, write the test functions in the same style as my
existing tests. Each test must assert the status code AND the response body or a
side effect. Do not change my application code.

main.py:
[PASTE]

test_main.py:
[PASTE]
Explain this FastAPI behaviour from the docs, not from memory
I am using FastAPI [VERSION] with Pydantic [VERSION]. Explain this behaviour I am seeing,
and base your answer on how the current FastAPI and Pydantic v2 documentation describes
it. If something you say depends on a version, say which. If you are not sure, say so
instead of guessing.

What I did: [e.g. "sent PATCH with {"title": null}"]
What I expected: [EXPECTATION]
What happened (status code and full response body): [PASTE]
Relevant code: [PASTE THE ENDPOINT AND MODELS]
Watch out

FastAPI and Pydantic both changed in ways that AI tools often miss. Watch for Pydantic v1 code in v2 projects: .dict() instead of model_dump(), class Config: instead of model_config = ConfigDict(...), @validator instead of @field_validator, orm_mode instead of from_attributes. Watch for @app.on_event("startup"), which FastAPI now documents as deprecated in favour of lifespan. AI also tends to return 200 for creation, use async def around blocking database calls, build SQL with f-strings, and write tests that only check status_code == 200. Check generated code against the FastAPI docs linked below and run the tests before you trust it.

Go deeper
Lesson 4 of 77 in the full course

Week 2 starts on how LLMs actually work.

The full course continues with 72 more lessons like this one, a real deliverable every week, and the AI Skills module.

See what unlocks