01/12Files vs databases

LEARN TO PROGRAM · CHAPTER 11 · ADVANCED

Data and databases

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.

Every code block runs in your browser. Edit it, break it, press Run.

01 · FILES VS DATABASES

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.

csv-file.js

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:

  • Finding means reading from the start. The 3D shows the read head crawling byte by byte.
  • Changing means rewriting. If the power fails halfway, the file is half old and half new.
  • Two writers at once overwrite each other's changes.
  • No rules: nothing stops a typo, a missing comma, or a city in the price column. A name like 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.

1 trillion+SQLite databases estimated to be in active use. SQLite is a whole database in one file, built into every Android phone and iPhone and into Chrome, Firefox and Safari. SQLite

02 · TABLES AND KEYS

Tables, rows 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:

schema.sql
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:

  • A primary key identifies each row: unique, never empty, and it shouldn't change. Names make bad keys (two people called Ana, or Ana marries and changes hers), so most tables use a number.
  • A foreign key is a column that holds another table's primary key. 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:

keys.js

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.

foreign-key.sql

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

Asking questions: SELECT and 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):

first.sql

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.

where.sql

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:

select.js

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.

filter-sort.sql

Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.

NULL: the value that means "unknown"

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:

null.sql

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.

challenge-1.sql

Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.

04 · JOIN

JOIN: putting tables back together

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:

join.sql

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.

join.js

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:

left-join.sql

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.)

challenge-2.sql

Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.

05 · GROUP BY

GROUP BY: from rows to answers

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.

group-by.sql

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:

group-by.js

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:

having.sql

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.

challenge-3.sql

Tables: customers(id, name, city) · orders(id, customer_id, item, total, day). Each run starts from fresh data.

06 · INDEXES

Indexes: B-trees

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:

  • The root page here holds 40, 80 and 120, so it splits the keys into four ranges: under 40, 40 to 79, 80 to 119, 120 and up.
  • Each branch page splits its range four ways again.
  • The leaf pages hold the keys themselves (and in a real table, the rows or where to find them). In the B+tree variant nearly every database uses, leaves are also linked in order, so a range like 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:

btree.js

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:

index.sql

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

Transactions and ACID

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.

transfer.sql

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?

overdraw.sql

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:

  • Atomic: all of it or none of it. No half-done transfer is ever left behind.
  • Consistent: every transaction takes the database from one valid state to another; rules like keys and CHECKs are never broken at a commit.
  • Isolated: transactions running at the same time don't see each other's half-done work. (How strictly is a setting: the next section.)
  • Durable: once 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.

transaction.js

The undo list is the log in miniature. Press Step through and watch it fill, then empty backwards on the rollback.

08 · ISOLATION

Isolation: when transactions overlap

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":

lost-update.js

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.

AnomalyWhat happensRuled out by
Dirty readYou read another transaction's change before it commits; then it rolls back.Read Committed and above
Non-repeatable readYou read a row twice in one transaction and get two different values.Repeatable Read and above
PhantomYou run the same WHERE twice and new rows have appeared.Serializable (also PostgreSQL's Repeatable Read)
Lost updateTwo read-modify-writes; the second overwrites the first.Serializable (PostgreSQL's Repeatable Read also catches it; MySQL's does not)
Write skewTwo 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:

  • Let the database do the arithmetic in one statement: UPDATE items SET stock = stock - 1 WHERE id = 7.
  • Lock the row while you work: SELECT stock FROM items WHERE id = 7 FOR UPDATE makes the second shopper wait, as in the second loop of the 3D.
  • Check a version number when you write, and retry if it changed (optimistic concurrency):
optimistic.js

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

Normalization: each fact in one place

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:

  • Update: Ana moves, you update two of her three rows, and now the database says she lives in two cities.
  • Insert: you can't record a new customer until they order something.
  • Delete: cancel Dara's only order and you lose that Dara exists at all.
normalize.js

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:

  • 1NF: one value per cell. No lists of items squeezed into one column.
  • 2NF: no column depends on just part of a multi-column key.
  • 3NF: no column depends on another non-key column (city depends on the customer, not on the order).

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

NoSQL: other shapes of data

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:

TypeThe ideaGood forExamples
Key-valueA giant map: put(key, value), get(key). The store doesn't look inside values.Sessions, carts, caches, feature flagsRedis, DynamoDB
DocumentEach record is a nested JSON-like document, fetched whole in one read.Records read and written as one unit; fields that varyMongoDB, Firestore
GraphNodes and edges are first-class; queries walk relationships.Social networks, recommendations, fraud ringsNeo4j
Wide-columnRows sorted by key, each with its own set of columns, split across many machines.Huge write volumes, time seriesCassandra, Bigtable
four-shapes.js

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

Caching: remember the answer

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:

lru-cache.js

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):

stale.js
2"There are only two hard things in Computer Science: cache invalidation and naming things." (Phil Karlton) Fowler, TwoHardThings

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

Choosing: which database when

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 forWhy
Data for one app on one device, a prototype, testsSQLiteA full SQL database in one file. No server to run.
A server app with many users, money, anything that must be rightPostgreSQL (or MySQL)Transactions, constraints, joins, decades of hardening.
The same reads over and over, fastA cache (Redis) in frontMemory beats disk; keep the database as the source of truth.
Records read and written whole, with shapes that varyA document store (MongoDB)One read per record, no fixed schema.
Questions about connections, many hops deepA graph database (Neo4j)Following edges is its native operation.
Enormous write rates across many machinesWide-column (Cassandra)Built to spread writes; you design tables around your queries.
Big analytical scans and sumsA 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

Now you do it

Type the code yourself rather than pasting it: producing an answer builds memory that reading does not. Use the hints one at a time.

1

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"].

exercise-1.js
2

Write a hash join

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.

exercise-2.js
3

GROUP BY and HAVING

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 }].

exercise-3.js
4

Walk the B-tree

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.

exercise-4.js
5

Stop the lost update

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.

exercise-5.js

RECALL

Check yourself, from memory

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

Resources

  • PRACTICE · FREESQLBoltShort interactive lessons with a query box on every page: SELECT to JOIN to GROUP BY in an afternoon.
  • BOOK · FREESelect Star SQLZi Chong KaoAn interactive book that teaches SQL by answering real questions about a real dataset.
  • COURSE · FREEIntro to Database Systems (CMU 15-445)Carnegie Mellon UniversityHow a database is built inside: pages, buffer pools, B+trees, joins, concurrency control, recovery. Lectures are free on YouTube.
  • BOOK · FREEUse The Index, LukeMarkus WinandA free book on SQL indexing for developers: what makes a query fast or slow, database by database.
  • DOCS · FREEPostgreSQL tutorialThe PostgreSQL Global Development GroupInstall a real database and work through tables, queries, joins, transactions and more.
  • ARTICLE · FREESQLite: Query PlanningSQLiteHow SQLite uses B-tree indexes to answer a query, with clear diagrams. The best short read on indexes there is.
  • BOOK · FREEArchitecture of a Database SystemHellerstein, Stonebraker and HamiltonA free 119-page tour of how real database engines are put together, from the people who built several.
  • ARTICLE · FREEJepsen: Consistency ModelsKyle KingsburyA clickable map of isolation and consistency levels, each with what it allows and forbids.

SOURCES

Where the facts come from

  1. Codd (1970), A Relational Model of Data for Large Shared Data Banks, CACM 13(6) The paper that introduced tables (relations), keys and normalization.
  2. Chamberlin and Boyce (1974), SEQUEL: A Structured English Query Language The IBM language that became SQL.
  3. ISO/IEC 9075-1:2023, the SQL standard (framework)
  4. SQLite: Most Widely Deployed and Used Database Engine Estimates over one trillion SQLite databases in active use.
  5. Bayer and McCreight (1972), Organization and maintenance of large ordered indices, Acta Informatica 1 The B-tree.
  6. Comer (1979), The Ubiquitous B-Tree, ACM Computing Surveys 11(2) B+trees: keys in the leaves, leaves linked for range scans.
  7. Cormen, Leiserson, Rivest and Stein, Introduction to Algorithms, 4th ed., ch. 18 (B-Trees)
  8. PostgreSQL docs: Database Page Layout Pages are normally 8 kB.
  9. SQLite docs: PRAGMA page_size Default page size 4096 bytes.
  10. MySQL 8.4 docs: innodb_page_size Default 16 KB.
  11. Haerder and Reuter (1983), Principles of Transaction-Oriented Database Recovery, ACM Computing Surveys 15(4) Where the acronym ACID comes from.
  12. PostgreSQL docs: Write-Ahead Logging
  13. SQLite: Atomic Commit In SQLite
  14. Berenson et al. (1995), A Critique of ANSI SQL Isolation Levels, SIGMOD Lost update, write skew and snapshot isolation, defined precisely.
  15. PostgreSQL docs: Transaction Isolation Read Committed is the default.
  16. PostgreSQL docs: MVCC introduction Readers never block writers, and writers never block readers.
  17. PostgreSQL docs: Index types CREATE INDEX makes a B-tree by default.
  18. MySQL 8.4 docs: InnoDB transaction isolation levels REPEATABLE READ is the InnoDB default.
  19. SQLite: Isolation In SQLite Transactions are serializable.
  20. Ports and Grittner (2012), Serializable Snapshot Isolation in PostgreSQL, VLDB
  21. Kent (1983), A Simple Guide to Five Normal Forms in Relational Database Theory, CACM 26(2)
  22. DeCandia et al. (2007), Dynamo: Amazon's Highly Available Key-value Store, SOSP
  23. Chang et al. (2006), Bigtable: A Distributed Storage System for Structured Data, OSDI
  24. MongoDB docs: Data Modeling
  25. Neo4j: Cypher manual, introduction
  26. Redis docs: Key eviction LRU and LFU eviction policies.
  27. Norvig, Teach Yourself Programming in Ten Years: approximate timings Main memory about 100 ns; a disk seek about 8 ms.
  28. Fowler, TwoHardThings Phil Karlton's line about cache invalidation.
  29. OWASP: SQL Injection Prevention Cheat Sheet
  30. Python docs: sqlite3, placeholders
  31. Kleppmann, Designing Data-Intensive Applications (O'Reilly) Chapters 2, 3 and 7: data models, storage and indexes, transactions.