Topic 38

B-tree — The Default, and What It Costs

Index Basics

A B-tree index is a balanced tree of 8 KB pages. Its leaves hold the indexed values in sorted order, each paired with the address of a physical row version; the levels above hold separator keys that say which child to descend into. That one structure answers equality, ranges, IN lists, IS NULL, sorted output without a sort, and MIN/MAX — and it has to be maintained by every write that touches the indexed column, which is the half of the deal that never makes it onto the ticket saying "add an index".

Cartwheel's orders table carries nine of them. Three have not been scanned once since statistics were last reset. Every insert on Saturday morning, at 3,000 orders a minute, writes ten things: the row itself and one entry in each of the nine indexes, all of it logged and all of it shipped to pg-replica-a. This topic is what the tree buys and what those ten writes cost, because an indexing decision made from only the first half is a guess.

The Shape of the Tree

Every lookup starts at the root page and descends: read the root, pick the child whose key range contains the value, read that page, repeat until the leaf level. Depth grows logarithmically because an internal page on a bigint key holds several hundred separators, so each additional level multiplies the number of addressable rows by that fan-out. A 40-million-row index on orders.id is three or four levels deep. The root and the level below it are read on every single lookup, so they live permanently in shared_buffers; the marginal cost of a point lookup is one leaf page read plus one heap page read.

So "will the index still be fast when the table is ten times bigger" has an unexciting answer: ten times the rows buys at most one more level, and that level is probably cached. Leaf pages also carry sibling links, so a range scan descends once and then walks sideways along the leaf level instead of returning to the root for every value. BETWEEN over a narrow range therefore costs barely more than a single lookup.

One point lookup, from the root to the row version itself
Root page
Read first, on every lookup
pick the child whose key range contains the value
Internal levels
Several hundred separator keys per page
descend again — 40 million rows is three or four levels
Leaf page
The indexed values in sorted order
each paired with the address of a physical row version
Heap page
The row version that address points at
one leaf read plus one heap read — the whole marginal cost

What It Can Answer

The access method is defined by five operators, <, <=, =, >= and >, and everything else it serves is built out of them. BETWEEN and IN are combinations. IS NULL and IS NOT NULL work because nulls are stored in the tree at a defined end. Sorted output falls out of the leaf order, so ORDER BY placed_at DESC LIMIT 20 can be answered by walking the leaf level backwards with no sort node at all, and max(placed_at) is a one-row scan of one end.

One index on orders.placed_at, and the queries it does and does not serve
CREATE INDEX orders_placed_at_idx ON orders (placed_at);

-- served: range, ordering, and the extreme value
SELECT * FROM orders WHERE placed_at >= '2026-08-01'
                       AND placed_at <  '2026-09-01';
SELECT * FROM orders ORDER BY placed_at DESC LIMIT 20;
SELECT max(placed_at) FROM orders;

-- not served: the column is wrapped in a function call
SELECT * FROM orders
 WHERE date_trunc('month', placed_at) = '2026-08-01';

The three that work all compare the indexed column itself against a constant. The fourth compares a computed value, and the index stores no such value, so the only way to evaluate it is to compute date_trunc for all 40 million rows. Rewriting it as a range on the bare column fixes it for free; indexing the expression instead is the subject of a later topic, and it is a real answer rather than a workaround.

Pattern matching is the one place where the default operator class is not enough. A B-tree serves LIKE or ~ when the pattern is a constant anchored at the start, so sku LIKE 'CW-88%' qualifies and sku LIKE '%88' never can, but outside the C locale that needs an index built with text_pattern_ops, which compares character by character rather than by collation rules. The trade is fixed: such an index serves pattern matching and equality and cannot serve ordinary < or > comparisons. A column needing both gets two indexes, and a leading wildcard needs a different access method entirely.

The Write Cost

For a table four years in production, nine indexes is not an unusual number, and no single one of them was a bad idea on the day it was added.

The index list on orders, as psql prints it under \d orders
Indexes:
    "orders_pkey" PRIMARY KEY, btree (id)
    "orders_public_id_key" UNIQUE, btree (public_id)
    "orders_customer_id_idx" btree (customer_id)
    "orders_customer_id_placed_at_idx" btree (customer_id, placed_at DESC)
    "orders_placed_at_idx" btree (placed_at)
    "orders_status_idx" btree (status)
    "orders_status_placed_at_idx" btree (status, placed_at)
    "orders_customer_id_status_idx" btree (customer_id, status)
    "orders_total_idx" btree (total)

Read that list as a write path, not as a menu. Inserting one order means locating the correct leaf page in nine separate trees and adding an entry to each, occasionally splitting a page in the process, and writing every one of those changes to the WAL that pg-replica-a has to replay. An update that changes an indexed column has to insert a new entry in every index, because the index points at a physical row version and the update produced a new one — the mechanism Chapter 7 examines from the vacuum side, where those superseded entries become the cleanup work. Deletes and superseded versions leave entries behind that only vacuum removes, so the nine indexes also set how long a vacuum of orders takes.

The same list read as a write path: one order, ten writes
INSERT one order3,000 a minute on a Saturday morning
Locate the leaf in nine treesone descent per index
Add an entry to eachoccasionally splitting a page in the process
Ten writes, all loggedthe row plus nine index entries
Replayed on pg-replica-athe WAL carries every one of them

Selectivity, and When the Index Loses

An index scan pays a random page read per matching row plus a visit to the heap page holding that row; a sequential scan reads the table in physical order, which storage and the kernel both reward. Postgres prices random reads well above sequential ones, so the crossover arrives early: past roughly 5 to 10 percent of the table matching, reading everything in order is genuinely cheaper than doing hundreds of thousands of scattered lookups. "The planner is ignoring my index" is usually a report of the planner being right about a query that returns 30 percent of orders. It is occasionally a bad row estimate instead, and Chapter 9 is where you learn to tell those two apart from the plan rather than by argument.

Size, and the Bloat With a Schema Cause

Budget roughly 16 bytes per row for the entry itself on a bigint or timestamptz key, and about twice that once per-page overhead, the internal levels and the free space a B-tree carries at its default 90% fill are counted in. On orders that is under a gigabyte per index and nobody notices. On delivery_events, at 1.2 billion rows, the same arithmetic produces the 40 GB B-tree on occurred_at that this chapter later replaces with something three orders of magnitude smaller. There is also a hard ceiling on a single entry: an index tuple cannot exceed approximately one third of a page, about 2.7 KB, and exceeding it fails the INSERT at run time on whichever row happens to be long — not at CREATE INDEX time, and not in testing.

Insertion order decides how much of that space is wasted. Keys that arrive in ascending order pack the rightmost leaf and move on, and B-tree builds fill leaf pages to a fillfactor of 90 by default. Keys that arrive in random order land in the middle of existing pages, split them into two roughly half-full pages, and leave them that way, producing an index twice the size it needs to be with a schema decision as its cause. Fixing one that has already bloated is a rebuild, and doing it without blocking writes is the last topic of this chapter. That is exactly why Cartwheel's orders.public_id is a v7 UUID and not a v4: v7 values sort by time, so the index appends instead of scattering.

Insertion order decides how much of the index is wasted space
Ascending keys — a v7 UUID
Values sort by time, so each new key packs the rightmost leaf and moves on. The build fills leaf pages to a fillfactor of 90, and the index is the size it needs to be.
Random keys — a v4 UUID
Values land in the middle of existing pages, split them into two roughly half-full pages, and leave them that way. The index is twice the size it needs to be, with a schema decision as its cause.

Deduplication and Skip Scan

Since 13, B-trees deduplicate. Groups of entries with identical keys are merged into a single posting list tuple: the key value is stored once, followed by a sorted array of row addresses. On orders_status_idx, where 40 million rows carry perhaps six distinct statuses, that is the difference between an unusable index and a compact one. It is on by default and controlled per index by the deduplicate_items storage parameter. It does not apply everywhere: numeric and jsonb are excluded because display scale must be preserved, float4 and float8 because -0 and 0 must stay distinguishable, along with nondeterministic collations, container types such as arrays, and any index carrying an INCLUDE column.

Since 18 there is a skip scan. A multicolumn B-tree can now be used when an early column has no equality constraint from the query, by generating a matching-every-value constraint internally for that column and combining it with a real constraint on a later one. The planner takes it only when there are so few distinct values in the unconstrained column that most leaf pages can be skipped outright. With many distinct values the whole index would have to be read, and a sequential scan wins.

Both features mean that indexing advice written before 13 is now too pessimistic: a low-cardinality column is no longer automatically a bad index, and a composite is no longer automatically dead weight for queries that skip its leading column. The write path in this topic did not get any cheaper, so neither feature is a reason to add indexes more freely.

Index scan vs bitmap scan vs sequential scan

Index scan — walks the tree and visits heap rows one at a time, in index order, interleaving index reads with heap reads. Right for a handful of rows, and the only form that delivers rows already sorted.

Bitmap scan — collects all matching row addresses first, sorts them by physical page, then reads the heap in page order so no page is visited twice. Right for hundreds or thousands of rows, and it is the mechanism that lets two separate indexes be combined for one query.

Sequential scan — reads every page in physical order and filters. Right whenever a large fraction of the table matches, because sequential I/O beats scattered I/O by a wider margin than the row counts suggest.

Common Mistakes
  • Adding one index per WHERE clause seen in a slow-query report — every insert into orders then writes ten entries, every superseded row version leaves nine to clean up, and the cost is paid on every write forever while the reads it helps happen twice a day.
  • Expecting an index to make any query fast — a report that returns 30 percent of orders will sequential-scan, and should.
  • Indexing a column that is written constantly and searched rarely, such as a running counter — the update path pays on every write for a lookup nobody issues.
  • Building a B-tree on a concatenated text key that can exceed roughly 2.7 KB — the failure is an INSERT error in production on the one row that happens to be long, not an error when the index is created.
  • Choosing a v4 UUID as the key of a large table — the random insertion order splits leaf pages in the middle and leaves them half full, so the index is permanently twice the size it needs to be.
  • Keeping three never-scanned indexes on a hot table because "they might help" — they cost write throughput, WAL volume, replay on the replica and vacuum time, all measurably.
Best Practices
  • Justify every index with the query it serves and the fraction of the table that query returns, and put that justification in the migration next to the CREATE INDEX.
  • Prefer a few well-ordered composite indexes to many single-column ones, since a composite already serves every prefix of itself.
  • Compare an index against the columns it must be kept in step with: count the writes per second on that column before counting the reads.
  • Watch idx_scan in pg_stat_user_indexes and treat a long-zero index as a removal candidate rather than as insurance.
  • Use text_pattern_ops when a non-C-locale column must serve anchored LIKE queries, and keep a separate default-opclass index if the same column is also sorted or range-scanned.
  • Accept sequential scans for low-selectivity queries instead of forcing an index onto them.
Comparable toolsInnoDB clustered B+-trees where the primary key is the tableOracle B-tree indexes and index-organized tablesSQL Server one clustered index plus nonclustered onesSQLite B-tree only, with rowid tables as the default

Knowledge Check

What does a B-tree leaf entry actually store?

  • The key value plus the address of a physical row version
  • The key value plus the table's primary key for that row
  • The key value plus a full copy of the row it points at
  • A hash of the key value plus the page number it lives on

Cartwheel's orders table has nine indexes. What does that cost on a single insert?

  • Ten writes, the row itself plus one entry in each one of the nine indexes
  • One write, with the nine index updates deferred to the next vacuum
  • Two writes, since only the primary key and unique indexes are maintained
  • Nine writes, because the heap row is only written at commit time

A non-C-locale text column has to serve both anchored LIKE patterns and ordinary range comparisons. What does that take?

  • Two indexes: one with text_pattern_ops and one with the default class
  • One text_pattern_ops index, which serves patterns and ranges alike
  • One default index, since an anchored LIKE works with any operator class
  • One index plus a rewrite of every range predicate as a LIKE pattern

Why does an index on a v4 UUID key end up roughly twice the size of one on a time-ordered key?

  • A v4 UUID is stored as a wider value than a time-ordered one
  • Random keys split leaf pages in the middle and leave them half full
  • Deduplication cannot compress v4 values but compresses v7 ones well
  • Each v4 UUID needs two index entries to preserve its sort position

When will the planner actually use 18's skip scan on a multicolumn index whose leading column is unconstrained?

  • Whenever the index was declared with the skip scan storage parameter
  • When the leading column has few enough distinct values to skip most leaf pages
  • Whenever the query constrains none of the index's columns at all
  • Whenever the leading column holds a large number of distinct values

You got correct