PostgreSQL index types
Lab: BRIN, partial and hash indexes measured
The idea in 30 seconds
Measure three specialised indexes against a plain B-tree on five million events. BRIN answered a one-day query from a 32 kB index instead of 107 MB. A partial index was about 140 times smaller for the rows a query actually wanted. And a hash index, the obvious choice for equality, came out bigger and slower than a B-tree, and took two and a half minutes to build on a two-value column.
Helps to know first: B-tree indexes
On this page
Before you start
You need about 1.5 GB of free space in a throwaway database. Our numbers: PostgreSQL 18.3 on an Apple Silicon laptop, each query’s second run, with parallel workers disabled so the plans are easy to compare (SET max_parallel_workers_per_gather = 0;).
SELECT setseed(0.5);
CREATE TABLE ev (id bigint, created_at timestamptz, status text, email text);
INSERT INTO ev
SELECT g, timestamptz '2025-01-01' + g * interval '6 seconds',
CASE WHEN random() < 0.01 THEN 'pending' ELSE 'done' END,
'user' || (g % 200000) || '@example.com'
FROM generate_series(1, 5000000) g;
CREATE TABLE ev_shuffled AS SELECT * FROM ev ORDER BY random();
VACUUM ANALYZE ev;
VACUUM ANALYZE ev_shuffled;
ev is 365 MB: five million events, one every six seconds, in arrival order. About 1% are pending, and every email appears 25 times. ev_shuffled holds the same rows in random order.
1. BRIN for time ranges
Count one day’s events (14,400 rows) with no index, a B-tree, and a BRIN index:
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM ev WHERE created_at >= '2025-03-01' AND created_at < '2025-03-02';
CREATE INDEX ev_btree ON ev (created_at); -- measure, then DROP INDEX ev_btree;
CREATE INDEX ev_brin ON ev USING brin (created_at);
CREATE INDEX evs_brin ON ev_shuffled USING brin (created_at);
| Index | Time | Pages read | Index size | Yours |
|---|---|---|---|---|
| None | 195 ms | 46,727 | — | |
| B-tree | 0.8 ms | 43 | 107 MB | |
| BRIN | 1.8 ms | 259 | 32 kB | |
BRIN on ev_shuffled (forced with SET enable_seqscan = off) | 279 ms | 46,787 |
BRIN was a little slower than the B-tree and about 3,400 times smaller. On the shuffled copy it was useless: every block range spanned the whole year, so it read the entire table, more slowly than a plain scan. Left alone, the planner did not use it there. Check SELECT correlation FROM pg_stats WHERE tablename = 'ev' AND attname = 'created_at';: 1.000 here, −0.002 for the shuffled table.
2. Partial index for the rows you want
The work queue needs the newest pending events, 1% of the table:
SELECT id FROM ev WHERE status = 'pending' ORDER BY created_at DESC LIMIT 20;
CREATE INDEX ev_status_created ON ev (status, created_at); -- measure, then drop
CREATE INDEX ev_pending ON ev (created_at) WHERE status = 'pending';
| Index | Pages read | Index size | Yours |
|---|---|---|---|
(status, created_at) on every row | 17 | 150 MB | |
(created_at) WHERE status = 'pending' | 16 | 1,112 kB |
Both find 20 rows instantly. The partial index does it while holding entries for only 1% of the table, and costs nothing when the 99% of done events are inserted. It can only serve queries that ask for pending events: WHERE status = 'done' ORDER BY created_at DESC LIMIT 20 could not use it, and took 674 ms.
3. Hash against B-tree
A hash index looks ideal for email = '...': it supports only equality, and goes straight to a bucket.
CREATE INDEX ev_email_btree ON ev (email); -- measure, then drop
CREATE INDEX ev_email_hash ON ev USING hash (email);
SELECT count(*) FROM ev WHERE email = 'user4242@example.com';
Index on email | Pages read | Index size | Yours |
|---|---|---|---|
| B-tree | 5 | 40 MB | |
| Hash | 26 | 142 MB |
The B-tree won on both counts. Each email appears 25 times, and B-tree deduplication stores each repeated key once with a list of row pointers. A hash index stores an entry per row, and rows with the same value pile into the same bucket’s overflow pages. The documentation says hash indexes suit unique or nearly unique data. A hash index also cannot help LIKE 'user4242@%', which read the whole table.
The worst case
On a separate one-million-row table, we put a hash index on status, which has only two values:
| Index | Build time | Size |
|---|---|---|
Hash on status | 2 min 35 s | 47 MB |
Partial B-tree (id) WHERE status = 'pending' | 81 ms | 232 kB |
Two values means two enormous buckets, each a long chain of overflow pages.
Clean up
DROP TABLE ev, ev_shuffled;
Check yourself
Why was the BRIN index about 3,400 times smaller than the B-tree on
created_at?Show answer
It stores one minimum and maximum per range of blocks instead of one entry per row. For five million rows that is a handful of pages.
When can a query use
CREATE INDEX ... WHERE status = 'pending'?Show answer
Only when its own condition guarantees
status = 'pending', so that every row it needs is in the index.Why was the hash index on
emaillarger than the B-tree?Show answer
Each email appears 25 times. The B-tree deduplicated repeated keys into one entry with a list of rows; the hash index kept an entry per row in overflow chains.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 65.5 BRIN IndexesPrimary· §65.5 BRIN Indexes
BRIN stores summary information per block range; it is designed for very large tables whose columns correlate with physical location.
- 2.PostgreSQL documentation: 11.8 Partial IndexesPrimary· §11.8 Partial Indexes
A partial index contains entries only for rows satisfying its predicate and can be used only by queries whose condition implies it.
- 3.PostgreSQL documentation: 65.6 Hash IndexesPrimary· §65.6 Hash Indexes
Hash indexes support only =; uneven hash distributions create overflow chains that must be scanned, so an unbalanced hash index can be worse than a B-tree; they suit unique or nearly unique data.
- 4.PostgreSQL documentation: 65.1 B-Tree IndexesPrimary· §65.1.4.3 Deduplication
B-tree deduplication stores repeated keys once with a list of row pointers.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: 65.5 BRIN IndexesPrimary· §65.5.1 Introduction
How pages_per_range trades index size against precision, and how new rows get summarised.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.