Module 02 · ~8 hours · Priority: High

Your First API

Build endpoints with no database at all, so "web framework" and "data storage" become two separate ideas in your head instead of one blurry one.

What a web framework actually does the key insight

What
A framework turns the raw HTTP text from Module 01 into Python objects, picks one of your functions based on the method and path, and turns whatever that function returns back into HTTP text.
Where
Between the network socket and your business logic. It is the only layer you did not write and cannot avoid.
When
The moment you have more than one URL to serve. Parsing HTTP by hand is a solved problem you should never solve again.
How
Two separate programs: Uvicorn owns the socket and speaks HTTP; FastAPI decides what to do with each request. They talk over a contract called ASGI.
This entire module has no database. Your data is a Python list of dictionaries sitting in memory. Restart the server and every change you made is gone — that is not a bug you are going to fix, it is the design of this module. "Web framework" and "data storage" are two completely different ideas, and the fastest way to keep them separate in your head is to learn one with the other switched off. Module 03 starts SQL, Module 05 brings the ORM, and Module 06 does the swap.

In Module 01 you typed an HTTP request by hand and watched the bytes come back. Everything a framework does sits on top of exactly that. Nothing magic happens — four boring jobs happen, in order:

  • Parse. The bytes GET /places/2 HTTP/1.1\r\nHost: localhost\r\n\r\n become a structured thing: a method, a path, a query string, headers, a body.
  • Route. Look at the method and the path together and find the one function registered for that combination. GET /places and POST /places are different routes despite the identical path.
  • Call. Pull the arguments your function declared out of the request, convert them to the types you asked for, and call you. If the conversion fails, you are never called at all.
  • Serialise. Take your return value, turn it into JSON, attach a status code and headers, and hand it back as bytes.

Now the distinction that this module exists to install. You will type uvicorn main:app and see a server start, and it is easy to conclude that Uvicorn and FastAPI are two names for one thing. They are not, and confusing them will cost you a whole afternoon at some point.

UvicornFastAPI
Kind of thingA serverA framework
Owns the TCP socketYes — it is the thing listening on port 8000No, and it has no idea what a socket is
Speaks HTTPYes — parses request bytes, writes response bytesNo — it receives an already-parsed dictionary
Knows about your routesNoYes — that is its whole job
Who calls whomCalls FastAPI, once per requestGets called, returns a response
Replaceable byHypercorn, Daphne, GranianStarlette, Litestar, Quart

ASGI is the contract between them. ASGI (Asynchronous Server Gateway Interface) says: an application is a callable that accepts three things — a scope dictionary describing the request, a receive function to read the body, and a send function to write the response. That is the entire agreement. Because both sides only depend on that shape, you can swap Uvicorn for Hypercorn without touching a line of FastAPI, and swap FastAPI for Litestar without touching your server command.

curl --> raw bytes over TCP: "GET /places/2 HTTP/1.1\r\nHost: ...\r\n\r\n" | v socket on port 8000 +--------------------------------------------------------------+ | UVICORN - the server. Owns the socket, speaks HTTP. | | parses the bytes, builds the ASGI scope dict: | | {"type": "http", "method": "GET", "path": "/places/2", ...} | +----------------------------|---------------------------------+ | ASGI: await app(scope, receive, send) v +--------------------------------------------------------------+ | FASTAPI - the framework. Never touches the socket. | | router: match ("GET", "/places/{place_id}") -> get_place | | convert the path segment "2" into the int 2, validate it | +----------------------------|---------------------------------+ v your get_place(place_id=2) <- your code, at last | returns a plain Python dict v FastAPI: dict -> JSON bytes, status 200, content-type header | await send({"type": "http.response.body"...}) v Uvicorn: writes those bytes back down the same socket

Hold onto that picture. When something goes wrong later, the first question worth asking is always "how far down this diagram did the request get?" — a connection refused died at the socket, a 404 died at the router, a 422 died at the conversion step, and a 500 means your function ran and something after it exploded.

In other stacks: Express is app.get('/places/:id', (req, res) => …), with req.params.id, req.query.tag and req.body instead of function arguments. The interesting difference is structural: app.listen(3000) means Express is the server as well as the framework — one library doing both jobs that Uvicorn and FastAPI keep apart. Django and Rails split them the way Python does (Gunicorn or Puma runs the server, the framework handles routing), which is why in those stacks too you start your app with a command naming a server, not the framework.

Your first endpoint

What
A Python function with a decorator above it that registers the function for one HTTP method and one path.
Where
main.py at the root of the city-guide folder you built in Module 00.
When
Right now. Get one route answering before you think about shape, structure, or folders.
How
Create an app object, decorate a function, and point Uvicorn at the module and the variable.

Activate the virtual environment from Module 00 and make sure both libraries are there:

$ source .venv/bin/activate
$ pip install fastapi uvicorn
$ pip freeze > requirements.txt

Then the smallest thing that works — six lines:

# main.py
from fastapi import FastAPI

app = FastAPI(title="City Guide API")

@app.get("/health")
def health():
    return {"status": "ok"}
$ uvicorn main:app --reload

INFO:     Will watch for changes in these directories: ['/…/city-guide']
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [27046] using StatReload
INFO:     Started server process [27051]
INFO:     Application startup complete.

Read that command as two halves separated by a colon. main is the module — the file main.py, imported by name, which is why you must run this from the folder containing it. app is the variable inside that module. Uvicorn imports the module, looks up the variable, checks that it is an ASGI callable, and starts serving it. Rename the file to server.py and the command becomes uvicorn server:app. Rename the variable to api and it becomes uvicorn main:api. There is no convention being enforced here, only an import path.

--reload watches your files and restarts the server process whenever one changes. It is the difference between a two-second and a fifteen-second edit loop, and you should use it every minute of local development. You must never use it in production: the file watcher burns CPU polling your disk forever, a restart drops every in-flight request on the floor, and production code does not change under a running server anyway — if it does, something is very wrong. In Module 11 you will start the same app with no reloader and multiple worker processes instead.

Now open http://127.0.0.1:8000/docs in a browser. You did not write that page. It is Swagger UI, and every endpoint you add appears in it with its parameters, its types, and a button that sends a real request.

  • The docs are generated from your type hints, not from comments. Visit /openapi.json and you will see a machine-readable description of your whole API. /docs is a JavaScript page that renders that file.
  • This is why the type hints are not decoration. The same annotation that gives you conversion and validation also gives you the documentation. One piece of information, three uses, no way for them to drift apart.
  • Your function name becomes the operation id — health becomes health_health_get in the schema. Name your functions as if a stranger will read them in a generated client, because one day they will.
  • Use /docs for exploring and curl for verifying. The docs page is faster to click, but curl is what you can paste into a bug report, a script, or a note to yourself.
If the server starts but curl says "connection refused", check the port line. Uvicorn prints the exact address it bound to. Something else already on 8000 (as Module 00 warned about 5432) means you either stop that thing or run uvicorn main:app --reload --port 8001 and curl the new port.

Path params, query params, and bodies

What
The three places a client can put data in an HTTP request, each with a different meaning and a different correct use.
Where
In the URL path, after the ? in the URL, and in the request body respectively.
When
Every endpoint you will ever write picks from these three. Picking wrong is the most common design mistake in a first API.
How
Declare them as function arguments. FastAPI decides which is which from the path string and the type.
KindLooks likeMeansUse it for
Path parameter/places/2Which resource this URL isIdentity. The id of the one thing you are addressing.
Query parameter/places?tag=foodHow to narrow a collectionFiltering, sorting, paging, searching. Always optional.
Request bodyJSON after the headersThe payload you are sendingCreating or changing a resource. POST, PUT, PATCH.

The test that settles almost every case: if removing the value would leave you pointing at a different resource, it belongs in the path. If removing it would leave you looking at the same collection with fewer filters applied, it belongs in the query. /places/2 without the 2 is meaningless. /places?tag=food without the tag is still a perfectly good request — all the places.

In FastAPI you declare all three as ordinary function arguments and the rules are mechanical:

@app.get("/places/{place_id}")        # name in braces = path parameter
def get_place(place_id: int):
    ...

@app.get("/places")                   # not in the path, simple type = query parameter
def list_places(tag: str | None = None, min_rating: int | None = None):
    ...

@app.post("/places")                  # not in the path, Pydantic model = request body
def create_place(payload: PlaceCreate):
    ...

The type hint is doing real work here, not documenting. place_id: int means FastAPI takes the text "2" out of the URL, converts it to the integer 2, and hands it to you. Ask for /places/abc and the conversion fails, so your function is never called and the client gets this, with a status of 422:

{"detail":[{"type":"int_parsing","loc":["path","place_id"],
  "msg":"Input should be a valid integer, unable to parse string as an integer",
  "input":"abc"}]}

You wrote no validation code. You wrote a colon and the word int. Notice the loc field — it tells the client exactly which part of the request was wrong, which is the difference between an error a caller can fix and an error they file a ticket about.

Bodies need a model. A body is a whole JSON object with several fields, so you describe it with a Pydantic BaseModel — a class where each attribute is a field with a type:

from pydantic import BaseModel, Field

class PlaceCreate(BaseModel):
    name: str = Field(min_length=1)      # reject the empty string
    neighbourhood: str
    rating: int = Field(ge=1, le=5)      # 1 to 5 inclusive, enforced for you
    tags: list[str] = []                 # optional, defaults to empty
$ curl -s -X POST http://127.0.0.1:8000/places \
    -H "Content-Type: application/json" \
    -d '{"name":"X","neighbourhood":"Y","rating":9}'

{"detail":[{"type":"less_than_equal","loc":["body","rating"],
  "msg":"Input should be less than or equal to 5","input":9,"ctx":{"le":5}}]}
  • Query parameters are always strings on the wire. ?min_rating=4 arrives as the text "4"; the int annotation converts it. Same mechanism as the environment variables in Module 00, same trap if you forget.
  • Give every query parameter a default. No default means required, and a required query parameter is almost always a sign that the value actually belonged in the path.
  • GET requests do not carry bodies. Some clients will let you, most proxies will drop it. Filtering goes in the query string, always.
  • Secrets never go in a path or query. URLs land in server logs, browser history, and referrer headers. Module 07 puts the token in a header for exactly this reason.

The whole CRUD surface type this out

What
Create, read, update, delete — the four operations every resource-shaped API supports, expressed as HTTP methods on two URLs.
Where
One main.py, one module-level list, no folders yet. Structure arrives in Module 06 when there is something to structure.
When
This shape is the default for anything that looks like a row in a table. Deviate deliberately, not by accident.
How
Six routes over /places and /places/{place_id}, plus a filter on the collection.

Here is the complete file. Type it — do not copy it. The typing is where you notice that @app.get and @app.post on the same path are two different routes, and that every write path starts by finding the place first.

# main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

app = FastAPI(title="City Guide API")

# ------------------------------------------------------------------
# THE STORE. A Python list in memory. It is NOT a database. Restart
# and it returns to these three rows. Deliberate: this module is the
# web framework only. Module 06 replaces this with Postgres.
# ------------------------------------------------------------------
places = [
    {"id": 1, "name": "Blue Bottle Coffee", "neighbourhood": "Mission",
     "rating": 4, "tags": ["coffee", "food"]},
    {"id": 2, "name": "Dolores Park", "neighbourhood": "Mission",
     "rating": 5, "tags": ["outdoors"]},
    {"id": 3, "name": "City Lights Books", "neighbourhood": "North Beach",
     "rating": 3, "tags": ["books"]},
]
next_id = 4                      # the world's worst primary key generator


class PlaceCreate(BaseModel):    # the body for POST and PUT - all fields required
    name: str = Field(min_length=1)
    neighbourhood: str
    rating: int = Field(ge=1, le=5)
    tags: list[str] = []


class PlacePatch(BaseModel):     # the body for PATCH - every field optional
    name: str | None = Field(default=None, min_length=1)
    neighbourhood: str | None = None
    rating: int | None = Field(default=None, ge=1, le=5)
    tags: list[str] | None = None


def find_place(place_id: int) -> dict:
    """Return the place dict, or raise the 404 for us. One place to change."""
    for place in places:
        if place["id"] == place_id:
            return place
    raise HTTPException(status_code=404, detail=f"Place {place_id} not found")


@app.get("/health")
def health():
    return {"status": "ok", "places_in_memory": len(places)}


@app.get("/places")
def list_places(tag: str | None = None, min_rating: int | None = None):
    results = places
    if tag is not None:
        results = [p for p in results if tag in p["tags"]]
    if min_rating is not None:
        results = [p for p in results if p["rating"] >= min_rating]
    return results


@app.get("/places/{place_id}")
def get_place(place_id: int):
    return find_place(place_id)


@app.post("/places", status_code=201)
def create_place(payload: PlaceCreate):
    global next_id
    place = {"id": next_id, **payload.model_dump()}
    places.append(place)
    next_id += 1
    return place


@app.put("/places/{place_id}")
def replace_place(place_id: int, payload: PlaceCreate):
    """Full replace: the body must contain every field. Missing = 422."""
    place = find_place(place_id)
    place.update(payload.model_dump())
    return place


@app.patch("/places/{place_id}")
def update_place(place_id: int, payload: PlacePatch):
    """Partial update: only the fields the client actually sent are touched."""
    place = find_place(place_id)
    place.update(payload.model_dump(exclude_unset=True))
    return place


@app.delete("/places/{place_id}", status_code=204)
def delete_place(place_id: int):
    place = find_place(place_id)
    places.remove(place)
    return None                  # 204 means "no content" - do not return a body

PUT versus PATCH is a real distinction, not a style preference. PUT says "here is the complete new state of this resource; make it look exactly like this". Send a PUT without rating and you get a 422, because you did not describe the whole thing. PATCH says "here are the fields I want changed; leave everything else alone". {"rating": 5} is a legitimate PATCH and a broken PUT.

The line that makes PATCH work is payload.model_dump(exclude_unset=True). Without exclude_unset, the fields the client never mentioned come through as None and you would helpfully wipe the name off the record. This is a bug people ship, and the symptom — "editing the rating cleared the address" — sounds like a database problem when it is four characters of Python.

  • Read the routes as method + path pairs. GET /places is a collection, GET /places/2 is a member, POST /places adds to the collection. The plural noun in the URL is doing the naming; the verb is the HTTP method, never a word in the path. /getPlaces and /deletePlace are the mistake this shape exists to prevent.
  • find_place exists so the 404 lives in one place. Four endpoints need "find it or fail"; writing that four times means fixing it four times. It is also the seam where Module 06 swaps a list scan for a database query, and only this function changes.
  • global next_id is here to be honest, not to be copied. Two requests arriving at the same instant could read the same value. A real database generates ids atomically, which is one of the several jobs you are currently doing by hand and badly.
  • The filter builds up. results starts as everything and each present parameter narrows it. That progressive-narrowing shape is exactly how you will build a SQL WHERE clause in Module 03 and a SQLAlchemy query in Module 05.
Say it out loud once: this list is not storage. It is not persisted, it is not shared between processes, and it does not survive a restart — which --reload performs every time you save the file. If you POST a place and then fix a typo in main.py, your place is gone. Nothing is broken. You are watching the exact gap that a database exists to fill, and you should watch it happen at least once on purpose.

Status codes and the shape you return

What
The three-digit number in the response line, and the JSON body that goes with it.
Where
Set per route with status_code=, and per failure with HTTPException.
When
Every endpoint. The status code is the part of your response that machines read; the body is for humans and clients.
How
Return a value for success, raise HTTPException for failure. Never return an error dict with a 200.

Returning a bare dictionary is fine for exactly this module. Whatever you return gets converted to JSON, so a dict is the shortest path to a working endpoint. It stops being fine the moment there is a database behind it: your rows will carry fields you never meant to publish, the response shape becomes whatever the storage layer happens to hold, and there is nothing describing the output in /openapi.json. Module 06 fixes this with response_model — a Pydantic model on the way out as well as on the way in. For now, notice the looseness rather than solving it.

Failure is HTTPException. You raise it anywhere — including deep inside a helper like find_place — and FastAPI catches it and turns it into a real response:

raise HTTPException(status_code=404, detail=f"Place {place_id} not found")

# on the wire:
HTTP/1.1 404 Not Found
content-type: application/json

{"detail":"Place 999 not found"}
CodeNameYou send it when
200OKA read succeeded, or an update succeeded and you are returning the new state.
201CreatedA POST created something new. The default 200 is a lie here.
204No ContentIt worked and there is deliberately nothing to send back. DELETE.
400Bad RequestThe request is malformed in a way you had to detect yourself.
404Not FoundThe request was perfectly valid; the thing it names does not exist.
422Unprocessable EntityThe request was understood but the values fail validation. FastAPI sends this for you.
500Internal Server ErrorYour code raised something you did not handle. Always your bug.

Why /places/999 is a 404 and /places/abc is a 422. Both look like "you asked for a place that isn't there", but they fail at different points in the diagram from the first section. 999 is a perfectly well-formed request: it parsed, it routed, it converted to an integer, your function ran, and the answer to a valid question is "that resource does not exist" — 404. abc never got that far. The conversion to int failed, so get_place was never called; nobody ever looked for a place, because there was no id to look for. That is a 422: the request itself is unprocessable.

The practical consequence: a 404 tells the client "try a different id", a 422 tells them "your request is wrong, fix your code". Collapse them both into 400 and you have thrown away the one bit of information the caller needed.

  • A 200 with {"error": "not found"} is a bug you will meet in real codebases. Every client, cache, proxy, monitor and retry policy in the world reads the status code. Hiding a failure inside a success response makes your API invisible to all of them.
  • 204 means no body at all. Return None. Returning a dict with a 204 produces a response that says "no content" while carrying content, and strict clients will choke on it.
  • Deleting something already deleted is a 404 here, and reasonable people argue for 204. Pick one and be consistent across the API; the sin is having DELETE /places/2 answer differently from DELETE /users/2.
  • You cannot serialise everything. FastAPI quietly converts more than you expect — a datetime becomes an ISO string, a set becomes a JSON array — but anything with no JSON equivalent produces a 500 and a traceback. The Build step makes you trigger one.

Your first test 20 minutes, whole module changes

What
A Python function that makes a request to your app and asserts something about the response, run by pytest.
Where
tests/test_places.py, next to main.py, from today onward.
When
Now — at three endpoints, not at thirty. Tests are cheap to start and expensive to retrofit.
How
TestClient calls your app in-process. No server running, no port, no network.

Make the argument to yourself properly, because "I'll add tests later" is the default and it is wrong. Testing is not a professionalism ritual you perform once the fun part is done. It is the fastest feedback loop available to you. Right now, checking that all six endpoints still work means: start the server, remember six curl commands, type them, read six blobs of JSON with your eyes, and decide. That is ninety seconds and it uses attention you need for the actual problem. pytest -q is under a second and it uses none. You will run it a hundred times this month.

The compounding matters more than the speed. Module 06 swaps this list for a database and Module 07 puts auth in front of it — both are changes that can silently break endpoints you are not looking at. With tests, "did I break the read path?" is a command. Without them, it is a hope. Twenty minutes here changes how every later module feels.

$ pip install pytest httpx        # TestClient is built on httpx
$ pip freeze > requirements.txt   # keep this current, every time
$ mkdir tests && touch conftest.py    # conftest.py is EMPTY, next to main.py

That empty conftest.py is not decoration and skipping it produces the single most common first-test failure. pytest adds the folder containing conftest.py to Python's import path, which is what makes from main import app resolve. Without it you get:

E   ModuleNotFoundError: No module named 'main'

Which is the same error as Module 00's, from the same cause: Python looking for a module in a place it was never told to look.

# tests/test_places.py
from fastapi.testclient import TestClient

from main import app

client = TestClient(app)


def test_list_places_returns_200():
    response = client.get("/places")
    assert response.status_code == 200
    assert len(response.json()) >= 3


def test_missing_place_returns_404():
    response = client.get("/places/9999")
    assert response.status_code == 404
    assert response.json()["detail"] == "Place 9999 not found"


def test_create_place_returns_201():
    response = client.post("/places", json={
        "name": "Tartine Bakery",
        "neighbourhood": "Mission",
        "rating": 5,
        "tags": ["food"],
    })
    assert response.status_code == 201
    assert response.json()["id"] > 0
$ pytest -q
...                                                       [100%]
3 passed in 0.33s        # one dot per test. The time will differ; the 3 must not
  • TestClient does not use the network. It calls your ASGI app directly — it stands exactly where Uvicorn stands in the first diagram, building the scope itself. That is why no server needs to be running and why the tests take milliseconds.
  • Three tests is a real suite. One happy read, one expected failure, one write. Those three shapes catch a startling proportion of the mistakes you will actually make.
  • Assert on the status code first, the body second. The status is the contract; the body is the detail. A test that only checks the body will pass while your API returns 200 for a failure.
  • These tests share the in-memory list, so they leak into each other. Run the create test twice in one process and the list grows — which is why the assertions say >= 3 and > 0 rather than exact numbers. That is a real weakness, not a trick: Module 09 fixes it properly with fixtures and a fresh database per test.
In other stacks: Node uses Jest or Vitest with supertest wrapping the Express app the same in-process way; Django ships django.test.Client; Rails has integration tests over the Rack app. The pattern is universal and worth naming: you test the application object, not a running server on a port. Anything that requires a live process and a real socket is a slower, flakier category of test, and you want as few of those as you can get away with.

Build this

Target: a running City Guide API with six working routes over an in-memory list — list with filters, read one, create, replace, partially update, delete — correct status codes on all of them, three passing tests, and a committed requirements.txt. No database is involved anywhere, and you can say why in one sentence.
  1. Write main.py exactly as above and start it with uvicorn main:app --reload. Confirm /health answers before you write anything else.
  2. Open /docs and drive all six endpoints from the browser. Create a place there, then find it with GET /places from curl — same data, two clients.
  3. Exercise the filter: GET /places?tag=food&min_rating=4, then a filter that matches nothing. An empty list is a 200, not a 404 — the collection exists, it is just empty.
  4. Break it on purpose, twice. First request /places/abc and read the 422 body: note that loc says ["path","place_id"] and that your function never ran. Second, add the /broken endpoint below, request it, and read the 500 in your terminal.
  5. Write tests/test_places.py and the empty conftest.py, get 3 passed, then deliberately change status_code=201 to 200 and watch which test fails and what it prints.
  6. POST a place, then save main.py to trigger the reloader, then ask for the place you created. It is gone. Commit with a message that says what you learned.
# add this to main.py for step 4, then delete it again
class Coords:
    __slots__ = ("lat", "lon")          # no __dict__, so nothing can inspect it
    def __init__(self, lat, lon):
        self.lat, self.lon = lat, lon

@app.get("/broken")
def broken():
    return {"coords": Coords(37.75, -122.42)}

Try the same endpoint returning {"tags": {"food", "coffee"}} (a set) or {"seen_at": datetime.now()} first. Both succeed — FastAPI's encoder knows those two — and the set comes back as a JSON array in a different order every time you restart the server. That silent success is worth as much as the failure: the encoder handles what it has a rule for and blows up on what it does not, and you cannot tell which is which by guessing.

Verify it yourself

Start the server in one terminal, run these in another. Every status code below is exact — if yours differs, something is genuinely wrong, and that is useful.

$ curl -s http://127.0.0.1:8000/health
{"status":"ok","places_in_memory":3}

$ curl -s http://127.0.0.1:8000/places/2
{"id":2,"name":"Dolores Park","neighbourhood":"Mission","rating":5,"tags":["outdoors"]}

$ curl -s "http://127.0.0.1:8000/places?tag=food&min_rating=4"
[{"id":1,"name":"Blue Bottle Coffee","neighbourhood":"Mission","rating":4,"tags":["coffee","food"]}]
                                   # exactly one place matches both filters

$ curl -s -i http://127.0.0.1:8000/places/999 | head -1
HTTP/1.1 404 Not Found             # valid request, missing resource

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/places/abc
422                                # never reached your function

$ curl -s -i -X POST http://127.0.0.1:8000/places \
    -H "Content-Type: application/json" \
    -d '{"name":"Tartine Bakery","neighbourhood":"Mission","rating":5,"tags":["food"]}' | head -1
HTTP/1.1 201 Created

$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8000/places \
    -H "Content-Type: application/json" \
    -d '{"name":"X","neighbourhood":"Y","rating":9}'
422                                # rating must be 1-5, enforced by Field(le=5)

$ curl -s -X PATCH http://127.0.0.1:8000/places/1 \
    -H "Content-Type: application/json" -d '{"rating":5}'
{"id":1,"name":"Blue Bottle Coffee","neighbourhood":"Mission","rating":5,"tags":["coffee","food"]}
                                   # only rating changed - name survived

$ curl -s -o /dev/null -w '%{http_code}\n' -X PUT http://127.0.0.1:8000/places/1 \
    -H "Content-Type: application/json" -d '{"rating":5}'
422                                # PUT is a full replace: missing fields are an error

$ curl -s -i -X DELETE http://127.0.0.1:8000/places/2 | head -1
HTTP/1.1 204 No Content

$ curl -s -o /dev/null -w '%{http_code}\n' -X DELETE http://127.0.0.1:8000/places/2
404                                # already gone

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/docs
200                                # the docs page you did not write

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/broken
500                                # and a full traceback in the server terminal

$ pytest -q
...                                                       [100%]
3 passed in 0.33s                  # a trailing "2 warnings" from a library is fine

The 500 above prints this in the terminal running Uvicorn. Read it with the Module 00 method — last line first — and notice that the frames themselves are the diagram from section one, bottom to top:

ERROR:    Exception in ASGI application
Traceback (most recent call last):
  File ".../uvicorn/protocols/http/h11_impl.py", line 409, in run_asgi
    result = await app(scope, receive, send)
  File ".../starlette/routing.py", line 290, in handle
  File ".../fastapi/routing.py", line 448, in app
    content = await serialize_response(
  File ".../fastapi/routing.py", line 303, in serialize_response
    return jsonable_encoder(response_content)
ValueError: [TypeError("'Coords' object is not iterable"),
             TypeError('vars() argument must have __dict__ attribute')]

Prove it

Write your answers in notes/module-02.md before opening any of them. Where your answer differs from the one here, work out which is right — that gap is the module.

1. You run curl http://127.0.0.1:8000/places/2. List everything that happens, in order, between curl sending bytes and your get_place function running.

Show answer

curl resolves 127.0.0.1, opens a TCP connection to port 8000, and writes the request line, headers, and a blank line. Uvicorn — the process holding that socket open — reads those bytes and parses them into an ASGI scope dictionary: type http, method GET, path /places/2, query string, headers. It then awaits your ASGI app with app(scope, receive, send). FastAPI's router compares method and path against its registered routes, matches GET /places/{place_id}, and extracts "2" as the raw path segment. It reads get_place's signature, sees place_id: int, converts "2" to 2 — and only if that succeeds does it call your function. Every step before yours can end the request without you ever running.

2. Rating filter, place id, and a new place's name: which of path, query, and body is correct for each, and why?

Show answer

The place id is a path parameter: it identifies which resource the URL names, and removing it leaves you addressing nothing. The rating filter is a query parameter: it narrows a collection that is perfectly meaningful without it, and it is one of many optional filters that can combine freely — putting it in the path would give you a combinatorial mess of URLs like /places/rating/4/tag/food. The new place's name goes in the body, because it is part of a payload with several fields you are sending for creation, and because bodies are not logged, cached, or bookmarked the way URLs are. A useful cross-check: two URLs differing only in a query parameter refer to the same underlying collection; two URLs differing in a path segment refer to different resources.

3. Someone asks for /places/999 and there is no place 999. Why is 404 correct and 400 wrong?

Show answer

400 means the request itself was malformed — the server could not make sense of what was sent. /places/999 is entirely well formed: correct method, valid path shape, an integer where an integer belongs. The server understood it completely and executed it; the answer is that no such resource exists. That is exactly what 404 means. The distinction is not pedantry, it is actionable: 404 tells the caller "your request is fine, try a different id", while 400 tells them "your code is broken, do not retry this". Sending 400 here points the caller at their own request builder for a bug that lives in your data. And note the third case: /places/abc is a 422, because the value was present but unprocessable — validation failed before any lookup happened at all.

4. What is Uvicorn doing that FastAPI is not?

Show answer

Uvicorn owns the network. It binds the TCP socket, accepts connections, reads and writes raw bytes, parses the HTTP protocol (including keep-alive, chunked encoding, and the request line and headers), runs the asyncio event loop, and manages worker processes. FastAPI does none of that and cannot — hand it a socket and it would not know what to do. FastAPI matches routes, reads your function signatures, converts and validates inputs, calls your code, serialises the return value, and generates the OpenAPI schema. The clean test: TestClient exercises your whole FastAPI app with no socket, no port, and no Uvicorn anywhere. Everything it can still do is framework work; everything it replaced is server work.

5. Why must --reload never be used in production?

Show answer

It starts a supervisor process that watches your source directory forever, which costs CPU and file handles on a machine that has better things to do. A detected change restarts the worker immediately, dropping in-flight requests and losing all process state — a restart mid-deploy would return errors to real users. It also fights with the process manager: in production something else (a container runtime, systemd, multiple Uvicorn workers) is responsible for lifecycle, and two supervisors disagreeing about who restarts what produces failures nobody can reproduce. Underneath all of that sits the assumption: reload exists because your files change while the server runs. In production they do not — a new version is a new deployment. Module 11 runs the same app with no reloader, in a container, started fresh each time.

6. Two people use your API at the same time from different laptops. What breaks, and which of those breakages does a database actually fix?

Show answer

With one Uvicorn process they share the same list, so they see each other's writes — and can corrupt them: two concurrent POSTs can read the same next_id and produce duplicate ids. With more than one worker process, it is worse and stranger: each process has its own copy of the list, so a place created on one is invisible on the other, and the same GET returns different answers depending on which worker got it. Nothing survives a restart or a deploy in either case. A database fixes all of it, and it is worth naming why: one shared store outside your processes, atomic id generation, transactions so concurrent writes cannot interleave badly, and durability on disk. Those four properties — not "somewhere to keep things" — are what you are buying in Modules 03 through 06.