Storage
B-tree indexes
A B-tree index is a sorted tree of 8 kB pages. A lookup walks from the root through internal pages to a leaf, and the leaf entry points at the row in the table. Inserts may split pages; deduplication and bottom-up deletion help avoid splits.
In plain words
A B-tree index works like the index of a big book, organised in levels. The top page (the root) says "keys below 50 are on the left, the rest on the right". The next level narrows it down further, until you reach a leaf page that lists actual values together with the address of the row in the table.
Because every step cuts the search space, even a table with a billion rows needs only 4 or 5 page reads to find a value. Leaves are also linked to their neighbours, so a range like BETWEEN 30 AND 60 just walks sideways.
When a leaf is full and a new value must go into it, the page splits into two half-full pages, and the parent gets a pointer to the new one. PostgreSQL tries hard to avoid splits: it merges duplicate values into one entry (deduplication) and removes entries for row versions nobody can see anymore (bottom-up deletion).
Pick a scenario and, for the search, a key to look for.
For DBAs and operators
Structure
Block 0 is the metapage; it records the root block and the tree level. Every non-rightmost page has a high key (upper bound) and a right-link to its sibling. Internal pages hold separator keys and downlinks; leaf pages hold index tuples with heap TIDs (block, line pointer). This is the Lehman & Yao design: a reader that arrives at a page that was just split simply follows the right-link, so searches never need to lock the whole path.
CREATE EXTENSION pageinspect;
SELECT root, level, allequalimage FROM bt_metap('my_idx');
SELECT itemoffset, ctid, itemlen, htid, cardinality(tids) AS n_tids
FROM bt_page_items('my_idx', 1) LIMIT 5;Page splits and fillfactor
When a leaf has no room, it first tries to free space: delete entries already marked LP_DEAD by earlier index scans (simple deletion), then bottom-up deletion (only for inserts flagged "index unchanged", see below, or unique-index inserts that met a duplicate), then deduplication. Only then does it split. A normal split moves about half the data to a new page; a split of the rightmost leaf (ever-increasing keys like sequences or timestamps) leaves the left page fillfactor full (90 by default for B-trees) so sequential inserts produce dense pages. CREATE INDEX and REINDEX also fill leaves to fillfactor. Lower it (WITH (fillfactor = 70)) only for indexes with many random inserts into existing ranges.
Deduplication (PostgreSQL 13+)
Equal keys are merged into one posting list tuple: the key stored once, followed by a sorted array of TIDs. On inserts it happens lazily, only when a leaf would otherwise split; CREATE INDEX and REINDEX deduplicate while building. It is controlled by the deduplicate_items storage parameter (on by default). It is not used for numeric, jsonb, float4/float8, container types (arrays, composites, ranges), nondeterministic collations or indexes with INCLUDE columns; bt_metap().allequalimage tells whether it is possible. Indexes created before 13 need a REINDEX after pg_upgrade to use it.
Bottom-up deletion (PostgreSQL 14+)
An UPDATE that cannot be HOT (see HOT updates) inserts a new entry into every index, even ones whose key did not change. The executor passes an "index unchanged" hint for those. When such an insert hits a full leaf, nbtree visits the heap pages behind the duplicate entries and deletes the entries for versions that are dead to everyone, before considering a split. This stops "version churn" bloat between VACUUM runs.
Watching bloat and usage
SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch,
pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes ORDER BY pg_relation_size(indexrelid) DESC;
CREATE EXTENSION pgstattuple;
SELECT avg_leaf_density, leaf_fragmentation FROM pgstatindex('my_idx');Index pages are only reused after VACUUM marks them deleted and they become safe to recycle, so an index rarely shrinks by itself. REINDEX INDEX CONCURRENTLY rebuilds a bloated index without blocking writes.
For developers: inside the source
Where it lives
nbtree/README is the design document (Lehman & Yao, page deletion, deduplication, bottom-up deletion); read it first. Structures are in nbtree.h.
nbtree.c:bthandler()(theIndexAmRoutine),btinsert(),btgettuple(),btbulkdelete().nbtsearch.c:_bt_search(),_bt_moveright(),_bt_binsrch(),_bt_compare(),_bt_first(),_bt_next(),_bt_readpage().nbtinsert.c,nbtsplitloc.c,nbtdedup.c: inserts, splits, deduplication and bottom-up deletion.nbtpage.c(metapage,_bt_getroot(), page deletion_bt_pagedel()),nbtsort.c(btbuild()),nbtpreprocesskeys.c(scan-key preprocessing, including 18's skip arrays),nbtxlog.c(redo).
Key structures
BTMetaPageDatain block 0:btm_root,btm_level,btm_fastroot,btm_allequalimage.BTPageOpaqueDatain each page's special space:btpo_prev,btpo_next,btpo_level,btpo_flags(P_ISLEAF(),P_RIGHTMOST(),P_INCOMPLETE_SPLIT()…) andbtpo_cycleid. On non-rightmost pages offsetP_HIKEY(1) is the high key.- Index tuples are
IndexTupleData; pivot tuples may have truncated suffixes, posting-list tuples are recognised withBTreeTupleIsPosting()and read withBTreeTupleGetHeapTID(). BTScanInsertData(from_bt_mkscankey()) is the insertion scan key;BTStackDataremembers the descent path for_bt_insert_parent().
Insert call flow
btinsert() → _bt_doinsert() → _bt_search_insert() (tries the cached rightmost leaf from RelationGetTargetBlock() with a conditional lock, else _bt_search()) → _bt_check_unique() for unique indexes → _bt_findinsertloc(). On a full leaf, _bt_delete_or_dedup_one_page() runs _bt_simpledel_pass() for LP_DEAD items, then _bt_bottomupdel_pass() if the executor passed indexUnchanged or a unique index saw a duplicate, then _bt_dedup_pass() if deduplicate_items and allequalimage. Then _bt_insertonpg(), which may call _bt_split() (split point from _bt_findsplitloc()) and _bt_insert_parent(). A split interrupted by a crash leaves BTP_INCOMPLETE_SPLIT, completed later by _bt_finish_split().
Concurrency
Only buffer content locks, normally one page at a time. A descent that lands on a page split after it read the parent follows btpo_next in _bt_moveright() while the key is above the high key, so no lock coupling is needed. Index scans that find dead heap tuples set LP_DEAD hints via _bt_killitems().
Observe and experiment
- pageinspect:
bt_metap(),bt_page_stats(),bt_multi_page_stats(),bt_page_items(). - amcheck:
bt_index_check()andbt_index_parent_check()(optionsheapallindexed,rootdescend,checkunique); TAP tests incontrib/amcheck/t/. - In 18,
EXPLAIN ANALYZEprintsIndex Searchesper index scan node, useful to watch skip scans and ScalarArrayOp lookups. - Regression suite:
src/test/regress/sql/btree_index.sql.
Parameters that shape this
enable_indexscan enable_indexonlyscan enable_bitmapscan random_page_cost maintenance_work_mem max_parallel_maintenance_workers
Related topics
Index types Page layout HOT updates VACUUM Scan methods Free space map and visibility map MVCC: row versions