Programming with Python › SQL and Relational Databases › Day 85
Hands-on lab — Day 85: Relational Databases and SQLite
- ← Back to the Day 85 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-085-relational-databases-and-sqlite/
Commands
Setup
cd labs/sections/programming-with-python/day-085-relational-databases-and-sqlite
sqlite3 --version
python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
mkdir -p scratch && cp examples/* scratch/ Run
python3 scratch/json_pain.py
cd scratch && sqlite3 library.db < schema.sql
cd scratch && sqlite3 library.db < seed.sql
cd scratch && ls -l library.db
cd scratch && python3 file_facts.py library.db
cd scratch && sqlite3 library.db < queries.sql
cd scratch && sqlite3 library.db < constraints_demo.sql # exits 1 on purpose
cd scratch && sqlite3 typing.db < typing_demo.sql # exits 1 on purpose
cd scratch && python3 table_scan.py
cd scratch && python3 scan_vs_sql.py library.db
cd scratch && python3 library_py.py library.db
cd starter && sqlite3 mine.db < schema.sql Test
bash tests/run_tests.sh File tree
examples/books.json examples/constraints_demo.sql examples/file_facts.py examples/json_pain.py examples/library_py.py examples/queries.sql examples/scan_vs_sql.py examples/schema.sql examples/seed.sql examples/table_scan.py examples/typing_demo.sql expected-output/constraints.txt expected-output/FIELDS.md expected-output/first-database.txt expected-output/json-pain.txt expected-output/scan-vs-sql.txt expected-output/test-run.txt expected-output/typing-and-strict.txt expected-output/walkthrough.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/books.json starter/schema.sql starter/table_scan.py tests/run_tests.sh troubleshooting.md
Lab README
Day 085 lab — Your First Database
Lesson
- Lesson title: Relational Databases and SQLite
- Day number: 85 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-085-relational-databases-and-sqlite
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-085-relational-databases-and-sqlitewhen the site is running.
Purpose
Yesterday you shipped a toolkit that kept its state in a JSON file, written atomically. That was the right call. In this lab you find out precisely where it stops being the right call, by measuring it — and then you build the thing that replaces it, from an empty directory to a working database, in one sitting.
You will do four things, in this order:
- Make the file fail.
json_pain.pymeasures four costs on your own machine: the bytes rewritten to change one field, the typo'd member id that nothing rejects, two writers who both "succeed" while one update vanishes, and the full parse-and-scan that answering "which loans are overdue?" costs. Real numbers, not an argument. - Build a database. A schema for
books,membersandloans, seeded in one transaction, then proven to be one ordinary file — you read the first sixteen bytes of the header yourself and confirm they are the documented magic string. - Write the query engine, then throw it away.
table_scan.pyimplementsWHERE, the column list andORDER BYby hand over a list of dicts. Thenscan_vs_sql.pyruns your loop and the equivalentSELECTand asserts the results are identical, row for row, in order. That assertion is the whole lesson: SQL is your loop, written by somebody else. - Ask the question the file could not answer. One
SELECT, three tables, three overdue borrowers, and you never said how to find them.
Along the way you meet the two things about SQLite that surprise everybody:
its typing is dynamic (a TEXT value will sit happily in a column you declared
INTEGER, and no comparison will ever match it), and its foreign keys are off
until you turn them on.
All 44 checks run offline. There is no server, no port, no credential, and no
third-party package — the standard library and the sqlite3 shell, nothing
else. The suite checks that mechanically.
Learning objectives
- Measure, rather than assert, the four points at which a JSON file stops being an adequate store: whole-file rewrites, absent constraints, lost updates, and a question that costs a full scan.
- Write a schema as a promise the engine keeps, using
PRIMARY KEY,NOT NULL,UNIQUE,CHECKandREFERENCES, and see each one refuse a bad write. - Turn
PRAGMA foreign_keyson, and see the identical write accepted when it is off — the rule is opt-in, per connection. - Prove a database is one ordinary file by reading its header bytes and checking that page size times page count equals the file length.
- Implement
restrict,projectandorder_byfrom first principles, and assert mechanically that the SQL returns exactly what they do. - Demonstrate SQLite's dynamic typing and type affinity, then fix it with a
STRICTtable and watch the same insert be refused. - See atomicity for yourself:
ROLLBACKundoing both halves of a two-statement transaction, from the shell and from Python. - Pass a hostile value as a parameter and understand why that ends SQL injection.
- Read both SQLite version numbers on your machine and explain why they are allowed to differ.
Prerequisites
- The Day 85 lesson (read it first).
- Days 64–66: files, JSON, and an exception strategy. The JSON file this lab dismantles is the one you have been writing since then.
- Day 70: modelling a domain with objects. A table is that model, written down somewhere the engine can enforce it.
- Day 84: the automation toolkit and its JSON state file. This lab is the direct answer to that lab's fifth extension exercise.
- A terminal and a text editor. Nothing to install.
Supported operating systems
- macOS — fully supported. Captures taken on macOS 26.5.1 (Apple Silicon,
arm64), Python 3.14.0, bash 3.2.57,
sqlite3shell 3.51.0. - Linux — fully supported on any distribution with Python 3.11+, bash and
the
sqlite3shell (sudo apt install sqlite3on Debian or Ubuntu). - Windows — use WSL and follow the Linux path. On native Windows,
tests/run_tests.shis a bash script and will not run; the SQL and Python files themselves work unchanged, andexpected-output/FIELDS.mdrecords what may legitimately differ rather than guessing at captures never taken.
Hardware requirements
Any computer that runs Python 3.11 or newer. The database built here is 28,672
bytes — seven pages. The largest thing the lab creates is a temporary 8 MB JSON
file that json_pain.py deletes before it exits. No GPU, no special memory,
and the whole harness finishes in a few seconds.
Required software
python3, 3.11 or newer (captures on 3.14.0), with the standard-librarysqlite3module — already there.- The
sqlite3command-line shell (captures on 3.51.0). bashfor the test harness — preinstalled on macOS and Linux.
No packages to install. See
requirements/README.md, which also explains why the
two SQLite version numbers on your machine may differ.
Free and open-source options
Everything here is free, and SQLite is unusual even among free software:
its source code is in the public domain, not merely permissively licensed.
Python and its sqlite3 module are free under the Python Software Foundation
License. bash is free under the GPL. There is no paid tier of any of it, no
account, and nothing to sign up for.
The lesson's Alternatives section covers PostgreSQL, MySQL/MariaDB, DuckDB and the commercial tier honestly — including the case, which is common, where SQLite is simply the right answer and reaching for a server is the expensive mistake.
Installation
cd labs/sections/programming-with-python/day-085-relational-databases-and-sqlite
sqlite3 --version
python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
That is the installation. Read both numbers, and note whether they agree — on the authoring machine they do not, and that is normal.
If the sqlite3 shell is missing, install it (sudo apt install sqlite3 on
Debian or Ubuntu) or point the harness at one you have:
SQLITE=/path/to/sqlite3 PYTHON=/path/to/python3 bash tests/run_tests.sh
File structure
day-085-relational-databases-and-sqlite/
├── README.md ← you are here
├── metadata.yml
├── examples/ ← the finished work, all runnable
│ ├── json_pain.py ← why the JSON file stops paying — measured
│ ├── books.json ← the books table before it was a table
│ ├── schema.sql ← the promise: 3 tables, 7 kinds of refusal
│ ├── seed.sql ← fixed dates, one transaction
│ ├── queries.sql ← the shell walkthrough and dot-commands
│ ├── constraints_demo.sql ← 7 refused writes, then ROLLBACK vs COMMIT
│ ├── typing_demo.sql ← dynamic typing, affinity, and STRICT
│ ├── file_facts.py ← read the 16-byte header yourself
│ ├── table_scan.py ← restrict / project / order_by, by hand
│ ├── scan_vs_sql.py ← asserts the two agree, row for row
│ └── library_py.py ← the module: parameters, row_factory, with
├── starter/ ← YOUR work
│ ├── schema.sql ← 8 numbered exercises; applies as shipped
│ ├── table_scan.py ← 3 numbered exercises
│ └── books.json
├── tests/
│ └── run_tests.sh ← 44 behavioural checks, one exit code
├── expected-output/
│ ├── test-run.txt ← the full harness run
│ ├── first-database.txt ← both versions, ls -l, the header bytes
│ ├── walkthrough.txt ← queries.sql in .mode box
│ ├── constraints.txt ← the seven refusals and the rollback
│ ├── typing-and-strict.txt ← affinity, then STRICT refusing
│ ├── scan-vs-sql.txt ← the identical-results proof
│ ├── json-pain.txt ← the four measured costs
│ └── FIELDS.md ← what must match, what may differ
├── requirements/
│ ├── requirements.txt ← deliberately empty; the note says why
│ └── README.md
├── troubleshooting.md
└── security.md
How to run
Everything below runs from this directory. Work in a scratch copy so your own
library.db is yours:
mkdir -p scratch && cp examples/* scratch/ && cd scratch
## 1. First, make the file fail. Real numbers on your machine.
python3 json_pain.py
## 2. Build the database from nothing.
sqlite3 library.db < schema.sql
sqlite3 library.db < seed.sql
## 3. It is one ordinary file. Prove it two ways.
ls -l library.db
python3 file_facts.py library.db
## 4. The shell walkthrough: dot-commands, then SQL.
sqlite3 library.db < queries.sql
## 5. Or drive it interactively. .quit or Ctrl-D to leave.
sqlite3 library.db
## sqlite> .tables
## sqlite> .schema loans
## sqlite> .mode box
## sqlite> .headers on
## sqlite> SELECT title, year FROM books ORDER BY year;
## sqlite> .quit
## 6. Watch the schema refuse seven bad writes, then a transaction undo itself.
## EXPECT ERRORS. Exit code 1 here means it worked.
sqlite3 library.db < constraints_demo.sql; echo "exit: $?"
## 7. Dynamic typing, and STRICT as the fix.
sqlite3 typing.db < typing_demo.sql; echo "exit: $?"
## 8. The query engine you write by hand.
python3 table_scan.py
## 9. The assertion the whole lab is built on.
python3 scan_vs_sql.py library.db; echo "exit: $?"
## 10. The same database from Python: parameters, rows, transactions.
python3 library_py.py library.db
## 11. Your task. Build it yourself from the starter.
cd ../starter
sqlite3 mine.db < schema.sql # applies as shipped; creates books
python3 table_scan.py # names the next exercise
## ... complete exercises 1-8 in schema.sql and 1-3 in table_scan.py ...
And the whole thing behind one command, from the lab directory:
bash tests/run_tests.sh
echo "exit code: $?"
What the commands do
python3 json_pain.py— builds JSON loan files of 10, 1,000 and 50,000 records in a temporary directory, changes one field in each, and reports the bytes read and written to do it. Then stores an impossible member id without complaint, races two writers over one file and loses an update, and finally parses 8.6 MB to answer one question. It cleans up after itself.sqlite3 library.db < schema.sql— creates the database. Note that the file did not exist a moment ago: SQLite creates it on first write, with no service, no port and no configuration anywhere else on the machine.sqlite3 library.db < seed.sql— inserts the rows inside oneBEGIN; ... COMMIT;. Every date is a literal, neverdate('now'), so the captures stay true tomorrow.python3 file_facts.py library.db— opens the file in binary mode and reads bytes 0–15 (the magic string), 16–17 (page size) and 28–31 (page count), then asks the engine the same two questions withPRAGMAand checks they agree with each other and with the file's length.sqlite3 library.db < queries.sql— the walkthrough:.tables,.schema,.mode box,.headers on,.nullvalue, then realSELECTs, aGROUP BY, the three-table overdue query, andEXPLAIN QUERY PLANshowing which index the planner chose without being told.sqlite3 library.db < constraints_demo.sql— seven writes the schema refuses (a foreign key twice,NOT NULL,UNIQUE, twoCHECKs, a duplicate primary key), a count proving nothing changed, then the same two-statement transaction rolled back and committed. It exits 1 on purpose.sqlite3 typing.db < typing_demo.sql— five inserts into an ordinary table showing what affinity does and does not convert,typeof()reporting the storage class actually used, aWHEREthat silently drops a row, and then the same value refused by aSTRICTtable. Also exits 1 on purpose.python3 table_scan.py— the hand-written engine, and its own cost: six predicate calls to find four rows, because a list has no index.python3 scan_vs_sql.py library.db— runs both and exits non-zero if they differ by a single row. It prints them side by side so you can see they do not.python3 library_py.py library.db— the three habits:PRAGMA foreign_keyson, values passed as parameters (with a hostile one to prove the point), andwith connection:as a transaction that rolls the whole group back.bash tests/run_tests.sh— all 44 checks, in nine sections, on copies made in a temporary directory. Exits 0 on success, non-zero on any failure.
Expected output
The harness ends like this — a real captured run; see
expected-output/test-run.txt for all of it:
9. Nothing here reaches the network or needs anything installed
ok: no executable lab file contains a network address of any kind
ok: no lab file imports a third-party package — standard library only
ok: every database this run created lives under a temporary directory
44 checks, 0 failure(s).
The header, read off the disk
(expected-output/first-database.txt):
first 16 B: b'SQLite format 3\x00'
hex: 53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00
matches the documented magic string: True
page size (header bytes 16-17): 4,096 bytes
page count (header bytes 28-31): 7
pages * page size: 28,672 bytes
The cost the JSON file was quietly paying
(expected-output/json-pain.txt):
50,000 loans: file 8,622,249 bytes | changed 27 bytes | read+wrote 17,244,490 bytes to do it
Seventeen megabytes moved to change twenty-seven bytes. The database rewrites the 4,096-byte page holding that row.
Dynamic typing, caught in the act
(expected-output/typing-and-strict.txt):
│ 2 │ not-a-number │ text │ stored as text │ text │
That row is in a column declared INTEGER. SELECT count(*) ... WHERE year < 2000 then returns 4 out of 5 rows and says nothing about the one it dropped.
The STRICT version of the same table answers:
Runtime error near line 71: cannot store TEXT value in INTEGER column tight.year (19)
And the assertion the lab is built on
(expected-output/scan-vs-sql.txt):
IDENTICAL: 4 rows, same values, same order.
The engine ran your loop. That is the whole trick.
expected-output/FIELDS.md states exactly which of
these values must be identical on your machine and which are expected to
differ — including the two SQLite version numbers, which the suite reports and
deliberately does not require to match.
Validation steps
bash tests/run_tests.shends with44 checks, 0 failure(s).and exits 0.sqlite3 --versionandpython3 -c "import sqlite3; print(sqlite3.sqlite_version)"both print a3.xnumber. They need not be the same number.python3 file_facts.py library.dbprintsmatches the documented magic string: True, and page size times page count equals the sizels -lreports.sqlite3 library.db < constraints_demo.sqlproduces sevenRuntime errorlines namingFOREIGN KEY,NOT NULL,UNIQUEandCHECK, then reports6 / 4 / 7 / 2— proof that a refused write changes nothing.- Re-running the same file with
PRAGMA foreign_keys = OFFaccepts the bad member id. The rule is per-connection and opt-in. sqlite3 typing.db < typing_demo.sqlshowsnot-a-numberstored withtypeofoftextin anINTEGERcolumn, aWHERE year < 2000matching 4 of 5 rows, and theSTRICTtable refusing the same value withcannot store TEXT value in INTEGER column.python3 scan_vs_sql.py library.dbprintsIDENTICAL: 4 rowsand exits 0. Change one line oftable_scan.pyand it exits 1 — try it.- The overdue query returns exactly three rows,
days_lateof 55, 21 and 6. sqlite3 library.db "SELECT count(*) FROM loans"is unchanged after aROLLBACKof a two-statement transaction, and thecopiescolumn is back to its old value too.python3 library_py.py library.dbreportsloans before: 7, after: 7after awith connection:block whose second write failed.- After the harness,
find . -name "*.db"inside the lab finds nothing.
Tests
bash tests/run_tests.sh
Expected final line: 44 checks, 0 failure(s). The command exits 0 on success
and non-zero on any failure.
Two sections are worth reading before you run it. Section 3 proves the
schema's refusals and then does the opposite: it runs the identical rejected
write with PRAGMA foreign_keys=OFF and requires it to be accepted. A
suite that only ever checks the happy configuration would let you ship a
database with its most important rule switched off. Section 5 is the
identical-results check; break any of restrict, project or order_by and
watch it go red, which is the fastest way to convince yourself the check is
real.
Section 1 is worth reading for what it deliberately does not assert: it prints both SQLite library versions and requires only that each is readable. On the authoring machine they differ, and writing the equality assertion would have meant either a failing suite or a false claim.
Cleanup
rm -rf scratch
rm -f starter/mine.db starter/starter.db
find . -type d -name __pycache__ -prune -exec rm -rf -- {} +
git checkout -- starter/ # optional: reset your work
A SQLite database is one ordinary file. Deleting it removes it completely —
there is no service to stop, no registry entry, no configuration elsewhere on
the machine. If you see library.db-journal or library.db-wal beside it,
those belong to the same database and go with it.
tests/run_tests.sh makes its own temporary directory with mktemp -d and
removes it in a trap, so a completed run leaves nothing behind and one of its
checks asserts exactly that.
Troubleshooting
See troubleshooting.md. The five you are most likely to
meet: the two SQLite versions disagreeing (normal, not a fault); UNIQUE constraint failed: books.book_id, which means you applied seed.sql twice;
FOREIGN KEY constraint failed, which is the lab working — while the same
write being accepted means PRAGMA foreign_keys is off; a query returning
fewer rows than you expect, which is almost always type affinity and is
diagnosed with typeof(); and no such table, which usually means
sqlite3.connect created an empty database next door because the path was
relative.
Security notes
See security.md. Short version: pass values as parameters,
never build a statement out of a string — examples/library_py.py proves the
point with a hostile value and leaves the loans table intact. Turn foreign
keys on, because off they enforce nothing. And know what a database file is:
no users, no passwords, no encryption, so the filesystem permissions are the
access control, deleting a row does not scrub its bytes, and a -wal file
beside the database is part of it. This lab needs no credential, opens no port,
reaches no network and needs no sudo.
Extension exercises
- Make the overdue query lie. Insert a loan whose
due_onwas written as'16/08/2026'instead of'2026-08-16'. TheCHECKcatches that shape, so first work out what shape would pass it and still sort wrongly. Then fix it properly — and decide whether aCHECKis the right tool or whether you want the column typed differently. - Add the missing table. A library has copies, not just titles: one row per physical book, each belonging to a title, and a loan points at a copy. Redesign the schema, and note what the change does to the overdue query.
- Measure the index. Load 100,000 loans, time the overdue query, drop
loans_open, time it again, and put it back. Then runEXPLAIN QUERY PLANin both states. Write down what changed and what did not — the answers must be identical, only the work differs. - Convert the whole schema to STRICT. Every column must then be declared
INT,INTEGER,REAL,TEXT,BLOBorANY. Find out which of your declarations were not really types at all. - Turn WAL on.
PRAGMA journal_mode = WAL;, then look at the directory: there are now three files, and the mode persists in the database rather than in the connection. Open two shells and confirm one can read while the other holds a write transaction. Then confirm two writers still cannot. - Replace Day 84's state file for real. Take
feedkit'sfeedkit-state.jsonand design the two tables that hold the same information. Write down, before you start, what you gain — concurrent readers, a query, no whole-file rewrite — and what you lose: a file you could read withcat, and an atomic write you could explain in a paragraph. - Break every check on purpose, one at a time. Remove a
CHECK, drop theNOT NULL, comment outPRAGMA foreign_keys = ON, return the wrong column order fromproject. Run the harness after each. Four defects, four red checks, and you now know rather than hope that each property is genuinely asserted.
Navigation
- Previous day: Day 84 — shipping an automation toolkit
(
labs/sections/programming-with-python/day-084-shipping-an-automation-toolkit/). This lab is the direct answer to that one's fifth extension exercise. - Next day: Day 86 —
SELECT: filtering, sorting and aggregating (labs/sections/programming-with-python/). - This week: Week 13, SQL and Relational Databases. Day 87 is joins, which today's overdue query previews on purpose.
Expected output
FIELDS.md
# What must match, and what may differ
Every file in this directory is a real capture from a real run on the authoring
machine (macOS 26.5.1, Apple Silicon, Python 3.14.0, bash 3.2.57, sqlite3 shell
3.51.0, SQLite 3.53.3 inside Python, 2026-08-16). Nothing here was typed by
hand. Use this page to tell a genuine difference from an expected one.
## Must match exactly
These are facts about SQLite and about the data, not about your machine.
| Value | Where | Why it cannot differ |
| --- | --- | --- |
| `b'SQLite format 3\x00'` | `first-database.txt` | The first 16 bytes of every SQLite database file, fixed by the file format |
| `53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00` | `first-database.txt` | The same 16 bytes in hexadecimal |
| `matches the documented magic string: True` | `first-database.txt` | As above |
| `pages * page size` equals the file size | `first-database.txt` | A database file is a whole number of pages, always |
| `tables: books, loans, members` | `first-database.txt` | What `schema.sql` creates |
| The seven `Runtime error` lines and their constraint names | `constraints.txt` | Each one is a rule written in `schema.sql` |
| `6 / 4 / 7 / 2` after the refused writes | `constraints.txt` | A refused write changes nothing |
| `year_class` = `text` for `not-a-number` | `typing-and-strict.txt` | SQLite type affinity, documented behaviour |
| `rows_matching_year_lt_2000 = 4` out of `rows_total = 5` | `typing-and-strict.txt` | Every INTEGER sorts before every TEXT |
| `cannot store TEXT value in INTEGER column tight.year` | `typing-and-strict.txt` | What STRICT is for |
| `IDENTICAL: 4 rows, same values, same order.` | `scan-vs-sql.txt` | The whole point of the lab |
| `4 row(s); 6 predicate calls to find them` | `scan-vs-sql.txt` | Six books, four published before 1980 |
| The three overdue borrowers and `days_late` of 55, 21 and 6 | `walkthrough.txt` | Computed from fixed dates in `seed.sql` against a fixed 2026-08-16 |
| `loans before: 7, after: 7` | `scan-vs-sql.txt` | Atomicity: the good write is undone with the bad one |
| `44 checks, 0 failure(s).` and exit 0 | `test-run.txt` | The suite either passes or it does not |
## Expected to differ
| Value | Why |
| --- | --- |
| `sqlite3 --version` | Your shell links whatever SQLite your operating system or package manager shipped |
| `sqlite3.sqlite_version` in Python | Your Python links its own copy, and it need not be the same one |
| Whether the two versions agree at all | On the authoring machine they differ (3.51.0 against 3.53.3). On yours they may match. **Neither case is a fault**, and the suite deliberately does not assert equality — it reports both and checks only that each is readable |
| The owner, group, timestamp and `@` flag in `ls -l` | Your account, your filesystem. The capture shows `you staff` because the real username was removed |
| `size: 28,672 bytes` and `page count: 7` | Stable for this exact schema and seed on a 4,096-byte page. A build of SQLite with a different default `page_size` gives different numbers that still satisfy `pages * page_size == file size` |
| `PRAGMA page_size: 4,096` | 4,096 is what both SQLite builds on this machine chose. The page size is a compile-time and per-database setting, so another build may choose differently — read yours off the header rather than assuming this one |
| `PRAGMA journal_mode: delete` | The default rollback journal. If you or a tool has set WAL on the file, this reads `wal` |
| Byte counts in `json-pain.txt` | The JSON is generated with a loop, so the totals are reproducible on a given Python — but `json.dumps` spacing has changed across Python versions before and may again |
| Line-drawing characters in `.mode box` output | The shell draws these with box-drawing characters. A terminal or pipe without UTF-8 renders them differently. The numbers inside are what matter |
| `EXPLAIN QUERY PLAN` wording | The planner's output is a human-readable description, not an interface. A different SQLite version may word it differently or, legitimately, choose a different plan |
## Deliberately not asserted
`tests/run_tests.sh` reports both SQLite versions and checks that each is
readable, and does **not** require them to be equal. Writing that assertion
would have made the suite fail on the authoring machine, and "make the test
match the machine" is the wrong direction: the honest fact is that a shell and
a language binding are two programs, and the version that matters is the one
belonging to the program you are actually running.
constraints.txt
$ sqlite3 library.db < constraints_demo.sql
=== 1. A typo in a member id. There is no member 999. ===
Runtime error near line 17: FOREIGN KEY constraint failed (19)
=== 2. A loan of a book that does not exist ===
Runtime error near line 22: FOREIGN KEY constraint failed (19)
=== 3. A member with no name ===
Runtime error near line 27: NOT NULL constraint failed: members.name (19)
=== 4. A second member with an address already in use ===
Runtime error near line 32: UNIQUE constraint failed: members.email (19)
=== 5. A negative number of copies ===
Runtime error near line 37: CHECK constraint failed: copies >= 0 (19)
=== 6. A loan due before it was borrowed ===
Runtime error near line 41: CHECK constraint failed: due_on >= borrowed_on (19)
=== 7. The same primary key twice ===
Runtime error near line 46: UNIQUE constraint failed: books.book_id (19)
=== After seven refused writes, the data is exactly as it was ===
┌───────┬─────────┬───────┬──────────────────┐
│ books │ members │ loans │ copies_of_book_1 │
├───────┼─────────┼───────┼──────────────────┤
│ 6 │ 4 │ 7 │ 2 │
└───────┴─────────┴───────┴──────────────────┘
=== 8. Atomicity: two writes, one transaction, one mistake ===
Borrowing a book is really two facts: a new loan row, and one fewer
copy on the shelf. Neither is true on its own.
inside the transaction:
┌───────┬──────────────────┐
│ loans │ copies_of_book_3 │
├───────┼──────────────────┤
│ 8 │ 2 │
└───────┴──────────────────┘
after ROLLBACK — the A in ACID, and it is all or nothing:
┌───────┬──────────────────┐
│ loans │ copies_of_book_3 │
├───────┼──────────────────┤
│ 7 │ 3 │
└───────┴──────────────────┘
=== 9. The same transaction, committed this time ===
┌───────┬──────────────────┐
│ loans │ copies_of_book_3 │
├───────┼──────────────────┤
│ 8 │ 2 │
└───────┴──────────────────┘
=== 10. Put it back, so the lab is repeatable ===
┌───────┬──────────────────┐
│ loans │ copies_of_book_3 │
├───────┼──────────────────┤
│ 7 │ 3 │
└───────┴──────────────────┘
exit: 1 (seven refused writes; an exit code of 1 here is success)
first-database.txt
$ sqlite3 --version
3.51.0 2025-06-12 13:14:41 f0ca7bba1c5e232e5d279fad6338121ab55af0c8c68c84cdfb18ba5114dcaapl (64-bit)
$ python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
3.53.3
# Two different numbers on this machine. The shell and the Python
# module are separate programs, each linking its own copy of SQLite.
$ sqlite3 library.db < schema.sql
$ sqlite3 library.db < seed.sql
$ ls -l library.db
-rw-r--r--@ 1 you staff 28672 Aug 16 07:28 library.db
$ python3 file_facts.py library.db
path: library.db
size: 28,672 bytes
first 16 B: b'SQLite format 3\x00'
as text: 'SQLite format 3' + b'\x00'
hex: 53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00
matches the documented magic string: True
page size (header bytes 16-17): 4,096 bytes
page count (header bytes 28-31): 7
pages * page size: 28,672 bytes
PRAGMA page_size: 4,096
PRAGMA page_count: 7
PRAGMA journal_mode: delete
tables: books, loans, members
The bytes on disk and the engine agree, because there is only one
artefact here: a file you could copy with cp and mail to somebody.
json-pain.txt
$ python3 json_pain.py
1. A one-field change rewrites the whole file
10 loans: file 1,712 bytes | changed 27 bytes | read+wrote 3,416 bytes to do it
1,000 loans: file 170,584 bytes | changed 27 bytes | read+wrote 341,160 bytes to do it
50,000 loans: file 8,622,249 bytes | changed 27 bytes | read+wrote 17,244,490 bytes to do it
The database rewrites the page that holds the row (4096 bytes here), not the file.
2. Nothing stops a member id that does not exist
stored happily: member_id=999
json.dump has no opinion about what a member id means.
3. Two writers, one lost update
loans in the file afterwards: 4
loan 1 returned_on: None
Both writes 'succeeded' atomically. A's update is gone anyway:
atomicity protects the FILE, not the two readers who raced over it.
4. 'Which loans are overdue?' costs a full load and a full scan
parsed 8,622,241 bytes and examined 50,000 records
to answer a question with 33,334 rows in it
There is no cheaper path. The file has no index, because a file has no idea what a due date is.
scan-vs-sql.txt
$ python3 table_scan.py
loaded 6 rows from books.json
hand-written scan: books published before 1980, oldest first
1968 The Art of Computer Programming Donald E. Knuth
1970 A Relational Model of Data Edgar F. Codd
1975 The Mythical Man-Month Frederick P. Brooks
1976 A Discipline of Programming Edsger W. Dijkstra
4 row(s); 6 predicate calls to find them
the SQL that replaces every line of this:
SELECT title, author, year FROM books
WHERE year IS NOT NULL AND year < 1980
ORDER BY year;
$ python3 scan_vs_sql.py library.db
sqlite3 module reports SQLite library 3.53.3
by hand (table_scan.py) | by SQL (SELECT ...)
---------------------------------------------+---------------------------------------------
1968 The Art of Computer Programming | 1968 The Art of Computer Programming
1970 A Relational Model of Data | 1970 A Relational Model of Data
1975 The Mythical Man-Month | 1975 The Mythical Man-Month
1976 A Discipline of Programming | 1976 A Discipline of Programming
IDENTICAL: 4 rows, same values, same order.
The engine ran your loop. That is the whole trick.
exit: 0
$ python3 library_py.py library.db
SQLite library linked into Python: 3.53.3
Compare that with `sqlite3 --version` in your shell. On many
machines the two numbers differ, and neither is wrong: the shell
and the Python module are separate programs, each carrying its own
copy of the library. Check the one you are actually running.
foreign_keys = 1
overdue as of 2026-08-16:
2026-06-22 Ada Lovelace The Mythical Man-Month
2026-07-26 Grace Hopper A Discipline of Programming
2026-08-10 Ada Lovelace Structure and Interpretation
looking up a member whose name is an attempted injection:
value: "Ada'; DROP TABLE loans; --"
rows returned: 0
loans table still has 7 rows
The value was never parsed as SQL. It was a string, and the
engine compared it to a column. That is all a parameter is.
The dangerous version, which this file deliberately does not run:
f"SELECT ... WHERE name = '{name}'"
Build a statement out of a value once and you have handed the
value the ability to be a statement.
transaction refused: FOREIGN KEY constraint failed
loans before: 7, after: 7
The first INSERT succeeded and was then undone with the second.
Atomicity is not 'each statement works'; it is 'the group did'.
test-run.txt
$ bash tests/run_tests.sh
Day 085 — Your First Database
1. The tools report themselves, and they do not have to agree
sqlite3 shell library: 3.51.0
python3 module library: 3.53.3
ok: the sqlite3 shell reports a SQLite 3 library version
ok: python3 can import sqlite3 and report its library version
the two DIFFER on this machine — this is normal, not a fault
ok: both SQLite library versions could be read and reported
2. The database is one ordinary file, and the header says so
ok: schema.sql applies cleanly
ok: seed.sql applies cleanly inside one transaction
ok: library.db is a plain regular file
ok: the first 16 bytes are the literal string SQLite format 3 plus a NUL
ok: header page size and count match the engine and the file length
ok: the schema is data: sqlite_schema lists books, loans and members
3. The schema is a promise, and the engine keeps it
ok: a loan naming member 999, who does not exist, is refused
ok: a loan of book 404, which does not exist, is refused
ok: a member with a NULL name is refused
ok: a second member with an address already in use is refused
ok: a negative number of copies is refused
ok: a loan due before it was borrowed is refused
ok: the same primary key twice is refused
ok: after seven refused writes the data is byte-for-byte unchanged (6/4/7/2)
ok: with foreign_keys OFF the SAME bad write is accepted — the rule is opt-in
4. Typing is dynamic by default, and STRICT is the fix
ok: an ordinary INTEGER column ACCEPTS the text not-a-number
ok: and stores it with storage class text, in a column declared INTEGER
ok: text that looks like an integer is converted by affinity to integer
ok: the text row silently fails year < 2000 — two rows in, one row out
ok: a STRICT table REFUSES the same text value
ok: and the refused row was never written to the STRICT table
ok: STRICT still allows the LOSSLESS text-to-integer conversion
5. The hand-written scan and the SQL return the same rows
ok: scan_vs_sql.py reports the two results IDENTICAL and exits 0
ok: restrict, project and order_by each behave as the operator they name
ok: the scan reports its own cost: 6 predicate calls to find 4 rows
6. The question a JSON file could not answer cheaply
ok: one SELECT finds exactly 3 overdue loans as of 2026-08-16
ok: and names the borrowers by joining three tables in one statement
ok: filtering by hand over every row gives the identical answer
7. A transaction is all or nothing
ok: ROLLBACK undoes BOTH writes, not just the last one
ok: COMMIT keeps both writes together
ok: in Python, one failing write in a with-block undoes the good one too
ok: a hostile value passed as a PARAMETER is compared, never executed
8. The starter is runnable, and carries its exercises
ok: the starter schema applies cleanly before you have written a line
ok: and creates the one worked table, books, for you to build on
ok: the starter schema carries its 8 numbered exercises
ok: the starter scan carries its 3 numbered exercises
ok: running the unfinished starter names the next exercise instead of a traceback
ok: and exits non-zero, so an unfinished lab cannot look finished
9. Nothing here reaches the network or needs anything installed
ok: no executable lab file contains a network address of any kind
ok: no lab file imports a third-party package — standard library only
ok: every database this run created lives under a temporary directory
44 checks, 0 failure(s).
exit code: 0
typing-and-strict.txt
$ sqlite3 typing.db < typing_demo.sql
--- 1. An ordinary table. INTEGER affinity, no enforcement. ---
What is actually stored — typeof() reports the STORAGE CLASS:
┌────┬──────────────┬────────────┬─────────────────────────┬─────────────┐
│ id │ year │ year_class │ title │ title_class │
├────┼──────────────┼────────────┼─────────────────────────┼─────────────┤
│ 1 │ 1970 │ integer │ converted to integer │ text │
│ 2 │ not-a-number │ text │ stored as text │ text │
│ 3 │ 1975 │ integer │ float that fits │ text │
│ 4 │ 1975.5 │ real │ float that does not fit │ text │
│ 5 │ 1968 │ integer │ 42 │ text │
└────┴──────────────┴────────────┴─────────────────────────┴─────────────┘
The consequence: a comparison that quietly finds nothing.
Row 2 holds the TEXT value not-a-number. In SQLite every INTEGER sorts
before every TEXT, so that row can never satisfy year < 2000.
Five rows in, four rows out, and nothing anywhere said so.
┌────────────────────────────┐
│ rows_matching_year_lt_2000 │
├────────────────────────────┤
│ 4 │
└────────────────────────────┘
┌────────────┐
│ rows_total │
├────────────┤
│ 5 │
└────────────┘
--- 2. The same table, declared STRICT. ---
Lossless text-to-integer conversion still happens:
┌────┬──────┬────────────┐
│ id │ year │ year_class │
├────┼──────┼────────────┤
│ 1 │ 1970 │ integer │
└────┴──────┴────────────┘
But the value that is genuinely not an integer is now REFUSED:
Runtime error near line 72: cannot store TEXT value in INTEGER column tight.year (19)
And the row was never written:
┌───────────────┐
│ rows_in_tight │
├───────────────┤
│ 1 │
└───────────────┘
exit: 1 (the STRICT refusal is the point of the file)
walkthrough.txt
$ sqlite3 library.db < queries.sql
=== .tables — what tables exist ===
books loans members
=== .schema books — the exact text of the promise ===
CREATE TABLE books (
book_id INTEGER PRIMARY KEY, -- the key: unique, never NULL, never reused
-- while the row lives. In SQLite this exact
-- spelling aliases the internal rowid.
title TEXT NOT NULL,
author TEXT NOT NULL,
year INTEGER, -- nullable on purpose: we do not always know
copies INTEGER NOT NULL DEFAULT 1
CHECK (copies >= 0) -- a negative number of copies is not
-- a bug to find later; it is a write
-- the database will refuse now
);
=== every book, newest first, NULL year last ===
┌─────────┬─────────────────────────────────┬─────────────────────┬──────┬────────┐
│ book_id │ title │ author │ year │ copies │
├─────────┼─────────────────────────────────┼─────────────────────┼──────┼────────┤
│ 3 │ Structure and Interpretation │ Abelson and Sussman │ 1985 │ 3 │
│ 5 │ A Discipline of Programming │ Edsger W. Dijkstra │ 1976 │ 1 │
│ 4 │ The Mythical Man-Month │ Frederick P. Brooks │ 1975 │ 1 │
│ 1 │ A Relational Model of Data │ Edgar F. Codd │ 1970 │ 2 │
│ 2 │ The Art of Computer Programming │ Donald E. Knuth │ 1968 │ 1 │
│ 6 │ Notes on Data Storage │ Anonymous │ NULL │ 4 │
└─────────┴─────────────────────────────────┴─────────────────────┴──────┴────────┘
=== the members table ===
┌───────────┬────────────────┬─────────────────────────┬────────────┐
│ member_id │ name │ email │ joined_on │
├───────────┼────────────────┼─────────────────────────┼────────────┤
│ 1 │ Ada Lovelace │ ada@library.invalid │ 2025-01-14 │
│ 2 │ Grace Hopper │ grace@library.invalid │ 2025-03-02 │
│ 3 │ Alan Turing │ alan@library.invalid │ 2026-02-20 │
│ 4 │ Barbara Liskov │ barbara@library.invalid │ 2026-07-30 │
└───────────┴────────────────┴─────────────────────────┴────────────┘
=== the question the JSON file could not answer cheaply ===
Which loans are overdue as of 2026-08-16, who has them, and how late?
One statement. The engine decides how to find the rows.
┌──────────────┬──────────────────────────────┬────────────┬───────────┐
│ borrower │ book │ due │ days_late │
├──────────────┼──────────────────────────────┼────────────┼───────────┤
│ Ada Lovelace │ The Mythical Man-Month │ 2026-06-22 │ 55 │
│ Grace Hopper │ A Discipline of Programming │ 2026-07-26 │ 21 │
│ Ada Lovelace │ Structure and Interpretation │ 2026-08-10 │ 6 │
└──────────────┴──────────────────────────────┴────────────┴───────────┘
=== the same shape of question, one table, no join ===
┌─────────────────┐
│ loans_still_out │
├─────────────────┤
│ 5 │
└─────────────────┘
┌───────────┬────────────┐
│ member_id │ open_loans │
├───────────┼────────────┤
│ 1 │ 2 │
│ 2 │ 1 │
│ 3 │ 1 │
│ 4 │ 1 │
└───────────┴────────────┘
=== how the engine plans to run that overdue query ===
EXPLAIN QUERY PLAN is the planner telling you what it chose.
QUERY PLAN
|--SEARCH l USING INDEX loans_open (returned_on=?)
|--SEARCH m USING INTEGER PRIMARY KEY (rowid=?)
`--SEARCH b USING INTEGER PRIMARY KEY (rowid=?)
=== what SQLite itself thinks its schema is ===
sqlite_schema is an ordinary table you can query. The schema is data.
┌───────┬────────────────────────────┬──────────┐
│ type │ name │ tbl_name │
├───────┼────────────────────────────┼──────────┤
│ index │ loans_by_member │ loans │
│ index │ loans_open │ loans │
│ index │ sqlite_autoindex_members_1 │ members │
│ table │ books │ books │
│ table │ loans │ loans │
│ table │ members │ members │
└───────┴────────────────────────────┴──────────┘
exit: 0
Source files
examples/books.json (1106 bytes)
{
"_comment": "The books table before it was a table. This is exactly the shape a Week 12 state file had: a list of records with no schema, no keys the engine knows about, and no rule stopping a sixth field appearing on the third record. It is the input to table_scan.py, and the rows are identical to those seed.sql inserts, so the hand-written scan and the SQL can be compared row for row.",
"books": [
{"book_id": 1, "title": "A Relational Model of Data", "author": "Edgar F. Codd", "year": 1970, "copies": 2},
{"book_id": 2, "title": "The Art of Computer Programming", "author": "Donald E. Knuth", "year": 1968, "copies": 1},
{"book_id": 3, "title": "Structure and Interpretation", "author": "Abelson and Sussman", "year": 1985, "copies": 3},
{"book_id": 4, "title": "The Mythical Man-Month", "author": "Frederick P. Brooks", "year": 1975, "copies": 1},
{"book_id": 5, "title": "A Discipline of Programming", "author": "Edsger W. Dijkstra", "year": 1976, "copies": 1},
{"book_id": 6, "title": "Notes on Data Storage", "author": "Anonymous", "year": null, "copies": 4}
]
}
examples/constraints_demo.sql (3352 bytes)
-- Day 085 — the writes the database refuses, and the transaction that undoes
-- itself. This is what the JSON file could never do.
--
-- Run it: sqlite3 library.db < constraints_demo.sql
--
-- EXPECT ERRORS. Every "Runtime error" below is the point of the file: the
-- engine rejecting a write that would have made the data untrue. The shell
-- reports each one, keeps going, and exits non-zero at the end. An exit code
-- of 1 here is success.
PRAGMA foreign_keys = ON;
.mode box
.headers on
.print '=== 1. A typo in a member id. There is no member 999. ==='
INSERT INTO loans (loan_id, book_id, member_id, borrowed_on, due_on)
VALUES (99, 1, 999, '2026-08-16', '2026-09-06');
.print ''
.print '=== 2. A loan of a book that does not exist ==='
INSERT INTO loans (loan_id, book_id, member_id, borrowed_on, due_on)
VALUES (98, 404, 1, '2026-08-16', '2026-09-06');
.print ''
.print '=== 3. A member with no name ==='
INSERT INTO members (member_id, name, email, joined_on)
VALUES (9, NULL, 'nobody@library.invalid', '2026-08-16');
.print ''
.print '=== 4. A second member with an address already in use ==='
INSERT INTO members (member_id, name, email, joined_on)
VALUES (10, 'Impostor', 'ada@library.invalid', '2026-08-16');
.print ''
.print '=== 5. A negative number of copies ==='
UPDATE books SET copies = -1 WHERE book_id = 1;
.print ''
.print '=== 6. A loan due before it was borrowed ==='
INSERT INTO loans (loan_id, book_id, member_id, borrowed_on, due_on)
VALUES (97, 1, 1, '2026-08-16', '2026-08-01');
.print ''
.print '=== 7. The same primary key twice ==='
INSERT INTO books (book_id, title, author) VALUES (1, 'Duplicate', 'Nobody');
.print ''
.print '=== After seven refused writes, the data is exactly as it was ==='
SELECT
(SELECT count(*) FROM books) AS books,
(SELECT count(*) FROM members) AS members,
(SELECT count(*) FROM loans) AS loans,
(SELECT copies FROM books WHERE book_id = 1) AS copies_of_book_1;
.print ''
.print '=== 8. Atomicity: two writes, one transaction, one mistake ==='
.print 'Borrowing a book is really two facts: a new loan row, and one fewer'
.print 'copy on the shelf. Neither is true on its own.'
BEGIN;
INSERT INTO loans (loan_id, book_id, member_id, borrowed_on, due_on)
VALUES (50, 3, 4, '2026-08-16', '2026-09-06');
UPDATE books SET copies = copies - 1 WHERE book_id = 3;
.print 'inside the transaction:'
SELECT (SELECT count(*) FROM loans) AS loans, (SELECT copies FROM books WHERE book_id = 3) AS copies_of_book_3;
ROLLBACK;
.print 'after ROLLBACK — the A in ACID, and it is all or nothing:'
SELECT (SELECT count(*) FROM loans) AS loans, (SELECT copies FROM books WHERE book_id = 3) AS copies_of_book_3;
.print ''
.print '=== 9. The same transaction, committed this time ==='
BEGIN;
INSERT INTO loans (loan_id, book_id, member_id, borrowed_on, due_on)
VALUES (50, 3, 4, '2026-08-16', '2026-09-06');
UPDATE books SET copies = copies - 1 WHERE book_id = 3;
COMMIT;
SELECT (SELECT count(*) FROM loans) AS loans, (SELECT copies FROM books WHERE book_id = 3) AS copies_of_book_3;
.print ''
.print '=== 10. Put it back, so the lab is repeatable ==='
BEGIN;
DELETE FROM loans WHERE loan_id = 50;
UPDATE books SET copies = copies + 1 WHERE book_id = 3;
COMMIT;
SELECT (SELECT count(*) FROM loans) AS loans, (SELECT copies FROM books WHERE book_id = 3) AS copies_of_book_3;
examples/file_facts.py (2903 bytes)
#!/usr/bin/env python3
"""Prove that a database is one ordinary file, and read its header yourself.
The SQLite file format is documented and stable: the first 16 bytes of every
database file are the ASCII string "SQLite format 3" followed by a single NUL
byte. Bytes 16 and 17 are the page size, big-endian. Bytes 28-31 are the
number of pages ("the database size in pages").
This script does not take the documentation's word for it. It opens the file
in binary mode and reads the bytes.
Run it: python3 file_facts.py library.db
"""
from __future__ import annotations
import sqlite3
import sys
from pathlib import Path
EXPECTED_MAGIC = b"SQLite format 3\x00"
def main(argv: list[str]) -> int:
path = Path(argv[1]) if len(argv) > 1 else Path("library.db")
if not path.exists():
print(f"no such database: {path}", file=sys.stderr)
return 1
raw = path.read_bytes()
header = raw[:16]
print(f"path: {path.name}")
print(f"size: {path.stat().st_size:,} bytes")
print(f"first 16 B: {header!r}")
print(f"as text: {header[:15].decode('ascii')!r} + {header[15:]!r}")
print(f"hex: {header.hex(' ')}")
print(f"matches the documented magic string: {header == EXPECTED_MAGIC}")
print()
# Bytes 16-17: page size in bytes, big-endian. The value 1 means 65536.
page_size = int.from_bytes(raw[16:18], "big")
if page_size == 1:
page_size = 65536
# Bytes 28-31: size of the database file in pages.
page_count = int.from_bytes(raw[28:32], "big")
print(f"page size (header bytes 16-17): {page_size:,} bytes")
print(f"page count (header bytes 28-31): {page_count}")
print(f"pages * page size: {page_size * page_count:,} bytes")
print()
# The same two numbers, asked of the engine rather than read off the disk.
connection = sqlite3.connect(path)
try:
from_engine_size = connection.execute("PRAGMA page_size").fetchone()[0]
from_engine_count = connection.execute("PRAGMA page_count").fetchone()[0]
journal_mode = connection.execute("PRAGMA journal_mode").fetchone()[0]
tables = [
row[0]
for row in connection.execute(
"SELECT name FROM sqlite_schema"
" WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
" ORDER BY name"
)
]
finally:
connection.close()
print(f"PRAGMA page_size: {from_engine_size:,}")
print(f"PRAGMA page_count: {from_engine_count}")
print(f"PRAGMA journal_mode: {journal_mode}")
print(f"tables: {', '.join(tables)}")
print()
print("The bytes on disk and the engine agree, because there is only one")
print("artefact here: a file you could copy with cp and mail to somebody.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
examples/json_pain.py (6244 bytes)
#!/usr/bin/env python3
"""Why the JSON state file stops being enough — measured, not asserted.
Week 12's toolkit kept its state in one JSON file, written atomically. That
was the right call then. This script shows, with real numbers on your own
machine, the four places it stops paying:
1. A one-field change rewrites the whole file.
2. Nothing stops a typo'd member id from being stored.
3. Two writers who read, then write, silently lose one of the two updates.
4. "Which loans are overdue?" costs a full load and a full scan.
Run it: python3 json_pain.py
It writes and deletes files inside a temporary directory of its own and
leaves nothing behind.
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
TODAY = "2026-08-16" # written down, not looked up — see seed.sql
def make_loans(count: int) -> list[dict[str, object]]:
"""A loan list shaped exactly like the loans table, as plain dicts."""
loans: list[dict[str, object]] = []
for index in range(1, count + 1):
loans.append(
{
"loan_id": index,
"book_id": (index % 6) + 1,
"member_id": (index % 4) + 1,
"borrowed_on": "2026-06-01",
"due_on": "2026-06-22",
"returned_on": None if index % 3 else "2026-06-20",
}
)
return loans
def atomic_write(path: Path, payload: str) -> int:
"""The Day 84 atomic write, unchanged. Returns bytes written."""
encoded = payload.encode("utf-8")
handle = tempfile.NamedTemporaryFile(
dir=str(path.parent), delete=False, mode="wb", suffix=".tmp"
)
try:
with handle:
handle.write(encoded)
handle.flush()
os.fsync(handle.fileno())
os.replace(handle.name, path)
except BaseException:
Path(handle.name).unlink(missing_ok=True)
raise
return len(encoded)
def main() -> int:
with tempfile.TemporaryDirectory(prefix="day085-json-") as tmp:
root = Path(tmp)
path = root / "loans.json"
# ---------------------------------------------------------------
print("1. A one-field change rewrites the whole file")
for count in (10, 1_000, 50_000):
loans = make_loans(count)
payload = json.dumps({"loans": loans}, indent=2)
written = atomic_write(path, payload)
# Mark loan 1 as returned. One field. One row.
loaded = json.loads(path.read_text(encoding="utf-8"))
loaded["loans"][0]["returned_on"] = TODAY
rewritten = atomic_write(
path, json.dumps(loaded, indent=2)
)
changed_field = len(f'"returned_on": "{TODAY}"')
print(
f" {count:>6,} loans: file {rewritten:>9,} bytes"
f" | changed {changed_field:>3} bytes"
f" | read+wrote {written + rewritten:>10,} bytes to do it"
)
print(
" The database rewrites the page that holds the row"
" (4096 bytes here), not the file."
)
# ---------------------------------------------------------------
print()
print("2. Nothing stops a member id that does not exist")
loans = make_loans(5)
loans.append(
{
"loan_id": 6,
"book_id": 3,
"member_id": 999, # there is no member 999
"borrowed_on": "2026-08-01",
"due_on": "2026-08-22",
"returned_on": None,
}
)
atomic_write(path, json.dumps({"loans": loans}, indent=2))
stored = json.loads(path.read_text(encoding="utf-8"))["loans"][-1]
print(f" stored happily: member_id={stored['member_id']}")
print(" json.dump has no opinion about what a member id means.")
# ---------------------------------------------------------------
print()
print("3. Two writers, one lost update")
atomic_write(path, json.dumps({"loans": make_loans(3)}, indent=2))
# Writer A reads the whole file.
writer_a = json.loads(path.read_text(encoding="utf-8"))
# Writer B reads the same whole file, a millisecond later.
writer_b = json.loads(path.read_text(encoding="utf-8"))
# A returns loan 1. B adds loan 4. Both write the whole file back.
writer_a["loans"][0]["returned_on"] = TODAY
atomic_write(path, json.dumps(writer_a, indent=2))
writer_b["loans"].append(
{
"loan_id": 4,
"book_id": 1,
"member_id": 2,
"borrowed_on": TODAY,
"due_on": "2026-09-06",
"returned_on": None,
}
)
atomic_write(path, json.dumps(writer_b, indent=2))
final = json.loads(path.read_text(encoding="utf-8"))["loans"]
print(f" loans in the file afterwards: {len(final)}")
print(f" loan 1 returned_on: {final[0]['returned_on']!r}")
print(
" Both writes 'succeeded' atomically. A's update is gone anyway:"
)
print(
" atomicity protects the FILE, not the two readers who raced"
" over it."
)
# ---------------------------------------------------------------
print()
print("4. 'Which loans are overdue?' costs a full load and a full scan")
loans = make_loans(50_000)
atomic_write(path, json.dumps({"loans": loans}, indent=2))
size = path.stat().st_size
loaded = json.loads(path.read_text(encoding="utf-8"))["loans"]
overdue = [
row
for row in loaded
if row["returned_on"] is None and row["due_on"] < TODAY
]
print(f" parsed {size:,} bytes and examined {len(loaded):,} records")
print(f" to answer a question with {len(overdue):,} rows in it")
print(
" There is no cheaper path. The file has no index, because a"
" file has no idea what a due date is."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/library_py.py (5287 bytes)
#!/usr/bin/env python3
"""The same database from Python, with the three habits that matter.
1. Turn foreign keys ON. SQLite leaves them off for backward compatibility,
and the sqlite3 module does not turn them on for you. A REFERENCES clause
with foreign_keys OFF is a comment.
2. Pass values as PARAMETERS, never by building a string. This is the whole
of SQL injection defence and it is one character of extra typing.
3. Use row_factory so a row is something you can read, not a tuple you have
to count along.
Run it: python3 library_py.py library.db
"""
from __future__ import annotations
import sqlite3
import sys
from pathlib import Path
TODAY = "2026-08-16"
def connect(path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(path)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
def overdue(connection: sqlite3.Connection, as_of: str) -> list[sqlite3.Row]:
"""The overdue question, with the date supplied as a parameter."""
return connection.execute(
"""
SELECT m.name AS borrower, b.title AS book, l.due_on AS due
FROM loans AS l
JOIN members AS m ON m.member_id = l.member_id
JOIN books AS b ON b.book_id = l.book_id
WHERE l.returned_on IS NULL
AND l.due_on < ?
ORDER BY l.due_on
""",
(as_of,),
).fetchall()
def main(argv: list[str]) -> int:
database = Path(argv[1]) if len(argv) > 1 else Path("library.db")
if not database.exists():
print(f"no such database: {database}", file=sys.stderr)
return 1
# The two versions this machine reports. They are allowed to differ: the
# sqlite3 command-line shell and the Python module each link their own
# copy of the SQLite library, and neither is wrong.
print(f"SQLite library linked into Python: {sqlite3.sqlite_version}")
print("Compare that with `sqlite3 --version` in your shell. On many")
print("machines the two numbers differ, and neither is wrong: the shell")
print("and the Python module are separate programs, each carrying its own")
print("copy of the library. Check the one you are actually running.")
print()
connection = connect(database)
try:
print(f"foreign_keys = {connection.execute('PRAGMA foreign_keys').fetchone()[0]}")
print()
print(f"overdue as of {TODAY}:")
for row in overdue(connection, TODAY):
print(f" {row['due']} {row['borrower']:<14} {row['book']}")
print()
# ------------------------------------------------------------------
# Parameters, and why. The value below is hostile on purpose.
# ------------------------------------------------------------------
hostile = "Ada'; DROP TABLE loans; --"
print("looking up a member whose name is an attempted injection:")
print(f" value: {hostile!r}")
found = connection.execute(
"SELECT member_id, name FROM members WHERE name = ?", (hostile,)
).fetchall()
print(f" rows returned: {len(found)}")
still_there = connection.execute("SELECT count(*) FROM loans").fetchone()[0]
print(f" loans table still has {still_there} rows")
print(" The value was never parsed as SQL. It was a string, and the")
print(" engine compared it to a column. That is all a parameter is.")
print()
print(" The dangerous version, which this file deliberately does not run:")
print(' f"SELECT ... WHERE name = \'{name}\'"')
print(" Build a statement out of a value once and you have handed the")
print(" value the ability to be a statement.")
print()
# ------------------------------------------------------------------
# A transaction that is all or nothing.
# ------------------------------------------------------------------
before = connection.execute("SELECT count(*) FROM loans").fetchone()[0]
try:
with connection: # commits on success, rolls back on any exception
connection.execute(
"INSERT INTO loans"
" (loan_id, book_id, member_id, borrowed_on, due_on)"
" VALUES (?, ?, ?, ?, ?)",
(60, 1, 2, TODAY, "2026-09-06"),
)
# A second write that cannot succeed: member 999 does not exist.
connection.execute(
"INSERT INTO loans"
" (loan_id, book_id, member_id, borrowed_on, due_on)"
" VALUES (?, ?, ?, ?, ?)",
(61, 1, 999, TODAY, "2026-09-06"),
)
except sqlite3.IntegrityError as error:
print(f"transaction refused: {error}")
after = connection.execute("SELECT count(*) FROM loans").fetchone()[0]
print(f"loans before: {before}, after: {after}")
print("The first INSERT succeeded and was then undone with the second.")
print("Atomicity is not 'each statement works'; it is 'the group did'.")
finally:
connection.close()
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
examples/queries.sql (2974 bytes)
-- Day 085 — the first end-to-end walkthrough, in the sqlite3 shell.
--
-- Run it: sqlite3 library.db < queries.sql
--
-- Everything before the first SELECT is a DOT-COMMAND. Dot-commands are not
-- SQL: they are instructions to the shell program, they take no semicolon,
-- and no other SQLite client understands them. Confusing the two is the
-- single most common first-day mistake.
-- Note: a dot-command takes the WHOLE line. A trailing "-- comment" after one
-- is read as an argument and the command fails with a usage message. Comments
-- about dot-commands go on their own line, like these.
--
-- .mode box draw results in a box; the default is a pipe-separated list
-- .headers on print column names above the rows
-- .nullvalue NULL show NULL as the word NULL instead of an empty cell
.mode box
.headers on
.nullvalue NULL
.print '=== .tables — what tables exist ==='
.tables
.print ''
.print '=== .schema books — the exact text of the promise ==='
.schema books
.print ''
.print '=== every book, newest first, NULL year last ==='
SELECT book_id, title, author, year, copies
FROM books
ORDER BY year IS NULL, year DESC;
.print ''
.print '=== the members table ==='
SELECT member_id, name, email, joined_on FROM members ORDER BY member_id;
.print ''
.print '=== the question the JSON file could not answer cheaply ==='
.print 'Which loans are overdue as of 2026-08-16, who has them, and how late?'
.print 'One statement. The engine decides how to find the rows.'
SELECT
m.name AS borrower,
b.title AS book,
l.due_on AS due,
CAST(julianday('2026-08-16') - julianday(l.due_on) AS INTEGER) AS days_late
FROM loans AS l
JOIN members AS m ON m.member_id = l.member_id
JOIN books AS b ON b.book_id = l.book_id
WHERE l.returned_on IS NULL
AND l.due_on < '2026-08-16'
ORDER BY days_late DESC;
-- JOIN is Day 87's subject. It is here on purpose: the point of today is
-- that a question spanning three tables is still ONE request, and you never
-- said how to answer it.
.print ''
.print '=== the same shape of question, one table, no join ==='
SELECT count(*) AS loans_still_out FROM loans WHERE returned_on IS NULL;
SELECT
member_id,
count(*) AS open_loans
FROM loans
WHERE returned_on IS NULL
GROUP BY member_id
ORDER BY open_loans DESC, member_id;
.print ''
.print '=== how the engine plans to run that overdue query ==='
.print 'EXPLAIN QUERY PLAN is the planner telling you what it chose.'
EXPLAIN QUERY PLAN
SELECT m.name, b.title
FROM loans AS l
JOIN members AS m ON m.member_id = l.member_id
JOIN books AS b ON b.book_id = l.book_id
WHERE l.returned_on IS NULL;
.print ''
.print '=== what SQLite itself thinks its schema is ==='
.print 'sqlite_schema is an ordinary table you can query. The schema is data.'
SELECT type, name, tbl_name FROM sqlite_schema ORDER BY type, name;
examples/scan_vs_sql.py (2271 bytes)
#!/usr/bin/env python3
"""Run the hand-written scan and the SQL, and prove they agree.
This is the assertion the lesson rests on: SQL is not a different kind of
answer, it is the same answer with the loop written by somebody else. If the
two disagree by a single row, one of them is wrong, and the script exits
non-zero rather than printing a comforting summary.
Run it: python3 scan_vs_sql.py library.db
"""
from __future__ import annotations
import sqlite3
import sys
from pathlib import Path
from table_scan import load_books, scan
QUERY = """
SELECT title, author, year
FROM books
WHERE year IS NOT NULL AND year < 1980
ORDER BY year
"""
def by_hand() -> list[tuple[object, ...]]:
rows = scan(
load_books(),
where=lambda row: row["year"] is not None and row["year"] < 1980,
columns=["title", "author", "year"],
sort_key="year",
)
return [tuple(row.values()) for row in rows]
def by_sql(database: Path) -> list[tuple[object, ...]]:
connection = sqlite3.connect(database)
try:
return [tuple(row) for row in connection.execute(QUERY)]
finally:
connection.close()
def main(argv: list[str]) -> int:
database = Path(argv[1]) if len(argv) > 1 else Path("library.db")
if not database.exists():
print(f"no such database: {database}", file=sys.stderr)
print("build it first: sqlite3 library.db < schema.sql", file=sys.stderr)
return 1
manual = by_hand()
engine = by_sql(database)
print(f"sqlite3 module reports SQLite library {sqlite3.sqlite_version}")
print()
print(f"{'by hand (table_scan.py)':<44} | {'by SQL (SELECT ...)':<44}")
print("-" * 44 + "-+-" + "-" * 44)
for left, right in zip(manual, engine):
print(f"{left[2]} {left[0]:<38} | {right[2]} {right[0]:<38}")
print()
if manual == engine:
print(f"IDENTICAL: {len(manual)} rows, same values, same order.")
print("The engine ran your loop. That is the whole trick.")
return 0
print("DIFFERENT — one of these is wrong:", file=sys.stderr)
print(f" by hand: {manual}", file=sys.stderr)
print(f" by SQL : {engine}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
examples/schema.sql (3491 bytes)
-- Day 085 — the finished schema for library.db
--
-- Read this as a written-down promise. Every line either names a fact the
-- database will store, or a rule the database will refuse to break. Nothing
-- here is advice: the engine enforces all of it, for every writer, forever.
--
-- Apply it with: sqlite3 library.db < schema.sql
PRAGMA foreign_keys = ON; -- SQLite defaults this OFF for backward
-- compatibility. Without it, REFERENCES below is
-- documentation rather than a rule. Turn it on in
-- every connection, every time.
-- ---------------------------------------------------------------------------
-- books: one row per title the library owns.
-- ---------------------------------------------------------------------------
CREATE TABLE books (
book_id INTEGER PRIMARY KEY, -- the key: unique, never NULL, never reused
-- while the row lives. In SQLite this exact
-- spelling aliases the internal rowid.
title TEXT NOT NULL,
author TEXT NOT NULL,
year INTEGER, -- nullable on purpose: we do not always know
copies INTEGER NOT NULL DEFAULT 1
CHECK (copies >= 0) -- a negative number of copies is not
-- a bug to find later; it is a write
-- the database will refuse now
);
-- ---------------------------------------------------------------------------
-- members: one row per person who may borrow.
-- ---------------------------------------------------------------------------
CREATE TABLE members (
member_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, -- a candidate key: also unique, also
-- identifies the row. We chose member_id
-- as the primary key because an address
-- can change and a key should not.
joined_on TEXT NOT NULL -- ISO-8601 'YYYY-MM-DD'. SQLite has no
-- date type; see the CHECK below.
CHECK (joined_on LIKE '____-__-__')
);
-- ---------------------------------------------------------------------------
-- loans: one row per act of borrowing. This is the table that makes the
-- database earn its keep — it is where the typo'd member id used to live.
-- ---------------------------------------------------------------------------
CREATE TABLE loans (
loan_id INTEGER PRIMARY KEY,
book_id INTEGER NOT NULL REFERENCES books(book_id),
member_id INTEGER NOT NULL REFERENCES members(member_id),
borrowed_on TEXT NOT NULL CHECK (borrowed_on LIKE '____-__-__'),
due_on TEXT NOT NULL CHECK (due_on LIKE '____-__-__'),
returned_on TEXT CHECK (returned_on LIKE '____-__-__'), -- NULL
-- means "still out". A NULL here is not
-- missing data; it is a fact about the world.
CHECK (due_on >= borrowed_on)
);
-- An index is not part of the model — the answers are identical with or
-- without it. It is a promise about speed, not about meaning.
CREATE INDEX loans_by_member ON loans(member_id);
CREATE INDEX loans_open ON loans(returned_on) WHERE returned_on IS NULL;
examples/seed.sql (2028 bytes)
-- Day 085 — a small, deliberately reproducible dataset for library.db
--
-- Apply it with: sqlite3 library.db < seed.sql
--
-- Every date here is a literal. Nothing uses date('now'), and that is a
-- deliberate choice: a fixture that depends on today's date produces a
-- capture that stops matching tomorrow. The lab's "today" is 2026-08-16,
-- written down rather than looked up.
PRAGMA foreign_keys = ON;
BEGIN; -- one transaction: either every row below lands, or none does
INSERT INTO books (book_id, title, author, year, copies) VALUES
(1, 'A Relational Model of Data', 'Edgar F. Codd', 1970, 2),
(2, 'The Art of Computer Programming', 'Donald E. Knuth', 1968, 1),
(3, 'Structure and Interpretation', 'Abelson and Sussman', 1985, 3),
(4, 'The Mythical Man-Month', 'Frederick P. Brooks', 1975, 1),
(5, 'A Discipline of Programming', 'Edsger W. Dijkstra', 1976, 1),
(6, 'Notes on Data Storage', 'Anonymous', NULL, 4);
-- Row 6 has a NULL year. "We do not know" is a different fact from
-- "the year is zero", and the schema lets us say so.
INSERT INTO members (member_id, name, email, joined_on) VALUES
(1, 'Ada Lovelace', 'ada@library.invalid', '2025-01-14'),
(2, 'Grace Hopper', 'grace@library.invalid', '2025-03-02'),
(3, 'Alan Turing', 'alan@library.invalid', '2026-02-20'),
(4, 'Barbara Liskov', 'barbara@library.invalid', '2026-07-30');
INSERT INTO loans (loan_id, book_id, member_id, borrowed_on, due_on, returned_on) VALUES
-- returned, on time
(1, 2, 1, '2026-05-01', '2026-05-22', '2026-05-19'),
(2, 3, 2, '2026-06-10', '2026-07-01', '2026-06-28'),
-- still out, not yet due
(3, 1, 3, '2026-08-10', '2026-08-31', NULL),
(4, 6, 4, '2026-08-14', '2026-09-04', NULL),
-- still out, OVERDUE as of 2026-08-16
(5, 4, 1, '2026-06-01', '2026-06-22', NULL),
(6, 5, 2, '2026-07-05', '2026-07-26', NULL),
(7, 3, 1, '2026-07-20', '2026-08-10', NULL);
COMMIT;
examples/table_scan.py (4374 bytes)
#!/usr/bin/env python3
"""A query engine in about sixty lines, so you can see what SQL replaces.
This is the from-scratch half of Day 085. A table here is a list of dicts —
the same shape you have been writing to JSON since Day 65 — and the three
operations below are the three that almost every SELECT is made of:
project (the column list) -> restrict (WHERE) -> sort (ORDER BY)
Relational algebra, which Codd published in 1970, calls the first two
PROJECTION and RESTRICTION (usually "selection"). SQL's SELECT statement is
a surface syntax over exactly these. Writing them by hand once is the point:
after this, `SELECT title FROM books WHERE year < 1980 ORDER BY year` is not
magic, it is a request for the loop you just wrote.
Run it: python3 table_scan.py
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Callable, Iterable, Sequence
Row = dict[str, Any]
Table = list[Row]
# --------------------------------------------------------------------------
# The three operators.
# --------------------------------------------------------------------------
def restrict(rows: Iterable[Row], predicate: Callable[[Row], bool]) -> Table:
"""WHERE. Keep the rows the predicate accepts. One full pass, every row.
Note what this does NOT do: it does not look at an index, because there
is no index. Every WHERE over a plain list is a full table scan, and the
cost is exactly len(rows) predicate calls whether one row matches or all
of them do.
"""
kept: Table = []
for row in rows:
if predicate(row):
kept.append(row)
return kept
def project(rows: Iterable[Row], columns: Sequence[str]) -> Table:
"""The column list after SELECT. Keep only the named fields, in order."""
return [{column: row[column] for column in columns} for row in rows]
def order_by(rows: Table, key: str, descending: bool = False) -> Table:
"""ORDER BY. A sort, which is why it costs more than the scan it follows.
NULL has to be decided rather than assumed: Python refuses to compare
None with an int, so we sort NULLs last and say so out loud. SQLite's own
default is NULLs FIRST for ascending order; the difference is a real one
and pretending otherwise is how two "identical" queries disagree.
"""
return sorted(
rows,
key=lambda row: (row[key] is None, row[key] if row[key] is not None else 0),
reverse=descending,
)
def scan(
rows: Table,
where: Callable[[Row], bool],
columns: Sequence[str],
sort_key: str,
descending: bool = False,
) -> Table:
"""The whole pipeline, in the order an engine would run it.
Restrict first, then project, then sort. The order matters for cost, not
for the answer: sorting six rows after filtering is cheaper than sorting
fifty thousand before it. Choosing that order is precisely the job you
hand to the query planner when you write SQL instead.
"""
return order_by(project(restrict(rows, where), columns), sort_key, descending)
# --------------------------------------------------------------------------
# The same data the database holds, as plain Python.
# --------------------------------------------------------------------------
def load_books(path: Path | None = None) -> Table:
"""Read books.json — the pre-database version of the books table."""
source = path or Path(__file__).with_name("books.json")
return json.loads(source.read_text(encoding="utf-8"))["books"]
def main() -> int:
books = load_books()
print(f"loaded {len(books)} rows from books.json")
print()
print("hand-written scan: books published before 1980, oldest first")
result = scan(
books,
where=lambda row: row["year"] is not None and row["year"] < 1980,
columns=["title", "author", "year"],
sort_key="year",
)
for row in result:
print(f" {row['year']} {row['title']:<34} {row['author']}")
print()
print(f"{len(result)} row(s); {len(books)} predicate calls to find them")
print()
print("the SQL that replaces every line of this:")
print(" SELECT title, author, year FROM books")
print(" WHERE year IS NOT NULL AND year < 1980")
print(" ORDER BY year;")
return 0
if __name__ == "__main__":
raise SystemExit(main())
examples/typing_demo.sql (3167 bytes)
-- Day 085 — SQLite's type system, honestly.
--
-- Most databases check the type of a value against the type of the column and
-- refuse a mismatch. SQLite does not, by default. A column's declared type is
-- an AFFINITY: a preference the engine applies when it can, and abandons when
-- it cannot. The value keeps its own storage class regardless.
--
-- Run it: sqlite3 typing.db < typing_demo.sql
--
-- Read every line of the output. Several of them will look like bugs. They
-- are documented behaviour, and knowing them is the difference between
-- trusting your data and hoping.
.mode box
.headers on
-- ---------------------------------------------------------------------------
.print '--- 1. An ordinary table. INTEGER affinity, no enforcement. ---'
CREATE TABLE loose (
id INTEGER PRIMARY KEY,
year INTEGER, -- INTEGER affinity
title TEXT -- TEXT affinity
);
-- Text that LOOKS like a number is converted. Affinity applied successfully.
INSERT INTO loose (id, year, title) VALUES (1, '1970', 'converted to integer');
-- Text that does not look like a number is stored as text, in a column the
-- schema calls INTEGER. No error. No warning. No log line.
INSERT INTO loose (id, year, title) VALUES (2, 'not-a-number', 'stored as text');
-- A float in an INTEGER column, losslessly representable, becomes an integer.
INSERT INTO loose (id, year, title) VALUES (3, 1975.0, 'float that fits');
-- A float that would lose information keeps its own class.
INSERT INTO loose (id, year, title) VALUES (4, 1975.5, 'float that does not fit');
-- And an integer in a TEXT column goes the other way.
INSERT INTO loose (id, year, title) VALUES (5, 1968, 42);
.print ''
.print 'What is actually stored — typeof() reports the STORAGE CLASS:'
SELECT id, year, typeof(year) AS year_class, title, typeof(title) AS title_class
FROM loose ORDER BY id;
.print ''
.print 'The consequence: a comparison that quietly finds nothing.'
.print 'Row 2 holds the TEXT value not-a-number. In SQLite every INTEGER sorts'
.print 'before every TEXT, so that row can never satisfy year < 2000.'
.print 'Five rows in, four rows out, and nothing anywhere said so.'
SELECT count(*) AS rows_matching_year_lt_2000 FROM loose WHERE year < 2000;
SELECT count(*) AS rows_total FROM loose;
-- ---------------------------------------------------------------------------
.print ''
.print '--- 2. The same table, declared STRICT. ---'
-- STRICT was added in SQLite 3.37.0 (2021). In a STRICT table every column
-- must be declared as one of INT, INTEGER, REAL, TEXT, BLOB or ANY, and the
-- engine enforces it.
CREATE TABLE tight (
id INTEGER PRIMARY KEY,
year INTEGER,
title TEXT
) STRICT;
.print 'Lossless text-to-integer conversion still happens:'
INSERT INTO tight (id, year, title) VALUES (1, '1970', 'converted to integer');
SELECT id, year, typeof(year) AS year_class FROM tight;
.print ''
.print 'But the value that is genuinely not an integer is now REFUSED:'
INSERT INTO tight (id, year, title) VALUES (2, 'not-a-number', 'rejected');
.print ''
.print 'And the row was never written:'
SELECT count(*) AS rows_in_tight FROM tight;
metadata.yml (1492 bytes)
lesson_id: D085
day: 85
kind: guided-build
languages: [sql, python, bash]
setup_commands:
- cd labs/sections/programming-with-python/day-085-relational-databases-and-sqlite
- sqlite3 --version
- 'python3 -c "import sqlite3; print(sqlite3.sqlite_version)"'
- mkdir -p scratch && cp examples/* scratch/
run_commands:
- python3 scratch/json_pain.py
- cd scratch && sqlite3 library.db < schema.sql
- cd scratch && sqlite3 library.db < seed.sql
- cd scratch && ls -l library.db
- cd scratch && python3 file_facts.py library.db
- cd scratch && sqlite3 library.db < queries.sql
- 'cd scratch && sqlite3 library.db < constraints_demo.sql # exits 1 on purpose'
- 'cd scratch && sqlite3 typing.db < typing_demo.sql # exits 1 on purpose'
- cd scratch && python3 table_scan.py
- cd scratch && python3 scan_vs_sql.py library.db
- cd scratch && python3 library_py.py library.db
- cd starter && sqlite3 mine.db < schema.sql
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- rm -rf scratch
- rm -f starter/mine.db starter/starter.db
- find . -type d -name __pycache__ -prune -exec rm -rf -- {} +
- 'git checkout -- starter/ # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-08-16'
executed_on: 'macOS 26.5.1 (Apple Silicon, arm64), Python 3.14.0, bash 3.2.57, sqlite3 shell 3.51.0, SQLite 3.53.3 as linked into Python — bash tests/run_tests.sh -> 44 checks, 0 failure(s), exit 0'
requirements/README.md (3164 bytes)
# What this lab needs, and where it comes from
Nothing to install. That is unusual enough in a programming course to be worth
a page of its own, because it is a fact about databases rather than a
convenience: SQLite is not a service you run, it is a library that is already
inside the tools you have.
## The two things you need
| Thing | Where it comes from | Cost | How to check |
| --- | --- | --- | --- |
| The `sqlite3` command-line shell | Preinstalled on macOS. On Debian or Ubuntu, `sudo apt install sqlite3`; on Fedora, `sudo dnf install sqlite`. On Windows, use WSL, or download the precompiled shell from the SQLite website | Free; public domain | `sqlite3 --version` |
| The `sqlite3` Python module | Part of the Python standard library since Python 2.5. You already have it | Free; part of Python | `python3 -c "import sqlite3; print(sqlite3.sqlite_version)"` |
Python **3.11 or newer** is what the captures were taken on. Nothing in the lab
requires a feature newer than that; the type-hint syntax used in the starter
(`Path | None`) needs 3.10 or newer.
## Run those two commands now, and read both numbers
```bash
sqlite3 --version
python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
```
On the authoring machine they print **3.51.0** and **3.53.3** — two different
SQLite libraries on one computer. That is not a misconfiguration and there is
nothing to fix. The shell is a program that links its own copy of SQLite; the
Python module is a different program that links its own. Both read and write
the same file format, and a database written by one is read by the other
without conversion, which is precisely the guarantee the file format exists to
give.
The number that matters is whichever belongs to the program you are running at
the time. If a `STRICT` table works in the shell and fails in Python, the
question to ask is not "what version is installed" but "what version is
*this* program using".
`tests/run_tests.sh` reports both and asserts neither is missing. It
deliberately does not assert they are equal.
## What the lab deliberately does not use
- **No ORM.** SQLAlchemy and Django's ORM are excellent, and both hide exactly
the thing this lesson is about. You write the SQL here.
- **No database server.** Nothing to start, nothing to stop, no port, no
password, no `sudo`. This is the difference between an embedded database and
a client-server one, and feeling it is part of the lesson.
- **No third-party package at all.** `tests/run_tests.sh` checks this
mechanically: it greps every Python file in the lab for an import of
`requests`, `httpx`, `urllib3`, `pandas` or `sqlalchemy` and fails if it
finds one.
- **No network.** The same suite fails if any executable lab file contains a
URL. There is nothing to download and nothing to reach.
## If the shell is missing
Everything except the dot-command walkthrough can be done from Python alone,
because the module carries its own copy of the engine. `tests/run_tests.sh`
needs the shell and will tell you so rather than silently skipping the checks
that use it; point it at one you have with
`SQLITE=/path/to/sqlite3 bash tests/run_tests.sh`.
requirements/requirements.txt (648 bytes)
# Day 085 — Your First Database
#
# This file is deliberately empty of packages.
#
# There is nothing to install. The lab needs Python 3.11 or newer and the
# sqlite3 command-line shell, and both `sqlite3` the shell and `sqlite3` the
# Python module ship with their respective tools. No pip install, no virtual
# environment, no network.
#
# It exists so that `pip install -r requirements/requirements.txt` succeeds
# and does nothing, and so that the absence of dependencies is a written-down
# decision rather than an omission somebody has to guess about.
#
# See requirements/README.md for what the lab uses and where each piece
# comes from.
starter/books.json (1106 bytes)
{
"_comment": "The books table before it was a table. This is exactly the shape a Week 12 state file had: a list of records with no schema, no keys the engine knows about, and no rule stopping a sixth field appearing on the third record. It is the input to table_scan.py, and the rows are identical to those seed.sql inserts, so the hand-written scan and the SQL can be compared row for row.",
"books": [
{"book_id": 1, "title": "A Relational Model of Data", "author": "Edgar F. Codd", "year": 1970, "copies": 2},
{"book_id": 2, "title": "The Art of Computer Programming", "author": "Donald E. Knuth", "year": 1968, "copies": 1},
{"book_id": 3, "title": "Structure and Interpretation", "author": "Abelson and Sussman", "year": 1985, "copies": 3},
{"book_id": 4, "title": "The Mythical Man-Month", "author": "Frederick P. Brooks", "year": 1975, "copies": 1},
{"book_id": 5, "title": "A Discipline of Programming", "author": "Edsger W. Dijkstra", "year": 1976, "copies": 1},
{"book_id": 6, "title": "Notes on Data Storage", "author": "Anonymous", "year": null, "copies": 4}
]
}
starter/schema.sql (5777 bytes)
-- Day 085 starter — build library.db yourself.
--
-- The books table below is complete. Read it as a worked model: every line is
-- either a fact the database will store or a rule it will refuse to break.
-- Then write the two tables underneath it.
--
-- Apply what you have at any point with:
--
-- sqlite3 library.db < schema.sql
-- sqlite3 library.db ".schema"
--
-- As shipped, this file applies cleanly and creates ONE table. That is on
-- purpose: you should be able to run it, see something work, and then add to
-- it. Delete library.db and re-apply after every change.
--
-- Eight numbered exercises. Each one names the exact thing to write and the
-- check in tests/run_tests.sh that will confirm it.
PRAGMA foreign_keys = ON;
-- ===========================================================================
-- The worked model.
-- ===========================================================================
CREATE TABLE books (
book_id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL,
year INTEGER,
copies INTEGER NOT NULL DEFAULT 1 CHECK (copies >= 0)
);
-- ===========================================================================
-- EXERCISE 1 — the members table.
--
-- Write CREATE TABLE members with four columns:
-- member_id INTEGER PRIMARY KEY
-- name TEXT, never NULL
-- email TEXT, never NULL, and UNIQUE
-- joined_on TEXT, never NULL
--
-- Checked by: "members has a primary key and a unique email"
-- ===========================================================================
-- ===========================================================================
-- EXERCISE 2 — the email is a CANDIDATE KEY, not the primary key.
--
-- Nothing to type here. Write your answer as a comment on the line below:
-- why did we choose member_id as the primary key when email would also
-- identify the row uniquely? (Hint: what happens to every loan row when
-- somebody changes their address?)
--
-- your answer:
-- ===========================================================================
-- ===========================================================================
-- EXERCISE 3 — the loans table, and the constraint that is the whole point.
--
-- Write CREATE TABLE loans with:
-- loan_id INTEGER PRIMARY KEY
-- book_id INTEGER, never NULL, REFERENCES books(book_id)
-- member_id INTEGER, never NULL, REFERENCES members(member_id)
-- borrowed_on TEXT, never NULL
-- due_on TEXT, never NULL
-- returned_on TEXT, nullable — NULL means "still out"
--
-- The two REFERENCES clauses are the reason this lab exists. They are what
-- makes a typo'd member id an error at write time rather than a mystery
-- three months later.
--
-- Checked by: "loans refuses a member_id that does not exist"
-- ===========================================================================
-- ===========================================================================
-- EXERCISE 4 — a CHECK constraint the schema enforces for you.
--
-- Add a table-level CHECK to loans so that due_on can never be earlier than
-- borrowed_on. Write it inside the CREATE TABLE above, after the last column:
--
-- CHECK (due_on >= borrowed_on)
--
-- Then convince yourself it works by trying to insert a loan due yesterday.
--
-- Checked by: "loans refuses a due date before the borrow date"
-- ===========================================================================
-- ===========================================================================
-- EXERCISE 5 — an index, and what it is not.
--
-- Add: CREATE INDEX loans_by_member ON loans(member_id);
--
-- Then write, as a comment, the answer to this: does adding an index change
-- any answer the database gives? If not, what does it change?
--
-- your answer:
--
-- Checked by: "an index named loans_by_member exists"
-- ===========================================================================
-- ===========================================================================
-- EXERCISE 6 — a STRICT table, and what it buys you.
--
-- Add a second, separate table:
--
-- CREATE TABLE readings (
-- reading_id INTEGER PRIMARY KEY,
-- book_id INTEGER NOT NULL REFERENCES books(book_id),
-- pages INTEGER NOT NULL
-- ) STRICT;
--
-- Then try to insert the text 'lots' into pages in both this table and a
-- non-STRICT one, and watch only one of them refuse.
--
-- Checked by: "readings is declared STRICT and refuses a text page count"
-- ===========================================================================
-- ===========================================================================
-- EXERCISE 7 — seed your database.
--
-- Write INSERT statements for at least three books, two members and three
-- loans, wrapped in a single BEGIN; ... COMMIT; so that either all of them
-- land or none does. At least one loan must be unreturned and overdue as of
-- 2026-08-16, so exercise 8 has something to find.
--
-- Checked by: "the seeded database has books, members and loans"
-- ===========================================================================
-- ===========================================================================
-- EXERCISE 8 — the question the JSON file could not answer cheaply.
--
-- Write, as a comment below, one SELECT that returns every loan still out and
-- overdue as of 2026-08-16. Then run it in the shell:
--
-- sqlite3 library.db ".mode box" ".headers on"
--
-- and paste your statement at the sqlite> prompt.
--
-- your query:
--
-- Checked by: "the overdue query returns only unreturned, past-due loans"
-- ===========================================================================
starter/table_scan.py (4542 bytes)
#!/usr/bin/env python3
"""Day 085 starter — write the query engine, then let SQL replace it.
Three functions, three exercises. Together they are the whole of
`SELECT columns FROM table WHERE condition ORDER BY column`, and writing them
once is the point: afterwards SQL is not magic, it is a request for the loop
you wrote here.
Run it at any time: python3 table_scan.py
As shipped it stops at the first unwritten function and tells you which one.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Callable, Iterable, Sequence
Row = dict[str, Any]
Table = list[Row]
# ===========================================================================
# EXERCISE 1 — restrict (this is WHERE).
#
# Return a new list holding only the rows for which predicate(row) is true.
# Keep the input order. One pass, every row, no cleverness.
#
# Then answer in a comment: how many times is predicate called when exactly
# one of fifty thousand rows matches?
#
# your answer:
#
# Checked by: "restrict keeps only matching rows, in order"
# ===========================================================================
def restrict(rows: Iterable[Row], predicate: Callable[[Row], bool]) -> Table:
raise NotImplementedError("EXERCISE 1: implement restrict (WHERE)")
# ===========================================================================
# EXERCISE 2 — project (this is the column list after SELECT).
#
# Return a new list of dicts holding only the named columns, in the order
# they were named. Do not modify the input rows.
#
# Checked by: "project keeps only the named columns, in order"
# ===========================================================================
def project(rows: Iterable[Row], columns: Sequence[str]) -> Table:
raise NotImplementedError("EXERCISE 2: implement project (the column list)")
# ===========================================================================
# EXERCISE 3 — order_by (this is ORDER BY).
#
# Sort the rows by row[key]. Some values are None, and Python refuses to
# compare None with an int, so you must DECIDE where NULLs go rather than
# letting the sort crash. Put them last, and say so in a comment.
#
# Hint: sorted(rows, key=lambda row: (row[key] is None, ...))
#
# Then answer in a comment: SQLite's own default puts NULLs FIRST in an
# ascending sort. Why does a difference like that matter?
#
# your answer:
#
# Checked by: "order_by sorts ascending and puts NULLs last"
# ===========================================================================
def order_by(rows: Table, key: str, descending: bool = False) -> Table:
raise NotImplementedError("EXERCISE 3: implement order_by (ORDER BY)")
# ---------------------------------------------------------------------------
# Given to you: the pipeline. Restrict, then project, then sort.
# The order matters for cost, not for the answer — and choosing that order is
# exactly the job you hand to the query planner when you write SQL instead.
# ---------------------------------------------------------------------------
def scan(
rows: Table,
where: Callable[[Row], bool],
columns: Sequence[str],
sort_key: str,
descending: bool = False,
) -> Table:
return order_by(project(restrict(rows, where), columns), sort_key, descending)
def load_books(path: Path | None = None) -> Table:
source = path or Path(__file__).with_name("books.json")
return json.loads(source.read_text(encoding="utf-8"))["books"]
def main() -> int:
books = load_books()
print(f"loaded {len(books)} rows from books.json")
try:
result = scan(
books,
where=lambda row: row["year"] is not None and row["year"] < 1980,
columns=["title", "author", "year"],
sort_key="year",
)
except NotImplementedError as unwritten:
print(f"not finished yet — {unwritten}")
print("Write that function, then run this file again.")
return 1
for row in result:
print(f" {row['year']} {row['title']:<34} {row['author']}")
print()
print(f"{len(result)} row(s); {len(books)} predicate calls to find them")
print()
print("EXERCISE 4 — now let the engine do it. Build library.db from")
print("schema.sql, then run this and confirm it returns the same rows:")
print(" SELECT title, author, year FROM books")
print(" WHERE year IS NOT NULL AND year < 1980 ORDER BY year;")
return 0
if __name__ == "__main__":
raise SystemExit(main())
tests/run_tests.sh (21187 bytes)
#!/usr/bin/env bash
# Tests for the Day 085 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# These checks ask whether a database actually gives you what a file did not.
# The happy path — "a SELECT returned some rows" — is a small part of it. The
# rest are the properties that make the difference worth the trouble:
#
# * is the database really one ordinary file, with the documented header?
# * does the schema REFUSE the writes it promised to refuse — including the
# typo'd member id that a JSON file accepted without comment?
# * is SQLite's typing really dynamic, and does STRICT really fix it?
# * does the hand-written table scan return EXACTLY what the SQL returns?
# * is a transaction all-or-nothing, from the shell and from Python?
#
# Everything runs offline. There is no server, no network call, and no third-
# party package: the standard library and the sqlite3 shell, nothing else.
# Every database is built inside a temporary directory that is removed in a
# trap, so a completed run leaves nothing behind.
set -u
export PYTHONDONTWRITEBYTECODE=1
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0
work_root=""
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
cleanup() {
[ -n "${work_root}" ] && [ -d "${work_root}" ] && rm -rf "${work_root}"
}
trap cleanup EXIT INT TERM
resolve_tool() {
local tool="$1" override="$2"
if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
return 1
}
python_bin="$(resolve_tool python3 "${PYTHON:-}")" || {
echo "FAIL: python3 not found on PATH." >&2
echo " Install Python 3.11 or newer and try again." >&2
exit 1
}
sqlite_bin="$(resolve_tool sqlite3 "${SQLITE:-}")" || {
echo "FAIL: the sqlite3 shell was not found on PATH." >&2
echo " macOS ships it. On Debian or Ubuntu: sudo apt install sqlite3" >&2
echo " Or point this suite at one: SQLITE=/path/to/sqlite3 bash tests/run_tests.sh" >&2
exit 1
}
work_root="$(mktemp -d "${TMPDIR:-/tmp}/day085-XXXXXX")"
work="${work_root}/lab"
mkdir -p "${work}"
cp "${lab_dir}/examples/"*.sql "${lab_dir}/examples/"*.py "${lab_dir}/examples/books.json" "${work}/"
echo "Day 085 — Your First Database"
echo
# ===========================================================================
echo "1. The tools report themselves, and they do not have to agree"
# ===========================================================================
shell_version="$("${sqlite_bin}" --version 2>/dev/null | awk '{print $1}')"
module_version="$("${python_bin}" -c 'import sqlite3; print(sqlite3.sqlite_version)' 2>/dev/null)"
echo " sqlite3 shell library: ${shell_version:-unknown}"
echo " python3 module library: ${module_version:-unknown}"
case "${shell_version}" in
3.*) check "the sqlite3 shell reports a SQLite 3 library version" "yes" ;;
*) check "the sqlite3 shell reports a SQLite 3 library version" "no" ;;
esac
case "${module_version}" in
3.*) check "python3 can import sqlite3 and report its library version" "yes" ;;
*) check "python3 can import sqlite3 and report its library version" "no" ;;
esac
# This is deliberately NOT an equality assertion. The shell and the Python
# module are separate programs, each linking its own copy of the library, and
# on many machines the numbers differ. The check is that both are readable.
if [ "${shell_version}" = "${module_version}" ]; then
echo " the two agree on this machine"
else
echo " the two DIFFER on this machine — this is normal, not a fault"
fi
check "both SQLite library versions could be read and reported" \
"$([ -n "${shell_version}" ] && [ -n "${module_version}" ] && echo yes || echo no)"
# ===========================================================================
echo
echo "2. The database is one ordinary file, and the header says so"
# ===========================================================================
if (cd "${work}" && "${sqlite_bin}" library.db < schema.sql >/dev/null 2>&1); then
check "schema.sql applies cleanly" "yes"
else
check "schema.sql applies cleanly" "no"
fi
if (cd "${work}" && "${sqlite_bin}" library.db < seed.sql >/dev/null 2>&1); then
check "seed.sql applies cleanly inside one transaction" "yes"
else
check "seed.sql applies cleanly inside one transaction" "no"
fi
check "library.db is a plain regular file" \
"$([ -f "${work}/library.db" ] && echo yes || echo no)"
if "${python_bin}" - "${work}/library.db" <<'PY' >/dev/null 2>&1
import sys
from pathlib import Path
header = Path(sys.argv[1]).read_bytes()[:16]
sys.exit(0 if header == b"SQLite format 3\x00" else 1)
PY
then
check "the first 16 bytes are the literal string SQLite format 3 plus a NUL" "yes"
else
check "the first 16 bytes are the literal string SQLite format 3 plus a NUL" "no"
fi
if "${python_bin}" - "${work}/library.db" <<'PY' >/dev/null 2>&1
import sqlite3, sys
from pathlib import Path
path = Path(sys.argv[1])
raw = path.read_bytes()
page_size = int.from_bytes(raw[16:18], "big")
page_size = 65536 if page_size == 1 else page_size
page_count = int.from_bytes(raw[28:32], "big")
connection = sqlite3.connect(path)
engine_size = connection.execute("PRAGMA page_size").fetchone()[0]
engine_count = connection.execute("PRAGMA page_count").fetchone()[0]
connection.close()
ok = (page_size == engine_size
and page_count == engine_count
and page_size * page_count == path.stat().st_size)
sys.exit(0 if ok else 1)
PY
then
check "header page size and count match the engine and the file length" "yes"
else
check "header page size and count match the engine and the file length" "no"
fi
tables="$("${sqlite_bin}" "${work}/library.db" \
"SELECT group_concat(name, ',') FROM (SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name)" 2>/dev/null)"
check "the schema is data: sqlite_schema lists books, loans and members" \
"$([ "${tables}" = "books,loans,members" ] && echo yes || echo no)"
# ===========================================================================
echo
echo "3. The schema is a promise, and the engine keeps it"
# ===========================================================================
refuses() {
local label="$1" statement="$2"
if "${sqlite_bin}" "${work}/library.db" "PRAGMA foreign_keys=ON; ${statement}" >/dev/null 2>&1; then
check "${label}" "no"
else
check "${label}" "yes"
fi
}
refuses "a loan naming member 999, who does not exist, is refused" \
"INSERT INTO loans (loan_id,book_id,member_id,borrowed_on,due_on) VALUES (99,1,999,'2026-08-16','2026-09-06');"
refuses "a loan of book 404, which does not exist, is refused" \
"INSERT INTO loans (loan_id,book_id,member_id,borrowed_on,due_on) VALUES (98,404,1,'2026-08-16','2026-09-06');"
refuses "a member with a NULL name is refused" \
"INSERT INTO members (member_id,name,email,joined_on) VALUES (9,NULL,'x@library.invalid','2026-08-16');"
refuses "a second member with an address already in use is refused" \
"INSERT INTO members (member_id,name,email,joined_on) VALUES (10,'Impostor','ada@library.invalid','2026-08-16');"
refuses "a negative number of copies is refused" \
"UPDATE books SET copies = -1 WHERE book_id = 1;"
refuses "a loan due before it was borrowed is refused" \
"INSERT INTO loans (loan_id,book_id,member_id,borrowed_on,due_on) VALUES (97,1,1,'2026-08-16','2026-08-01');"
refuses "the same primary key twice is refused" \
"INSERT INTO books (book_id,title,author) VALUES (1,'Duplicate','Nobody');"
counts="$("${sqlite_bin}" "${work}/library.db" \
"SELECT (SELECT count(*) FROM books)||'/'||(SELECT count(*) FROM members)||'/'||(SELECT count(*) FROM loans)||'/'||(SELECT copies FROM books WHERE book_id=1)" 2>/dev/null)"
check "after seven refused writes the data is byte-for-byte unchanged (6/4/7/2)" \
"$([ "${counts}" = "6/4/7/2" ] && echo yes || echo no)"
# The teachable negative: SQLite leaves foreign keys OFF unless you ask.
# The identical statement that was refused above is accepted here.
if "${sqlite_bin}" "${work}/library.db" \
"PRAGMA foreign_keys=OFF; INSERT INTO loans (loan_id,book_id,member_id,borrowed_on,due_on) VALUES (96,1,999,'2026-08-16','2026-09-06');" >/dev/null 2>&1
then
check "with foreign_keys OFF the SAME bad write is accepted — the rule is opt-in" "yes"
"${sqlite_bin}" "${work}/library.db" "DELETE FROM loans WHERE loan_id=96;" >/dev/null 2>&1
else
check "with foreign_keys OFF the SAME bad write is accepted — the rule is opt-in" "no"
fi
# ===========================================================================
echo
echo "4. Typing is dynamic by default, and STRICT is the fix"
# ===========================================================================
typing_db="${work}/typing.db"
"${sqlite_bin}" "${typing_db}" "CREATE TABLE loose (id INTEGER PRIMARY KEY, year INTEGER);" >/dev/null 2>&1
"${sqlite_bin}" "${typing_db}" "CREATE TABLE tight (id INTEGER PRIMARY KEY, year INTEGER) STRICT;" >/dev/null 2>&1
if "${sqlite_bin}" "${typing_db}" "INSERT INTO loose VALUES (1,'not-a-number');" >/dev/null 2>&1; then
check "an ordinary INTEGER column ACCEPTS the text not-a-number" "yes"
else
check "an ordinary INTEGER column ACCEPTS the text not-a-number" "no"
fi
stored_class="$("${sqlite_bin}" "${typing_db}" "SELECT typeof(year) FROM loose WHERE id=1;" 2>/dev/null)"
check "and stores it with storage class text, in a column declared INTEGER" \
"$([ "${stored_class}" = "text" ] && echo yes || echo no)"
"${sqlite_bin}" "${typing_db}" "INSERT INTO loose VALUES (2,'1970');" >/dev/null 2>&1
converted="$("${sqlite_bin}" "${typing_db}" "SELECT typeof(year) FROM loose WHERE id=2;" 2>/dev/null)"
check "text that looks like an integer is converted by affinity to integer" \
"$([ "${converted}" = "integer" ] && echo yes || echo no)"
matched="$("${sqlite_bin}" "${typing_db}" "SELECT count(*) FROM loose WHERE year < 2000;" 2>/dev/null)"
total="$("${sqlite_bin}" "${typing_db}" "SELECT count(*) FROM loose;" 2>/dev/null)"
check "the text row silently fails year < 2000 — two rows in, one row out" \
"$([ "${matched}" = "1" ] && [ "${total}" = "2" ] && echo yes || echo no)"
if "${sqlite_bin}" "${typing_db}" "INSERT INTO tight VALUES (1,'not-a-number');" >/dev/null 2>&1; then
check "a STRICT table REFUSES the same text value" "no"
else
check "a STRICT table REFUSES the same text value" "yes"
fi
strict_rows="$("${sqlite_bin}" "${typing_db}" "SELECT count(*) FROM tight;" 2>/dev/null)"
check "and the refused row was never written to the STRICT table" \
"$([ "${strict_rows}" = "0" ] && echo yes || echo no)"
if "${sqlite_bin}" "${typing_db}" "INSERT INTO tight VALUES (2,'1970');" >/dev/null 2>&1; then
strict_converted="$("${sqlite_bin}" "${typing_db}" "SELECT typeof(year) FROM tight WHERE id=2;" 2>/dev/null)"
check "STRICT still allows the LOSSLESS text-to-integer conversion" \
"$([ "${strict_converted}" = "integer" ] && echo yes || echo no)"
else
check "STRICT still allows the LOSSLESS text-to-integer conversion" "no"
fi
# ===========================================================================
echo
echo "5. The hand-written scan and the SQL return the same rows"
# ===========================================================================
if (cd "${work}" && "${python_bin}" scan_vs_sql.py library.db >/dev/null 2>&1); then
check "scan_vs_sql.py reports the two results IDENTICAL and exits 0" "yes"
else
check "scan_vs_sql.py reports the two results IDENTICAL and exits 0" "no"
fi
if (cd "${work}" && "${python_bin}" - <<'PY' >/dev/null 2>&1
import sys
from table_scan import restrict, project, order_by
rows = [
{"a": 3, "b": "x", "c": 0},
{"a": 1, "b": "y", "c": 0},
{"a": None, "b": "z", "c": 0},
{"a": 2, "b": "w", "c": 0},
]
kept = restrict(rows, lambda r: r["a"] is not None and r["a"] > 1)
assert [r["a"] for r in kept] == [3, 2], kept
projected = project(rows, ["b", "a"])
assert list(projected[0].keys()) == ["b", "a"], projected[0]
assert "c" not in projected[0], projected[0]
assert rows[0] == {"a": 3, "b": "x", "c": 0}, "project must not mutate its input"
sorted_rows = order_by(rows, "a")
assert [r["a"] for r in sorted_rows] == [1, 2, 3, None], sorted_rows
sys.exit(0)
PY
); then
check "restrict, project and order_by each behave as the operator they name" "yes"
else
check "restrict, project and order_by each behave as the operator they name" "no"
fi
if (cd "${work}" && "${python_bin}" table_scan.py 2>/dev/null | grep -q "4 row(s); 6 predicate calls"); then
check "the scan reports its own cost: 6 predicate calls to find 4 rows" "yes"
else
check "the scan reports its own cost: 6 predicate calls to find 4 rows" "no"
fi
# ===========================================================================
echo
echo "6. The question a JSON file could not answer cheaply"
# ===========================================================================
overdue_sql="$("${sqlite_bin}" "${work}/library.db" \
"SELECT count(*) FROM loans WHERE returned_on IS NULL AND due_on < '2026-08-16';" 2>/dev/null)"
check "one SELECT finds exactly 3 overdue loans as of 2026-08-16" \
"$([ "${overdue_sql}" = "3" ] && echo yes || echo no)"
names="$("${sqlite_bin}" "${work}/library.db" \
"SELECT group_concat(m.name, '|') FROM loans l JOIN members m ON m.member_id=l.member_id WHERE l.returned_on IS NULL AND l.due_on < '2026-08-16' ORDER BY l.due_on;" 2>/dev/null)"
check "and names the borrowers by joining three tables in one statement" \
"$([ -n "${names}" ] && echo yes || echo no)"
if (cd "${work}" && "${python_bin}" - <<'PY' >/dev/null 2>&1
"""The same answer computed both ways, and required to agree."""
import sqlite3
import sys
TODAY = "2026-08-16"
connection = sqlite3.connect("library.db")
by_sql = sorted(
connection.execute(
"SELECT loan_id FROM loans WHERE returned_on IS NULL AND due_on < ?",
(TODAY,),
).fetchall()
)
# Now the file version: pull every row out and filter it in Python, which is
# exactly what the JSON state file forced you to do.
all_loans = connection.execute(
"SELECT loan_id, returned_on, due_on FROM loans"
).fetchall()
connection.close()
by_hand = sorted(
(row[0],) for row in all_loans if row[1] is None and row[2] < TODAY
)
assert by_sql == by_hand, (by_sql, by_hand)
assert len(by_sql) == 3, by_sql
sys.exit(0)
PY
); then
check "filtering by hand over every row gives the identical answer" "yes"
else
check "filtering by hand over every row gives the identical answer" "no"
fi
# ===========================================================================
echo
echo "7. A transaction is all or nothing"
# ===========================================================================
before="$("${sqlite_bin}" "${work}/library.db" "SELECT count(*) FROM loans;" 2>/dev/null)"
"${sqlite_bin}" "${work}/library.db" <<'SQL' >/dev/null 2>&1
BEGIN;
INSERT INTO loans (loan_id,book_id,member_id,borrowed_on,due_on) VALUES (50,3,4,'2026-08-16','2026-09-06');
UPDATE books SET copies = copies - 1 WHERE book_id = 3;
ROLLBACK;
SQL
after_rollback="$("${sqlite_bin}" "${work}/library.db" \
"SELECT count(*)||'/'||(SELECT copies FROM books WHERE book_id=3) FROM loans;" 2>/dev/null)"
check "ROLLBACK undoes BOTH writes, not just the last one" \
"$([ "${after_rollback}" = "${before}/3" ] && echo yes || echo no)"
"${sqlite_bin}" "${work}/library.db" <<'SQL' >/dev/null 2>&1
BEGIN;
INSERT INTO loans (loan_id,book_id,member_id,borrowed_on,due_on) VALUES (50,3,4,'2026-08-16','2026-09-06');
UPDATE books SET copies = copies - 1 WHERE book_id = 3;
COMMIT;
SQL
after_commit="$("${sqlite_bin}" "${work}/library.db" \
"SELECT count(*)||'/'||(SELECT copies FROM books WHERE book_id=3) FROM loans;" 2>/dev/null)"
check "COMMIT keeps both writes together" \
"$([ "${after_commit}" = "$((before + 1))/2" ] && echo yes || echo no)"
if (cd "${work}" && "${python_bin}" - <<'PY' >/dev/null 2>&1
import sqlite3, sys
connection = sqlite3.connect("library.db")
connection.execute("PRAGMA foreign_keys = ON")
before = connection.execute("SELECT count(*) FROM loans").fetchone()[0]
try:
with connection:
connection.execute(
"INSERT INTO loans (loan_id,book_id,member_id,borrowed_on,due_on)"
" VALUES (60,1,2,'2026-08-16','2026-09-06')")
connection.execute(
"INSERT INTO loans (loan_id,book_id,member_id,borrowed_on,due_on)"
" VALUES (61,1,999,'2026-08-16','2026-09-06')")
except sqlite3.IntegrityError:
pass
after = connection.execute("SELECT count(*) FROM loans").fetchone()[0]
connection.close()
sys.exit(0 if before == after else 1)
PY
); then
check "in Python, one failing write in a with-block undoes the good one too" "yes"
else
check "in Python, one failing write in a with-block undoes the good one too" "no"
fi
if (cd "${work}" && "${python_bin}" library_py.py library.db 2>/dev/null | grep -q "loans table still has"); then
check "a hostile value passed as a PARAMETER is compared, never executed" "yes"
else
check "a hostile value passed as a PARAMETER is compared, never executed" "no"
fi
# ===========================================================================
echo
echo "8. The starter is runnable, and carries its exercises"
# ===========================================================================
starter_work="${work_root}/starter"
mkdir -p "${starter_work}"
cp "${lab_dir}/starter/"* "${starter_work}/"
if (cd "${starter_work}" && "${sqlite_bin}" starter.db < schema.sql >/dev/null 2>&1); then
check "the starter schema applies cleanly before you have written a line" "yes"
else
check "the starter schema applies cleanly before you have written a line" "no"
fi
starter_tables="$("${sqlite_bin}" "${starter_work}/starter.db" \
"SELECT group_concat(name) FROM sqlite_schema WHERE type='table';" 2>/dev/null)"
check "and creates the one worked table, books, for you to build on" \
"$([ "${starter_tables}" = "books" ] && echo yes || echo no)"
exercise_count="$(grep -c "^-- EXERCISE" "${lab_dir}/starter/schema.sql" || true)"
check "the starter schema carries its 8 numbered exercises" \
"$([ "${exercise_count}" = "8" ] && echo yes || echo no)"
scan_exercises="$(grep -c "^# EXERCISE" "${lab_dir}/starter/table_scan.py" || true)"
check "the starter scan carries its 3 numbered exercises" \
"$([ "${scan_exercises}" = "3" ] && echo yes || echo no)"
if (cd "${starter_work}" && "${python_bin}" table_scan.py 2>/dev/null | grep -q "EXERCISE 1"); then
check "running the unfinished starter names the next exercise instead of a traceback" "yes"
else
check "running the unfinished starter names the next exercise instead of a traceback" "no"
fi
starter_exit=0
(cd "${starter_work}" && "${python_bin}" table_scan.py >/dev/null 2>&1) || starter_exit=$?
check "and exits non-zero, so an unfinished lab cannot look finished" \
"$([ "${starter_exit}" -ne 0 ] && echo yes || echo no)"
# ===========================================================================
echo
echo "9. Nothing here reaches the network or needs anything installed"
# ===========================================================================
if "${python_bin}" - "${lab_dir}" <<'PY' >/dev/null 2>&1
import re, sys
from pathlib import Path
root = Path(sys.argv[1])
banned = re.compile(r"https?://(?!\S*\.invalid)", re.IGNORECASE)
offenders = []
for directory in ("examples", "starter", "tests"):
for path in (root / directory).rglob("*"):
if path.is_file() and path.suffix in {".py", ".sql", ".sh", ".json"}:
text = path.read_text(encoding="utf-8", errors="ignore")
if banned.search(text):
offenders.append(path.name)
for name in offenders:
print(name, file=sys.stderr)
sys.exit(1 if offenders else 0)
PY
then
check "no executable lab file contains a network address of any kind" "yes"
else
check "no executable lab file contains a network address of any kind" "no"
fi
if "${python_bin}" - "${lab_dir}" <<'PY' >/dev/null 2>&1
import re, sys
from pathlib import Path
root = Path(sys.argv[1])
third_party = re.compile(r"^\s*(import|from)\s+(requests|httpx|urllib3|pandas|sqlalchemy)\b", re.M)
offenders = []
for directory in ("examples", "starter", "tests"):
for path in (root / directory).rglob("*.py"):
if third_party.search(path.read_text(encoding="utf-8", errors="ignore")):
offenders.append(path.name)
for name in offenders:
print(name, file=sys.stderr)
sys.exit(1 if offenders else 0)
PY
then
check "no lab file imports a third-party package — standard library only" "yes"
else
check "no lab file imports a third-party package — standard library only" "no"
fi
check "every database this run created lives under a temporary directory" \
"$([ ! -e "${lab_dir}/tests/library.db" ] && [ ! -e "${lab_dir}/starter/starter.db" ] && echo yes || echo no)"
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 085
Most first-day database problems are one of five things. Each entry below says what you will see, what is actually happening, and what to do.
sqlite3: command not found
The shell is not installed or not on your PATH. macOS ships it. On Debian or
Ubuntu, sudo apt install sqlite3; on Fedora, sudo dnf install sqlite. On
Windows, use WSL and follow the Linux path.
You still have the engine either way: python3 -c "import sqlite3" works
without the shell, because the Python module carries its own copy of the
library. Only the dot-command walkthrough needs the shell.
sqlite3 --version and Python disagree
They are allowed to. On the authoring machine the shell reports 3.51.0 and
Python reports 3.53.3. The two are separate programs that each link their own
copy of SQLite; both read and write the same file format, and neither is
wrong. This is a fact worth knowing rather than a fault to fix — see
requirements/README.md.
The version that matters is the one belonging to whichever program you are running. If a feature works in one and not the other, check that program's number, not the machine's.
Runtime error: FOREIGN KEY constraint failed
This is the lab working. It means you tried to write a loan naming a member or a book that does not exist, and the database refused. Look at the id you used.
The opposite problem is more interesting: if the same bad write is
accepted, your connection has foreign keys switched off. SQLite defaults
PRAGMA foreign_keys to OFF for backward compatibility, and it is a
per-connection setting — not a property of the file, and not remembered
between sessions. Turn it on at the top of every script and every connection:
PRAGMA foreign_keys = ON;
connection.execute("PRAGMA foreign_keys = ON")
A REFERENCES clause with foreign keys off is a comment.
Runtime error: UNIQUE constraint failed: books.book_id
You applied seed.sql twice. The primary key rejects the second copy, which is
exactly its job. Start clean:
rm -f library.db
sqlite3 library.db < schema.sql
sqlite3 library.db < seed.sql
Note what did not happen: the second run did not half-load. seed.sql
wraps its inserts in BEGIN; ... COMMIT;, so the whole batch either lands or
none of it does.
Error: table books already exists
You applied schema.sql to a database that already has it. Same fix: delete
the file and start again. CREATE TABLE IF NOT EXISTS would silence the error,
and you should resist it here — the message is telling you the truth about the
state of the file.
Usage: .headers on|off or extra argument: "draw"
You put a -- comment on the same line as a dot-command. A dot-command takes
the whole line, so the comment is read as an argument. Dot-commands are not
SQL: no semicolon, no trailing comments. Put the comment on its own line.
A query returns fewer rows than you expect, and nothing is wrong
Check the storage classes:
SELECT year, typeof(year) FROM books;
If a row shows text in a column declared INTEGER, that value was written as
text and SQLite kept it that way. In SQLite's sort order every integer comes
before every text value, so WHERE year < 2000 will never match it — silently.
This is type affinity, it is documented, and STRICT tables are the fix. See
examples/typing_demo.sql, which demonstrates the whole thing in twenty lines.
cannot store TEXT value in INTEGER column
A STRICT table refusing a value, which is what you asked it to do. Either fix
the value or, if the column genuinely holds mixed types, declare it ANY.
STRICT needs SQLite 3.37.0 or newer. If the shell rejects the keyword
outright, check sqlite3 --version.
database is locked
Another connection holds a write transaction on the file. Usually it is an
interactive sqlite3 session you left open at the sqlite> prompt with an
uncommitted BEGIN. Type COMMIT; or ROLLBACK; there, or .quit.
This is the honest limit of SQLite's concurrency model: many readers at once,
one writer at a time. PRAGMA journal_mode = WAL lets readers carry on while
one writer works, which helps a great deal and does not change the one-writer
rule.
no such column: Ada
You wrote WHERE name = "Ada". In SQL, double quotes mean an identifier — a
column or table name — and single quotes mean a string. SQLite tries the
identifier first and only falls back to treating it as a string, which is why
the mistake sometimes works and sometimes produces this. Use single quotes for
text, always.
Better still, from Python, do not quote anything: pass the value as a parameter and let the driver deal with it.
sqlite3.OperationalError: no such table: books
You connected to the wrong file. sqlite3.connect("library.db") uses a
relative path, so it depends on your working directory — and if the file does
not exist, SQLite creates a new empty one rather than complaining. That is why
this error usually means "you just created an empty database next door".
ls -l library.db # is it where you think, and is it 0 bytes?
sqlite3 library.db ".tables"
The starter's table_scan.py prints "not finished yet"
That is the starter telling you which exercise is next. Implement the function it names and run it again. It exits non-zero until all three are written, on purpose: an unfinished lab should not be able to look finished.
tests/run_tests.sh fails on one check
Read the FAIL: line — each one names the property, not the file. Then run the
matching example by hand from examples/ to see the full output. The harness
copies everything into a temporary directory, so a failure never leaves your
own library.db in a strange state.
Something left a library.db where you did not want one
Delete it. A SQLite database is one ordinary file with no registry entry, no
service and no configuration anywhere else — rm library.db removes it
completely. If you see library.db-journal or library.db-wal alongside it,
those belong to the same database; remove them together, and only when no
process has the database open.
Security notes
Security notes — Day 085
This lab starts no server, opens no port, needs no credential, and never reaches the network. What it does do is teach you to put data in a file that other programs will read, so the security content here is real rather than ceremonial.
What this lab does to your machine
- Creates ordinary files (
library.db,typing.db,starter.db) in directories you name. Nothing else. - Runs
python3andsqlite3, both already installed. - Needs no
sudo, no privileged port, no system configuration, no service. tests/run_tests.shbuilds every database inside amktemp -ddirectory and removes it in atrap, so a completed run leaves nothing behind. One of its checks asserts exactly that.
SQL injection, and the one habit that ends it
The single most consequential security lesson of the whole week is one character of extra typing.
Never build a SQL statement by putting a value into a string. Pass the value as a parameter and let the driver keep it a value:
## WRONG. The value can become part of the statement.
connection.execute(f"SELECT * FROM members WHERE name = '{name}'")
## RIGHT. The value is compared, never parsed.
connection.execute("SELECT * FROM members WHERE name = ?", (name,))
examples/library_py.py demonstrates this with a hostile value —
Ada'; DROP TABLE loans; -- — passed as a parameter. It returns zero rows,
the loans table is untouched, and the point is made without ever running the
dangerous version.
Two things worth being precise about, because half-understood advice is what gets people hurt:
- Escaping is not the fix. Writing your own quote-doubling function means being right about every encoding, every dialect quirk and every edge case, forever. Parameters move the problem to the engine, which is where it belongs.
- Parameters are for values, not for identifiers. You cannot write
ORDER BY ?and pass a column name. If a table or column name genuinely has to be chosen at runtime, validate it against an allow-list you wrote — never interpolate whatever arrived.
Python's sqlite3 module also gives you executemany for a batch and
execute with named parameters (:name) when positional ? gets hard to
read. Both keep the same guarantee.
Constraints are a security control, not just a tidiness one
Most "data corruption" is not an attack; it is a write nobody checked. The
schema in examples/schema.sql refuses seven kinds of bad write, and
tests/run_tests.sh proves each refusal. NOT NULL, UNIQUE, CHECK and
REFERENCES are the cheapest validation you will ever deploy, because they
apply to every writer — including the script somebody writes next year that
never heard of your validation code.
Turn foreign keys on. SQLite defaults PRAGMA foreign_keys to OFF for
backward compatibility, per connection. Off, a REFERENCES clause enforces
nothing. The lab's suite demonstrates this directly: the same write that is
refused with the pragma on is accepted with it off.
The database file is data, with everything that implies
- There is no access control inside the file. SQLite has no users, no roles
and no passwords. Anybody who can read the file can read all of it, and
anybody who can write it can change all of it. Permissions on the file are
the security model —
chmod 600it if it holds anything private. - It is not encrypted. A SQLite database is plainly readable with
stringsor a hex editor. Encrypted variants exist as separate products; the ordinary library does not encrypt. If the data must be encrypted at rest, encrypt the filesystem or use a build that offers it, and do not assume otherwise. - Deleting a row does not scrub the bytes. The space is marked free and
reused later.
VACUUMrebuilds the file, andPRAGMA secure_delete = ONoverwrites deleted content. Neither is on by default. - Backups are file copies, and timing matters. Copying the file while a
write is in progress can capture a torn state. Use the shell's
.backupcommand or the module'sConnection.backup(), both of which take a consistent snapshot of a live database. - A journal or WAL file beside it is part of the database. Copying
library.dband leavinglibrary.db-walbehind can lose recent commits. Move them together.
Untrusted database files
Opening a SQLite file is not as innocent as opening a text file. The file format is complex, and a deliberately corrupted database can trigger bugs in the engine. SQLite's own documentation treats "opening a database from an untrusted source" as a security-relevant act and offers defences for it. Nothing in this lab opens a file you did not create, and that is the habit to carry: treat a database file you were sent the way you would treat any other executable-adjacent input.
Privacy: a database accumulates, which is the point and the risk
Day 84 made this argument about a state file and it applies with more force
here, because a database makes accumulation easy and querying cheap. A schema
is a written-down decision about what you keep; write it deliberately. Store
the fields you need rather than the fields you were given, decide how long
rows live before you write the first one, and remember that a members table
with names and addresses carries the same obligations as any other record
about a person — including the obligation to be able to delete it.
What this lab deliberately does not do
- No server, so no listening socket and no authentication to get wrong.
- No credential of any kind, so nothing to leak.
- No network, asserted mechanically: the suite fails if any executable lab file contains a URL.
- No third-party package, asserted the same way — so no supply chain beyond Python itself.
- No
sudo, and nothing written outside the lab directory or a temporary one.