Knowledge Guide
HomeDatabasesIndexing & Storage

Indexing & Storage — Fanout Arithmetic, Optimizer Stats, Composite/Skip Scans & NULL/OR Pitfalls (Deep Dive)

A B+tree's depth is not a rule of thumb pulled from memory — it falls straight out of one division: how many (key, pointer) entries fit on a page. Get that division right and you can derive the depth of any index, for any key size, on the spot — instead of reciting “3–4 levels” and hoping the interviewer doesn't ask why.

This page assumes you already know the B+tree basics — internal vs leaf nodes, point lookup vs range scan, clustered vs secondary — covered in How Indexes Work and Indexes in Practice. It goes one level deeper into exactly the mechanisms an interviewer probes past the basics: the fanout arithmetic that makes depth re-derivable, why a busy table doesn't actually pay for every level on every lookup, how the optimizer decides selectivity in the first place, the composite-index rule and its real exception (skip scan), how insertion order fragments the tree, and the NULL/OR/NOT IN pitfalls that quietly break either correctness or index usage. The selectivity crossover arithmetic itself and the B-tree-vs-LSM storage-engine trade-off are covered in depth elsewhere in this guide — this page cross-references both rather than re-deriving them, and focuses on the indexing-specific angle: the structure itself, not the storage engine or the query planner's cost model in general.

1. Fanout arithmetic: deriving depth instead of memorizing it

Every internal (routing) page in a B+tree holds a fixed number of (key, pointer) entries — the page's fanout. That number is just page capacity divided by entry size:

fanout = page_size ÷ (key_size + pointer_size)

Take a typical 8 KB page, a 16-byte key (e.g. a UUID stored as raw binary, or two 8-byte columns), and an 8-byte child pointer:

fanout = 8192 ÷ (16 + 8) = 8192 ÷ 24 ≈ 341 entries per page

Depth follows directly: a tree of depth d can address up to fanoutᴸ rows (root at the top, d−1 internal levels, leaf at the bottom). So the rule is: pick the smallest d such that fanoutᴸ ≥ row count.

Traced example: how many levels for 100,000,000 rows?

Depth dCapacity = 341dCovers 100M rows?
1341no
2116,281no
339,651,821 (≈39.65M)no — short by more than half
413,521,270,961 (≈13.52B)yes

3413 only reaches about 39.65 million — a 100-million-row table doesn't fit at depth 3, so it needs depth 4 (root + two internal levels + leaf). This is the arithmetic worth checking every time: a commonly repeated version of this example rounds straight to “100M rows ≈ depth 3” without checking that 341³ actually falls short of 100M — recompute it, don't recite it.

The same rule re-derives depth for any key size, because only the fanout changes:

Entry size (key+pointer)Fanout (8KB page)Depth for 100M rows
16 B (8B bigint key + 8B pointer)8192÷16 = 5125123 = 134,217,728 ≥ 100M ⇒ d = 3
24 B (16B key + 8B pointer)8192÷24 ≈ 3413413 ≈ 39.65M < 100M ⇒ d = 4
72 B (64B VARCHAR key + 8B pointer)8192÷72 ≈ 1131133 ≈ 1.44M < 100M ⇒ d = 4 (1134≈163M)

The qualitative takeaway a candidate should say out loud: narrower keys → higher fanout → the tree stays shallower for longer as the table grows. A bigint primary key needs one fewer level than a wide composite or text key at the same row count — which is exactly why "narrow, sequential primary keys" is standard schema-design advice, not folklore.

Bar-style diagram of B+tree capacity at depth 1 through 4 for fanout 341: depth 3 gives 39,651,821 rows (short of 100 million), depth 4 gives 13,521,270,961 rows (sufficient)
Bar-style diagram of B+tree capacity at depth 1 through 4 for fanout 341: depth 3 gives 39,651,821 rows (short of 100 million), depth 4 gives 13,521,270,961 rows (sufficient)

2. The buffer-pool caveat: a busy table does NOT pay 4 disk reads per lookup

The naive version of the fanout result — “depth 4 ⇒ 4 disk reads per lookup” — is wrong for any table that gets queried more than occasionally, because it silently assumes every page read is a cache miss. It isn't. The buffer pool (Postgres shared_buffers, InnoDB buffer pool) caches recently-touched pages in RAM, and the upper levels of a B+tree are the cheapest pages in the world to keep resident: they're tiny and they're touched by every single lookup, so they're the least likely pages to ever get evicted.

Continuing the 100M-row / fanout-341 example: the page counts per level work out to leaf ≈ 293,256 pages, one level up ≈ 860 pages, one level above that 3 pages, and a single root page — 864 pages total above the leaf, or exactly 6.75 MiB at 8 KB/page. That's a rounding error against a buffer pool that's typically gigabytes. In practice those 864 pages sit pinned in RAM permanently, and every lookup's root-to-just-above-leaf traversal costs zero real I/O. The leaf level, at ≈293,256 pages (≈2.24 GiB), is far too big to guarantee full residency — it's the first place a lookup can actually hit disk. And if the index is a secondary index, there's a further hop to the heap to fetch the row, which is usually the coldest, most random read of all.

So the corrected claim: on a warm, frequently-queried table, a lookup typically costs 0–2 real disk reads, not a flat 4 — 0 if everything relevant is hot, up to 2 if both the leaf page and the heap page happen to be cold. The “3–4 disk reads” number only describes a fully cold cache (e.g. right after a restart, or a table nobody has touched recently). Don't assume either number — measure it: EXPLAIN (ANALYZE, BUFFERS) in Postgres reports shared hit (buffer-pool hit, free) vs shared read (an actual I/O) per node.

Diagram showing root, L2 and L1 levels of a B+tree (864 pages, 6.75 MiB) pinned in the buffer pool with zero I/O, versus the leaf level (293,256 pages, 2.24 GiB) and heap fetch which are the real disk-read candidates
Diagram showing root, L2 and L1 levels of a B+tree (864 pages, 6.75 MiB) pinned in the buffer pool with zero I/O, versus the leaf level (293,256 pages, 2.24 GiB) and heap fetch which are the real disk-read candidates

3. Optimizer statistics: how the planner knows a predicate's selectivity in the first place

The Query Execution deep-dive on this guide derives the full selectivity-crossover arithmetic — the flat seq-scan cost vs. the linearly-growing index-scan cost, and the point where they cross. That page assumes the planner already has a selectivity number to plug into the formula. This section covers where that number actually comes from, because it's the part that breaks silently: the statistics.

ANALYZE (run automatically by autovacuum/autoanalyze, or by hand) samples the table and writes per-column entries into pg_statistic (exposed readably via pg_stats):

The planner picks between two very different quality estimates depending on where the query's literal value falls:

Stale statistics is the failure mode that actually bites in production: after a large bulk load, bulk delete, or a sudden shift in value distribution, the MCV list and histogram still reflect the old data until the next ANALYZE runs. The planner then estimates selectivity from a distribution that no longer exists — which is exactly the "cardinality error propagates up the tree" failure mode the Query Execution page describes: one wrong leaf-level estimate feeds the cost formula of every join and sort above it, and can flip the chosen plan several levels up from where the real problem is.

4. Composite indexes: leftmost prefix, and the skip-scan exception

A composite index on (a, b) is physically sorted by a first, and only sorted by b within each distinct value of a. That's the mechanism behind the “leftmost prefix” rule: a predicate on a alone, or on (a, b) together, can seek directly, because a's ordering is global across the whole index. A predicate on b alone cannot — b's values are scattered inside every one of a's partitions, so there's no single contiguous range of the index to seek to. The textbook conclusion: (a, b) cannot serve WHERE b = ?.

That conclusion is the absolute reading, and it has a real, engine-supported exception: index skip scan (MySQL 8.0+, Oracle's “index skip scan”, and, natively, PostgreSQL 18+). The mechanism defeats the leftmost-prefix rule specifically when a has low cardinality:

  1. The engine first finds the distinct values of a — cheap, because a is the leading, sorted column, so this is effectively a loose/distinct index scan, not a full pass over every row.
  2. For each distinct value of a, it runs a normal, cheap range probe for b = ? within that a-partition — which is sargable again, because inside a fixed a, b really is locally sorted.
  3. It unions the results across all of a's distinct values.

Traced example

Table orders, 10,000,000 rows, index on (region, order_date). region has 5 distinct values. Query: WHERE order_date = '2026-07-01' — no predicate on region at all.

5 probes vs. touching all 10,000,000 rows is the entire value proposition, and it only exists because distinct(region) = 5 is tiny. The cost model makes this explicit and bounded: skip-scan cost ≈ distinct(a) × probe cost. If region instead had, say, 500,000 distinct values, skip scan would issue 500,000 probes — almost certainly worse than a full scan — so the planner treats skip scan as one candidate plan among several and costs it like any other; it is never applied blindly. Note also that this is engine-specific: PostgreSQL 18 (GA September 2025) added native B-tree skip scan, automatically chosen by the planner when it's cost-effective — on PostgreSQL 17 and earlier, the closest workaround is a hand-written recursive-CTE “loose index scan” over the distinct leading values.

Diagram of index skip scan: composite index (region, order_date) split into 5 region partitions, each probed independently for order_date=07-01, instead of scanning all 10 million entries
Diagram of index skip scan: composite index (region, order_date) split into 5 region partitions, each probed independently for order_date=07-01, instead of scanning all 10 million entries

5. Insertion order and fragmentation: the index-specific angle

The Relational Model deep-dive on this guide already covers the UUIDv4-vs-monotonic primary-key trade-off from the key-design angle (natural vs. surrogate keys, InnoDB clustering). The index-specific mechanism underneath that recommendation is where a random key forces a page split. A B+tree keeps every leaf at the same depth by splitting a leaf in two the moment an insert overflows it, then pushing the new middle key up to the parent (which may split too). That's cheap and rare when inserts land at the rightmost leaf, because a leaf only needs to split when it's actually full — and an ever-increasing key (auto-increment, UUIDv7, ULID) always appends past the current maximum, so exactly one leaf (the rightmost one) ever fills up and splits, in order, one at a time.

A random key like UUIDv4 destroys that locality: each insert's key hashes to an essentially random position across the entire existing keyspace, so it can overflow any leaf, not just the rightmost one. At scale this means splits happen constantly, scattered across the whole tree, leaves end up roughly half-full on average (freshly split pages start half-empty), and pages that are logically adjacent by key order are physically scattered across disk — the opposite of the sequential-append pattern a storage engine is optimized for. The net effect: more pages for the same data (bloat), worse buffer-pool hit rate (adjacent keys aren't adjacent on disk, so a range scan or a warm working set needs more distinct pages resident to get the same hit rate), and more write amplification from constant splitting.

6. NULL, OR, NOT IN — three ways a predicate quietly breaks index use (or correctness)

Pitfalls a working engineer actually hits

Judgment layer: how a senior engineer decides

Takeaways

Related pages


Re-authored/Deepened for this guide.

🤖 Don't fully get this? Learn it with Claude

Stuck on Indexing & Storage — Fanout Arithmetic, Optimizer Stats, Composite/Skip Scans & NULL/OR Pitfalls (Deep Dive)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Indexing & Storage — Fanout Arithmetic, Optimizer Stats, Composite/Skip Scans & NULL/OR Pitfalls (Deep Dive)** (Databases) and want to truly understand it. Explain Indexing & Storage — Fanout Arithmetic, Optimizer Stats, Composite/Skip Scans & NULL/OR Pitfalls (Deep Dive) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Indexing & Storage — Fanout Arithmetic, Optimizer Stats, Composite/Skip Scans & NULL/OR Pitfalls (Deep Dive)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Indexing & Storage — Fanout Arithmetic, Optimizer Stats, Composite/Skip Scans & NULL/OR Pitfalls (Deep Dive)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Indexing & Storage — Fanout Arithmetic, Optimizer Stats, Composite/Skip Scans & NULL/OR Pitfalls (Deep Dive)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes