PostgreSQL index types
Are these the right index types?
The idea in 30 seconds
Reviewing an index means asking three questions: which operator does the query use, how are the values distributed, and in what order do rows arrive? A B-tree is rarely wrong, but on a large append-only table a BRIN or partial index can do the same job at a sliver of the size, and a hash index on a column with few distinct values can be a disaster.
Helps to know first: B-tree indexes
On this page
The situation
The events table receives a steady stream of new rows and is never updated: five million today, growing by a million a month. Three queries are slow. An AI assistant opened a pull request.
Review taskIndex the events table
Pull request: “Add indexes for the three slow event queries.”
-- daily report: count events per day
CREATE INDEX ON events (created_at);
-- work queue: newest pending events (about 1% of rows)
CREATE INDEX ON events USING hash (status);
-- support tool: look up a user's events by email
CREATE INDEX ON events USING hash (email);Would you approve it? Write the review you would leave.
Your call
Model review
Request changes. The first index works but is far bigger than it needs to be; the second will hurt badly; the third is the wrong type for this data.
1. created_at on an append-only table: use BRIN. Rows arrive in time order, so created_at follows the table’s physical order exactly. On a five-million-row copy, a B-tree on created_at was 107 MB and a BRIN index was 32 kB: about 3,400 times smaller, and a day’s count took 1.8 ms instead of 0.8 ms. For a daily report that difference does not matter; 107 MB of index growing every month, and its write cost on every insert, does.
2. A hash index on status is the worst choice available. status has two values, so a hash index puts every row into two buckets with enormous overflow chains. On a one-million-row test table it took 2 minutes 35 seconds to build, at 47 MB. And the queue query also sorts by created_at, which a hash index cannot help with. Use a partial index on exactly the rows the queue wants:
CREATE INDEX ON events (created_at) WHERE status = 'pending';On our five-million-row copy that was 1.1 MB, against 150 MB for an ordinary index on (status, created_at), and just as fast for the queue query.
3. A hash index on email loses to a B-tree here. Every user has many events, so each email repeats. In our test the B-tree on email was 40 MB (repeated keys are deduplicated) and the hash index 142 MB, and the B-tree read fewer pages per lookup. Use a plain B-tree.
What I would ask for:
CREATE INDEX CONCURRENTLY ON events USING brin (created_at);
CREATE INDEX CONCURRENTLY ON events (created_at) WHERE status = 'pending';
CREATE INDEX CONCURRENTLY ON events (email);Compare it with yours. Did your review catch the main problems?
How to decide
Start with B-tree. It serves equality, ranges, sorting and IS NULL, and it deduplicates repeated values. Choose something else only for a reason.
Prefer BRIN for very large tables whose values follow insertion order, typically timestamps on append-only data, unless the queries need single rows or the order gets scrambled by updates.
Prefer a partial index when queries always target a small, well-defined subset of rows, like a queue of pending work.
Prefer hash only for equality on unique or nearly unique values, and measure it against a B-tree first. On repeated values it loses.
Use GIN or GiST when the operator demands it: containment and arrays for GIN, overlaps and nearest-neighbour for GiST.
Check yourself
Why was a hash index on
statusso slow to build?Show answer
statushas only two values, so all rows land in two hash buckets, each becoming a very long chain of overflow pages.What makes
created_aton this table a good fit for BRIN?Show answer
The table is append-only and rows arrive in time order, so each block range covers a narrow time span and its minimum and maximum let whole ranges be skipped.
Which queries can use
CREATE INDEX ON events (created_at) WHERE status = 'pending'?Show answer
Queries whose
WHEREclause guaranteesstatus = 'pending', such as the work-queue query. Queries for other statuses cannot use it.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 65.6 Hash IndexesPrimary· §65.6 Hash Indexes
Hash indexes are most suitable for unique or nearly unique data; uneven distributions create overflow chains and can make a hash index worse than a B-tree.
- 2.PostgreSQL documentation: 65.5 BRIN IndexesPrimary· §65.5 BRIN Indexes
BRIN suits very large tables whose column values correlate with physical location.
- 3.PostgreSQL documentation: 11.8 Partial IndexesPrimary· §11.8 Partial Indexes
Partial indexes avoid indexing common values and are used only by queries whose condition implies the predicate.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: 11.8 Partial IndexesPrimary· §11.8
Three worked examples, one very close to this review's work queue, and a fourth that warns against using many partial indexes in place of partitioning.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.