PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 16: B-tree indexes

B-tree indexes: composite, covering and ordered

Level 4: Performance30 min

The B-tree is the default index and covers equality, ranges, sorting and uniqueness. Most index design comes down to which columns, in which order, and whether the index can answer the query on its own.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Order composite index columns: equality first, then range or sort
  • Use an index to satisfy ORDER BY ... LIMIT without a sort
  • Build covering indexes with INCLUDE for index-only scans, and know about skip scan in 18

conceptThe concept

A B-tree keeps keys sorted, so it serves =, <, >, BETWEEN, IN, IS NULL, prefix LIKE 'abc%' (with the C collation or text_pattern_ops) and ORDER BY. A composite index on (a, b) is sorted by a, then by b within each a. It is efficient for a = ?, a = ? AND b = ?, a = ? AND b > ? and a = ? ORDER BY b.

The rule of thumb: equality columns first, then the range or sort column. With (customer_id, created_at DESC), "the latest 3 orders of customer 42" is a single index descent that stops after 3 entries: no sort and no scan of older orders.

A covering index includes every column the query needs, enabling an Index Only Scan. INCLUDE (total) adds payload columns that are stored in leaf pages but are not part of the key. Before 18, a filter on only the second column of an index could not use it efficiently; 18 adds skip scan, which probes each distinct value of a low-cardinality leading column.

whyWhy it matters

The right composite index often replaces two or three single-column ones and turns a sort of 100,000 rows into reading 10 index entries. The wrong column order makes the index almost useless for the query you built it for.

Use caseReal-world scenario

The customer page shows "latest orders", the warehouse screen filters by status and date, and the finance export needs id and total for a date range. Each gets one well-shaped index.

Try itExamples

Run and edit these in the interactive lesson.

Equality first, sort second
BEGIN;
CREATE INDEX orders_cust_created_idx ON orders (customer_id, created_at DESC);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, total
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 3;
ROLLBACK;
No Sort node: the index already returns rows newest first, and the Limit stops after three.
The wrong column order
BEGIN;
CREATE INDEX orders_created_cust_idx ON orders (created_at, customer_id);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, total
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 3;
ROLLBACK;
The index is sorted by created_at first, so the planner must walk it backwards and check customer_id for each entry until it finds three matches, or ignore it. Compare the buffers with the previous example.
Covering index with INCLUDE
BEGIN;
CREATE INDEX orders_created_incl_idx ON orders (created_at) INCLUDE (total, status);
EXPLAIN (ANALYZE, BUFFERS)
SELECT created_at, total
FROM orders
WHERE created_at >= '2025-06-01' AND created_at < '2025-06-08' AND status = 'delivered';
ROLLBACK;
Skip scan on a leading column you did not filter
BEGIN;
CREATE INDEX orders_status_created_idx ON orders (status, created_at);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM orders
WHERE created_at >= '2025-06-01' AND created_at < '2025-06-03';
ROLLBACK;
status has only five values, so PostgreSQL 18 probes the index once per status (and a few extra boundary searches) instead of scanning it all. Index Searches shows how many descents happened.

internalsUnder the hood

Every index adds work to every INSERT and to UPDATEs of indexed columns, and it also stops HOT updates when an indexed column changes (see the MVCC lesson). B-tree pages are 8 kB; since 13, duplicate keys are deduplicated, which makes indexes on low-cardinality columns much smaller.

Avoid / preferDo this, not that

Avoid
CREATE INDEX ON orders (created_at, customer_id);
-- for: WHERE customer_id = $1 ORDER BY created_at DESC
Prefer
CREATE INDEX ON orders (customer_id, created_at DESC);

Equality columns first, then the sort column. The good index jumps to one customer and reads rows already in order.

Avoid
CREATE INDEX ON orders (status);
CREATE INDEX ON orders (customer_id);
CREATE INDEX ON orders (created_at);  -- one per column, just in case
Prefer
-- index the access paths your top queries use, measured from pg_stat_statements

Every index costs write time, WAL, memory and VACUUM work. Unused single-column indexes are pure overhead.

PitfallsCommon mistakes

  • Creating one single-column index per column "just in case". The planner can combine them with BitmapAnd, but one composite index matching the query is usually far better, and every index slows writes.
  • Putting the range column before the equality column in a composite index.
  • Indexing a column that is wrapped in a function in the WHERE clause (lower(email)). Index the expression instead (next lesson).

LimitsWhen not to use it

Do not index columns with few distinct values (booleans, small status sets) on their own; the planner will rarely use them. A partial index on the rare value is usually what you want.

SecuritySecurity notes

Case-insensitive uniqueness matters for logins: with a plain unique index, Ann@x.com and ann@x.com can be two accounts. Use a unique index on lower(email) or a citext column.

ChallengeTry it yourself

Create the best index for "the latest 10 delivered orders of a customer" and prove the plan for customer 1 (the busiest customer) uses an index and needs no Sort node.

Show one solution
One solution
BEGIN;
CREATE INDEX orders_cust_status_created ON orders (customer_id, status, created_at DESC);
ANALYZE orders;
SELECT NOT ('Sort' = ANY (pgm.plan_nodes($q$SELECT * FROM orders WHERE customer_id = 1 AND status = 'delivered' ORDER BY created_at DESC LIMIT 10$q$))) AS no_sort,
       pgm.uses_index($q$SELECT * FROM orders WHERE customer_id = 1 AND status = 'delivered' ORDER BY created_at DESC LIMIT 10$q$) AS uses_index;
ROLLBACK;
For a customer with only two or three matching orders, a bitmap scan followed by a tiny sort can be cheaper, and the planner rightly picks it.

InterviewQuestions you should be able to answer

How do you choose the column order of a composite B-tree index?
Put columns compared with equality first, then the column used for a range or ORDER BY, because the index is sorted by the leading column and can only use a range on the first non-equality column efficiently. Among equality columns, order matters less for that query, so choose an order that also serves other queries (left-prefix rule). Since 18, skip scan helps when a low-cardinality leading column is not filtered, but it does not replace good design.
What is a covering index and what does INCLUDE do?
A covering index contains every column the query reads, so PostgreSQL can use an Index Only Scan and skip the table, provided the visibility map marks the pages all-visible. INCLUDE adds non-key payload columns to the leaf pages: they are returned by the index but do not affect ordering or uniqueness, so you can cover a query without widening the key of a unique index.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation