Course 3 · Foundations

Inside a Database I — Storage & Indexes

You've been writing SELECT and INSERT against Postgres and DuckDB since Course 2 — but where do those rows actually go, and why is one query instant while another takes a minute? This chapter opens the box. We'll follow a row down to the bytes on disk, see why finding it can be slow, and learn the two structures — the page and the B-tree — that make databases fast.

Pages: how rows physically live on disk

Start with the simplest possible mental model. A table is just a file on disk. Postgres literally stores each table as one or more files in its data directory; when you ran INSERT in Course 2, bytes were appended to a real file. This file is called a heap — "heap" because, by default, rows go in wherever there's room, in no particular order.

But the database doesn't read or write that file one row at a time, and it doesn't read it one byte at a time either. It works in fixed-size chunks called pages (sometimes "blocks"). In Postgres a page is 8 KB. Every read from disk and every write to disk moves whole pages, never half a page. A page is the database's atom of I/O.

A heap file, then, is just an array of these fixed-size pages, and each page is a little container packed with rows:

HEAP FILE (table "orders") = an array of fixed-size 8 KB pages ┌──────────────┬──────────────┬──────────────┬──────────────┐ │ page 0 │ page 1 │ page 2 │ page 3 ... │ └──────────────┴──────────────┴──────────────┴──────────────┘ │ ▼ zoom into one page ┌─────────────────────────────────────────────────────────┐ │ header │ ptr→ ptr→ ptr→ … (slot array) │ │ │ │ (free space) │ │ │ │ … ← row 3 │ ← row 2 │ ← row 1 │ ← row 0 │ └─────────────────────────────────────────────────────────┘ rows fill in from the END; pointers grow from the START

Each page has a small header, a slot array of little pointers, and the rows themselves. The rows pack in from the bottom and the pointers grow from the top until they meet in the middle — at which point the page is full and the next row goes on a new page. The pointer indirection is a neat trick: a row can be addressed as "page 2, slot 5," and if the row later has to move within the page, only the slot pointer changes, not its address.

Why fixed-size pages?

Disks (and the operating system underneath the database — see Chapter 08) are built to move data in fixed-size blocks. Reading 8 KB costs almost exactly the same as reading 1 byte, because the expensive part is finding the data and starting the transfer, not the bytes themselves. So the database amortizes that cost: it grabs a whole page at once, betting that if you wanted one row, you'll soon want its neighbours. Fixed sizes also make the bookkeeping simple — page number × 8 KB gives the exact byte offset in the file.

Hold onto one consequence of this: the cost of a query is dominated by how many pages it has to read, not how many rows. If a thousand matching rows happen to sit on three pages, that's three reads. If a thousand rows are scattered one-per-page across a huge table, that's a thousand reads. This single idea explains almost everything that follows.

The problem indexes solve

Suppose orders has 50 million rows spread over a few hundred thousand pages, and you ask:

sql
SELECT * FROM orders WHERE order_id = 998877;

How does the database find that one row? With only a heap, it has no idea where row 998877 is — remember, the heap has no order. So it does the only thing it can: it reads every page, checking every row, until it finds a match (or reaches the end). This is a full table scan, and its cost grows linearly with the table size — in the Big-O terms from Chapter 02, it's O(n). For 50 million rows, that's tens of thousands of page reads to fetch a single record. Painful.

The library card catalog

Imagine a library with no catalog. To find one book by title, you'd walk every shelf, reading spines — the full table scan. The card catalog fixes this: a separate set of cards, sorted by title, each pointing to a shelf location. You binary-search the cards (a handful of lookups), read the shelf address, and walk straight to the book. The catalog is not the books; it's a small, sorted side-structure that turns "search everything" into "look it up." That is exactly what a database index is.

An index is an auxiliary data structure, stored alongside the table, that keeps some column (or columns) sorted and maps each value to the location of its row — typically a "page N, slot M" pointer. Because it's kept sorted, the database can search it the way you'd search a sorted phone book: jump to the middle, decide left or right, repeat. That's a logarithmic search, O(log n) — for 50 million rows, roughly 26 steps instead of 50 million. Once the index hands back the row's location, the database reads exactly that one heap page. Two structures, working together: the index to locate, the heap to store.

B-tree indexes, deeply

"Keep it sorted and binary-search it" is the idea; the structure that actually delivers it is the B-tree — the default index type in Postgres, DuckDB, MySQL, SQLite, and essentially every relational database. It builds directly on the trees you met in Chapter 02, with one crucial twist for disk.

A B-tree is a balanced search tree: values are sorted left-to-right, and every leaf is the same distance from the root, so no lookup is ever much longer than another. You search it like a binary search tree — at each node, compare your value and follow the right child pointer down — until you reach a leaf, which holds the pointer into the heap.

B-TREE on orders(order_id) searching for order_id = 47 ┌───────────────────────────────────────────────────────────┐ │ ROOT [ 30 | 60 | 90 ] │ └───────────────────────────────────────────────────────────┘ <30 │ 30–60 │ 60–90 │ ≥90 │ │ ▼ (47 is here: between 30 and 60) ┌─────────┐ ┌──────────────────┐ ┌─────────┐ ┌─────────┐ │ … │ │ [ 40 | 50 ] │ │ … │ │ … │ ← internal └─────────┘ └──────────────────┘ └─────────┘ └─────────┘ 40–50 │ (47 is here) ▼ ┌────────────────────────┐ │ 40→ 43→ 45→ 47→ 48→ 50→ │ ← LEAF: values + heap pointers └────────────────────────┘ (also chained to next leaf →) found 47 → heap pointer → read that one page

Two properties make this the workhorse of databases:

  • O(log n) point lookups. Each level you descend narrows the search; the depth of a balanced tree over n values is logarithmic. Find any one value in a handful of steps regardless of table size.
  • Efficient range scans. Because the leaves hold values in sorted order and are chained together in a linked list, a query like WHERE order_id BETWEEN 40 AND 60 descends once to find 40, then simply walks the leaves rightward until it passes 60. ORDER BY and </>/BETWEEN all benefit. A hash index could do point lookups but not this — which is why B-trees are the default.
Why a B-tree and not a plain binary tree?

A binary tree branches two ways per node, so over a billion rows it's about 30 levels deep — and if each node sits on its own disk page, that's 30 page reads per lookup. The "B" twist: each B-tree node is sized to fill one page, so a single node holds hundreds of keys and branches hundreds of ways (high fan-out). Branching ~250 ways instead of 2, a billion rows fit in just 3–4 levels — 3–4 page reads instead of 30. Since page reads are the expensive thing (the rule from the first section), the B-tree is a binary search re-shaped to minimize them. It's the same logarithmic idea from Chapter 02, tuned for the physics of disk.

Indexes aren't free

If indexes make reads so fast, why not index every column? Because an index is a real, separate structure that has to be stored and, more importantly, kept in sync. Every time you INSERT, UPDATE, or DELETE a row, the database must update not just the heap but every index on that table — finding the right leaf in each B-tree and inserting the new key in sorted position. Five indexes mean five extra structures to maintain on every single write.

Indexes give you…Indexes cost you…
Point lookups in O(log n) instead of O(n) full scans.Extra disk space — an index can be a sizable fraction of the table.
Fast range scans and ORDER BY on the indexed columns.Slower writes — every INSERT/UPDATE/DELETE must maintain every index.
Fast JOINs and uniqueness checks on key columns.Maintenance — unused or redundant indexes are pure overhead.

So indexing is a trade: you spend storage and write speed to buy read speed. The practical rule of thumb: index the columns you frequently filter on (WHERE), join on, or sort by — especially in big tables — and don't index columns you only ever read back, or tables so small a scan is already instant. A write-heavy logging table wants few indexes; a read-heavy reporting table can afford more.

Two refinements you'll meet again:

  • Composite index — an index on several columns together, e.g. (customer_id, order_date). It's sorted by the first column, then the second within that. Great for WHERE customer_id = 7 ORDER BY order_date; the column order matters, because it can't efficiently use the second column alone.
  • Covering index — one that includes every column a query needs, so the database can answer entirely from the index without ever touching the heap (an "index-only scan"). Saves the second lookup.
Don't guess — measure

Whether an index actually helps a given query is something you ask the database, not your intuition. The tool is EXPLAIN, which shows whether a query used a "Seq Scan" (full table scan) or an "Index Scan." We give it a full chapter next — for now, just know the lever exists and that the answer is observable.

Row-oriented vs columnar storage

So far we've assumed a row lives contiguously inside a page — all of order 998877's fields (id, customer, date, amount) sitting together. That's row-oriented storage, and it's what Postgres uses. But it's not the only way to lay bytes on disk, and the alternative is the single biggest reason DuckDB and Postgres feel so different on big analytical queries.

Logical table: order_id │ customer │ amount ─────────┼──────────┼─────── 1 │ alice │ 120 2 │ bob │ 80 3 │ carol │ 200 ROW-ORIENTED (Postgres) — one row's fields together on a page: [1│alice│120] [2│bob│80] [3│carol│200] [4│dan│95] … └─ to read "amount" you must touch every row's whole record ─┘ COLUMNAR (DuckDB / Parquet) — each column stored together: order_id: [ 1, 2, 3, 4, … ] customer: [ alice, bob, carol, dan, … ] amount: [ 120, 80, 200, 95, … ] ← read just this run for a SUM

Same logical table, two physical layouts. The difference decides which queries are cheap:

Row-oriented (Postgres)Columnar (DuckDB)
Stores togetherAll fields of one rowAll values of one column
Great at"Fetch / update this whole record" (OLTP)"Aggregate one column over billions of rows" (OLAP)
SELECT * WHERE id = 7One page, all fields right there ✓Must gather field from every column ✗
SUM(amount) over everythingReads every full row to get one field ✗Reads only the amount column ✓
CompressionModest (mixed types per page)Excellent (a column = one type, similar values)

Now the payoff. Consider SELECT SUM(amount) FROM orders over a billion rows. In a row store, amount is a tiny slice of each record, but the fields are interleaved — to read every amount the database must drag every full row's worth of pages off disk, hauling along the customer, date, and everything else it doesn't need. In a columnar store, every amount sits in one contiguous run; the engine reads that column and nothing else, touching a fraction of the bytes. And because a column holds one data type with similar, often repetitive values, it compresses beautifully (a column of mostly-equal amounts shrinks dramatically), so there are fewer bytes to read in the first place. That's why DuckDB can sum a billion-row column in a heartbeat.

The flip side is just as real: to fetch one whole record, a row store grabs it from a single spot, while a columnar store must reassemble it from many separate column files. This is the heart of the Course 2 pairing — Postgres for OLTP (transactional apps: read and write individual rows constantly) and DuckDB for OLAP (analytics: scan and aggregate huge columns). Neither is "better"; they make opposite bets about which queries you'll run. We'll see this same columnar layout again on disk in Chapter 07 when we meet Parquet.

Hands-on: watch an index change things

Let's make it concrete in Postgres from Course 2. (DuckDB works too — it accepts the same SQL.) Open psql and run these in order. We'll build a table with a million rows, query it, add an index, and ask the planner what it did.

sql
-- 1. a table with a million rows; no index yet (just the heap)
CREATE TABLE demo_orders AS
SELECT g AS order_id,
       (g % 1000)        AS customer_id,
       (random() * 500)::int AS amount
FROM generate_series(1, 1000000) AS g;

-- 2. find one row. With no index this is a FULL TABLE SCAN.
EXPLAIN ANALYZE
SELECT * FROM demo_orders WHERE order_id = 998877;

-- 3. now build a B-tree index on the column we filter by
CREATE INDEX idx_demo_order_id ON demo_orders (order_id);

-- 4. ask the exact same question again
EXPLAIN ANALYZE
SELECT * FROM demo_orders WHERE order_id = 998877;
✓ You should see

In step 2, the plan begins with Seq Scan on demo_orders and an execution time in the tens of milliseconds — Postgres read its way through the whole heap. In step 4, after the index exists, the plan changes to Index Scan using idx_demo_order_id and the time drops to a fraction of a millisecond — often hundreds of times faster for the very same query. You just watched O(n) become O(log n) with your own eyes. (Don't worry about reading the full EXPLAIN output yet — that's the whole job of Chapter 04.)

One more thing to notice: building the index in step 3 took real time and real disk space — that's the write-side cost from the trade-off table, paid up front so every future read is cheap. Clean up with DROP TABLE demo_orders; when you're done.

✓ Check yourself

  • Why is a page (block) the unit of disk I/O, and why does query cost track pages read more than rows matched?
  • In one sentence: how does an index turn an O(n) scan into an O(log n) lookup?
  • Why do databases use B-trees with high fan-out instead of plain binary trees?
  • Name the trade an index makes — what does it buy, and what does it cost?
Exercise — Reason about reads, writes, and layout

(1) Explain why an index speeds up reads but slows down writes. (2) You're building a dashboard whose main query is SELECT SUM(revenue) FROM events over a billion rows. Predict whether row-oriented (Postgres-style) or columnar (DuckDB-style) storage will serve it better, and say why.

Solution

(1) A read benefits because the index is a sorted side-structure: instead of scanning every page (O(n)), the database descends a B-tree in a few steps (O(log n)) and jumps straight to the row's page. A write suffers because the index has to stay in sync with the truth: every INSERT/UPDATE/DELETE must not only change the heap but also locate and modify the right leaf in every index on the table, keeping each one correctly sorted. More indexes ⇒ faster reads but more work per write. It's a trade, not a free win.

(2) Columnar wins, decisively. The query reads exactly one column, revenue, over a billion rows. In columnar storage all the revenue values sit together in one contiguous, well-compressed run, so the engine reads just that column and little else. A row store would have to drag every full row's pages off disk to pluck out one field from each — far more bytes for the same answer. (If instead the dashboard fetched individual whole records by id, the row store would be the better pick. The layout choice follows the query shape.)

Next

You know where rows live and how indexes find them fast — next we'll watch the database plan a query against those structures and keep your data correct under concurrent writes. → Inside a Database — Queries & Transactions