All rules Rule BP006 · QS
PostgreSQL
note
QS · Query shape
hosted, paid, --remote

Which column should lead a composite index, the range or the equality?

Composite index led by a range column ahead of an equality column

Note: this works and blocks nothing, but a performance regression is likely.

What happens

A B-tree answers a query by walking leading columns with equality first, then scanning one range. With the range column first, (created_at, status) for WHERE status = 'x' AND created_at > …, the index has to scan every row in the date range and filter status afterwards. Put the equality column first and the scan shrinks to exactly the matching rows. Only fires when a query in the migration itself shows that shape.

Why it is dangerous on a populated table

Nothing blocks, but the cost scales with the table and shows up as a slower query plan or a longer maintenance window rather than an outage.

Fires on

CREATE INDEX idx_orders_created_status ON orders (created_at, status);
UPDATE orders SET archived = true WHERE status = 'closed' AND created_at < '2024-01-01';

The safe pattern

Order the index equality-columns-first, range-column-last, so the range scan only covers rows that already match the equality. If the range column really must lead (for ORDER BY on it alone), say so with a second index, this rule only reads the queries in the file.

CREATE INDEX CONCURRENTLY idx_orders_status_created ON orders (status, created_at);

Fixtures

The rule ships with these files and the test suite runs them on every change: the first set must fire, the second must stay silent.

Fires (1)

range leads
SET lock_timeout = '5s';
CREATE INDEX idx_orders_created_status ON orders (created_at, status);
UPDATE orders SET archived = true WHERE status = 'closed' AND created_at < '2024-01-01';

Stays silent (3)

equality leads
SET lock_timeout = '5s';
CREATE INDEX idx_orders_status_created ON orders (status, created_at);
UPDATE orders SET archived = true WHERE status = 'closed' AND created_at < '2024-01-01';
leading column equality
SET lock_timeout = '5s';
CREATE INDEX idx_orders_created_status ON orders (created_at, status);
UPDATE orders SET archived = true WHERE created_at = '2024-01-01' AND status = 'closed';
no query evidence
SET lock_timeout = '5s';
CREATE INDEX idx_orders_created_status ON orders (created_at, status);

How to check locally

Catch this before it ships

This rule runs in the hosted service on Startup and above: add --remote with a team token, or use the GitHub Action. No install, nothing leaves your machine:

npx bolvrk check migration.sql --remote