Module A-5·49 min read

An index is not a free performance boost. Every index has a write cost, a bloat trajectory, and a planner interaction that can go wrong.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module 5 — Indexes: B-Tree Internals, GIN, GiST, and When Each One Hurts You

What this module covers: An index is not a free performance boost. Every index has a write cost that compounds with every INSERT, UPDATE, and DELETE. Every index bloats over time. Every index has a specific structure that makes it fast for some query patterns and useless — or actively harmful — for others. This module covers the internal mechanics of every major Postgres index type, how the planner decides whether to use them, and the discipline of indexing for write-heavy production systems.


The Fundamental Trade-off

Before any index mechanics: the decision to add an index is always a trade-off between read speed and write cost.

Every index on a table is an additional data structure that must be kept consistent with the heap. When you insert a row, Postgres inserts into the heap and into every index. When you update a row, Postgres updates every index whose columns changed. When you delete a row, Postgres marks the row dead in the heap and in every index.

On a table with 8 indexes:

  • An INSERT writes to 9 locations (1 heap + 8 indexes)
  • An UPDATE that changes 3 indexed columns writes to 7 locations (1 heap old + 1 heap new + 5 unchanged index entries + 2 old index deletes + 2 new index inserts — and WAL for all of it)
  • A DELETE marks dead in 9 locations

This is before considering the WAL generated for each operation. With full page writes after each checkpoint (Module 3), each of those 9 page modifications can generate 8KB of WAL on first write post-checkpoint.

The discipline of indexing: add indexes for queries that are hot enough to justify the write cost on every insert/update/delete. Remove indexes that are not being used. Be precise about which columns need indexing and in which order.


B-Tree Index Internals

The B-tree is Postgres's default index type. CREATE INDEX without specifying a type creates a B-tree. Understanding it at the page level makes every other B-tree behavior — splits, bloat, planner decisions, ordering — obvious.

The B-Tree Structure

A B-tree index is a balanced tree of fixed-size 8KB pages. There are three kinds of pages:

Meta page (page 0): Contains the root page pointer and fast-root pointer. The root is where all searches start.

Internal pages (branch nodes): Contain key-pointer pairs. Each entry holds an index key value and a pointer to the child page where values ≤ that key live. Internal pages do not contain heap TIDs — they are navigation only.

Leaf pages: Contain index entries, each being a key value + heap TID (page number + offset). Leaf pages are linked in a doubly-linked list in sorted order — this is what makes range scans efficient. You find the first matching leaf entry, then follow the right-sibling pointer without traversing the tree again.

sql

B-Tree Searches

A point lookup (e.g., WHERE block_height = 18500050) traverses from root to leaf:

  1. Read root page → find the internal entry where 18500050 falls → get child pointer
  2. Read internal page → find the next child pointer
  3. Read leaf page → find entries with block_height = 18500050, extract TIDs
  4. For each TID: heap fetch (check visibility, return row)

For a 3-level tree with 100M rows, this is 3 page reads + 1 heap read = 4 random I/Os per row. With shared_buffers warmed up, those pages are in cache and the lookup is pure CPU.

Range Scans and the Leaf Chain

For WHERE block_height BETWEEN 18500000 AND 18500100:

  1. Tree traversal to find the first leaf entry ≥ 18500000
  2. Scan right along the leaf chain, collecting TIDs
  3. Sort TIDs (or not, depending on the access pattern)
  4. Fetch each heap page

The leaf chain scan is sequential — efficient. The heap fetches are random — potentially expensive if the matching rows are spread across many heap pages. This is where correlation matters (covered below).

Page Splits: The Source of B-Tree Bloat

When a leaf page fills up, Postgres splits it: the existing entries are divided between the current page and a new page, and a pointer to the new page is inserted into the parent. If the parent is also full, it splits too — cascading upward.

The right-growth optimization: When inserts are strictly sequential (e.g., a BIGSERIAL primary key), Postgres detects this and always splits to the right — new pages are appended rather than split. This produces densely-packed pages with no wasted space. Sequential primary keys are cheaper to maintain in indexes than random ones.

Random inserts cause 50% fill factor after splits. By default, Postgres splits a full page roughly in half. After the split, both pages are ~50% full. On a random-insert workload, pages frequently hover between 50% and 100% full — wasting up to 50% of index storage.

Setting a lower fillfactor leaves room for updates without splits:

sql

For indexes on frequently-updated columns, a lower fillfactor reduces page splits at the cost of larger index size. For append-only tables, use the default (90) or even higher.

HOT Updates and Index Overhead

Heap Only Tuple (HOT) updates are a critical optimization. When you update a row and the updated columns are NOT indexed, Postgres can avoid updating any indexes:

  1. Old tuple is marked dead in the heap
  2. New tuple is written to the same heap page (if space allows)
  3. The old tuple's ctid pointer chains to the new tuple
  4. No index entries are modified

HOT updates generate significantly less WAL and avoid index bloat. They only work when:

  • The updated columns are not part of any index
  • The new tuple fits on the same heap page as the old tuple
sql

If hot_pct is low on a high-update table, audit which columns are being updated versus which are indexed. Sometimes removing an index on a column that is updated frequently (but not queried with it) dramatically improves write throughput.

Index Correlation

Correlation measures how well the physical order of rows in the heap matches the logical order of values in an index. It ranges from -1 (perfectly reversed) to +1 (perfectly aligned).

sql

When correlation is low, an index scan fetches rows from scattered heap pages — potentially one random I/O per row. For large result sets, the planner may choose a sequential scan instead, because reading heap pages sequentially is more efficient than random fetches.

This is why CLUSTER (which rewrites the table in index order) can dramatically speed up range queries on low-correlation columns — but it's a full table rewrite and acquires an exclusive lock.


Multi-Column Indexes

A multi-column (composite) index on (a, b) stores entries sorted by a first, then b within each a value.

sql

This index efficiently supports:

  • WHERE block_height = X — uses the index (leftmost column)
  • WHERE block_height = X AND status = Y — uses both columns
  • WHERE block_height BETWEEN X AND Y — range on leading column
  • ORDER BY block_height, status — sorted output

It does not efficiently support:

  • WHERE status = Y — cannot use the index without the leading column
  • ORDER BY status — not sorted by trailing column alone

The leading column rule: The index is only usable when the query predicates include the leftmost column(s). A query on status alone cannot use idx_transactions_height_status.

Column order matters for range queries: Put equality predicates first, range predicates last.

sql

Partial Indexes

A partial index indexes only the rows that match a WHERE condition. This is one of the most underused features in Postgres.

sql

Partial indexes:

  • Are smaller (fewer entries → fewer pages → more cache hits)
  • Have lower write cost (only insert/delete when the condition is met)
  • Can be more selective (same selectivity over a smaller row set)

The pattern for status-filtered tables: If 95% of rows are in a terminal state (confirmed, failed) and 5% are active (pending), a partial index on active rows is 20x smaller than a full index and much more cache-friendly.

sql

Expression Indexes

An expression index indexes the result of a function or expression, not a raw column value.

sql
sql

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.