Composite indexes and index design
Lab: covering indexes, overlapping indexes and foreign keys
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;
| Index | Plan | Pages read | Index size | Yours |
|---|---|---|---|---|
(customer_id) | Bitmap scan, then the table | 91 | 14 MB | |
(customer_id) INCLUDE (total) | Index-only scan, 0 heap fetches | 5 | 60 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 used | Pages read | Yours |
|---|---|---|
(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 customers | Time (two runs) | Yours |
|---|---|---|
No index on orders.customer_id | 3,648 ms, 3,677 ms | |
| With the index | 11.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
Why was the covering index four times bigger than the plain one?
Show answer
It stores
totalin every entry, and indexes withINCLUDEcolumns cannot use deduplication, so the repeatedcustomer_idvalues are stored once per row instead of once per value.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 ontenant_idjust as well.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.PostgreSQL documentation: 11.9 Index-Only Scans and Covering IndexesPrimary· §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.PostgreSQL documentation: 65.1 B-Tree IndexesPrimary· §65.1.4.3 Deduplication
INCLUDE indexes can never use deduplication.
- 3.PostgreSQL documentation: 5.5 ConstraintsPrimary· §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.PostgreSQL documentation: 11.9 Index-Only Scans and Covering IndexesPrimary· §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.