Skip to content
EL
Contents

B-tree indexes

Should this index ship?

DecisionAdvanced6 min read20 min hands-on#indexing#operations

The idea in 30 seconds

An index is a decision with a cost on every write, so review it like code. Start from the query, not the columns: one index shaped like the query usually beats one index per column. Build it without blocking writes, and check afterwards that something actually uses it.

Helps to know first: How a table is stored on disk (coming soon)

On this page

The situation

Your product has an assets page. For each customer organization it lists the 50 newest active assets:

SELECT id, name, created_at
FROM assets
WHERE org_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 50;

The assets table has 2 million rows from 1,000 organizations and is written to all day. The largest customer owns 10% of the rows; most own a fraction of a percent. About 95% of assets are active. Right now the table has no indexes besides the ones this change adds, and the page is slow.

An AI assistant opened a pull request to fix it.

Review taskAdd indexes for the assets page

Pull request: “Speed up the assets list. Adds an index for every column the query filters or sorts on.”

-- migrations/20261018_add_asset_indexes.sql
CREATE INDEX idx_assets_org_id     ON assets (org_id);
CREATE INDEX idx_assets_created_at ON assets (created_at);
CREATE INDEX idx_assets_status     ON assets (status);

Would you approve it? Write the review you would leave.

Your call

How to decide on an index

Start from the query, not the columns. Write down the queries the index is for. “Index every column in the WHERE clause” is how you end up with this pull request.

Prefer one index shaped like the query, unless different queries need different leading columns. Put the columns compared with = first, then the column used for a range or for ORDER BY. Equality on the leading columns plus a range or order on the next column lets PostgreSQL read one contiguous slice of the index.

Prefer no index on a column most rows share, unless a query needs the rare values. An index on status where 95% of rows are active costs a write on every insert and almost never wins a plan. Indexes that cover only the rare rows come later, in PostgreSQL index types.

Build with CONCURRENTLY on any table that takes writes, unless the table is new or empty. Then check the result: if the build failed, drop the INVALID index and run it again.

Test with your skew. The pull request’s indexes were fine for the biggest customer, but for a small one they read about 1,200 times more pages than the composite index. Always try the extremes.

Check afterwards that something uses it. Look at idx_scan in pg_stat_user_indexes after the index has seen real traffic, and drop indexes that stay at zero.

Reproduce the evidence

The numbers in the review come from this setup on PostgreSQL 18.3. Run it on a throwaway database to see the plans yourself.

CREATE TABLE assets (id bigint, org_id int, created_at timestamptz, status text, name text);
INSERT INTO assets
SELECT g,
       CASE WHEN g % 10 = 0 THEN 1 ELSE 2 + (g % 999) END,      -- org 1 owns 10% of rows
       timestamptz '2026-01-01' + random() * interval '270 days',
       CASE WHEN random() < 0.95 THEN 'active' ELSE 'archived' END,
       'asset-' || g
FROM generate_series(1, 2000000) g;
VACUUM ANALYZE assets;

-- The pull request's indexes:
CREATE INDEX idx_assets_org_id     ON assets (org_id);
CREATE INDEX idx_assets_created_at ON assets (created_at);
CREATE INDEX idx_assets_status     ON assets (status);
ANALYZE assets;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name, created_at FROM assets
WHERE org_id = 500 AND status = 'active' ORDER BY created_at DESC LIMIT 50;

SELECT indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes WHERE relname = 'assets';

-- The alternative:
DROP INDEX idx_assets_org_id, idx_assets_created_at, idx_assets_status;
CREATE INDEX idx_assets_org_created ON assets (org_id, created_at);
ANALYZE assets;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name, created_at FROM assets
WHERE org_id = 500 AND status = 'active' ORDER BY created_at DESC LIMIT 50;

The statistics view updates shortly after each query, so run the pg_stat_user_indexes query a second or two after the EXPLAIN. Timestamps and statuses are random, so your page counts will differ a little; the gap between the two plans will be the same order of magnitude.

Check yourself

  1. A query filters on org_id = $1 and sorts by created_at DESC LIMIT 50. Why does one index on (org_id, created_at) beat separate indexes on each column?

    Show answer

    Inside one org_id, the composite index’s entries are already sorted by created_at, so PostgreSQL jumps to that organization and reads the newest 50 entries. With separate indexes it can follow only one: walking created_at means discarding every other organization’s rows until 50 match.

  2. Why did the pull request’s indexes look fine for the largest customer and terrible for a small one?

    Show answer

    Walking created_at from the newest finds 50 rows quickly when the organization owns 10% of the table, but has to pass tens of thousands of other organizations’ rows when it owns 0.1%.

  3. What happens to writes during a plain CREATE INDEX, and what do you give up with CONCURRENTLY?

    Show answer

    A plain build takes a SHARE lock, so inserts, updates and deletes wait until it finishes. CONCURRENTLY keeps writes flowing but does more total work, takes longer, cannot run in a transaction block, and can leave an INVALID index behind that you must drop and rebuild.

  4. How do you find out whether an index is earning its keep?

    Show answer

    Check idx_scan in pg_stat_user_indexes after a period of real traffic. An index that stays at zero is pure write and storage cost.

Finished reading?

Saved in this browser only.

Sources

Where each claim on this page comes from.

  1. 1.
    PostgreSQL documentation: 11.3 Multicolumn Indexes
    Primary· §11.3 Multicolumn Indexes

    A multicolumn B-tree is most efficient with constraints on its leading columns; equality on leading columns plus a range on the next one limits the part of the index scanned.

  2. 2.
    PostgreSQL documentation: 11.4 Indexes and ORDER BY
    Primary· §11.4 Indexes and ORDER BY

    With ORDER BY … LIMIT n, an index matching the ORDER BY returns the first n rows without scanning the rest.

  3. 3.
    PostgreSQL documentation: CREATE INDEX
    Primary· Building Indexes Concurrently

    A plain CREATE INDEX locks the table against inserts, updates and deletes for the whole build; CONCURRENTLY avoids that, takes longer, cannot run inside a transaction block, and can leave an INVALID index behind on failure.

  4. 4.
    PostgreSQL documentation: 13.3 Explicit Locking
    Primary· §13.3.1 Table-Level Locks: SHARE

    CREATE INDEX without CONCURRENTLY takes a SHARE lock, which conflicts with the ROW EXCLUSIVE lock that writes need.

  5. 5.
    PostgreSQL documentation: 27.2 The Cumulative Statistics System
    Primary· §27.2 pg_stat_all_indexes: idx_scan

    idx_scan counts the index scans started on each index.

  6. 6.
    PostgreSQL documentation: 11.1 Indexes, Introduction
    Primary· §11.1 Introduction

    Indexes that are seldom or never used in queries should be removed.

Go further

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

  1. 1.
    Use The Index, Luke!: Anatomy of an SQL Index
    ReferenceMarkus Winand· Chapter 2: The Where Clause, Concatenated Indexes· location not yet verified

    The clearest explanation of why column order in a multicolumn index matters, with diagrams.

  2. 2.
    PostgreSQL documentation: CREATE INDEX
    Primary· Building Indexes Concurrently

    Read the whole section once before you add an index to a busy production table.

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