Skip to main content
RunBook Academy

PostgreSQLX · Query Planning, Indexes and Performance MethodPlanner

B-tree indexes and what they cost

Intermediate⏱ ~30 minpsql

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

Not yet marked complete on this device.

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.

Read-only / Safe500,000 inserts against 1, 4 and 7 indexes
$ 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.

QueryUses the index?
WHERE a = 1Yes
WHERE a = 1 AND b = 2Yes
WHERE a = 1 AND b = 2 AND c = 3Yes, fully
WHERE b = 2Poorly — a full index scan at best
WHERE a = 1 AND c = 3Partly: a seeks, c filters
ORDER BY a, bYes
ORDER BY b, aNo

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.
  • INCLUDE for 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

  1. Q1. A table has an index on (status, created_at). Which query will it serve most effectively?

  2. 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?

  3. 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?

  4. Q4. Which are genuine reasons an index can be larger than the table it indexes? Select all that apply.

  5. 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.

  6. 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.