Skip to content
EL
Contents

PostgreSQL index types

Lab: BRIN, partial and hash indexes measured

LabAdvanced4 min read40 min hands-on#indexing#query-planning

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);
IndexTimePages readIndex sizeYours
None195 ms46,727—
B-tree0.8 ms43107 MB
BRIN1.8 ms25932 kB
BRIN on ev_shuffled (forced with SET enable_seqscan = off)279 ms46,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';
IndexPages readIndex sizeYours
(status, created_at) on every row17150 MB
(created_at) WHERE status = 'pending'161,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 emailPages readIndex sizeYours
B-tree540 MB
Hash26142 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:

IndexBuild timeSize
Hash on status2 min 35 s47 MB
Partial B-tree (id) WHERE status = 'pending'81 ms232 kB

Two values means two enormous buckets, each a long chain of overflow pages.

Clean up

DROP TABLE ev, ev_shuffled;

Check yourself

  1. 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.

  2. 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.

  3. Why was the hash index on email larger 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. 1.
    PostgreSQL documentation: 65.5 BRIN Indexes
    Primary· §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. 2.
    PostgreSQL documentation: 11.8 Partial Indexes
    Primary· §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. 3.
    PostgreSQL documentation: 65.6 Hash Indexes
    Primary· §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. 4.
    PostgreSQL documentation: 65.1 B-Tree Indexes
    Primary· §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. 1.
    PostgreSQL documentation: 65.5 BRIN Indexes
    Primary· §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.