Skip to content
EL
Contents

Composite indexes and index design

Lab: covering indexes, overlapping indexes and foreign keys

LabAdvanced3 min read30 min hands-on#indexing#query-planning

The idea in 30 seconds

Three measurements that change how you design indexes. A covering index answered a query from 5 pages instead of 91, but was four times the size. A second index on just the first column of an existing one saved nothing. And deleting 50 customers took 3.6 seconds because the foreign key pointing at them had no index, against 11 ms with one.

Helps to know first: B-tree indexes, PostgreSQL index types

On this page

Before you start

The test data: 20,000 customers and two million orders. Our numbers come from PostgreSQL 18.3 on a laptop, second run of each query, with SET max_parallel_workers_per_gather = 0;.

SELECT setseed(0.3);
CREATE TABLE customers (id int PRIMARY KEY, name text);
INSERT INTO customers SELECT g, 'customer ' || g FROM generate_series(1, 20000) g;

CREATE TABLE orders (
  id          bigint PRIMARY KEY,
  customer_id int NOT NULL REFERENCES customers ON DELETE CASCADE,
  status      text NOT NULL,
  ordered_at  timestamptz NOT NULL,
  total       numeric(10,2) NOT NULL
);
INSERT INTO orders
SELECT g, 1 + (random() * 19999)::int,
       (ARRAY['paid','shipped','cancelled','refunded'])[1 + (random() * 3)::int],
       timestamptz '2024-01-01' + random() * interval '730 days',
       (random() * 500)::numeric(10,2)
FROM generate_series(1, 2000000) g;
VACUUM ANALYZE;

1. A covering index

“How much has customer 4242 spent?” needs total, which a plain index on customer_id does not have, so every matching row must be fetched from the table.

EXPLAIN (ANALYZE, BUFFERS) SELECT sum(total) FROM orders WHERE customer_id = 4242;

CREATE INDEX o_cust ON orders (customer_id);                          -- measure, then drop
CREATE INDEX o_cust_incl ON orders (customer_id) INCLUDE (total);
VACUUM orders;
IndexPlanPages readIndex sizeYours
(customer_id)Bitmap scan, then the table9114 MB
(customer_id) INCLUDE (total)Index-only scan, 0 heap fetches560 MB

The covering index answered from the index alone: 88 orders’ totals in 5 pages. But it is more than four times larger. Repeated customer ids are normally stored once by B-tree deduplication, and indexes with INCLUDE columns can never use it. A covering index is a trade: faster for this one query, heavier for every insert and in memory.

2. An index that adds nothing

Teams often add (customer_id) “for lookups” next to an existing (customer_id, ordered_at).

SELECT id, total FROM orders WHERE customer_id = 4242;
Index usedPages readYours
(customer_id)91
(customer_id, ordered_at)92

The two-column index serves a query on its first column just as well. A separate single-column index would cost another index’s worth of writes on every insert and gain nothing. Check for such pairs: an index that is a leading prefix of another is usually redundant.

3. The foreign key nobody indexed

orders.customer_id references customers. Deleting a customer has to find that customer’s orders (here, to cascade the delete), and PostgreSQL does not create an index on the referencing column automatically.

\timing on
BEGIN; DELETE FROM customers WHERE id BETWEEN 100 AND 149; ROLLBACK;
CREATE INDEX orders_customer_id ON orders (customer_id);
BEGIN; DELETE FROM customers WHERE id BETWEEN 100 AND 149; ROLLBACK;
Deleting 50 customersTime (two runs)Yours
No index on orders.customer_id3,648 ms, 3,677 ms
With the index11.9 ms, 9.7 ms

About 330 times slower without it: for each deleted customer, PostgreSQL scanned all two million orders to find theirs. The same happens on ON DELETE RESTRICT checks and when a referenced key is updated.

Clean up

DROP TABLE orders, customers;

Check yourself

  1. Why was the covering index four times bigger than the plain one?

    Show answer

    It stores total in every entry, and indexes with INCLUDE columns cannot use deduplication, so the repeated customer_id values are stored once per row instead of once per value.

  2. You find indexes on (tenant_id) and (tenant_id, created_at). What do you do?

    Show answer

    Check that nothing needs (tenant_id) alone (for example a unique constraint), then drop it: the two-column index serves queries on tenant_id just as well.

  3. Why did deleting customers take seconds without an index on orders.customer_id?

    Show answer

    Each delete has to find the orders that reference the customer. With no index on the referencing column, that is a full scan of the orders table for every deleted customer.

Finished reading?

Saved in this browser only.

Sources

Where each claim on this page comes from.

  1. 1.
    PostgreSQL documentation: 11.9 Index-Only Scans and Covering Indexes
    Primary· §11.9 Index-Only Scans and Covering Indexes

    An index-only scan needs all columns the query uses to be in the index; INCLUDE adds non-key payload columns so queries can be answered without visiting the table.

  2. 2.
    PostgreSQL documentation: 65.1 B-Tree Indexes
    Primary· §65.1.4.3 Deduplication

    INCLUDE indexes can never use deduplication.

  3. 3.
    PostgreSQL documentation: 5.5 Constraints
    Primary· §5.5.5 Foreign Keys

    Deleting a referenced row or updating a referenced column requires scanning the referencing table, and declaring a foreign key does not create an index on the referencing columns.

Go further

What to read next, and why it is worth the time.

  1. 1.
    PostgreSQL documentation: 11.9 Index-Only Scans and Covering Indexes
    Primary· §11.9

    Explains when an index-only scan still has to visit the table, which the next topic measures.

Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.