B-Tree Deduplication and Index Size #
An index on orders(status) is 2.1 GB on one server and 640 MB on another holding the same data. Scans through the larger one read three times as many pages, and the planner prices it accordingly. Both run PostgreSQL 16. The difference: the smaller index was built after upgrading, the larger one came across with pg_upgrade from PostgreSQL 12 and still uses the old on-disk format without deduplication.
B-tree structure and access patterns are covered in B-tree index optimization. Deduplication changes how duplicate keys are stored, which changes size, scan cost and bloat behaviour.
The Index Condition #
Before PostgreSQL 13, a B-tree stored one leaf entry per heap tuple: the key plus a tuple identifier. An index on a column with five distinct values over 100 million rows held 100 million entries, each repeating one of five keys.
From PostgreSQL 13, B-tree deduplication stores runs of equal keys as a single posting list entry: the key once, followed by a compact array of tuple identifiers. Deduplication happens lazily, when a leaf page would otherwise split, so an index converges to its compact form as it fills. The effect depends on repetition:
- Low-cardinality columns (
status,country,tenant_idwith large tenants, boolean flags, foreign keys to small tables) shrink dramatically — often by a factor of two to five. - Unique or near-unique columns (
id,email, timestamps) barely change; there is little to deduplicate. Unique indexes still benefit from deduplication of multiple versions of the same key created by updates, which reduces bloat on update-heavy tables.
Deduplication is not available for some indexes:
- indexes with
INCLUDEcolumns; - keys of types whose equality is not bitwise —
numeric,float4/float8,jsonb, and text under nondeterministic collations; - container types such as arrays, composites and ranges;
- indexes created before PostgreSQL 13 and brought forward by
pg_upgrade, until they are rebuilt.
It is on by default and can be disabled per index with WITH (deduplicate_items = off).
Smaller indexes help plans directly: fewer leaf pages to read for range scans and bitmap scans, fewer levels in large trees, and a lower cost in the planner’s index scan estimate, which can move the crossover toward index access as described in sequential vs index scans.
Annotated EXPLAIN Evidence #
SELECT indexrelid::regclass, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_index WHERE indrelid = 'orders'::regclass;
indexrelid | pg_size_pretty
-----------------------------+----------------
orders_pkey | 2014 MB
orders_status_idx | 2104 MB -- 5 distinct values, yet as large as the primary key
orders_customer_id_idx | 1980 MB
Check the on-disk version with pageinspect:
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT version, allequalimage FROM bt_metap('orders_status_idx');
version | allequalimage
---------+---------------
3 | f -- version 3: pre-13 format, deduplication impossible
A bitmap scan for one status before rebuilding:
Bitmap Heap Scan on orders (actual time=412.8..3204.1 rows=4020110 loops=1)
-> Bitmap Index Scan on orders_status_idx (actual time=402.6..402.6 rows=4020110 loops=1)
Index Cond: (status = 'returned'::text)
Buffers: shared hit=2210 read=19204 -- ~21k index pages read
After REINDEX INDEX CONCURRENTLY orders_status_idx:
-> Bitmap Index Scan on orders_status_idx (actual time=98.4..98.4 rows=4020110 loops=1)
Index Cond: (status = 'returned'::text)
Buffers: shared hit=4104 -- ~4k index pages; version 4, deduplicated
-- index size: 2104 MB → 612 MB
Step-by-Step Resolution #
-
Find large indexes on repetitive keys. Compare index size with distinct counts:
SELECT i.indexrelid::regclass AS index, pg_size_pretty(pg_relation_size(i.indexrelid)) AS size, s.n_distinct FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0] JOIN pg_stats s ON s.tablename = i.indrelid::regclass::text AND s.attname = a.attname WHERE i.indrelid = 'orders'::regclass; -
Check the B-tree version with
bt_metap(). Version 3 or lower means the index predates PostgreSQL 13 and must be rebuilt. -
Check eligibility.
INCLUDEcolumns,numeric, float,jsonb, array and nondeterministic-collation keys prevent deduplication. Consider whether a column type change (for examplenumericcodes tointeger) or droppingINCLUDEcolumns is worthwhile. -
Rebuild without blocking writes:
REINDEX INDEX CONCURRENTLY orders_status_idx;After a major-version upgrade with
pg_upgrade, schedule concurrent rebuilds for all large B-tree indexes — the procedure in REINDEX CONCURRENTLY without downtime. -
Measure leaf density after the rebuild with
pgstattuple:CREATE EXTENSION IF NOT EXISTS pgstattuple; SELECT leaf_pages, avg_leaf_density FROM pgstatindex('orders_status_idx'); -
Re-check plans that use the index; lower index scan costs can change access paths.
Before and After #
-- BEFORE: orders_status_idx, B-tree version 3 (pg_upgrade from 12)
Bitmap Index Scan on orders_status_idx Buffers: shared hit=2210 read=19204 index size 2104 MB
-- AFTER: REINDEX INDEX CONCURRENTLY
Bitmap Index Scan on orders_status_idx Buffers: shared hit=4104 index size 612 MB
Deduplication and update bloat #
On unique indexes, deduplication matters for a different reason. An update that is not HOT inserts a new index entry for the new row version while the old entry remains until vacuum. On a hot row updated many times between vacuums, the unique index accumulates several entries for the same key. PostgreSQL 13+ can deduplicate those versions and, from PostgreSQL 14, also performs bottom-up index deletion, removing entries for dead versions when a page would otherwise split. Together they keep unique indexes on update-heavy tables far smaller than they were on older releases, which reduces the index bloat discussed in measuring index bloat with pgstattuple. Neither mechanism works on indexes still in the version 3 format.
Deduplication has a small write cost: when a page fills, the server spends CPU merging entries before deciding whether to split. On indexes with unique keys and heavy insert load, that work finds nothing to merge. PostgreSQL skips it for unique indexes unless it detects duplicates from versions, so there is rarely a reason to disable it; deduplicate_items = off is worth testing only on insert-heavy, non-unique indexes with near-unique keys.
Common Pitfalls #
Assuming pg_upgrade modernises indexes. It keeps their on-disk format. Diagnostic signal: bt_metap version 3. Fix: REINDEX CONCURRENTLY after upgrading.
Adding INCLUDE columns to a repetitive index. It disables deduplication. Diagnostic signal: covering index several times larger than the key-only version. Fix: weigh index-only scan benefits against the size, as in INCLUDE columns vs composite keys.
Low-cardinality indexes that are never selective. Deduplication makes them small, not useful. Diagnostic signal: the planner never uses an index on a 50/50 boolean. Fix: a partial index on the rare value instead.
Numeric codes. numeric keys cannot deduplicate. Diagnostic signal: a code column index much larger than an equivalent integer index. Fix: store codes as integer types where values are integral.
Frequently Asked Questions #
What is B-tree deduplication in PostgreSQL? #
Introduced in PostgreSQL 13, it stores runs of identical keys in a B-tree leaf as one entry with a list of row pointers instead of repeating the key per row. Indexes on repetitive values become much smaller and cheaper to scan.
Why did my index not get smaller after upgrading to PostgreSQL 13 or later? #
Indexes carried over by pg_upgrade keep their old on-disk format, which cannot deduplicate. Rebuild them with REINDEX or REINDEX CONCURRENTLY to convert them to the new format.
Which indexes cannot use deduplication? #
Indexes with INCLUDE columns, indexes on numeric, float, jsonb or container types, text columns with nondeterministic collations, and indexes still in the pre-13 format.
Related #
- B-Tree Index Optimization — parent guide: B-tree structure and tuning
- Multicolumn Index Column Ordering — sibling: designing composite keys
- Measuring Index Bloat with pgstattuple — measuring density before and after