WHERE, with a NULL in the data
Write SELECT name FROM customers WHERE city = 'Lima' OR city IS NULL in JavaScript. result should be the array ["Ben", "Eli"].
LEARN TO PROGRAM · CHAPTER 11 · ADVANCED
Every program so far forgot everything the moment it stopped. Real programs need to remember: your messages, your bank balance, every order a shop ever took. That is what databases are for.
This chapter starts with a plain text file and ends with choosing between real database systems. On the way you will write SQL (it runs right here, on a small SQL engine in your browser), build a B-tree, break a transaction and fix it. Scroll, and the 3D follows.
01 · FILES VS DATABASES
A program's variables live in RAM, which forgets everything when the power goes (Memory shows why). To keep data you must write it to storage, a disk or SSD, and the simplest way is a file. Here is a tiny shop's customer list as a CSV file (comma-separated values), and the two things you'll want to do with it: find something, and change something.
To find Chen the program read 47 bytes. With a million customers it reads, on average, half the file. And to change one city it wrote the whole file out again.
That works for a hundred rows. It falls apart as data grows and more people use it at once:
Lee, Jr breaks the format itself.A database is a program that owns the files and fixes all four. It stores data in fixed-size pages (4 KB by default in SQLite, 8 KB in PostgreSQL, 16 KB in MySQL's InnoDB) and keeps an index that says which page holds what. On the right of the 3D, the database asks the index, then reads one page: two reads, however big the table. It also enforces rules, lets many programs work at once, and survives crashes. The rest of this chapter is how.
02 · TABLES AND KEYS
In 1970 Edgar F. Codd, at IBM, proposed keeping all data in tables (he called them relations). It is still how most data in the world is stored. A table has named columns, each holding one kind of value, and rows, one per thing. This is the sample shop the whole chapter uses, in SQL's own words:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
city TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
item TEXT,
total INTEGER,
day TEXT
);
Two columns matter more than the rest:
orders.customer_id = 3 means "this order belongs to customer 3". In the 3D, each order is wired to its customer.The point of declaring keys is that the database enforces them, so no program, however buggy, can store an order for a customer who doesn't exist. Here are those rules written by hand in JavaScript:
The last line throws. Comment it out and the program finishes. A database does these checks for you on every write.
And here is the real thing. Run it: the first insert works, the second is refused.
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
Try deleting a customer who has orders: DELETE FROM customers WHERE id = 1;
03 · SELECT · WHERE
SQL (Structured Query Language) is how you talk to a relational database. It grew out of SEQUEL, designed at IBM in 1974, and is now an ISO standard. It is declarative: you describe the answer you want, and the database works out how to get it. This console runs a small SQL engine written for this page, in your browser. Edit anything and press Run SQL (or Ctrl+Enter):
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
SELECT picks columns, FROM picks the table, WHERE keeps only the rows that pass a test. In the 3D, the scan bar tests each customer; the two in Oslo pass and step forward, then SELECT name drops the other columns.
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
Try WHERE city <> 'Oslo', WHERE name LIKE 'C%' or WHERE id IN (1, 4).
Under the words, it is the same as two array methods you already know:
SQL has more: AND, OR, ORDER BY to sort, LIMIT to take the first few. A query is read in this order, even though it is written differently: FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT.
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
Eli's city is NULL: nobody knows it. SQL takes that literally. Is an unknown city equal to Oslo? Unknown. Is it different from Oslo? Also unknown. WHERE keeps a row only when the test is true, so Eli falls through every comparison. Predict all three results, then run:
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
Your turn. Show the id and total of every order of tea or coffee that cost more than 10, biggest first.
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
04 · JOIN
Orders only store a customer's id. To print "Ana bought coffee" you need both tables at once. JOIN pairs up rows whose keys match:
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
Writing customers.name says which table a column comes from. With FROM orders AS o you can write the shorter o.total.
How does the database find the pairs? The obvious way, comparing every order with every customer, is a nested loop: 8 × 5 = 40 comparisons here, but a million × a million in a big shop. So for a join on = a database usually builds a hash table of one side and probes it with the other, which is what the 3D shows: customers flash once as they go into the hash table, then each order finds its customer in one step.
40 comparisons against 13 steps. Double both tables and the nested loop needs four times the work; the hash join, twice.
Notice Eli is missing from the result: he has no orders, so nothing matched him. A LEFT JOIN keeps every row of the left table, and fills the missing side with NULL:
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
COUNT(o.id) counts only non-NULL values, so Eli gets 0. Change it to COUNT(*) and see what happens.
Your turn. List every item Chen bought, with its total. (Hint: join, then filter on the customer's name.)
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
05 · GROUP BY
Questions like "how much did each city spend?" need rows squashed into summaries. Aggregate functions do the squashing: COUNT, SUM, AVG, MIN, MAX. GROUP BY says which rows go together. In the 3D, each joined row drops into its city's bucket, and each bucket becomes one tower as tall as its sum.
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
The same thing in JavaScript is a Map of buckets:
One rule trips everyone up: after grouping, each row of the result is a whole group, so every column you select must either be in the GROUP BY or inside an aggregate. Asking for name per city makes no sense, since Oslo has two. Try adding c.name to the SELECT above. (PostgreSQL refuses, like this engine; SQLite quietly picks one name for you, which hides bugs.)
WHERE filters rows before they are grouped. To filter whole groups after, use HAVING:
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
Your turn. How much did each customer spend? Show name and the sum, keep only those who spent more than 30, biggest first.
Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.
06 · INDEXES
With no index, WHERE id = 97 has one option: read every page of the table and test every row. That is a full scan, and the bottom half of the 3D loop shows it walking all 16 leaf pages. An index is a second structure, kept sorted, that leads straight to the right page. Almost every database uses the same one: the B-tree, invented by Rudolf Bayer and Edward McCreight in 1972.
A B-tree is a tree of pages. Each page holds many sorted keys, and between them, pointers to the pages below:
BETWEEN 40 AND 70 finds the first leaf, then walks sideways.To find 97, the lookup reads the root (97 is in 80–119), one branch (90–99), one leaf: 3 pages, against 16 for the scan. The gap grows fast. A real 8 KB page holds a few hundred keys, so each level multiplies the reach by a few hundred: 3 levels reach tens of millions of rows, 4 reach billions. Build one with a million keys:
Change PAGE to 10 and run it again: more levels, more pages per lookup. That's why databases use big pages.
This page's SQL engine builds real B+trees too (with small pages of 64 keys), and EXPLAIN shows what a query did. Here are 20,000 page visits, searched before and after an index:
Tables: visits(id, user_id, page, ms). Each run starts from fresh data.
Try a range: EXPLAIN SELECT * FROM visits WHERE user_id BETWEEN 100 AND 120; after the CREATE INDEX.
Indexes aren't free. Every INSERT, UPDATE and DELETE must update every index on the table, and each index takes space. Index the columns you search, join and sort on; leave the rest. Real databases show their plans the same way: EXPLAIN in PostgreSQL and MySQL, EXPLAIN QUERY PLAN in SQLite.
07 · TRANSACTIONS
Moving 30 from Ana to Ben is two writes: take 30 from Ana, give 30 to Ben. If the program crashes between them, 30 has vanished. A transaction groups writes so they happen all together or not at all. You open one with BEGIN and finish with COMMIT, or undo everything with ROLLBACK.
Tables: accounts(id, owner, balance). Each run starts from fresh data.
The accounts table has a rule: CHECK (balance >= 0). Now Ana tries to send 500. Ben's +500 succeeds first, then Ana's −500 breaks the rule. Does Ben keep the money?
Tables: accounts(id, owner, balance). Each run starts from fresh data.
Delete the BEGIN line and run it again. Without a transaction, Ben's +500 stays: money from nowhere.
The guarantees a transaction gives are known by the acronym ACID, coined by Theo Härder and Andreas Reuter in 1983:
COMMIT returns, the change survives a crash or power cut.How can a database promise that, when power can fail at any instant? With a write-ahead log. Before changing any data page, it appends a record of the change to a log, and at COMMIT it forces the log to disk. After a crash, recovery reads the log: changes from committed transactions are redone, changes from uncommitted ones are undone. The 3D runs both endings: a commit flushed to the log on disk, then a power cut halfway, after which recovery finds no COMMIT and puts Ana's 100 back.
The undo list is the log in miniature. Press Step through and watch it fill, then empty backwards on the rollback.
08 · ISOLATION
A busy database runs many transactions at once; running them strictly one after another would be far too slow. The trouble starts when two touch the same data. Two shoppers buy the last mugs at the same moment, and each program does "read the stock, then write stock − 1":
One after the other: 8. Interleaved: 9. Two mugs sold, one recorded. That is a lost update, the first loop in the 3D.
Each kind of overlap problem has a name. The SQL standard defines isolation levels by which ones they rule out; a stricter level is safer, and usually slower.
| Anomaly | What happens | Ruled out by |
|---|---|---|
| Dirty read | You read another transaction's change before it commits; then it rolls back. | Read Committed and above |
| Non-repeatable read | You read a row twice in one transaction and get two different values. | Repeatable Read and above |
| Phantom | You run the same WHERE twice and new rows have appeared. | Serializable (also PostgreSQL's Repeatable Read) |
| Lost update | Two read-modify-writes; the second overwrites the first. | Serializable (PostgreSQL's Repeatable Read also catches it; MySQL's does not) |
| Write skew | Two transactions each check a rule, then write different rows, and together break it. | Serializable only |
Write skew is the sneaky one. A hospital needs at least one doctor on call. Two doctors on call both feel ill; each transaction checks "is someone else on call?", sees yes, and signs off. Both commit. Nobody is on call. (The example is from Martin Kleppmann's Designing Data-Intensive Applications.)
Defaults differ, so know yours: PostgreSQL runs at Read Committed, MySQL's InnoDB at Repeatable Read, and SQLite's transactions are serializable. Ways to stop the lost update:
UPDATE items SET stock = stock - 1 WHERE id = 7.SELECT stock FROM items WHERE id = 7 FOR UPDATE makes the second shopper wait, as in the second loop of the 3D.Most modern databases (PostgreSQL, MySQL, Oracle) use MVCC, multi-version concurrency control: they keep old versions of changed rows, so a reader sees a consistent snapshot and never waits for a writer. PostgreSQL's Serializable level adds a check on top that aborts one of two transactions whose combination couldn't have happened one at a time; your code must then retry it.
09 · NORMALIZATION
Why split customers and orders at all? Why not one wide table with everything? Look at it: Ana's city appears on every one of her orders. That repetition causes three kinds of trouble, called anomalies:
Normalization is the discipline of splitting tables until every fact is stored exactly once, joined back by keys. In the 3D the repeated cells flash red, then the wide table splits: the city moves to a customers table, once per customer, and the order keeps only a customer_id. The classic steps are the normal forms:
William Kent summed up 3NF in 1983: every non-key column must provide a fact about "the key, the whole key, and nothing but the key".
Rules are there to be broken on purpose. Joins cost time, so systems that read far more than they write sometimes store a copy on purpose, like an order's total next to its items, or a whole report table rebuilt every night. That is denormalization, and it means taking on the job of keeping the copies in step.
10 · NOSQL
Relational databases are the default for good reason, but not every problem is shaped like tables. From the mid-2000s, companies serving huge traffic built stores that gave up joins or strict transactions in exchange for spreading data across many machines and simpler, faster access paths. Google's Bigtable (2006) and Amazon's Dynamo (2007) papers set the pattern. They are grouped as NoSQL, usually read as "not only SQL". Four families:
| Type | The idea | Good for | Examples |
|---|---|---|---|
| Key-value | A giant map: put(key, value), get(key). The store doesn't look inside values. | Sessions, carts, caches, feature flags | Redis, DynamoDB |
| Document | Each record is a nested JSON-like document, fetched whole in one read. | Records read and written as one unit; fields that vary | MongoDB, Firestore |
| Graph | Nodes and edges are first-class; queries walk relationships. | Social networks, recommendations, fraud rings | Neo4j |
| Wide-column | Rows sorted by key, each with its own set of columns, split across many machines. | Huge write volumes, time series | Cassandra, Bigtable |
The trade-offs are real. A document store makes reading a whole order trivial, but asking "which customers bought cake?" across documents is harder than a JOIN. Many NoSQL systems relax consistency across machines so that they keep working when the network splits. Spreading data over machines (sharding) and keeping copies (replication) is its own subject; the Cluster page shows it in hardware.
11 · CACHING
The fastest query is the one you don't run. Reading main memory takes about 100 nanoseconds; a seek on a spinning disk about 8 milliseconds, some 80,000 times longer. SSDs and networks sit in between, but the shape is the same: the same memory hierarchy that puts caches in every CPU. A cache keeps recent answers somewhere fast, usually in RAM, often in a store like Redis or Memcached in front of the database.
The usual pattern is cache-aside: look in the cache; on a hit, done; on a miss, ask the database and store the answer for next time. A cache is small, so when it is full something must go. LRU (least recently used) evicts whatever has gone unused longest. The 3D plays this exact request sequence through a 3-slot cache:
Change CAPACITY to 4 and rerun. A bigger cache raises the hit ratio, until the whole working set fits.
The hard part is not storing answers but knowing when they're wrong. When the database changes, the cached copy is stale until it is removed (invalidated) or expires (a TTL, time to live):
Databases cache too: every one keeps recently used pages in a buffer pool in RAM, so a hot B-tree root is almost never read from disk at all.
12 · CHOOSING
There is no best database, only a best fit. A rule of thumb many engineers use: start relational, and move only when you can name the problem it doesn't solve.
| If you need… | Reach for | Why |
|---|---|---|
| Data for one app on one device, a prototype, tests | SQLite | A full SQL database in one file. No server to run. |
| A server app with many users, money, anything that must be right | PostgreSQL (or MySQL) | Transactions, constraints, joins, decades of hardening. |
| The same reads over and over, fast | A cache (Redis) in front | Memory beats disk; keep the database as the source of truth. |
| Records read and written whole, with shapes that vary | A document store (MongoDB) | One read per record, no fixed schema. |
| Questions about connections, many hops deep | A graph database (Neo4j) | Following edges is its native operation. |
| Enormous write rates across many machines | Wide-column (Cassandra) | Built to spread writes; you design tables around your queries. |
| Big analytical scans and sums | A columnar engine (DuckDB, ClickHouse) | Reads only the columns a query touches. |
Many real systems use several: PostgreSQL as the source of truth, Redis as a cache, a search engine for text, a columnar warehouse for reports. Each copy is a cache with an invalidation problem, so add them only when you must. For how these pieces fit together across machines, see Systems and Cluster.
PRACTICE
Type the code yourself rather than pasting it: producing an answer builds memory that reading does not. Use the hints one at a time.
Write SELECT name FROM customers WHERE city = 'Lima' OR city IS NULL in JavaScript. result should be the array ["Ben", "Eli"].
Join orders to customers. Build a Map from customer id to customer once, then do one lookup per order. Push { name, total } into result, one row per order, in order.
Each row below is an order with its customer's name. Work out how much each customer spent, keep only those who spent more than 35 (that is the HAVING), and sort biggest first. result should be [{ name: "Ana", spent: 53 }, { name: "Dara", spent: 40 }].
Finish lookup(key): start at the root and, while the page is not a leaf, step into the child whose range holds the key. Count every page you read. lookup(97) should find 97 after reading 3 pages, and lookup(96) should read 3 pages and not find it.
Two shoppers read the stock at the same moment and both try to save. Right now the second write silently wins and the stock ends at 9. Make write refuse when the row's version has changed since seen was read (return false), and bump the version when it does write. The retry is already there: the stock should end at 8.
RECALL
Answer each one before you open it. Then come back tomorrow, and again next week, and answer them cold: retrieval spaced over days is the most reliable study method we know of. Why that works.
Why does finding one row in a CSV file get slower as the file grows, but not in a database?
What is the difference between a primary key and a foreign key?
Why does WHERE city = NULL return no rows?
WHERE or HAVING: which one can use SUM(total), and why?
Roughly how many pages does a B-tree lookup read in a table of 10 million rows?
What does each letter of ACID promise?
Name the anomaly: two transactions read stock = 10, both write 9.
GO FURTHER
SOURCES