Lecture 3 · Persistence and your first upstream PR
← Course home · ← Previous: Lecture 2 · Next: Lecture 4 →
At a glance
- When: Monday 2 November 2026, 16:00 Israel time, on Zoom
- You leave with: a backend whose data survives restarts, locally and in Docker, and your first upstream PR opened
- Goals: trace a request to a committed row, swap the storage without touching the contract, and tell a seed, a migration, a volume and a backup apart (details)
- Prerequisites: Lecture 2 works (
uv run pytestprints10 passed) anddocker run --rm hello-worldprints a greeting; repair: Lecture 2 completion check and Containers in one page- Product milestone: EX1 due next Monday, 9 Nov 2026, 23:59: today’s persistence, Dockerfile and README instructions complete it
- Time: prerequisite check about 10 min before class; lab about 3 h after class, plus the upstream PR (estimate)
- Done when:
uv run pytestprints12 passedfor the demo, and a record survivesdocker rm -f backendplus a newdocker runwith the same volume
On this page
Mission
Restart your Lecture 2 API and every record is gone. Today you give it a real memory, SQLite through SQLModel, without changing the HTTP contract or tests/test_recipes.py. Then you ship it as a Docker image whose data survives the container being deleted. Upstream, last week’s issue becomes a reproduced bug, a focused change and an opened PR.
Before you start
You need the tested CRUD API from Lecture 2, your Lecture 2 open-source issue or documentation gap (plus any maintainer replies), and Docker running (Docker Desktop, or Docker Engine on Linux; on Windows, work inside WSL2 Ubuntu). In backend/, run uv add sqlmodel before class.
Prerequisite check: about ten minutes in total, before class.
Check Pass when Repair From backend/:uv run pytest10 passed(or all of your own Lecture 2 tests pass)Lecture 2 completion check docker run --rm hello-worldIt prints Hello from Docker!Containers in one page · Lecture 1 git statusin your product repositorynothing to commit, working tree clean, and your.gitignorelists.venv/Git you need to operate curl --version7.82 or newer; otherwise use the -H 'Content-Type: application/json' -d '…'form from Lecture 2 wherever this page writes--jsonLecture 2 common pitfalls “New tools in today’s code” (below) You can say what four of the six do before opening the answers Python you need to read · Tests with pytest
New tools in today's code
Each tool appears in today’s demo code. Say what it does here, then check.
| Tool | Where you meet it | Question |
|---|---|---|
@asynccontextmanager on async def lifespan(app) | app/main.py | What runs before yield, and what after? |
A fixture that yields | tests/conftest.py | When does the code after yield run? |
monkeypatch.setattr(db, "engine", …) | tests/test_restart.py | What is replaced, and for how long? |
tmp_path | tests/test_restart.py | Where is the file, and who deletes it? |
app.dependency_overrides[get_session] = lambda: session | tests/conftest.py | What does every route receive instead of a fresh session? |
-> Iterator[Session] | app/db.py, tests/conftest.py | Why Iterator, not Session? |
Check your answers
- FastAPI enters the context when the server starts: the code before
yield(create tables, seed) runs once at startup, the code afteryield(engine.dispose()) once at shutdown. FastAPI: lifespan events. - After the test that used the fixture finishes, pass or fail: pytest runs the fixture up to
yield, hands the value to the test, then runs the rest as clean-up. - The module attribute
app.db.engineis replaced by an engine for a temporary file, and pytest puts the original back when the test ends. It works only becausemain.pylooks updb.engineat call time. - A fresh, empty directory unique to this test run; pytest creates it and cleans old ones up. Nothing touches your real
data/app.db. - The test’s one session, the same object for every request.
lambda: sessionis a function with no parameters that returns that session. get_sessionis a generator: ityields a session and resumes afterwards to close it. A function that yields is annotated with the type of what it produces,Iterator[Session].
Explain after: why would from app.db import engine in main.py make the restart test write to the real data/app.db? Answer it after demo step 5 (the Investigate prompt there has a model answer).
Professional practice: Explore authorization and concurrent edits at the storage boundary in the industry studio. This optional activity deepens today’s checkpoint without changing the assignment.
Learning checkpoint
By the end, you should be able to:
- Trace one request through its session to a committed row, and explain what survives a restart.
- Change persistence while preserving the API contract and its existing tests.
- Distinguish an idempotent seed, a schema migration, a persistent volume, and a backup.
Predict first: Why can a response look correct after flush() yet fail the restart test without commit()?
Check your prediction
flush() sends the INSERT inside a transaction that is still open. The database assigns the id, and the same session can read its own unfinished row, so the 201 body looks perfect. When the session closes without commit(), SQLAlchemy sends ROLLBACK and the row never reaches the file. The restart test starts a second “process” on the same file and finds only the seed rows. Unit of work shows the exact SQL.
Warm-up from earlier classes: answer from memory, then check. Ungraded.
- (Lecture 2) What does
PATCH {"title": null}mean in the Recipe Box contract, and whichmodel_dumparguments implement it?- (Lecture 2) Your fixture sets
app.dependency_overrides[get_store] = lambda: store. What does that line change, and when is it undone?- (Lecture 1) What does
--rmdo indocker run --rm hello-world, and what woulddocker ps -ashow without it?
Check your answers
nullmeans “leave this field unchanged”.body.model_dump(exclude_unset=True, exclude_none=True)keeps only the fields the client sent with a real value.- Every route that depends on
get_storereceives the test’s own store, so each test starts empty. The fixture clears the override afteryield, when the test ends. Today the same mechanism swaps in a test database. --rmdeletes the container when it exits. Without it,docker ps -astill lists the stopped container asExited (0). Today you will see what else disappears when a container is deleted: every file it wrote, unless the file lives in a volume.
Bridge if needed: Tables, rows, and SQL · State, transactions, and ownership · Containers in one page
Concepts
Same contract, new storage
Routes keep their URLs, bodies and status codes; only the storage behind them changes, so last week’s tests must pass untouched. The figure is the map for the whole lecture: the production path on the left, the two test paths on the right, and what survives which event.
docker rm. On a narrow screen, scroll the diagram sideways. Open the diagram at full size.Text version of the diagram
Production path (one request, top to bottom):
- Request
POST /recipesreaches the unchanged route. SessionDepcallsget_session(), which opens one Session for this request.- The Session holds pending objects in Python, flushes SQL inside an open transaction, and commits.
- The Engine (one per process) keeps a pool of connections to one database URL.
- The file
data/app.dbholds committed rows only. - In Docker,
/app/datais either inside the container’s writable layer (deleted bydocker rm) or a named volumebackend-datamounted there (kept untildocker volume rm).
Test paths:
tests/conftest.py:app.dependency_overrides[get_session]hands every route one session on an in-memory database (sqlite://withStaticPool); the lifespan never runs, sodata/app.dbis never touched.tests/test_restart.py:monkeypatchpointsdb.engineat a temporary file intmp_path, and twowith TestClient(app)blocks run the lifespan twice on that one file.
What survives:
| Event | Pending or flushed, not committed | Committed row in data/app.db (local) | Row in the container layer | Row in the named volume |
|---|---|---|---|---|
fastapi dev reloads after a save | lost | kept | — | — |
Process or container restart (docker stop/start) | lost | kept | kept | kept |
docker rm -f backend | lost | — | lost | kept |
docker volume rm backend-data | lost | — | — | lost |
Bridge: SQL in ten minutes
Skip this if you have written SELECT … WHERE and INSERT before. An ORM writes SQL for you, so you need just enough SQL to read what it sends.
| Word | Meaning | In Recipe Box |
|---|---|---|
| Table, row, column | A table is a named grid. Each row is one record; each column has a name and a type | Table recipe; one row per recipe; columns id, title, minutes, … |
| Primary key | A column whose value identifies exactly one row and is never repeated | id; SQLite assigns 1, 2, 3, … on INSERT |
NOT NULL | A rule that the column must have a value | title VARCHAR(200) NOT NULL |
| Index | A sorted lookup structure that makes searches by a column fast; it never changes results | ix_recipe_title on title |
| Transaction | Statements that take effect together at COMMIT, or not at all after ROLLBACK | One request’s INSERT and its COMMIT |
| ORM | Object-relational mapper: classes become tables, objects become rows, method calls become SQL | SQLModel, built on SQLAlchemy |
Predict first: run these four commands one at a time from backend/ (Python 3.12 or newer includes this SQLite shell, so there is nothing to install). Before each one, write down what it prints.
uv run python -m sqlite3 scratch.db "CREATE TABLE recipe (id INTEGER PRIMARY KEY, title VARCHAR(200) NOT NULL, minutes INTEGER NOT NULL);"
uv run python -m sqlite3 scratch.db "INSERT INTO recipe (title, minutes) VALUES ('Hummus', 15);"
uv run python -m sqlite3 scratch.db "SELECT id, title, minutes FROM recipe;"
uv run python -m sqlite3 scratch.db "INSERT INTO recipe (title, minutes) VALUES (NULL, 20);"
Check your prediction
- Nothing: the table now exists in the new file
scratch.db. - Nothing: one row was inserted and committed.
(1, 'Hummus', 15): you never sent anid; the primary key column chose 1. Each command is a separate process, so the row was already committed to the file.IntegrityError (SQLITE_CONSTRAINT_NOTNULL): NOT NULL constraint failed: recipe.title, with exit status 1. The database itself refused the row.
Delete the file afterwards: rm scratch.db.
SQLModel classes are Pydantic models that can also be SQLAlchemy tables. Recipe(table=True) maps to the recipe table; RecipeCreate is an ordinary Pydantic model with no table. Each call you write becomes SQL like this (copied from a run with echo=True, which logs every statement):
| You write (SQLModel) | The engine sends (SQL) |
|---|---|
session.add(recipe), then session.commit() | BEGIN, INSERT INTO recipe (title, minutes, servings, ingredients, tags) VALUES (?, ?, ?, ?, ?), COMMIT |
session.get(Recipe, 1) | SELECT … FROM recipe WHERE recipe.id = ? |
recipe.sqlmodel_update({"minutes": 35}), then commit() | UPDATE recipe SET minutes=? WHERE recipe.id = ?, COMMIT |
session.delete(recipe), then commit() | DELETE FROM recipe WHERE recipe.id = ?, COMMIT |
select(Recipe).order_by(Recipe.id).offset(0).limit(50) | SELECT … FROM recipe ORDER BY recipe.id LIMIT ? OFFSET ? |
Each ? is a placeholder: the values travel separately from the SQL text, so a title such as '); DROP TABLE recipe; -- is stored as data, never run. The longer ladder, with BEGIN and ROLLBACK in two terminals, is in Tables, rows, and SQL.
Engine, session and the session dependency
- The engine opens connections to one database URL and pools them. One per process.
- A session is one unit of work:
add()stages objects,commit()writes them together, andrefresh()reloads the row aftercommit()expired the object’s attributes. The engine is the library; a session is one visit with a borrowing slip. - Each request gets a session from the
yielddependencyget_session(); tests replace it.check_same_thread=Falseis needed because FastAPI runsdefendpoints in a thread pool.
SQLite URLs: sqlite:///./data/app.db is relative to the current directory, sqlite:////app/data/app.db (four slashes) is absolute, and sqlite:// is in memory.
Unit of work: add, flush, commit
| Call | What has happened | Seen by another connection? | Survives a restart? |
|---|---|---|---|
session.add(recipe) | Pending in Python only; recipe.id is None; no SQL sent | No | No |
session.flush() | INSERT sent inside the open transaction; the database assigned id | No | No: lost if never committed |
session.commit() | Flushes anything pending, then COMMIT; the row is in the file | Yes | Yes |
session.close() without commit() | ROLLBACK: everything since BEGIN is undone | — | — |
Step through the trace of one Falafel insert, logged with create_engine(url, echo=True):
session.add(recipe). Pending in Python only.recipe.idisNone, and the log shows no SQL at all.session.flush(). The log showsBEGIN (implicit), thenINSERT INTO recipe (title, minutes, servings, ingredients, tags) VALUES (?, ?, ?, ?, ?)with('Falafel', 60, 2, '', ''). Nowrecipe.idis1, yet a second connection to the same file still counts 0 rows.session.commit(). The log showsCOMMIT. The second connection now counts 1 row. SQLAlchemy also expires the object’s attributes, because the database may have changed them.session.refresh(recipe). A newBEGIN (implicit), thenSELECT … FROM recipe WHERE recipe.id = ?with(1,): the object is reloaded from the row.session.close().ROLLBACK. It ends the read transaction thatrefresh()opened, and nothing is lost. Had step 3 been skipped, this sameROLLBACKwould have erased theINSERT.
Optional: reproduce the trace yourself
Save this as trace.py in backend/ (after demo step 1), run uv run python trace.py, then delete trace.py and trace.db.
# trace.py (optional, not part of the app)
from sqlmodel import Session, SQLModel, create_engine
from app.models import Recipe
engine = create_engine("sqlite:///./trace.db", echo=True)
SQLModel.metadata.create_all(engine)
session = Session(engine)
recipe = Recipe(title="Falafel", minutes=60)
session.add(recipe)
print(">>> after add: id =", recipe.id)
session.flush()
print(">>> after flush: id =", recipe.id)
session.commit()
session.refresh(recipe)
print(">>> after refresh:", recipe.id, recipe.title)
session.close()
engine.dispose()
The >>> lines print None, then 1, then 1 Falafel, with the SQL from the stepper in between. The “second connection” counts come from a separate check with sqlite3.connect("trace.db"): 0 rows after the flush, 0 after a close without commit, 1 after a commit.
Check yourself: save() in today’s main.py calls add, commit and refresh, with no flush. At which call does the new recipe get its id, and at which call does it become safe from a restart?
Check your answer
Both happen inside commit(): it first flushes (the INSERT runs and the database assigns the id), then sends COMMIT, which makes the row durable. refresh() only reloads the expired attributes so the route can return them.
Simulator: follow one session Press add, flush, commit, rollback and restart on one recipe and watch the Python object, the open transaction, what another connection sees, and what survives a restart.
Worked example (one session on a trace.db file that starts empty):
| You run | Python object recipe | Open transaction | Another connection sees | After a restart |
|---|---|---|---|---|
session.add(recipe) | pending; id is None | none | 0 rows | 0 rows |
session.flush() | id is 1 | BEGIN and one uncommitted INSERT | 0 rows | 0 rows |
session.rollback() | out of the session; id still says 1, but no row 1 exists | none (ROLLBACK) | 0 rows | 0 rows |
session.add() a new recipe, then session.commit() | committed; id is 1 | none (BEGIN, INSERT, COMMIT) | 1 row | 1 row |
session.add() and session.flush() another, then restart | gone with the process | rolled back | 1 row | 1 row |
The interactive version of this simulator needs JavaScript; the worked example above is the same model.
Table model versus API schemas
| Class | Role | Validates input? |
|---|---|---|
RecipeBase | Shared fields and limits | (base only) |
Recipe(table=True) | The database table | No: table models skip validation in __init__ |
RecipeCreate, RecipeUpdate | POST and PATCH bodies, extra="forbid" | Yes |
RecipePublic | Response body, always has id | Yes |
Recipe(title="", minutes=0) does not raise, so routes validate through RecipeCreate first, then convert with Recipe.model_validate(body).
Version note: “table models skip validation in __init__” is true in SQLModel 0.0.48, the version in your lock (checked 17 October 2026); a future SQLModel release may validate there too. The rule that holds either way: validate at the boundary with RecipeCreate, and never write a table object you did not build through model_validate.
Tables, migrations and seeds
SQLModel.metadata.create_all(engine) creates missing tables but never alters existing ones: enough for EX1, while changing columns under real data needs migrations (the Alembic stretch goal). The lifespan creates tables and seeds at startup, and calls engine.dispose() at shutdown.
A seed inserts 2–3 demo rows into an empty database and must be idempotent: a second run inserts nothing. Keep rows as plain dicts and build fresh Recipe.model_validate(row) objects per call. Re-used table objects carry SQLAlchemy state, and a later seed can silently insert nothing (Drill 1).
Seeds are input too: validate them. You type seed rows by hand, so they can break the limits like any request body. Recipe(**row) would store a row with "minutes": 0 without complaint, and then every GET /recipes fails with a 500 when the response model rejects it (verified). Recipe.model_validate(row) refuses it at startup instead, with the field name in the error.
Defend it:
seed_if_emptyre-inserts the demo rows whenever the table is empty, including after a real user deletes every recipe and restarts the server (verified). Is that acceptable for your product? Give one reason and one cost.
What a strong answer mentions
- Reason to keep it for EX1: a grader who runs your image sees realistic data at once, and the rule is one line that a test pins down. It is fine if your README says “an empty database is re-seeded with demo rows on startup”.
- Cost: a user who deliberately emptied their list gets the demo rows back, which looks like a bug.
- Alternatives: seed only when
create_allhas just created the table, or seed only when a setting such asSEED_DEMO_DATA=1is present (on for development and demos, off for real data). Each needs a test for both cases.
Test isolation
Every test gets its own empty database, so nothing leaks between tests or into data/app.db.
| Strategy | Watch out for |
|---|---|
In-memory SQLite + StaticPool (used here) | Without StaticPool, each connection sees a different, empty database |
A temporary file per test (tmp_path) | Needed when a test restarts the app |
| A transaction per test, rolled back | More setup; mostly for PostgreSQL |
app.dependency_overrides[get_session] swaps in the test session. A plain TestClient(app) does not run the lifespan, so tests never touch the real file; with TestClient(app) does, which is what a restart test needs.
Where data lives
Locally, data is backend/data/app.db; git-ignore data/ and *.db, because a database is runtime state. In Docker, a container’s filesystem disappears with docker rm, while a named volume mounted at /app/data outlives it. EX1 needs a backend Dockerfile, so you write one today, adapted from uv’s Docker guide.
A backup is a separate copy you can restore somewhere else. A volume is not one: docker volume rm, a bad migration or a bug that deletes rows destroys the data in the volume as well. SQLite’s backup API copies a consistent snapshot even while the server is running (optional lab step 9).
Live demo
The instructor converts Recipe Box to SQLite, rerunning the unchanged tests after each file. Every command runs from backend/.
1. Models. Make the smallest change. Field now comes from sqlmodel: Pydantic’s own Field does not know index=True and emits a deprecation warning, which your filterwarnings = ["error"] turns into a failure. RecipeUpdate keeps its Lecture 2 fields but extends SQLModel; RecipePublic still adds id.
# backend/app/models.py
from pydantic import ConfigDict
from sqlmodel import Field, SQLModel
class RecipeBase(SQLModel):
title: str = Field(min_length=1, max_length=200, index=True)
minutes: int = Field(ge=1, le=24 * 60)
servings: int = Field(default=2, ge=1, le=50)
ingredients: str = Field(default="", max_length=5000)
tags: str = Field(default="", max_length=500)
class Recipe(RecipeBase, table=True):
id: int | None = Field(default=None, primary_key=True)
class RecipeCreate(RecipeBase):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
class RecipeUpdate(SQLModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
title: str | None = Field(default=None, min_length=1, max_length=200)
minutes: int | None = Field(default=None, ge=1, le=24 * 60)
servings: int | None = Field(default=None, ge=1, le=50)
ingredients: str | None = Field(default=None, max_length=5000)
tags: str | None = Field(default=None, max_length=500)
class RecipePublic(RecipeBase):
id: int
2. Engine and session dependency. Make the smallest change.
# backend/app/db.py
import os
from collections.abc import Iterator
from pathlib import Path
from typing import Annotated
from fastapi import Depends
from sqlalchemy import Engine
from sqlmodel import Session, SQLModel, create_engine
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/app.db")
def make_engine(url: str) -> Engine:
return create_engine(url, connect_args={"check_same_thread": False})
engine = make_engine(DATABASE_URL)
def create_db_and_tables() -> None:
db_file = engine.url.database # e.g. "./data/app.db"; None for in-memory
if db_file:
Path(db_file).parent.mkdir(parents=True, exist_ok=True)
SQLModel.metadata.create_all(engine)
def get_session() -> Iterator[Session]:
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
3. Lifespan, helpers and routes. Make the smallest change. main.py always says db.engine, never from app.db import engine: the restart test below swaps db.engine, and a name imported once would keep pointing at the real file.
# backend/app/main.py
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI, HTTPException, Query, status
from sqlmodel import Session, select
from app import db
from app.models import Recipe, RecipeCreate, RecipePublic, RecipeUpdate
from app.seed import seed_if_empty
@asynccontextmanager
async def lifespan(app: FastAPI):
db.create_db_and_tables()
with Session(db.engine) as session:
seed_if_empty(session)
yield
db.engine.dispose()
app = FastAPI(title="Recipe Box API", lifespan=lifespan)
def save(session: Session, recipe: Recipe) -> Recipe:
session.add(recipe)
session.commit()
session.refresh(recipe)
return recipe
def get_or_404(session: Session, recipe_id: int) -> Recipe:
recipe = session.get(Recipe, recipe_id)
if recipe is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Recipe not found")
return recipe
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/recipes", response_model=list[RecipePublic])
def list_recipes(
session: db.SessionDep,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 50,
) -> list[Recipe]:
query = select(Recipe).order_by(Recipe.id).offset(offset).limit(limit)
return list(session.exec(query).all())
@app.post("/recipes", response_model=RecipePublic, status_code=status.HTTP_201_CREATED)
def create_recipe(body: RecipeCreate, session: db.SessionDep) -> Recipe:
return save(session, Recipe.model_validate(body))
@app.get("/recipes/{recipe_id}", response_model=RecipePublic)
def get_recipe(recipe_id: int, session: db.SessionDep) -> Recipe:
return get_or_404(session, recipe_id)
@app.patch("/recipes/{recipe_id}", response_model=RecipePublic)
def update_recipe(recipe_id: int, body: RecipeUpdate, session: db.SessionDep) -> Recipe:
recipe = get_or_404(session, recipe_id)
recipe.sqlmodel_update(body.model_dump(exclude_unset=True, exclude_none=True))
return save(session, recipe)
@app.delete("/recipes/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_recipe(recipe_id: int, session: db.SessionDep) -> None:
session.delete(get_or_404(session, recipe_id))
session.commit()
All routes preserve Lecture 2’s contract. save() does add, commit and refresh; get_or_404() uses session.get(Recipe, recipe_id); the list runs select(Recipe).order_by(Recipe.id).offset(offset).limit(limit); DELETE calls session.delete(), then commit(). PATCH calls recipe.sqlmodel_update(body.model_dump(exclude_unset=True, exclude_none=True)), so only the fields the client sent change.
4. Seed. State the contract. An empty table gets three demo rows; a non-empty table gets nothing; every row passes the same limits as a request body.
# backend/app/seed.py
from sqlmodel import Session, select
from app.models import Recipe
SEED: list[dict] = [
{
"title": "Shakshuka",
"minutes": 25,
"servings": 2,
"ingredients": "4 eggs\n1 can tomatoes\n1 onion\n1 tsp paprika",
"tags": "breakfast,vegetarian",
},
{
"title": "Hummus",
"minutes": 15,
"servings": 4,
"ingredients": "chickpeas\ntahini\nlemon",
"tags": "vegan,quick",
},
{
"title": "Lentil soup",
"minutes": 40,
"servings": 4,
"ingredients": "lentils\ncarrot\nonion",
"tags": "vegan",
},
]
def seed_if_empty(session: Session) -> int:
"""Insert the demo rows only when the table is empty. Returns rows added."""
if session.exec(select(Recipe).limit(1)).first() is not None:
return 0
# Seeds are input too: model_validate checks the limits; Recipe(**row) does not.
session.add_all([Recipe.model_validate(row) for row in SEED])
session.commit()
return len(SEED)
5. Tests. Observe the real result. You may delete Lecture 2’s drill file tests/test_patch_null.py: title is now NOT NULL in SQLite, so if exclude_none=True ever goes missing, test_patch_changes_only_sent_fields fails with IntegrityError: NOT NULL constraint failed: recipe.title (TestClient re-raises the server’s exception instead of returning a 500). If your own domain has nullable fields, keep a null test that asserts the body. The only existing test file you edit:
# backend/tests/conftest.py
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool
from app.db import get_session
from app.main import app
@pytest.fixture
def session() -> Iterator[Session]:
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
engine.dispose() # else: ResourceWarning (an error under filterwarnings)
@pytest.fixture
def client(session: Session) -> Iterator[TestClient]:
app.dependency_overrides[get_session] = lambda: session
yield TestClient(app) # no `with`: the lifespan does not run
app.dependency_overrides.clear()
A new test starts the app twice on one file, and the seed test also checks every seed row against the POST rules:
# backend/tests/test_restart.py
from fastapi.testclient import TestClient
from app import db
from app.main import app
from app.models import RecipeCreate
from app.seed import SEED, seed_if_empty
def test_data_survives_restart(tmp_path, monkeypatch):
url = f"sqlite:///{tmp_path / 'app.db'}"
monkeypatch.setattr(db, "engine", db.make_engine(url)) # "process" 1
with TestClient(app) as client: # `with` runs lifespan startup + shutdown
assert len(client.get("/recipes").json()) == len(SEED) # seeded
client.post("/recipes", json={"title": "Falafel", "minutes": 60})
monkeypatch.setattr(db, "engine", db.make_engine(url)) # "process" 2
with TestClient(app) as client:
titles = [r["title"] for r in client.get("/recipes").json()]
assert titles == [row["title"] for row in SEED] + ["Falafel"] # no double seed
def test_seed_is_idempotent(session):
for row in SEED: # same rules as a POST body, including no unknown keys
RecipeCreate.model_validate(row)
assert seed_if_empty(session) == len(SEED)
assert seed_if_empty(session) == 0
Run uv run pytest: 12 passed (ten Lecture 2 tests plus these two).
Predict: in save(), replace session.commit() with session.flush() and run uv run pytest again. How many tests fail, and which?
Check your prediction
1 failed, 11 passed, and the failure is test_data_survives_restart. Every other test reads through the same session that flushed, so it sees its own unfinished rows. Only the restart test opens the file again, after the first session closed and rolled back. Undo the change.
Investigate: read
tests/test_restart.pyagain, then answer.
- Why does
test_restart.pyusewith TestClient(app)whileconftest.pyuses a plainTestClient(app)?- Why would
from app.db import engineinmain.pymake this test write to the realdata/app.db?- Why does the second “process” not add the three seed rows again?
Show model answers
withruns the lifespan: create tables, seed on startup, dispose on shutdown. The restart test needs exactly that, twice. Theconftest.pyclient must not run it, or every test would touch the real file.from app.db import enginecopies the reference once, at import time, intomain’s own namespace.monkeypatch.setattr(db, "engine", …)replaces only the attribute on thedbmodule, somainwould keep using the engine for./data/app.db. Looking updb.engineat call time sees the replacement.- The table already holds four rows, so
seed_if_emptyreturns 0 at its first check. The assertiontitles == [seed titles] + ["Falafel"]would fail with six or seven titles.
Trace it: follow client.post("/recipes", json={"title": "Falafel", "minutes": 60}) in the first with block of the restart test. Fill in the “State after” column yourself before opening the answer.
| Step | File : function | Input | State after | Next call |
|---|---|---|---|---|
| 1 | app/db.py : get_session | — | ? | create_recipe |
| 2 | app/main.py : create_recipe | RecipeCreate body | ? | save |
| 3 | app/main.py : save → session.add | Recipe object | ? | session.commit |
| 4 | session.commit | — | ? | session.refresh |
| 5 | FastAPI, with the returned Recipe | Recipe object | ? | get_session resumes |
| 6 | app/db.py : get_session after yield | — | ? | — |
Check your answer
- A new
Sessionon the engine thatmonkeypatchinstalled (the temporary file); no transaction yet. - The body has passed validation;
Recipe.model_validate(body)builds a table object withid=None. - Pending in the session only; no SQL sent.
INSERTthenCOMMIT; the row withid4 is in the temporary file (after the three seed rows); the attributes are expired, andrefreshreloads them.- The object is serialized through
RecipePublic: status201with"id": 4in the body. - The
with Session(...)block exits and the session closes: aROLLBACKthat only ends the read transactionrefreshopened. Nothing is lost, because the row was committed in step 4.
6. Look inside the file. Observe the real result. Start the server (uv run fastapi dev), add a recipe in /docs, stop it with Ctrl+C, start it again and list: your recipe is still there. Then read the file directly, from backend/ in a second terminal:
uv run python -m sqlite3 data/app.db "SELECT id, title, minutes FROM recipe ORDER BY id;"
uv run python -m sqlite3 data/app.db "SELECT sql FROM sqlite_master WHERE name = 'recipe';"
Python’s sqlite3 shell has no .schema command, so the second line asks the database for the statement that created the table. It shows title VARCHAR(200) NOT NULL and id INTEGER NOT NULL with PRIMARY KEY (id). If you have the standalone sqlite3 program, sqlite3 data/app.db ".schema recipe" also lists the index ix_recipe_title.
Predict: the schema says VARCHAR(200) and the model says minutes is at least 1. If you insert a 300-character title and minutes = 0 directly with python -m sqlite3, which rule stops it?
Check your prediction
Neither. SQLite accepts the row (verified). Only the rules from Drill 4 explain what happens next.
Watch out: SQLite ignores the 200 in
VARCHAR(200); here it enforces onlyNOT NULLand the primary key (SQLite datatypes, checked 17 October 2026). The length and range limits live in Pydantic, which is why only the backend may write this file.
7. Dockerfile. Make the smallest change. backend/Dockerfile (the # syntax line must stay first):
# syntax=docker/dockerfile:1
FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_NO_DEV=1 \
UV_PYTHON_DOWNLOADS=0 \
PYTHONUNBUFFERED=1
WORKDIR /app
# 1) Dependencies only: cached until uv.lock changes.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project
# 2) Application code.
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
# 3) Never run as root. /app/data holds the SQLite volume.
RUN useradd --create-home --uid 10001 appuser \
&& mkdir -p /app/data && chown appuser /app/data
USER appuser
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "8000"]
backend/.dockerignore lists, one per line: .venv, __pycache__, .pytest_cache, .ruff_cache, .env, data, *.db, tests.
The Dockerfile line by line: why each line is there, and what breaks without it
| Line | Why it is there | Without it |
|---|---|---|
# syntax=docker/dockerfile:1 | Asks for the current Dockerfile syntax, which understands RUN --mount | Docker 29 still builds (verified); older Docker versions may reject --mount |
FROM python:3.14-slim | A small Debian image with Python 3.14, the version in your .python-version | — (every image needs a base; a floating tag such as python:3-slim changes under you) |
COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /uvx /bin/ | Copies one pinned uv binary into the image | The next RUN fails: uv: not found |
ENV UV_COMPILE_BYTECODE=1 … | Compile .pyc files at build time (faster start); copy instead of link (the cache is another filesystem); skip the dev group, so pytest and ruff stay out of the image; never download a Python; print logs immediately, so docker logs shows them | Slower start, a bigger image with test tools in it, and log lines that may arrive late |
WORKDIR /app | Every later path is relative to /app, so the default ./data/app.db lands in /app/data | Files land in /, and the database is no longer in the volume |
RUN --mount=… uv sync --locked --no-install-project | Installs the dependencies only, exactly as locked; this layer is reused until uv.lock or pyproject.toml changes | Every code edit reinstalls every dependency |
COPY . /app | Copies your code, minus .dockerignore | No application in the image |
RUN … uv sync --locked | Installs your own project into .venv | — |
RUN useradd … chown appuser /app/data | Creates user 10001 and lets it write /app/data | Without the chown, the container stops as Exited (3) and docker logs shows sqlite3.OperationalError: unable to open database file (verified) |
USER appuser | The server runs as an ordinary user | It runs as root |
ENV PATH="/app/.venv/bin:$PATH" | The shell finds fastapi inside .venv | docker run fails: exec: "fastapi": executable file not found in $PATH (verified) |
EXPOSE 8000 | Documents the port; publishing is done by -p | Nothing changes |
CMD [… "--host", "0.0.0.0" …] | Listens on every interface inside the container, so Docker’s port forwarding can reach it | With 127.0.0.1, the container is Up but curl prints curl: (52) Empty reply from server (verified) |
Why a non-root user and a loopback port? If a bug or a compromised dependency lets someone run code in your server, a non-root user limits them to what uid 10001 may do (essentially, write /app/data) instead of everything root may do inside the container. Publishing with -p 127.0.0.1:8000:8000 lets only programs on your own computer reach this unauthenticated API, while -p 8000:8000 would publish it on every network interface, for example to everyone on the same Wi-Fi.
Version note: this Dockerfile is adapted from uv’s Docker guide (checked 17 October 2026). The guide bind-mounts only uv.lock for the first uv sync and uses --frozen there. This lecture also mounts pyproject.toml and uses --locked, so a lock that no longer matches pyproject.toml fails the build at step 4 instead of quietly installing the old versions.
8. Build, run, delete, run again. Observe the real result. Stop fastapi dev first (Ctrl+C). If it still holds port 8000, docker run cannot publish the port, and your curl silently reaches the dev server and its own data/app.db instead of the container: every check below would pass without Docker.
From backend/, one line at a time:
docker build -t recipe-box-backend .
docker run -d --name backend -p 127.0.0.1:8000:8000 -v backend-data:/app/data recipe-box-backend
docker ps --filter name=backend --format '{{.Names}} {{.Status}}'
curl -sS --retry 10 --retry-all-errors --retry-delay 1 localhost:8000/health
curl -sS --json '{"title": "Falafel", "minutes": 60}' localhost:8000/recipes
docker rm -f backend
docker run -d --name backend -p 127.0.0.1:8000:8000 -v backend-data:/app/data recipe-box-backend
curl -sS --retry 10 --retry-all-errors --retry-delay 1 localhost:8000/recipes
docker exec backend id
docker ps …must printbackend Up …(for examplebackend Up Less than a second). If it prints nothing, the container is not running:docker ps -a --filter name=backendshows why (Createdmeans the port was taken;Exited (…)means it crashed), anddocker logs backendshows the error.curl -sS --retry …waits for the server instead of guessing:-shides the progress bar,-Sstill shows errors, and--retry-all-errorsretries even an empty reply. One or twocurl: (52) Empty reply from serverlines while the server starts are normal; the last line must be{"status":"ok"}.docker exec backend idprintsuid=10001(appuser) gid=10001(appuser) groups=10001(appuser), and it fails if the container is not running.
The working directory is /app, so the default ./data/app.db lands in the volume. On curl older than 7.82, replace --json '…' with -H 'Content-Type: application/json' -d '…'.
Predict: before each step, say whether Falafel is still listed: (a) docker stop backend, then docker start backend; (b) docker rm -f backend, then a new docker run without -v; (c) docker rm -f backend, then a new docker run with -v backend-data:/app/data.
Text version of the diagram
A container has its own writable layer; a named volume is stored separately by Docker and mounted at /app/data.
docker build: an image; no container and no data yet.docker run -v backend-data:/app/data, then POST Falafel: the row is written to/app/data/app.db, which lives in the volume.docker stop backend, thendocker start backend: the same container restarts. Falafel is kept.docker rm -f backend: the container and its writable layer are deleted. The volume is untouched.docker run -v backend-data:/app/data: a new container mounts the same volume. Falafel is kept.docker runwithout-v:/app/datais inside the new container’s own layer, which starts empty. Falafel is gone; the seed adds 3 fresh rows.docker volume rm backend-data(only after the containers using it are removed): the data is gone for good, which is why a volume is not a backup.
Check your prediction
- (a) Kept. The container still exists, and so does everything it wrote.
- (b) Gone; only the 3 seed rows remain. Without
-v,/app/datais part of the new container’s own empty filesystem. - (c) Kept. The volume outlives every container that mounts it.
Note: in Lecture 8, Compose prefixes volume names with the project name (recipe-box_backend-data), so the full stack starts from a fresh volume and a fresh seed; today’s backend-data is not reused.
9. Rebuild after a change. Break it and watch the check fail.
Predict: (a) You add a comment to app/main.py and run docker build -t recipe-box-backend . again. Which of the seven numbered steps print CACHED? (b) You then add a dependency to the dependencies list in pyproject.toml by hand, without running uv lock, and build again. Which step fails, and with what message?
Check your prediction
- (a) Steps 2/7 (copy uv), 3/7 (
WORKDIR) and 4/7 (dependencies) printCACHED. Steps 5/7 (COPY . /app), 6/7 (install your project, under a second) and 7/7 (useradd) run again: every step after a changed layer reruns. That is why dependencies are installed before the code is copied. - (b) Step 4/7 fails:
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.followed byhint: To update the lockfile, run `uv lock`.Runuv lock, commituv.lock, and build again.
Hands-on lab
Apply each step to your own domain; Recipe Box is only the example. Plan your time:
- Core today: steps 1–5
- Before Lecture 4: steps 6–8 (EX1 is due that day, Mon 9 Nov 2026, 23:59)
- Extension (optional): step 9
Commands run from backend/.
Core today:
- Turn your Lecture 2 models into SQLModel classes: a base, a
table=Trueclass withid, and the Create, Update and Public schemas. - Add
app/db.pyand switch every route todb.SessionDep, keeping the contract. - Replace
tests/conftest.pywith the in-memory fixtures. Deletetests/test_patch_null.pyunless your domain has nullable fields (demo step 5 explains why). Checkpoint: your Lecture 2 tests pass, andgit diff --stat -- tests/test_recipes.py(your CRUD tests) prints nothing. - Write
app/seed.pywith 2–3 realistic rows as dicts, built withRecipe.model_validate(row), call it from the lifespan, and addtest_seed_is_idempotentwith its validation loop. Checkpoint: change one seed value to break a limit (for example"minutes": 0);uv run pytest -k idempotentfails with aValidationErrornaming that field. Undo it. - Add
tests/test_restart.py. Checkpoint: temporarily replacesession.commit()withsession.flush()insave(); the restart test must fail. Undo it.
Before Lecture 4:
- Check that your Lecture 1
.gitignorecoversdata/and*.db, then create a record withuv run fastapi dev, restart and confirm it is still there. - Stop
fastapi dev(Ctrl+C). Add theDockerfileand.dockerignore. Checkpoint:docker ps --filter name=backend --format '{{.Names}} {{.Status}}'showsUp, and a record survives the build,rm, run sequence from demo step 8. - Put setup, run and test commands (local and Docker) in your README, as EX1 requires (assignments). Document the one setting:
DATABASE_URL(defaultsqlite:///./data/app.db) in the README and as a line in.env.example.
Extension (optional):
-
Make a backup and open it. With the server stopped, from
backend/:uv run python -c "import sqlite3; sqlite3.connect('data/app.db').backup(sqlite3.connect('backup.db'))" uv run python -m sqlite3 backup.db "SELECT id, title FROM recipe ORDER BY id;"Expected: your rows, for example
(1, 'Shakshuka'),(2, 'Hummus'),(3, 'Lentil soup'). In Docker, run the same Python inside the container withdocker exec backend python -c "…", writing to/app/data/backup.db, then copy it out withdocker cp backend:/app/data/backup.db ./backup.db. Deletebackup.dbafterwards, or keep it outside the repository. A full restore rehearsal is in Evolve data without losing it.
EX1 progress: after step 8 your backend meets every EX1 item in the README: CRUD with Pydantic validation, persistent data, tests, a backend Dockerfile, and setup, run and test instructions. Before 9 Nov, a classmate follows your README from a clean clone and reports where they got stuck.
Break it on purpose
Make it fail, read the exact error, explain, fix, keep a test that catches it. For each drill, predict first, then run, then open the answer.
Drill 1 · The seed that lies. Build SEED_RECIPES = [Recipe.model_validate(row) for row in SEED] once at module level and call session.add_all(SEED_RECIPES) in seed_if_empty. Predict what uv run pytest -k idempotent prints, and then the full uv run pytest.
Check your prediction
-k idempotent passes. The full suite fails with assert 3 == 0: the restart test used those objects first, and the next seed “added 3” rows that never reached the new table. Fix: fresh objects per call. Passing alone but failing in the suite means shared state.
Drill 2 · The vanishing table. Remove poolclass=StaticPool from the fixture. Predict the error.
Check your prediction
Most tests fail with sqlite3.OperationalError: no such table: recipe: the endpoint runs in another thread, gets another connection, and therefore another empty in-memory database. Fix: restore StaticPool.
Drill 3 · The container that forgets. Run without -v backend-data:/app/data, add a record, docker rm -f backend, run again. Predict what GET /recipes lists.
Check your prediction
Only the seed rows: the record was written to the deleted container’s own layer. Fix: mount the volume, and put the exact run command in your README for the grader.
Drill 4 (optional) · The row that breaks the list. With uv run fastapi dev running, write a row that skips Pydantic, from backend/ in a second terminal:
uv run python -m sqlite3 data/app.db "INSERT INTO recipe (title, minutes, servings, ingredients, tags) VALUES ('Toast', 0, 1, '', '');"
curl -sS -i localhost:8000/recipes
Predict the status line, and where the explanation will appear.
Debug log: before you open the answer, write the first three lines of a debug log: the symptom (exact output), the smallest reproduction, and one hypothesis.
Check your prediction
HTTP/1.1 500 Internal Server Error with the body Internal Server Error; the client learns nothing more. The server terminal shows why: fastapi.exceptions.ResponseValidationError: 1 validation error: with 'loc': ('response', 3, 'minutes') and 'msg': 'Input should be greater than or equal to 1'. SQLite stored minutes = 0 because it does not know the Pydantic limit, and the response model refused to send it. One bad row breaks the whole list; GET /recipes/1 still works, GET /recipes/4 fails the same way.
Fix: uv run python -m sqlite3 data/app.db "DELETE FROM recipe WHERE minutes < 1;", and keep the rule “only the backend writes this file”. If you leave out servings, the INSERT itself fails with NOT NULL constraint failed: recipe.servings: the model’s default of 2 exists only in Python, not in the table.
Open-source track
Last week’s issue becomes PR #1. The rules live in the README’s open-source section and contribution etiquette.
-
Re-check first. Re-read CONTRIBUTING and the AI-use policy, look for duplicate work, and on the day you open the PR re-check and save a dated star snapshot (stars are measured when the PR is opened); it is PR #1’s star evidence:
gh repo view OWNER/REPO --json stargazerCount,licenseInfo,pushedAt,isFork gh issue view 123 --repo OWNER/REPO --comments gh pr list --repo OWNER/REPO --state all --search "words from the issue" - Reproduce it. Write a minimal reproduction: versions, smallest steps, expected versus actual; for a docs fix, what the page says versus what the code does. Cannot reproduce it? Say so on the issue.
- Investigate before you edit (about 20 minutes; the method, with a worked example, is in Read an unfamiliar repository):
- Find the real check commands: CONTRIBUTING’s development-setup section, and the
run:lines in.github/workflows/*.yml, which are what CI actually executes. - Find the code:
git grep -n "exact error text", thengit log --oneline -5 -- path/to/fileto see who changed it recently and why. - Run one existing test for that module before you edit, so you know the suite works on your machine.
- Find the real check commands: CONTRIBUTING’s development-setup section, and the
-
Make one focused change on a fresh branch of your fork, started from the current upstream code, in the project’s style:
gh repo fork OWNER/REPO --clone cd REPO git fetch upstream git switch -c fix/short-description upstream/maingh repo fork --cloneadds the original repository as theupstreamremote. If CONTRIBUTING names another base branch (for exampledevelop), start fromupstream/developinstead and add--base developtogh pr create. - Run the project’s own checks as CONTRIBUTING describes; add a test if the project expects one.
- Commit and open the PR. Follow the project’s commit style (
git commit -sif it requires a DCO, Developer Certificate of Origin, sign-off). Rungit push -u origin fix/short-description, thengh pr create --web, which targets the upstream repository, and fill in its template in the browser. - Write the description yourself. Without a template, use four headings: What (one or two sentences), Why (link the issue; “Fixes #123” only if fully resolved), How I verified (commands and results) and Notes for reviewers (open questions; AI-use disclosure if the project asks). Keep it short, specific and checkable, never claiming more than the diff shows.
- Record it in your evidence table. Opened is a checkpoint; only a merge counts.
If you are blocked:
| Situation | Next step |
|---|---|
| The issue is claimed, or a duplicate PR exists | Move to the next candidate on your shortlist; do not compete with an open PR |
| No maintainer reply to a non-trivial proposal | Keep waiting on it, and meanwhile open a small, self-evident docs or typo fix in the same or another qualifying project |
| You cannot reproduce the bug | Comment with your exact environment and steps, then pick another issue |
| A CLA bot asks you to sign an agreement | Read it; signing is your decision. If unsure, ask the instructor before signing |
| The project’s checks fail before you change anything | Note the failing command and output in your PR description, or ask on the issue; do not “fix” unrelated failures in the same PR |
Working with AI assistants
-
Contract-preserving refactor. Give the assistant a work order in the course’s one template:
Outcome: storage moves to SQLModel + SQLite; every URL, body and status code stays the same Files: app/models.py, app/db.py, app/main.py, app/seed.py, tests/conftest.py, tests/test_restart.py Non-goals: no edits to tests/test_recipes.py, no new endpoints, no Alembic Verify with: uv run pytest prints 12 passed; git diff --stat -- tests/test_recipes.py prints nothing Review focus: every session.commit(), the seed's model_validate, the conftest overrides Acceptance: tests/test_restart.py fails when commit() in save() becomes flush() Stop: if any test in tests/test_recipes.py would need an edit, stop and report whyRead the diff yourself before running it, then run the verify command.
- Make the test prove something. Ask for a restart test, then mutate the code:
session.commit()becomessession.flush(). A test that stays green under that mutation proves nothing (demo step 5 shows the expected result). - Open source. An assistant may explain unfamiliar code; you reproduce, run the checks and write every maintainer-facing word, within the project’s AI policy.
- AI-use note. Add one line to your README’s AI-use note for today: the tool, what it drafted, and the checks you ran (for example, the flush mutation).
Spot the outdated or unpinned suggestion:
@app.on_event("startup"): deprecated; use a lifespan.class Config: orm_mode = True: Pydantic v1. The v2 name ismodel_config = ConfigDict(from_attributes=True), which SQLModel already sets for you.create_engine("sqlite:///:memory:")in tests withoutStaticPool: Drill 2.FROM python:3-slimoruv:latest: floating tags that move to each new release (python:3-slimhas meant Python 3.15 since 9 October 2026), so the same Dockerfile builds something different next month. Pinpython:3.14-slimand a uv version.
Completion check
From backend/, with uv run fastapi dev stopped:
uv run pytest
uv run ruff check .
uv run ruff format --check .
docker build -t recipe-box-backend .
docker rm -f backend
docker run -d --name backend -p 127.0.0.1:8000:8000 -v backend-data:/app/data recipe-box-backend
docker ps --filter name=backend --format '{{.Names}} {{.Status}}'
curl -sS --retry 10 --retry-all-errors --retry-delay 1 localhost:8000/health
docker exec backend id
Expected: 12 passed for the demo (your own count for your domain), All checks passed!, N files already formatted, backend Up …, {"status":"ok"} (perhaps after one or two curl: (52) lines), and uid=10001(appuser) …. docker rm -f backend frees the name; if there is no container, its No such container message is harmless.
| Must work | Evidence you keep | If it fails, repair here |
|---|---|---|
uv run pytest passes: unchanged Lecture 2 tests, idempotent and validated seed, restart test | Terminal output in your PR description | Common pitfalls · Tests with pytest |
git diff --stat -- tests/test_recipes.py prints nothing | The empty output | Live demo step 5 |
docker ps shows backend Up, and docker exec backend id shows uid 10001 | Both outputs | Common pitfalls · Containers in one page |
A record survives docker rm -f backend plus a new docker run with the same volume | curl output before and after | Drill 3 · Where data lives |
git status shows no .db file and no data/ folder | git status output | Where data lives |
README has EX1’s setup, run and test commands (local and Docker), and documents DATABASE_URL | README on GitHub | Lab step 8 · Assignments |
| PR #1 is open upstream and listed in your evidence table | PR link and star snapshot | Open-source track |
Not yet? Repair and re-run before starting the next lecture’s lab; later layers assume this one works.
Quick quiz
Q1. Recipe(title="", minutes=0) raises no error. Why, and how does the API still reject that input?
In SQLModel 0.0.48, the version in your lock, table models skip validation in __init__. The route accepts a RecipeCreate, which validates, and only then converts it with Recipe.model_validate(body). That boundary rule stays correct even if a later SQLModel release validates table models too.
Q2. Your Lecture 2 test file passes unchanged against SQLite. What does that tell you?
The tests pin the HTTP contract (201 on create, 404 for a missing id, 422 for invalid bodies), not the storage. Needing edits would signal a breaking change or tests coupled to the implementation.
Q3. Why does the in-memory test database need StaticPool?
Each new connection to sqlite:// is a new, empty database. The tables live on one connection; the endpoint’s worker thread would open another. StaticPool shares one connection.
Q4. Why are seed rows plain dicts rather than ready-made Recipe objects?
A used table object carries SQLAlchemy state; adding it to a session for a new database can insert nothing while the seed reports success.
Q5. Which survives docker rm -f backend: the container's filesystem, a named volume, or both?
Only the named volume. docker rm deletes the container and its writable layer; the volume exists independently.
Q6. A user deletes every recipe and restarts the backend. What does GET /recipes return, and what are two ways to change that behavior?
The three demo rows: seed_if_empty sees an empty table and seeds again. Either seed only when the table has just been created, or seed only when a setting such as SEED_DEMO_DATA=1 is present. Keeping the simple version is fine for EX1 if the README says so.
Q7. Why is a named volume not a backup?
It is the live copy, not a second one. docker volume rm, a bad migration, or a bug that deletes rows destroys the data in the volume itself. A backup is a separate, consistent copy (for example from SQLite’s backup API) that you have actually restored once somewhere else.
Explain it: in one sentence, what is session.commit() for, compared with session.flush()? (Not what each one sends.)
Show a model answer
- Weak (narrates the SQL): “
flushsends theINSERT, andcommitsendsCOMMIT.” - Strong (purpose and the failure it prevents): “
commit()is the promise that the change is permanent and visible to everyone else;flush()only lets this session see its own unfinished work, so a route that flushes but never commits returns a believable201for a row that disappears when the session closes.”
Defend it: Recipe Box uses one SQLite file owned by the backend. A classmate says you should switch to PostgreSQL now. Defend SQLite for EX1 with one reason from the course requirements or your evidence, name one cost, and say what evidence would change your mind.
What a strong answer mentions
- Reason: the README says SQLite is enough; one file needs no extra service, account or password, and only the backend touches it, so the graders’ run command stays one
docker run. The restart and volume checks already prove persistence. - Cost: SQLite allows one writer at a time and has fewer migration features; scaling to several backend containers would need a different design.
- What would change your mind: measured
database is lockederrors or write latency under realistic concurrent use, a requirement for several backend instances, or features you need that SQLite lacks. Changing later is cheaper because the contract tests do not depend on the engine.
Stretch goals
Optional, once the required path works; do them after EX1 is submitted.
- Migrations with Alembic (after EX1).
uv add alembicanduv run alembic init -t pyproject migrations. Inmigrations/env.py, importapp.models, settarget_metadata = SQLModel.metadata, take the URL fromapp.db.DATABASE_URL, and passrender_as_batch=Trueto bothcontext.configure(...)calls. Thenuv run alembic revision --autogenerate -m "create recipe table"anduv run alembic upgrade head. Verified traps:- Without
import sqlmodelinmigrations/script.py.mako, upgrade fails withNameError: name 'sqlmodel' is not defined. - Autogenerate diffs against the live database: if
create_allalready made the table, the first migration is empty (pass). Generate the first revision against an empty database instead, for exampleDATABASE_URL=sqlite:///./data/fresh.db uv run alembic revision --autogenerate -m "create recipe table". - Once migrations exist, the lifespan’s
create_alland Alembic compete for the schema: replacecreate_allwith a documenteduv run alembic upgrade headstep (or runuv run alembic stamp headonce on a database thatcreate_allalready built). - A new NOT NULL column on a table with rows fails with
Cannot add a NOT NULL column with default value NULL; add aserver_defaultin the migration. - On a fresh clone there is no
data/folder until the app has started once, so autogenerate fails withunable to open database file:mkdir -p datafirst. Ruff also flagsmigrations/: runuv run ruff check --fix .anduv run ruff format .after each revision. The ruffpost_write_hooksthatalembic initleft commented out inpyproject.tomlonly lint; you still needuv run ruff format ..
Simpler is enough: for EX1,
create_allplus a disposable demo database is fine. Reach for Alembic when a schema change must keep real data. - Without
- A second table for your domain; decide what
DELETEdoes to its children.
Common pitfalls
no such table: recipein tests → an in-memory database withoutStaticPool→ restore the fixture.ResourceWarning: unclosed database(an error underfilterwarnings, often reported in an unrelated test) → an engine was never disposed →engine.dispose()in teardown and lifespan.IntegrityError: NOT NULL constraint failed: recipe.titleonPATCH {"title": null}→Nonecopied into a required column → dump withexclude_none=True.docker psshows nothing;docker ps -ashowsExited (3)→ the server crashed at startup → read why withdocker logs backend. Withunable to open database file, user 10001 cannot write/app/data, for example a bind-mounted host folder or a volume that an older image filled as root → mount a fresh named volume (-v backend-data:/app/data); Docker copies the image folder’s ownership into an empty volume.docker runprints a container ID and thenports are not available … address already in use→fastapi devstill holds 8000, and yourcurlmay silently reachfastapi devinstead of the container (docker ps -ashowsCreated) → stop the dev server,docker rm -f backend, run again; or publish with-p 127.0.0.1:8080:8000.Conflict. The container name "/backend" is already in use→ a container from an earlier run still exists →docker rm -f backend, then run again (a named volume keeps the data).The lockfile at `uv.lock` needs to be updated, but `--locked` was providedduring the build →pyproject.tomlchanged without relocking →uv lock, commituv.lock.- A new field is missing from the table →
create_allnever alters tables → reset only a disposable demo database, or use Alembic. Preserve real data and test its backup and restore before migrating it. - A second, empty
app.dbappears elsewhere → the relative URL follows the working directory → start frombackend/. GET /recipesreturns 500 after you edited the database by hand → a row breaks a Pydantic limit that SQLite does not enforce → read the server log (Drill 4), delete or fix the row, and let only the backend write the file.
Instructor notes
Suggested flow, with a minute budget per segment (estimates):
- Warm-up questions; show the Lecture 2 restart losing a recipe; the SQL bridge with the four
python -m sqlite3predictions (25 min). - The state figure and the unit-of-work stepper; convert
models.py,db.pyandmain.pylive, running the unchanged suite after each file (20 min). - Write
conftest.py, then run Drill 2 live so theStaticPoolreason sticks (15 min). - Seed and restart test. Peer-instruction vote before the flush mutation: “how many of the twelve tests fail?” Then the Investigate questions in pairs (25 min).
- Walk the Dockerfile; the predict-then-run volume sequence with the lifecycle figure; stop
fastapi devfirst (15 min). - Open source: investigate a repository together, walk the “If blocked” table, and improve a PR description draft as a class (30 min).
- Lab for the rest of the meeting; collect PR links at the end.
- Exit ticket: “In one sentence each: what makes a row survive a restart, and what makes it survive
docker rm?”
Core path if time runs short: segments 2, 4 and 5; the SQL bridge becomes pre-reading, and the open-source segment shrinks to the “If blocked” table.
Minimum viable EX1 path: lab steps 1–8. Alembic, the second table and the backup step are after EX1. PR #1 may be opened any time before the next meeting; the calendar is unchanged.
Emphasize: the contract is unchanged, table models do not validate, seeds are input too, and only a volume survives docker rm. Students get stuck on routes still calling the old store, the wrong working directory, the port clash with a running dev server (a false pass), and which tests to change (only conftest.py). For heavy upstream suites, running the relevant subset is fine when CONTRIBUTING allows it.
Read more
Before the lab (≤15 min): SQLModel: Test Applications with FastAPI and SQLModel. Read with two questions: why does the in-memory engine need StaticPool, and where is the dependency override cleared?
Go deeper (optional):
- FastAPI: SQL (relational) databases (its startup code still uses
@app.on_event, checked 17 October 2026; keep this lecture’s lifespan) - FastAPI: testing dependencies with overrides and lifespan events
- SQLite: datatypes, on what a column type does and does not enforce
- uv: using uv in Docker
- Docker: volumes
- GitHub: creating a pull request from a fork
- Course sites that teach SQL, storage and software design in more depth: Further study