Indexes: structure, column order and cost
An index lets Postgres read a few pages instead of the whole table. This sheet records real plans on a table of 1 million rows. It shows which index Postgres uses, how many pages it reads, and what each index costs on writes.
- 1An index is a sorted copy of some columns, each entry pointing to its row. A lookup reads about 3 index pages.
- 2Column order: equality columns first, then the sort or the range. The index then returns rows already in order.
- 3A covering index answers from the index alone, if the visibility map says the pages are all-visible.
- 4Every index costs every write. In the lab, 8 secondary indexes cut inserts from 345,748 to 21,695 rows a second.
Time: 60.7 ms against 0.081 ms, median of 7 runs. Pages read predict time better than any other number in a plan.
Without an index Postgres reads every page. With one, it reads a few index pages, then one table page per matching row.
| type | serves | status |
|---|---|---|
| B-tree | =, <, >, BETWEEN, ORDER BY, IS NULL | Default |
| Hash | = only | Equality only |
| GIN | Arrays @>, JSONB @> ?, full text @@, trigrams | Many values per row |
| GiST | Ranges &&, geometry, nearest neighbour <-> | Ranges, shapes |
| SP-GiST | Points, IP prefixes, text prefixes | Space partitions |
| BRIN | Ranges on a column in insert order | Physical order |
GiST also backs exclusion constraints (sheet T8). Trigram search needs the pg_trgm extension.
I use a B-tree unless the operator needs another type: GIN for arrays and JSONB, GiST for ranges, BRIN for huge append-only tables.
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id int1 NOT NULL,
status text NOT NULL,
created_at timestamptz2 NOT NULL,
total_cents int NOT NULL,
email text NOT NULL,
tags text[] NOT NULL
) WITH (autovacuum_enabled = off3);- 1About 100 orders per customer, spread through the table. A customer's rows sit on different pages.
- 2Rises with id, so it follows the physical order of the table. BRIN depends on that.
- 3The lab runs VACUUM itself, so every recorded plan sees the same visibility map.
- table
- 1,000,000 rows, 12,508 pages of 8 kB, 98 MB
- values
- 10,000 customers. Status: delivered 80%, shipped 10%, pending 5%, cancelled 4%, refunded 1%. One year of dates, in id order. The gift tag on 1% of rows.
- indexes
- (customer_id) 871 pages; two columns 3,853; with INCLUDE 4,954
I size an index against the table: here 1 million rows are 98 MB, and one integer index is 7 MB.
An index entry is the key plus a pointer to the row. The entries are sorted, so equal keys and ranges sit together; the rows they point to do not.
SELECT created_at, total_cents FROM orders WHERE customer_id = 4242 AND created_at >= '2026-07-01' ORDER BY created_at DESC LIMIT 10CREATE INDEX orders_customer_created ON orders (customer_id, created_at)- Limit
- Index Scan Backward on orders_customer_created on ordersIndex Cond: ((customer_id = 4242) AND (created_at >= '2026-07-01 00:00:00+00'::timestamp with time zone))
- pages read
- 13
- rows returned
- 10
- time
- under 0.1 ms
- index size
- 3,853 pages
The customer and the date range form one run of entries, in date order. Postgres reads it backward and stops at 10 rows: 13 pages, no sort.
Plans, rows and pages are exact and the same on every run. Times are medians of 7 runs on a shared 8-core laptop, shown as bands. Settings: max_parallel_workers_per_gather = 0; jit = off; synchronize_seqscans = off; TimeZone = 'UTC'.
I read the plan: which scan, how many pages, whether it sorts. The right composite index turns a sort and 113 pages into 13 pages and no sort.
index_for(query):
key = columns compared with = // any order among them1
IF the query sorts:
key += the ORDER BY columns // rows come out in order2
key += ONE column compared with a range3
include = other columns it selects // an index-only scan4
IF it always filters one value:
index only those rows // a partial index
IF it wraps a column in a function:
index the expression, not the column
check it:
EXPLAIN (ANALYZE, BUFFERS) query
read the pages, not only the time5- 1Their order does not change this query. Choose it so that other queries can use the same leading columns.
- 2The sort column must follow the equality columns directly. Then the plan has no Sort step and can stop at LIMIT.
- 3The scan seeks on the columns up to the first range. Columns after a range only filter entries inside the index.
- 4INCLUDE stores extra columns in the leaf pages without making them part of the key.
- 5Times change with the cache. Pages read are the same on every run and predict the time.
Tested source Go: the claims checked on the recorded plans
// The claims the sheet makes, checked on the recorded plans.
p := func(set, q string) []Node { return out.Plans[set+"|"+q] }
if s := scan(p("none", "customer")); s.Type != "Seq Scan" || s.Buffers != out.TablePages {
t.Errorf("no index: %s over %d pages, want a Seq Scan over all %d", s.Type, s.Buffers, out.TablePages)
}
if !slices.ContainsFunc(p("single", "recent"), func(n Node) bool { return n.Type == "Sort" }) {
t.Error("single-column index: the recent query should still sort")
}
if slices.ContainsFunc(p("composite", "recent"), func(n Node) bool { return n.Type == "Sort" }) {
t.Error("composite index: the recent query should read in index order, with no sort")
}
if a, b := root(p("composite", "recent")).Buffers, root(p("single", "recent")).Buffers; a*5 > b {
t.Errorf("composite: %d pages for the recent query, single: %d; want 5 times fewer", a, b)
}
if a, b := root(p("wrong-order", "recent")).Buffers, root(p("composite", "recent")).Buffers; a < 5*b {
t.Errorf("wrong order: %d pages for the recent query, right order: %d; want 5 times more", a, b)
}
if a, b := root(p("wrong-order", "one-day")).Buffers, root(p("composite", "one-day")).Buffers; a*10 > b {
t.Errorf("one day: %d pages with created_at first, %d with customer_id first; want 10 times fewer", a, b)
}
cov := scan(p("covering", "recent"))
if cov.Type != "Index Only Scan Backward" || !slices.Contains(cov.Detail, "Heap Fetches: 0") {
t.Errorf("covering index: %s %v, want an index-only scan with no heap fetches", cov.Type, cov.Detail)
}| query | right index | pages |
|---|---|---|
| WHERE customer_id = ? | (customer_id) | 113 |
| ... AND created_at >= ? ORDER BY created_at DESC LIMIT 10 | (customer_id, created_at) | 13 |
| the same, selecting created_at and total_cents only | ... INCLUDE (total_cents) | 4 |
| WHERE created_at in one day | (created_at, ...) | 15 |
An order that is wrong for one query can be right for another. Design each index for a named query.
I put the equality columns first. Then the column the query sorts by or ranges over. One customer's recent orders become one run of entries, read backward.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Postgres | Multi-column B-tree, read in both directions | Equality, range and ORDER BY from one index. DESC needs no second index. | Unique constraints, keyset paging |
| Postgres | B-tree deduplication (version 13 and later) | A repeated key is stored once per page with a list of row pointers. (customer_id) takes 871 pages for 1 million rows. | Low-cardinality columns |
| Postgres | INCLUDE columns (version 11 and later) | Extra columns in the leaf pages, outside the key. An index-only scan without a wider key. | Unique index plus payload |
| Postgres | Partial index: WHERE in CREATE INDEX | Index only the rows a query asks for: 34 times smaller here. | Unique among active rows |
| Postgres | Expression index | Index lower(email) or any immutable expression the query uses. | Case-insensitive lookups |
| Postgres | GIN | One entry per element of an array, JSONB document or text. | Full-text search, tags |
| Postgres | GiST and SP-GiST | Ranges, geometry and nearest-neighbour search. | Exclusion constraints (sheet T8) |
| Postgres | BRIN (128 pages per range by default) | The minimum and maximum per block range: 3 pages for 1 million rows. | Logs, events, time series |
| Postgres | Visibility map and index-only scans | Skip the table for pages that VACUUM marked all-visible. | Faster VACUUM of frozen pages |
| Postgres | HOT updates | An update that changes no indexed column, and fits on its page, writes no index entry. | Counters, status columns |
| Postgres | CREATE INDEX CONCURRENTLY | Build an index while writes continue. It takes longer and scans the table twice. | REINDEX CONCURRENTLY |
| Postgres | pg_stat_user_indexes (idx_scan) | Find indexes that no query uses, then drop them. | Capacity reviews |
| Postgres | EXPLAIN (ANALYZE, BUFFERS) | The plan, the rows and the pages each step read. | Every slow query |
| Postgres | Planner statistics (ANALYZE) | Limit Plans follow estimated rows. Stale statistics after a bulk load give wrong plans. |
Postgres gives me multi-column B-trees with INCLUDE, partial and expression indexes, GIN, GiST and BRIN, and EXPLAIN with page counts to check each one.
| step | plan Postgres chose | pages read | rows | time | index size | |
|---|---|---|---|---|---|---|
| Selectivity: one index on status, two values | ||||||
status = refunded, 1% of rowsSELECT sum(total_cents) FROM orders WHERE status = 'refunded' | Bitmap Heap Scan on ordersHeap Blocks: exact=6892 lossy=0 | 6,904 | 9,995 | 1 to 10 ms | orders_status: 861 pages | Index used |
status = delivered, 80% of rowsSELECT sum(total_cents) FROM orders WHERE status = 'delivered' | Seq Scan on ordersRows Removed by Filter: 199777 | 12,508 | 800,223 | over 100 ms | orders_status: 861 pages | Index not used |
| A function on the column | ||||||
a function on the columnSELECT sum(total_cents) FROM orders WHERE lower(email) = '[email protected]' | Seq Scan on ordersRows Removed by Filter: 999889 | 12,508 | 111 | over 100 ms | orders_email: 902 pages | Index not used |
the bare columnSELECT sum(total_cents) FROM orders WHERE email = '[email protected]' | Bitmap Heap Scan on ordersHeap Blocks: exact=110 lossy=0 | 113 | 111 | under 0.1 ms | orders_email: 902 pages | Index used |
an index on the expressionSELECT sum(total_cents) FROM orders WHERE lower(email) = '[email protected]' | Bitmap Heap Scan on ordersHeap Blocks: exact=110 lossy=0 | 113 | 111 | under 0.1 ms | orders_email_lower: 902 pages | Index used |
| Partial index: only the rows the query wants | ||||||
an index on every rowSELECT id, total_cents FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 20 | Index Scan on orders_status_created on orders | 11 | 20 | under 0.1 ms | orders_status_created: 4,795 pages | Indexes every row |
a partial index, pending rows onlySELECT id, total_cents FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 20 | Index Scan on orders_pending on orders | 10 | 20 | under 0.1 ms | orders_pending: 139 pages | 34× smaller |
| GIN: an element inside an array | ||||||
no indexSELECT count(*) FROM orders WHERE tags @> '{gift}' | Seq Scan on ordersRows Removed by Filter: 989900 | 12,508 | 10,100 | over 100 ms | none | Full scan |
a GIN index on the arraySELECT count(*) FROM orders WHERE tags @> '{gift}' | Bitmap Heap Scan on ordersHeap Blocks: exact=6994 lossy=0 | 7,000 | 10,100 | 1 to 10 ms | orders_tags: 142 pages | Index used |
| BRIN: a column in insert order | ||||||
a B-tree on created_atSELECT sum(total_cents) FROM orders WHERE created_at >= '2026-03-10' AND created_at < '2026-03-11' | Index Scan on orders_created on orders | 46 | 2,739 | 0.1 to 1 ms | orders_created: 2,745 pages | Fewest pages |
a BRIN index on created_atSELECT sum(total_cents) FROM orders WHERE created_at >= '2026-03-10' AND created_at < '2026-03-11' | Bitmap Heap Scan on ordersRows Removed by Index Recheck: 17734Heap Blocks: exact=0 lossy=256 | 258 | 2,739 | 1 to 10 ms | orders_created_brin: 3 pages | 915× smaller |
| Pagination: OFFSET against keyset | ||||||
OFFSET 500000SELECT id, created_at FROM orders ORDER BY created_at, id OFFSET 500000 LIMIT 20 | Index Only Scan on orders_created_id on ordersHeap Fetches: 0 | 1,919 | 500,020 | 10 to 100 ms | orders_created_id: 3,848 pages | Reads 500,020 rows |
keyset: after the last row seenSELECT id, created_at FROM orders WHERE (created_at, id) > ('2026-04-01 11:59:28.464', 500000) ORDER BY created_at, id LIMIT 20 | Index Only Scan on orders_created_id on ordersHeap Fetches: 0 | 4 | 20 | under 0.1 ms | orders_created_id: 3,848 pages | Reads 20 rows |
- 1% of rows can sit on 55% of the pages. Selectivity is about pages, not rows.
- BRIN stores a minimum and a maximum per 128 pages. It suits append-only tables and reads some extra pages per query.
- Keyset paging needs an index on the sort columns, with a unique column last to break ties.
An index helps when it cuts the pages read. A common value, a function on the column or a deep OFFSET can make it useless.
| step | plan Postgres chose | pages read | rows | time | |
|---|---|---|---|---|---|
| Covering index on (customer_id) INCLUDE (total_cents) | |||||
after VACUUMSELECT sum(total_cents) FROM orders_vm WHERE customer_id = 42 | Index Only Scan on orders_vm_customer on orders_vmHeap Fetches: 0 | 4 | 89 | under 0.1 ms | No table visit |
after an UPDATE of the customer's rowsSELECT sum(total_cents) FROM orders_vm WHERE customer_id = 42 | Index Only Scan on orders_vm_customer on orders_vmHeap Fetches: 89 | 17 | 89 | under 0.1 ms | Until VACUUM |
after VACUUM againSELECT sum(total_cents) FROM orders_vm WHERE customer_id = 42 | Index Only Scan on orders_vm_customer on orders_vmHeap Fetches: 0 | 4 | 89 | under 0.1 ms | No table visit |
An index-only scan skips the table only for pages VACUUM marked all-visible. After heavy updates it visits the table again until the next VACUUM.
| secondary indexes | rows a second | log bytes a row | against none |
|---|---|---|---|
| 0 | 345,748 | 192 | baseline |
| 1 | 201,322 | 331 | 1.7× slower |
| 2 | 163,702 | 397 | 2.1× slower |
| 4 | 75,964 | 615 | 4.6× slower |
| 8 | 21,695 | 1,538 | 15.9× slower |
100,000 rows into a table of 1,000,000, 1,000 rows per transaction, best of 2 rounds. Eight indexes no longer fit in shared_buffers, so each insert also waits on page reads.
Each index is another B-tree insert on every write. I keep only the indexes a named query needs, and check idx_scan before I add more.
| failure | what happens | fix | fixed with |
|---|---|---|---|
| Unused indexes | Every write pays for them, and they take memory from useful pages. | Find idx_scan = 0 over a full business cycle, then drop them. | pg_stat_user_indexes |
| Wrong column order | A full index scan: 3,944 pages instead of 114. | Equality columns first, then the sort or the range. | Composite index |
| A function on the column | The index is not used: a Seq Scan. | Index the expression, or rewrite the condition on the bare column. | Expression index |
| A common value | The planner ignores the index, which still costs every write. | Drop it, or make it partial on the rare values. | Partial index |
| CREATE INDEX on a busy table | Writes to the table wait until the build ends. | Build with CONCURRENTLY. A failed build leaves an invalid index to drop. | CONCURRENTLY |
| Stale statistics | After a bulk load, the planner picks a scan for the old row counts. | Run ANALYZE after large loads; tune autovacuum to analyze sooner. | ANALYZE |
| Index bloat | Heavy updates and deletes leave pages half empty. Scans read more pages. | REINDEX CONCURRENTLY; keep updates HOT where possible. | REINDEX |
| Deep OFFSET | Page 25,000 of a list reads 1,919 pages. | Keyset pagination on an index of the sort columns. | Keyset |
| Heap fetches in an index-only scan | The scan reads table pages again after updates. | Let autovacuum run often enough on hot tables. | VACUUM |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | The primary key only. | Lookups by id. Any other filter scans 12,508 pages: about 14 queries a second per core. | A query with a Seq Scan on a large table, on a hot path. |
| 2 | One composite index per hot query, equality columns first. | About 59,000 of those queries a second per core. | The plan still visits the table for every row. |
| 3 | Covering, partial and expression indexes for the hottest queries. Drop unused ones. | Index-only scans: about 71,000 of those queries a second per core. | Queries inside arrays or JSONB, or range queries over a huge log table. |
| 4 | GIN and BRIN, and time partitions for logs. | Element and document queries; time ranges from tiny indexes. | Ranked full-text search, fuzzy matching, or many filters in any combination. |
| 5 | A search engine fed from Postgres by change data capture. | Relevance ranking and facets across many fields. | Top of the ladder. |
Queries a second per core is 1,000 divided by the measured time in ms for the "latest 10" query: 1,000 / 0.017 for the composite index. Network and connection costs come on top. Demand example: 500 order-history views a second, 10× at peak.
I start with the primary key and add one index per slow, named query. I move search and analytics to their own stores only when Postgres indexes cannot serve them.
0 of 9 known
The index is (customer_id, created_at). A query filters on created_at only. Does Postgres use the index?
Why does status = 'delivered' scan the table even with an index on status?
Only 1% of the rows are refunded, yet the plan reads 6,904 of 12,508 pages. Why?
The query uses WHERE lower(email) = ... and there is an index on email. Why a Seq Scan?
An index-only scan reports Heap Fetches: 89. Why, and what fixes it?
Why is OFFSET 500000 slow even with an index on the sort order?
Which index for WHERE status = 'pending' ORDER BY created_at LIMIT 20, when 5% of rows are pending?
You add 8 secondary indexes to a write-heavy table. What happens to inserts?
When does a BRIN index beat a B-tree?
- table
- 1 million rows of about 100 bytes: 12,508 pages, 98 MB.
- lookup
- 3 index pages, then one table page per row: 113 pages for 111 rows.
- scan
- A Seq Scan of 1 million rows: tens of ms on one core (60.7 ms measured).
- index size
- One int column 871 pages; int and timestamp 3,853; BRIN 3.
- selectivity
- 1% of rows touched 55% of the pages.
- writes
- 0, 1, 4, 8 indexes: 346,000, 201,000, 76,000, 22,000 rows a second.
- paging
- OFFSET 500,000: 1,919 pages. Keyset: 4.
Postgres 16 on an 8-core laptop shared with other jobs. Pages are exact; rates and times are orders of magnitude.