SQL and PostgreSQL path · How a query runs · Topic 3 of 13

Indexes: B-trees, column order and covering

Not started

A table's rows sit in its heap in no particular order. An index is a separate structure that keeps some columns sorted, each entry pointing back at a row's location. PostgreSQL's default index type is the B-tree: a shallow, balanced tree of pages where even millions of entries are only three or four page reads from the root. It handles equality, ranges (<, BETWEEN), ORDER BY and prefix LIKE 'abc%' (with the right collation or operator class).

Why it matters: The right index turns a full scan into a few page reads; the wrong one costs every write and is never used.

  1. 1A B-tree is a sorted list you can jump into

    It finds an exact value or the start of a range in a few page reads, and returns entries in order.

  2. 2Leading columns first

    An index on (a, b) serves conditions on a, or on a and b; a condition on b alone can use it only by scanning much of it.

  3. 3Covering avoids the table

    If the index holds every column the query needs, PostgreSQL can answer with an index-only scan.

  4. 4Indexes are not free

    Every insert and most updates must change every index on the table, and the planner skips an index when a scan is cheaper.

1

How it works

The explanation, with examples you can run.

Column order in a composite index

An index on (customer_id, created_at) is sorted by customer first, then by time within each customer. The documentation's rule: equality conditions on leading columns, plus a range condition on the first column without an equality, limit the part of the index that is read.

CREATE INDEX orders_cust_created_idx ON orders (customer_id, created_at); EXPLAIN ANALYZESELECT id, total_cents FROM ordersWHERE customer_id = 42 ORDER BY created_at DESC LIMIT 10;

The plan, with costs left out:

Limit  (actual time=0.013..0.024 rows=10 loops=1)  ->  Index Scan Backward using orders_cust_created_idx on orders  (actual time=0.013..0.023 rows=10 loops=1)        Index Cond: (customer_id = 42)

No sort node: the entries for customer 42 are already in time order, so PostgreSQL walks them backwards and stops after ten. A query filtering only on created_at gets a sequential scan instead, because one day's orders are spread across every customer's section of the index. Put the column you filter by equality first, and the one you sort or range on after it.

Covering indexes and index-only scans

Usually an index scan still visits the table to fetch the other columns. If the index contains everything the query asks for, it need not:

CREATE INDEX orders_cust_cov_idx ON orders (customer_id, created_at) INCLUDE (total_cents); EXPLAIN ANALYZE SELECT created_at, total_cents FROM orders WHERE customer_id = 42;

Again without costs:

Index Only Scan using orders_cust_cov_idx on orders  (actual time=0.033..0.034 rows=25 loops=1)  Index Cond: (customer_id = 42)  Heap Fetches: 0

INCLUDE columns are stored in the index but not sorted on. There is a catch: the index does not know whether a row version is visible to your transaction, so PostgreSQL checks the table's visibility map, which VACUUM maintains. On a table with many recent changes, Heap Fetches climbs and the advantage shrinks.

When an index is not used

  • The condition is not on the indexed expression. WHERE lower(email) = ... needs CREATE INDEX ON customers (lower(email)); WHERE customer_id + 0 = 42 defeats the index on customer_id.
  • The condition is not selective. For a value that matches a large share of rows, reading the whole table in order is cheaper.
  • The leading column is missing from the condition, as above.
  • Types do not match, so a cast is applied to the column rather than to the constant.
  • The statistics are stale, so the planner thinks the condition matches far more, or fewer, rows than it does.

The cost side

Each index is written on every insert and on updates that change indexed columns (or that cannot be done as a heap-only update). pg_stat_user_indexes.idx_scan shows indexes that are never scanned; they are candidates for removal. On a busy table, build new indexes with CREATE INDEX CONCURRENTLY, which takes longer but does not block writes.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.There is an index on orders (customer_id, created_at). Which query can use it most efficiently?
  2. 2.There is an index on customers (email), but WHERE lower(email) = 'ana@example.com' does a sequential scan. Why?
  3. 3.A quarter of all orders have status = 'paid'. With an index on status, why does WHERE status = 'paid' still use a sequential scan?

0 of 3 right so far. Get all three to finish this topic.