PostgreSQLX · Query Planning, Indexes and Performance MethodPlanner
B-tree indexes and what they cost
What you'll learn
- Quantify the write cost of an index rather than assuming it is small
- Order composite index columns correctly for the queries that will use them
- Decide when INCLUDE, partial and expression indexes are the right tool
- Explain why index size can exceed table size
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
Indexes are discussed as though the only question is whether one helps a query. The write cost is real, measurable, and larger than most people expect.
What indexes cost on write
Three identical tables. The same statement inserts 500,000 rows into each. The only difference is how many indexes exist.
$ psql -U postgres -c "INSERT INTO idxN (id,a,b,c,d,e) SELECT g, g%1000, g%37, 'row'||g, now()-(g%10000)*interval '1 min', (g%99)::numeric FROM generate_series(1,500000) g" table time WAL storage
idx0 595 ms 90 MB 33 MB heap + 11 MB indexes (primary key only)
idx3 1574 ms 222 MB 33 MB heap + 23 MB indexes (pkey + 3)
idx6 3643 ms 325 MB 33 MB heap + 65 MB indexes (pkey + 6)6.1 times the insert time. 3.6 times the WAL. Identical data.
And read the storage column: idx6 has 65 MB of indexes over a 33 MB
heap. The indexes are twice the size of the data.
Composite index column order
For an index on (a, b, c), the leading columns determine what it can
serve.
| Query | Uses the index? |
|---|---|
WHERE a = 1 | Yes |
WHERE a = 1 AND b = 2 | Yes |
WHERE a = 1 AND b = 2 AND c = 3 | Yes, fully |
WHERE b = 2 | Poorly — a full index scan at best |
WHERE a = 1 AND c = 3 | Partly: a seeks, c filters |
ORDER BY a, b | Yes |
ORDER BY b, a | No |
The rules that follow:
Equality before range. An index on (status, created_at) serves
WHERE status = 'x' AND created_at > y well. (created_at, status)
does not, because once the range on created_at starts, status can
only filter what the range returns.
Most selective first — usually. Conventional advice, and it is
second to the equality rule. (status, created_at) beats
(created_at, status) for that query even if created_at is more
selective.
(a, b) makes an index on (a) redundant. A single-column index
whose column is the leading column of a composite index is nearly always
removable. This is the most common source of surplus indexes, and it is
not detected by exact-duplicate queries.
The specialised forms
INCLUDE
CREATE INDEX orders_customer_idx ON orders (customer_id) INCLUDE (status, amount);
INCLUDE columns are stored in the leaf pages but are not part of the
key. They cannot be searched or ordered by, and they enable an
index-only scan for a query that selects them.
Use it when a query filters on one column and selects two or three others. Adding them to the key instead would make the index larger and slow every insertion into it.
Partial
CREATE INDEX orders_pending_idx ON orders (created_at) WHERE status = 'pending';
Indexes only the rows matching the predicate. On a table where 2% of
rows are pending, this index is 2% of the size, is maintained only for
those rows, and serves the queue query perfectly.
This is frequently the highest-value index shape available and is
under-used. The planner uses it only when it can prove the query’s
WHERE clause implies the index’s, so the predicates must match closely.
Expression
CREATE INDEX users_lower_email_idx ON users (lower(email));
-- serves: WHERE lower(email) = lower($1)
Two benefits: the index can serve the expression, and — as lesson X-04
noted — ANALYZE starts collecting statistics for the expression, which
fixes estimates that were previously guesses.
The query must use the expression exactly as indexed.
WHERE lower(email) = 'x' uses it; WHERE email = 'x' does not.
Covering by accident
Any index can serve an index-only scan if every column the query needs
is in it. That is why SELECT id FROM orders WHERE customer_id = 5 can
be answered from (customer_id) alone — id is in the heap, but the
index-only scan for a count(*) or for the indexed column needs nothing
else.
It also requires the visibility map, from lesson VI-04. An index-only
scan reporting non-zero Heap Fetches is not getting the full benefit.
What to take from this
- Measured: seven indexes against one gave 6.1× insert time, 3.6× WAL, and index storage at twice the heap.
- WAL is the cost that travels — replication, archives, recovery time.
- Composite order: equality columns before range columns.
(a, b)makes(a)redundant. INCLUDEfor covering, partial for skewed predicates, expression indexes for functions — and expression indexes also fix estimates.- Index pages are reclaimed only when completely empty, so index bloat behaves differently from heap bloat.
- Deduplication since 13 weakened the old advice against indexing low-cardinality columns.
Cross-course references
- Linux for Production Sysadmins — Part XLI (Storage Performance) covers measuring the write cost an index adds, which is the half of the trade nobody records.
- Observability for Production Sysadmins — Part LIX (Database observability) covers exporting index scan counts, which is how an index that costs and never pays is identified.
Quiz
Knowledge check · 6 questions
Q1. A table has an index on (status, created_at). Which query will it serve most effectively?
Q2. Adding six indexes to a table made an identical bulk insert 6.1 times slower and generated 3.6 times the WAL. Which of those costs matters beyond this server?
Q3. A queue table holds 50 million rows of which about 2% are pending at any time, and the queue query filters on status and orders by created_at. What index shape fits best?
Q4. Which are genuine reasons an index can be larger than the table it indexes? Select all that apply.
Q5. Since PostgreSQL 13, B-tree deduplication stores a repeated key once with a list of pointers, which weakens the old advice against indexing low-cardinality columns.
Q6. A single-column index on (a) exists alongside a composite index on (a, b). Is the single-column index removable, and what would you check first?
Passing score: 75%. Answers are checked in this browser.