B-Tree Indexes: Structure and When They Win
A B-tree is the default and most versatile PostgreSQL index, implemented in src/backend/access/nbtree/. It supports equality and range operators (=, <, <=, >, >=, BETWEEN, IN), ORDER BY acceleration, and uniqueness enforcement — all from one balanced, ordered structure.
The one thing to understand first
A B-tree is the default and most versatile PostgreSQL index, implemented in src/backend/access/nbtree/. It supports equality and range operators (=, <, <=, >, >=, BETWEEN, IN), ORDER BY acceleration, and uniqueness enforcement — all from one balanced, ordered structure.
A B-tree wins because it keeps your keys sorted — and almost everything useful (selective equality, ranges, ORDER BY, uniqueness) is just a consequence of sorted order. Understand that, plus the leftmost-prefix rule for composite indexes, and most "why isn't my index used" questions answer themselves.
Structure: a balanced ordered tree
PostgreSQL implements a Lehman-Yao B-tree variant designed for high concurrency. The tree has:
- Internal pages — hold separator keys and downlinks routing a search toward the right leaf.
- Leaf pages — hold the indexed keys in sorted order, each with a TID pointing to the heap tuple. Leaves are linked left-to-right (and right-to-left) so a range scan can walk siblings without returning to the root.
Because the tree is balanced, any lookup costs O(log n) page reads — typically 3–4 page accesses even for huge tables.
High concurrency by design
The Lehman-Yao technique adds a high key and a right-link to every page. A descending search never needs to hold a lock on a parent while locking a child; if a concurrent split moved the target, the searcher follows the right-link to find it. This lets many sessions read and modify the same index with minimal blocking — central to PostgreSQL's write concurrency.
When a B-tree wins
- High selectivity equality —
WHERE email = ?returning few rows. - Range scans —
WHERE created_at >= ?reads a contiguous leaf range. - ORDER BY / LIMIT — the index already provides sorted output, enabling cheap top-N without a sort.
- Uniqueness — primary keys and unique constraints are enforced by a unique B-tree.
Column order in composite indexes
For an index on (a, b, c), the leftmost-prefix rule applies: it can satisfy predicates on a, a,b, or a,b,c, and an equality on a combined with a range on b. It cannot efficiently serve a query filtering only on b. Order columns by: equality predicates first, then the range/sort column last.
Index-only scans
If a query's needed columns are all in the index, PostgreSQL can answer from the index alone — but it must still confirm visibility. It checks the visibility map: if the heap page is marked all-visible, it skips the heap fetch. This is why a freshly bulk-loaded table benefits from VACUUM before index-only scans pay off, and why covering indexes use INCLUDE:
-- Covering index: key on (customer_id), payload total_amount
CREATE INDEX idx_orders_cust ON orders (customer_id) INCLUDE (total_amount);
-- Now SELECT total_amount WHERE customer_id = ? can be index-only.Layer 3 — Watch it happen on your own database
-- Depth and page counts of a real B-tree
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT btpo_level, type, live_items, dead_items
FROM bt_page_stats('idx_orders_cust', 1);
SELECT * FROM bt_metap('idx_orders_cust'); -- root, level (tree height)
-- Prove an index-only scan needs the visibility map
EXPLAIN (ANALYZE, BUFFERS)
SELECT total_amount FROM orders WHERE customer_id = 42; -- Index Only Scan
VACUUM orders; -- sets all-visible bits; Heap Fetches drops toward 0bt_metap shows the tree height (usually 3–4 even for huge tables — that is the O(log n) promise made concrete). The second block demonstrates why index-only scans need a recent VACUUM: before the visibility map is set you will see non-zero Heap Fetches in the plan; after vacuum they fall away.
Layer 4 — The levers this hands you
- Order composite columns deliberately: equality predicates first, then the single range/sort column last, so the leftmost-prefix rule works for you.
- Use covering indexes with
INCLUDEto make hot queries index-only without bloating the key. - Build without blocking using
CREATE INDEX CONCURRENTLYon busy tables. - Drop dead weight. Every write maintains every applicable index; find unused ones via
pg_stat_user_indexes.idx_scan = 0and remove them. - Let deduplication help on low-cardinality indexes — modern PostgreSQL compresses duplicate keys in leaves automatically.
Layer 5 — What an Oracle DBA should expect vs what they get
B-trees are universal, but the surrounding rules differ:
- Index-only scans depend on the visibility map. Oracle index-only access just needs the columns in the index. In Postgres, because the index entry does not record visibility, an index-only scan also consults the all-visible bit — so vacuum freshness directly affects whether it stays "index only."
- No bitmap indexes, no IOTs. Oracle bitmap indexes and Index-Organized Tables have no direct Postgres equivalent; for low-cardinality you rely on B-tree deduplication or BRIN, and there is no clustered-by-PK storage (use
CLUSTERas a one-off reorg). - **
INCLUDEcolumns ≈ Oracle covering via composite key,** but Postgres keeps the payload out of the ordered key, so uniqueness and tree size stay based on the key columns only. - Concurrency by right-links (Lehman-Yao), not by the specific latch/ITL mechanics you know; the net effect (readers rarely block on splits) is similar but the failure modes and tuning knobs are different.
Key takeaway
A PostgreSQL B-tree is a balanced, ordered, Lehman-Yao tree: O(log n) lookups, linked leaves for range scans, and high concurrency via high keys and right-links. It wins on selective equality, ranges, ordered output, and uniqueness. Design composite indexes around the leftmost-prefix rule, use INCLUDE for index-only coverage, keep vacuum current so index-only scans stay cheap, and prune indexes that never serve a scan.
Interview lens
How this shows up in interviews
What an interviewer is actually testing, the same mechanism seen from three roles, and the gap between a shallow answer and one that shows real depth.
What the interviewer is testing
Whether you understand what a B-tree provides — one ordered, balanced structure serving equality, range, ORDER BY, and uniqueness — and its limits.
Questions you should be able to answer
- ▸What operations can a B-tree accelerate, and why can one structure do all of them?
- ▸How does a B-tree enforce uniqueness?
- ▸When is a B-tree the wrong index?
Database engineer
You order composite index columns equality-first then range, and you know index-only scans need the visibility map plus covering columns (INCLUDE).
Software engineer
You match indexes to query shape via the leftmost-prefix rule instead of indexing every column.
Feature builder
You add the one composite/covering index a new query needs, not a pile of single-column indexes.
Shallow answer
'A B-tree is a sorted tree for fast lookups.' No account of range/ORDER BY/uniqueness or its limits.
Answer that shows depth
PostgreSQL's default index is a balanced, ordered B-tree (nbtree/) whose ordering lets one structure serve equality, range operators, ORDER BY (walk in key order), and uniqueness (probe before insert). Composite indexes obey the leftmost-prefix rule — equality columns first, then a single range column. Index-only scans skip heap fetches when the visibility map marks pages all-visible and the index covers the needed columns (optionally via INCLUDE). B-trees don't help containment, overlap, or full-text — those need GIN/GiST — and very large low-selectivity scans may prefer BRIN or a seq scan.
Pro lab notebook
The captured run behind every query
Every SQL query above is real and the source citations are verified. Pro unlocks the captured psql output — each query re-run against a real PostgreSQL 17.10 lab, with its verbatim result.
1 real lab transcript for this lesson
The explanation and the SQL above are free — so are the verified source citations. What Pro unlocks is the proof: every query in B-Tree Indexes: Structure and When They Win re-run against a real PostgreSQL 17.10 instance, with its verbatim psql output captured line for line.
What Pro unlocks here
- Every query in this lesson re-run against a real PostgreSQL 17.10 lab
- The verbatim psql output — page headers, row versions, plan nodes, catalog rows
- The corrected form of any query the source lesson got wrong, re-captured
- A copy-ready notebook you can replay yourself to confirm every claim
How this was verified
This explanation is anchored to PostgreSQL's source tree — the files linked above, on the REL_17_STABLE branch — and cross-checked against the official documentation for versions 15 through 18. Every SQL query shown was executed and its output captured in a throwaway PostgreSQL 17.10 lab; where a query needed correcting to run, that correction is noted inline.
Official documentation: Ch. 67 B-Tree Indexes