Database Indexing: B-Trees, Faster Reads and the Write Tax

A lookup on an unindexed column reads the whole table; a B-tree turns it into 3 or 4 page reads. An index is a sorted copy of a few columns, so it speeds up matching reads and taxes every write. Know clustered versus secondary, the leftmost-prefix rule, covering indexes, and when the planner scans anyway.

  • 8 mins read

  • |

  • Published Oct 29, 2026

Database Indexing: B-Trees, Faster Reads and the Write Tax

TL;DR - A lookup on an unindexed column reads the whole table; a B-tree turns it into 3 or 4 page reads. An index is a sorted copy of a few columns, so it speeds up matching reads and taxes every write. Know clustered versus secondary, the leftmost-prefix rule, covering indexes, and when the planner scans anyway.

30-second version (elevator pitch): "An index is a sorted structure mapping column values to row locations, so the database seeks in place of scanning. B-tree pages hold hundreds of entries, so 4 levels cover billions of rows, and range scans walk the leaf chain. Every index adds IO to each write, so I add them for queries I can name, put equality columns before range columns, and use a covering index for the hottest query."


Why a Lookup by Email Reads 20 GB

A users table holds 100 M rows at about 200 bytes each. Login runs SELECT id, password_hash FROM users WHERE email = ?. With email unindexed the engine checks every row: 100 M x 200 bytes = 20 GB per login. At 2 GB/s sequential that is 10 seconds per login, all of them sharing one disk.

With a B-tree on email, the lookup descends 4 levels, one 16 KB page per level: 4 x 16 KB = 64 KB. The top levels stay in memory, so the real cost is one or two SSD reads of about 100 microseconds.


How a B-Tree Index Works

How a B-Tree Index Works

What is an index? A separate structure holding one or more columns in sorted order, each entry pointing at its row.

What is a B-tree? A balanced tree whose nodes are fixed-size disk pages, 8 KB in Postgres and 16 KB in InnoDB. Internal pages hold separator keys and child pointers; leaf pages hold keys with row pointers. Every leaf sits at the same depth, so every lookup costs the same number of page reads; a full page splits in two and pushes a separator up.

What is fan-out? The number of children per internal page. At roughly 32 bytes per entry, a 16 KB page holds a few hundred, and fan-out compounds per level:

  • -Height 3 at fan-out 300: 300^3 = 27 M entries.
  • -Height 4: 300^4 = 8.1 billion entries.

So 100 M rows fit in height 4. The top two levels are 301 pages, under 5 MB, so they stay cached and a point lookup usually pays for one leaf read.

What is a B+tree? The variant every major database ships. All entries live in the leaves, and the leaves are linked left to right. A range query such as created_at BETWEEN a AND b descends once, then walks the leaf chain, which is also why ORDER BY on an indexed column can skip the sort.


Clustered, Secondary, Composite, Covering

Clustered Versus Secondary

What is a clustered index? One whose leaves hold the full rows, so the table is the index. InnoDB clusters every table on its primary key.

What is a secondary index? One whose leaves hold the indexed columns plus a row locator. In InnoDB the locator is the primary key, so a lookup by email is a double lookup: descend the email tree to get the id, then descend the primary key tree to get the row.

Keep the primary key small, because every secondary leaf entry carries a copy: an 8-byte bigint across 5 secondary indexes on 100 M rows is 4 GB of copies; a 36-byte UUID string is 18 GB. Keep it monotonic so inserts append to the rightmost leaf; random UUIDs split pages across the whole tree.

Composite Indexes and the Leftmost Prefix

What is a composite index? One B-tree over several columns, sorted by the first, then by the second within equal first values.

What is the leftmost-prefix rule? The tree seeks on a prefix of the column list from the left. An index on (tenant_id, created_at) serves filters on tenant_id, or on both. A filter on created_at alone finds its entries scattered across every tenant, so the planner scans.

Worked example: 100 M orders, 10,000 tenants, 10,000 orders each, hot query "tenant 7 in the last 24 hours", about 30 rows.

  • -(tenant_id, created_at): seek to tenant 7, then to yesterday, read 30 adjacent entries.
  • -(created_at, tenant_id): seek to yesterday, read every tenant's orders for that day, 10,000 x 30 = 300,000 entries, filter 299,970 away.

Rule: equality columns first, the range column next, then sort columns.

Covering Indexes

What is a covering index? One holding every column the query reads, so the engine answers from the leaves and skips the table. SELECT id FROM users WHERE email = ? is covered by any InnoDB index on email, since the leaf already carries the primary key.


What Indexes Cost on Writes

Each index is another B-tree to update. With 5 secondary indexes, one insert becomes 6 tree insertions, each dirtying a 16 KB page: around 96 KB of page writes for a 200-byte row, before the write-ahead log. That is write amplification, worst on random-valued columns, where each insert lands on a cold random leaf.

Rule of thumb: about 5 indexes on a write-heavy table, each named after the query it serves. Drop indexes whose usage counter stays at zero.

When the Planner Picks a Full Scan

  • -Low selectivity. WHERE status = 'active' matching 90 percent of rows means 90 M random page fetches via the index; a sequential 20 GB scan is cheaper.
  • -A function on the column. WHERE lower(email) = ? compares a computed value; the tree is sorted by the raw one. Add an expression index on lower(email).
  • -A leading wildcard. LIKE '%gmail.com' matches anywhere, so sort order is useless; LIKE 'jim%' is a range seek.

Other types to name: hash (equality alone), bitmap (low-cardinality analytics) and inverted (full-text search).

LSM Trees Versus B-Trees

An LSM tree buffers writes in a sorted in-memory table backed by a sequential log, flushes it as an immutable sorted file, and merges files in the background (compaction). Writes are sequential, so ingest throughput is high; reads may consult several files (read amplification, tempered by bloom filters), and compaction spends background IO. B-trees update pages in place and give predictable point reads. Say LSM (Cassandra, RocksDB) for event ingest, B-tree (Postgres, InnoDB) for transactional tables.


Operating Indexes

  • -Bloat. After heavy churn an index can sit at half density, doubling its cache footprint. Rebuild online and build new indexes concurrently so writes keep flowing.
  • -Partial indexes. WHERE status = 'pending' on a jobs table indexes 1 percent of rows and stays tiny and hot.
  • -Alternatives worth naming. Cache the hot lookup in Redis; denormalize a column onto the row that is read; hand free text to a search engine.

When to Use in Interviews

Raise indexing the moment you draw a table and name a query on it: "login by email, covering"; "orders by tenant and time, composite (tenant_id, created_at)"; "jobs queue, partial index on pending". For an ingest-heavy path, state the index budget and name LSM storage.


Summary

  • -Scan versus seek - 20 GB per unindexed lookup versus 4 page reads.
  • -Fan-out - 300^4 = 8.1 billion, so height 3 to 4 covers any OLTP table.
  • -Secondary lookups - InnoDB leaves hold the primary key; keep it small and monotonic.
  • -Leftmost prefix - Equality first, range next, then sort columns.
  • -Write cost - Budget about 5 indexes on hot tables.

Interview Q&A

  • -Why a B-tree over a hash index? - It serves equality, ranges and ordering in one structure.
  • -How many rows fit under 4 levels? - About 8.1 billion at fan-out 300.
  • -Index on (a, b): which queries use it? - Filters on a, or on a and b.
  • -When does the planner ignore an index? - Low selectivity, a function on the column, or a leading wildcard.
  • -LSM or B-tree for event ingest? - LSM, for sequential writes.

Disclosure: I build Pretense, a local proxy that swaps secrets, PII and code identifiers for stand-in tokens before a prompt leaves your laptop. Paste an EXPLAIN plan with its connection string into an AI assistant: table and column names come back byte-exact, and the credential stays a stand-in.

Read More Articles

From the System Design Laws newsletter.