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.
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:
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.GET /places and POST /places are different routes despite the identical path.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.
| Uvicorn | FastAPI | |
|---|---|---|
| Kind of thing | A server | A framework |
| Owns the TCP socket | Yes — it is the thing listening on port 8000 | No, and it has no idea what a socket is |
| Speaks HTTP | Yes — parses request bytes, writes response bytes | No — it receives an already-parsed dictionary |
| Knows about your routes | No | Yes — that is its whole job |
| Who calls whom | Calls FastAPI, once per request | Gets called, returns a response |
| Replaceable by | Hypercorn, Daphne, Granian | Starlette, 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.
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.
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.
main.py at the root of the city-guide folder you built in Module 00.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.
/openapi.json and you will see a machine-readable description of your whole API. /docs is a JavaScript page that renders that file.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./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.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.
? in the URL, and in the request body respectively.| Kind | Looks like | Means | Use it for |
|---|---|---|---|
| Path parameter | /places/2 | Which resource this URL is | Identity. The id of the one thing you are addressing. |
| Query parameter | /places?tag=food | How to narrow a collection | Filtering, sorting, paging, searching. Always optional. |
| Request body | JSON after the headers | The payload you are sending | Creating 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}}]}
?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.main.py, one module-level list, no folders yet. Structure arrives in Module 06 when there is something to structure./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.
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.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.--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_code=, and per failure with HTTPException.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"}
| Code | Name | You send it when |
|---|---|---|
| 200 | OK | A read succeeded, or an update succeeded and you are returning the new state. |
| 201 | Created | A POST created something new. The default 200 is a lie here. |
| 204 | No Content | It worked and there is deliberately nothing to send back. DELETE. |
| 400 | Bad Request | The request is malformed in a way you had to detect yourself. |
| 404 | Not Found | The request was perfectly valid; the thing it names does not exist. |
| 422 | Unprocessable Entity | The request was understood but the values fail validation. FastAPI sends this for you. |
| 500 | Internal Server Error | Your 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.
{"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.None. Returning a dict with a 204 produces a response that says "no content" while carrying content, and strict clients will choke on it.DELETE /places/2 answer differently from DELETE /users/2.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.tests/test_places.py, next to main.py, from today onward.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.>= 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.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.
requirements.txt. No database is involved anywhere, and you can say why in one sentence.
main.py exactly as above and start it with uvicorn main:app --reload. Confirm /health answers before you write anything else./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.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./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.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.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.
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')]
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.
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?
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?
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?
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?
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?
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.