INCLUDE Columns vs Composite Keys #
Both forms produce a covering index, and both let a query be answered without visiting the heap. The difference is what the extra columns can do: key columns are searchable and orderable and are stored throughout the tree; included columns are payload, stored only in the leaves, and cannot narrow anything.
Getting the split right produces a smaller index that covers the same queries. Getting it wrong produces a bloated tree that covers no more. The wider design context is in covering index design.
The Rule #
Ask one question per column: does the query use it to find or order rows, or only to return them?
- Used in a predicate, a join condition, or
ORDER BY→ key column. It must be part of the sorted key for the index to restrict or order on it. - Only returned in the select list →
INCLUDE. It just needs to be present in the leaf entry.
Two structural consequences follow. Key columns are copied into internal pages to guide descent, so a wide key inflates every level of the tree and can add a level, which costs an extra page read on every lookup. Included columns exist only at the leaf level, so they widen exactly one level.
The third consequence is about constraints: a UNIQUE index enforces uniqueness over its key columns alone, so INCLUDE lets a unique index cover additional columns without changing what it enforces.
Annotated Evidence #
Take a query that filters on two columns and returns a third:
SELECT customer_id, created_at, total_cents
FROM orders
WHERE customer_id = 4471 AND created_at > '2026-06-01';
With INCLUDE, the plan is index-only and the index is compact:
CREATE INDEX orders_cust_created_incl ON orders (customer_id, created_at) INCLUDE (total_cents);
Index Only Scan using orders_cust_created_incl on orders (actual time=0.03..1.94 rows=8412 loops=1)
Index Cond: (customer_id = 4471 AND created_at > '2026-06-01')
Heap Fetches: 0
Buffers: shared hit=34
Now a different query that also filters on total_cents:
SELECT customer_id FROM orders WHERE customer_id = 4471 AND total_cents > 50000;
Index Only Scan using orders_cust_created_incl on orders (actual rows=8412 loops=1)
Index Cond: (customer_id = 4471)
Filter: (total_cents > 50000) -- included column: filtered, not searched
Rows Removed by Filter: 7912
total_cents appears as a Filter, not part of the Index Cond. All 8,412 entries for that customer were read and 7,912 discarded. Move it into the key and it restricts the scan instead:
CREATE INDEX orders_cust_total_idx ON orders (customer_id, total_cents);
Index Only Scan using orders_cust_total_idx on orders (actual rows=500 loops=1)
Index Cond: (customer_id = 4471 AND total_cents > 50000)
Heap Fetches: 0
That is the whole distinction: Index Cond narrows, Filter does not.
Step-by-Step Design Workflow #
-
Collect the real predicates. Every column that appears in a
WHERE,JOIN, orORDER BYfor the queries this index serves belongs in the key set. -
Order the key columns by the rules in multicolumn index column ordering — equality predicates first, then the range or ordering column.
-
Put pure output columns in
INCLUDE:CREATE INDEX CONCURRENTLY orders_cust_created_incl ON orders (customer_id, created_at) INCLUDE (total_cents, status); -
Check the cost of the coverage. Wide payloads make leaf pages fat, which means more pages per range scan:
SELECT pg_size_pretty(pg_relation_size('orders_cust_created_incl')); SELECT tree_level, avg_leaf_density FROM pgstatindex('orders_cust_created_incl'); -
Use
INCLUDEon unique indexes freely, since uniqueness is enforced on the key only:CREATE UNIQUE INDEX CONCURRENTLY orders_pkey_covering ON orders (id) INCLUDE (customer_id, status); -
Verify the payoff:
Index Only ScanwithHeap Fetches: 0. If heap fetches remain high, the problem is visibility rather than coverage — see visibility map and index-only scan regressions.
Before and After #
-- BEFORE: total_cents in INCLUDE, but the query filters on it
Index Only Scan Index Cond: (customer_id = 4471)
Filter: (total_cents > 50000) Rows Removed by Filter: 7912
Buffers: shared hit=34
-- AFTER: total_cents promoted into the key
Index Only Scan Index Cond: (customer_id = 4471 AND total_cents > 50000)
(no Filter line)
Buffers: shared hit=6
The metric is Rows Removed by Filter falling to zero and buffer reads dropping with it — the scan now stops at the rows it needs instead of reading and discarding.
Common Pitfalls #
Putting a filtered column in INCLUDE. It will be checked, not searched. Diagnostic signal: the column appearing in Filter with a large Rows Removed by Filter. Fix: move it into the key.
Padding INCLUDE with wide columns. Fat leaf pages mean more pages per range scan, undoing part of the benefit. Diagnostic signal: a covering index several times larger than the key-only version with only marginally fewer heap fetches. Fix: include only what the hot queries actually return.
Adding a column to a unique key to cover it. That changes what the constraint enforces. Diagnostic signal: a UNIQUE (id, status) where uniqueness on id alone was intended. Fix: UNIQUE (id) INCLUDE (status).
Expecting coverage to survive without vacuum. Index-only scans still need all-visible heap pages. Diagnostic signal: Heap Fetches climbing on a perfectly covering index. Fix: vacuum frequency, not index design.
Covering a query that selects *. A covering index must contain every column the query returns, so SELECT * effectively demands the whole row in the index. Diagnostic signal: an index-only plan that reverts to a plain index scan the moment someone adds a column to the select list. Fix: name the columns the code actually needs, and accept that a covering index is a contract with a specific query shape.
Forgetting the write cost. Every included column must be updated in the index whenever it changes in the heap, and an update touching an included column cannot be a heap-only tuple update. Diagnostic signal: write throughput dropping after a wide covering index is added to a hot table. Fix: include only columns that are stable, or accept the trade knowingly.
Adding the same column to both the key and INCLUDE. It is stored twice for no benefit and PostgreSQL will reject an outright duplicate. Diagnostic signal: an index definition listing a column in both clauses. Fix: keep it in the key, where it is both searchable and returnable.
Frequently Asked Questions #
What can an INCLUDE column not do?
It cannot be searched, ordered by, or contribute to an Index Cond. It is payload stored in the leaf entries, present to satisfy the select list of an index-only scan.
Why is INCLUDE smaller than adding the column to the key? Key columns are stored in internal pages as well as leaves to guide tree descent, so a wide key inflates every level and can add a level. An included column widens only the leaf level.
Can a unique index have INCLUDE columns? Yes, and it is one of the best uses of the feature. Uniqueness applies to the key columns only, so you can cover extra columns without altering what the constraint enforces.
Related #
- Covering Index Design — parent guide: when covering pays for itself
- Building Effective Covering Indexes — sibling: choosing the covered column set
- Index Tuning & Strategy — section overview: the whole index design workflow
- Multicolumn Index Column Ordering — ordering the key columns you keep