Skip to content
Software Atlas

Indexes & B-Trees

How an index turns a scan of millions of rows into a handful of page reads: B-trees and page splits, range scans, composite and covering indexes, reading EXPLAIN, why the planner sometimes ignores your index — and what every index costs you on writes.

⏱ ~45 min read14 sections5 interactive widgetsPostgreSQL · Go · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceA database index is a sorted copy of one or more columns, with a pointer to where each row lives. Because it's sorted, the database can jump to the value you want instead of reading the whole table.

Here is a real experiment, run for this page on PostgreSQL 16. A users table holds 5 million rows (423 MB). We ask for one user by email:

SELECT * FROM users WHERE email = 'user4242424@example.com';
Pages readTime
No index54,121 (the whole table)297 ms
With an index on email40.05 ms

That's about 6,000× faster, and it's a best case for the slow version: the table was already cached in memory. On a cold disk, a bigger table, or a busy server, the no-index query takes seconds — for every single login.

The race below replays the real measurement. Each bar is the number of pages a plan reads: the top lane reads the table front to back; the bottom lane follows the index.

Page-read race
Same query, same 5M-row table: without and with an index. Real page counts, real timings.
SELECT * FROM users WHERE email = 'user4242424@example.com'— 1 of 5,000,000 rows
Seq Scanreads every table page, keeps 1 row
pages read: 0 / 54,121
Index Scan3 index pages + 1 table pageplanner's choice
pages read: 0 / 4
Bar length = pages read. Lanes finish in proportion to the real execution time.
Press start. Which lane do you expect to win?

What you'll learn

  1. What a table looks like on disk, and why "pages read" is the number that matters (§02).
  2. How a B-tree finds one key among millions in 3–4 steps, and what happens when a page fills up (§03–§04).
  3. Composite and covering indexes — the difference between an index that helps and one that sits unused (§05–§06).
  4. How to read EXPLAIN and why the database sometimes ignores your index on purpose (§07–§09).
  5. How to check plans and timings from Go and JavaScript code (§10).
  6. What indexes cost: every one makes writes slower and takes space (§11–§12).
Foundations§02

Tables, pages and the full scan

A table is a spreadsheet that lives on disk

A Database such as PostgreSQL keeps data in tables. A table is like a spreadsheet: each row is one record (one user) and each column is one field (email, country) — see Table / row / column. Every row also has a Primary key, here a number id.

This page uses one table throughout. Here is how it was created — the same table, 5 million rows, behind every number on this page:

CREATE TABLE users (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- 1, 2, 3, …
  email      text NOT NULL,
  last_name  text NOT NULL,
  first_name text NOT NULL,
  country    text NOT NULL,        -- 'US' for 40% of rows … 'IS' for 0.01%
  created_at timestamptz NOT NULL, -- one new user per minute since 2020
  deleted_at timestamptz           -- set for ~5% of rows ("soft-deleted")
);

SELECT id, email FROM users WHERE country = 'VN' ORDER BY created_at LIMIT 10;

Data is read in pages, not rows

The database doesn't store rows one by one on disk. It groups them into fixed-size blocks called pages — 8 KB each in Postgres. Our 5 million users fit about 92 rows per page, so the table is 54,121 pages (423 MB).

The disk always hands over whole pages. To read one 60-byte row, Postgres reads its whole 8 KB page. So the honest measure of how much work a query did is how many pages it touched, not how many rows it returned.

Postgres calls the table file the heap: rows go wherever there's free space, in no particular order. Every row has an address, its Row pointer — "page 45,860, slot 40" for the user we looked up in §01.

Without an index: read everything

Ask for one email and the database has no idea which page holds it. The heap isn't sorted by email (or by anything else). The only safe plan is a Full table scan: read all 54,121 pages and test every row.

Postgres will tell you what it did if you put EXPLAIN (ANALYZE, BUFFERS) in front of a query (§07 teaches how to read this output properly). For this page we turned off parallel query with SET max_parallel_workers_per_gather = 0, so each plan has one simple scan that's easy to read:

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users WHERE email = 'user4242424@example.com';
--  Seq Scan on users  (actual time=263.306..296.923 rows=1 loops=1)
--    Filter: (email = 'user4242424@example.com'::text)
--    Rows Removed by Filter: 4999999        ← checked 5 million rows to keep 1
--    Buffers: shared hit=11960 read=42161   ← 54,121 pages touched
--  Execution Time: 296.968 ms

Buffers: shared hit=… read=… counts pages. A hit was already in the database's memory (its Buffer cache); a read had to come from the operating system or the disk. Add them up: 11,960 + 42,161 = 54,121, the whole table.

With an index: a sorted list of (email → row address)

CREATE INDEX users_email_idx ON users (email);   -- took 3.7 s to build here

The index is a separate structure that holds, for every row, the email and that row's address, kept sorted by email. Nothing else from the row is copied. Because it's sorted, finding one email is like finding a word in a dictionary — you never read the words that come before it.

An index points into the table
INDEX on email (sorted)alice@mail.com→ (2,1)bob@mail.com→ (0,3)carol@mail.com→ (3,2)dan@mail.com→ (0,1)erin@mail.com→ (1,2)TABLE (heap): pages 0–3, unsortedpage 0danfrankbobpage 1ginaerinhalpage 2aliceivanjopage 3kimcarollee
Left: the index on email, sorted. Right: the table (heap), rows in arrival order, 3 per page in this toy. A lookup for carol finds her entry in the sorted index, reads the address (3,2) = page 3, slot 2, and reads exactly that one table page.

The same query now:

--  Index Scan using users_email_idx on users  (actual time=0.031..0.032 rows=1 loops=1)
--    Index Cond: (email = 'user4242424@example.com'::text)
--    Buffers: shared hit=4                  ← 3 index pages + 1 table page
--  Execution Time: 0.051 ms

Four pages: three inside the index to find the entry, then one table page to fetch the row it points to. Why exactly three is the B-tree story in §03.

Practitioner§03

Inside a B-tree

In one sentenceA B-tree is a sorted tree where every node is one full disk page holding hundreds of keys. That makes it so wide and shallow that any key among millions is at most 3–4 page reads away.

First try: a sorted list and binary search

The simplest index is one big sorted list of (email, row address). To find an email you use Binary search: look at the middle entry, throw away the half that can't contain your email, repeat.

For 5 million entries that's up to 23 steps (2²³ ≈ 8 million). In memory, 23 steps is nothing. On disk, the early jumps are far apart, so each lands on a different page; only the last ~7 steps fall inside one page (an 8 KB page holds ~200 entries). That's still ~15 page reads per lookup — and inserting a new email in the middle means shifting everything after it. Neither is acceptable for a table that changes all day.

A binary Tree (each node: one key, two children) fixes the inserts, but not the height: still ~23 levels, and every node is its own little allocation, so up to ~23 pages per lookup.

The widget below puts the sorted list and a B-tree side by side over the same 5 million keys.

Sorted list vs B-tree: pages read for one lookup
Finding entry #4,242,423 among 5 million sorted entries, 200 per 8 KB page (25,000 pages).
Binary search over one big sorted liststeps: 0 · page reads: 0
page 0page 25,000
—
B-tree: each node is a whole pagepage reads: 0
root page
1 page: which of ~200 ranges?
→
internal page
1 page: narrow to ~200 leaves
→
leaf page 21,212
1 page: the entry itself
Each orange line is a page binary search had to read. Watch where they land.

The B-tree idea: make each node a whole page

Since the disk reads a whole page anyway, put as many keys as fit into each node. An 8 KB page holds a few hundred short keys. A node with 200 keys has 201 children — the tree's Fan-out is ~200, not 2.

Each level multiplies the reach by the fan-out:

Levels (pages read per lookup)Keys reachable with fan-out ≈ 200
1200
2200 × 200 = 40,000
3200³ = 8,000,000
4200⁴ = 1,600,000,000

One extra level buys 200× more rows. That's why a lookup costs about the same on a table of 1 million rows and a table of 1 billion: 3 page reads versus 4.

B-tree vs B+tree

The textbook B-tree stores entries in every node. Databases use the variant called a B+tree, and so does the playground below:

  • All entries live in the leaves. Internal pages only hold signpost keys: "keys below 30 go left, 30 and up go right".
  • Every leaf links to its neighbour. Once you've found where a value starts, you can walk the leaves left to right and read keys in sorted order — §04 uses this for ranges and ORDER BY.
  • All leaves sit at the same depth, so every lookup costs the same number of reads. The tree is balanced.

(Everyone, including Postgres's docs, still calls it a "B-tree". So will we.)

Inserting: what happens when a page fills up

A new key goes into the leaf where it belongs, in sorted position. If that leaf is already full, the database does a Page split:

  1. Split the full leaf into two half-full leaves.
  2. Copy the first key of the new right-hand leaf up into the parent as a new signpost.
  3. If the parent is now over-full too, split it the same way, and so on upward.
  4. If the root splits, a new root is created above it. That's the only way the tree gets taller — it grows at the top, so all leaves stay at the same depth.

The playground uses tiny pages (3 or 4 keys) so you can watch splits happen. Real pages hold hundreds of keys, so splits are rarer — but they happen all the time on a busy table.

B+tree playground
Insert keys and watch pages split; find a key or scan a range and count the pages read.
keys: 7pages: 4height: 2 levelspages read by the last search: —
3050·1020·3040·506070
internal page (signposts)leaf page (the entries)changed by the last insert- - → leaf chain
|
rangeto
Each box is one page (here max 3 keys — real pages hold hundreds). Insert some keys, then find one.

Things to try:

  • Press insert 1…12 in order, then find any key. Count the highlighted nodes: that's the number of pages a lookup reads, and it equals the height.
  • Keep inserting random keys and watch the height counter: it grows only when the root splits. With these tiny 3-key pages a new level appears every ~3× growth; with real ~200-key pages, only every ~200× growth.
  • Inserting in order keeps splitting only the rightmost leaf; the left leaves stay half-full. Postgres notices this pattern and splits the rightmost page unevenly (about 90% / 10%) so sequential keys fill pages almost completely. We'll see why that matters for random IDs in §12.
Practitioner§04

Ranges, ORDER BY and the leaf chain

In one sentenceBecause a B-tree keeps keys sorted and its leaves are linked, a range query is one lookup to find the start, then a walk along the leaves until the end. Sorting comes free.

Range queries

A Range query asks for everything between two bounds:

CREATE INDEX users_created_idx ON users (created_at);

SELECT * FROM users
WHERE created_at >= '2024-03-01' AND created_at < '2024-03-02';  -- one day of sign-ups

The database descends the tree once, to the first leaf entry >= '2024-03-01', then follows the leaf links to the right, returning entries until it reaches one >= '2024-03-02'. It never looks at the rest of the index.

Try it in the playground: enter two bounds in the range row, press range scan, and watch the search go down once and then sideways along the bottom row.

B+tree playground
Insert keys and watch pages split; find a key or scan a range and count the pages read.
keys: 12pages: 9height: 3 levelspages read by the last search: —
35··1525·510·1520·2530·4555·3540·4550·5560·
internal page (signposts)leaf page (the entries)changed by the last insert- - → leaf chain
|
rangeto
12 keys, 3 per page. Press “range scan” to find everything between the two bounds.

The plan Postgres picked for that one-day query on our 5 million rows (1,440 sign-ups per day):

--  Index Scan using users_created_idx on users  (cost=0.43..63.75 rows=1416 width=63)
--    Index Cond: ((created_at >= '2024-03-01 …') AND (created_at < '2024-03-02 …'))

rows=1416 is the planner's guess before running; the true count is 1,440 — close enough. §07 explains cost.

ORDER BY … LIMIT: no sorting needed

Leaves are already in order, so "the 10 newest users" means: jump to the right end of the created_at index and walk backwards 10 entries. No sort, no reading the other 4,999,990 rows:

SELECT id, email FROM users ORDER BY created_at DESC LIMIT 10;
--  Limit
--    ->  Index Scan Backward using users_created_idx on users

Without the index, the database must read every row and sort (or at least keep a running top 10) before it can return anything. This is also why page-by-page lists in apps ("next 20 posts") should be ordered by an indexed column.

Why a hash index can't do ranges

A Hash index runs each key through a Hash function and drops it into the matching bucket. Finding one exact key is one bucket lookup — very fast. But hashing deliberately scatters neighbouring keys:

keyhash bucket (made-up numbers)
2024-03-01 09:007,113
2024-03-01 09:01212
2024-03-01 09:0240,551

To answer "everything on March 1" you'd have to check every bucket — a full scan. For the same reason a hash index can't help ORDER BY, <, >, BETWEEN or LIKE 'abc%'.

Practitioner§05

Composite indexes and the leftmost-prefix rule

In one sentenceA Composite index on (last_name, first_name) is sorted like a phone book: by last name, then by first name within each last name. It can find "Nguyen", or "Nguyen, An" — but not "everyone called An".
CREATE INDEX users_name_idx ON users (last_name, first_name);

The phone-book test

Picture the entries in order:

last_namefirst_name
KimAn
KimBao
NguyenAn
NguyenBao
NguyenDuc
SmithAn
  • last_name = 'Nguyen' — the Nguyens sit together. Jump to the first one, read until the last one. ✅
  • last_name = 'Nguyen' AND first_name = 'An' — inside the Nguyens, the Ans sit together too. Jump straight there. ✅
  • first_name = 'An' — the Ans are scattered: one under Kim, one under Nguyen, one under Smith… No single place to jump to. ❌

That's the Leftmost-prefix rule: an index on (a, b, c) can be searched using a, a + b, or a + b + c — any prefix of the column list, starting from the left. Skip a and the sort order is useless.

Measured on 5 million users

QueryPlanTime
WHERE last_name = 'Kim42' AND first_name = 'An'uses users_name_idx on both columns, 48 rows0.27 ms
WHERE last_name = 'Kim42'uses users_name_idx on last_name, 484 rows2.8 ms
WHERE first_name = 'An'Seq Scan — index ignored354 ms

The order of conditions inside WHERE doesn't matter: first_name = 'An' AND last_name = 'Kim42' uses the index just the same. What matters is which columns have conditions.

Try it: will this query use the index?

The checker below uses an index on (last_name, first_name, city) over 16 rows. Pick a query and see which part of the index gets read (the contiguous slice) and which rows actually match.

Will this query use the index?
Index: CREATE INDEX ON people (last_name, first_name, city). Pick a query.
#last_namefirst_namecity
1KimAnHanoi
2KimAnSeoul
3KimBaoBusan
4KimBaoSeoul
5KimMiaSeoul
6LeeAnBusan
7LeeDucHanoi
8LeeMiaSeoul
9NguyenAnHanoi✓
10NguyenAnHue✓
11NguyenBaoHanoi✓
12NguyenDucHue✓
13NguyenMiaHanoi✓
14SmithAnAustin
15SmithLiamBoston
16SmithMiaAustin
contiguous slice the index readsno seek: every entry/row must be read✓ returned
✅ Uses the index fully
entries read: 5 · rows returned: 5
Leftmost column → seek. All Nguyens sit next to each other: jump to the first, read until the last. Every entry read is a match.

Rules of thumb for column order

  1. Equality columns first, the range column last. In (last_name, created_at), the query last_name = 'Kim' AND created_at > '2024-01-01' finds the Kims, then reads one contiguous run of dates. In (created_at, last_name), it would read every row since January and check each last name.
  2. Put the column you always filter on first. If every query has tenant_id = $1 ($1 is a value your code passes in — §10), it goes first (very common in multi-customer apps).
  3. An index on (a, b) makes a separate index on (a) redundant — (a, b) already answers a-only queries. The reverse isn't true.
  4. Sorting follows the same rule. (last_name, first_name) serves ORDER BY last_name, first_name and WHERE last_name = $1 ORDER BY first_name without a sort step, but not ORDER BY first_name.
Practitioner§06

Covering indexes, heaps and clustered tables

In one sentenceEvery index lookup has two parts: find the entry in the index, then fetch the row from the table. If the index already holds every column the query asks for, the second part disappears.

The trip to the table is the expensive part

Remember §02: an index entry is just (key, row address). For SELECT *, the database follows each address to the table. For 484 matching rows spread across the table, that's up to 484 different table pages:

SELECT * FROM users WHERE last_name = 'Kim42';
--  Bitmap Heap Scan on users  … rows=484
--    Heap Blocks: exact=481                  ← 481 table pages visited
--    Buffers: shared hit=485                ← 481 table pages + 4 index pages
--  Execution Time: 0.443 ms

Now ask only for columns that are in the index (last_name, first_name):

SELECT first_name FROM users WHERE last_name = 'Kim42';
--  Index Only Scan using users_name_idx on users  … rows=484
--    Heap Fetches: 0                         ← never touched the table
--    Buffers: shared hit=24                 ← 24 pages instead of 485
--  Execution Time: 0.086 ms

That's an Index Only Scan: 20× fewer pages, ~5× faster, same index (both runs had every page in memory; when pages must come from disk, the gap in time grows towards the gap in pages). The index covers this query.

Making an index cover a query on purpose: INCLUDE

To cover SELECT first_name, email … WHERE last_name = $1, add email as a passenger column:

CREATE INDEX users_name_cov_idx ON users (last_name, first_name) INCLUDE (email);
--  Index Only Scan using users_name_cov_idx … Heap Fetches: 0 … Execution Time: 0.130 ms

A Covering index isn't free. On our table, adding email grew this index from 35 MB to 281 MB. The plain one was tiny because Postgres's B-tree deduplication stores a repeated (last_name, first_name) once; unique emails defeat that. Cover your hottest queries, not all of them.

The Postgres catch: the visibility map

Postgres keeps old versions of updated rows around for a while, so other transactions — groups of statements that succeed or fail as one unit, which must not see each other's half-done changes — can still read them. That design is called MVCC, explained in Transactions. The index alone can't tell whether an entry points at a row version your query is allowed to see.

So an Index Only Scan checks the Visibility map: one bit per table page meaning "everything on this page is visible to everyone". Bit set → trust the index. Bit cleared → go read the table page after all. That's what Heap Fetches counts. Any UPDATE — even one that writes the value a column already had, like the one below — creates a new row version, and that clears the bit on the pages involved:

UPDATE users SET deleted_at = NULL WHERE id % 3 = 0 AND last_name = 'Kim42';  -- touches 151 rows

SELECT first_name, email FROM users WHERE last_name = 'Kim42';
--  Index Only Scan …  Heap Fetches: 295   ← 295 entries pointed at pages whose bit was cleared

VACUUM users;   -- re-marks pages all-visible
--  Index Only Scan …  Heap Fetches: 0

VACUUM sets the bits again; normally the background autovacuum does this for you. On a table that's updated constantly, many pages are never all-visible, and an "index only" scan quietly turns into a normal index scan. Check Heap Fetches before celebrating.

Step through the same story:

The visibility map, step by step
Why the same Index Only Scan reads the table after an UPDATE, and stops again after VACUUM. Numbers are from the real run above.
Code
1-- Q = SELECT first_name, email … WHERE last_name = 'Kim42'
2Q; -- index-only scan (table just vacuumed)
3UPDATE users SET deleted_at = NULL
4 WHERE id % 3 = 0 AND last_name = 'Kim42';
5Q; -- same query again
6VACUUM users;
7Q; -- and once more
Table pages + visibility bit
p1 ✓ all-visible
p2 ✓ all-visible
p3 ✓ all-visible
p4 ✓ all-visible
p5 ✓ all-visible
p6 ✓ all-visible
Index Only Scan
idle
Plan says
Heap Fetches: —
The visibility map keeps one bit per table page (here 6 pages stand in for the real ones). VACUUM just ran, so every bit is set: every row on those pages is visible to every transaction.
1 / 6

Two ways to store a table: heap vs clustered

So far the table was a Heap — rows in no order, every index pointing at row addresses. That's Postgres. MySQL's default engine, InnoDB, does it differently:

Looking up a user by email: heap vs clustered
PostgreSQLpkey (id)id → addressemail indexemail → addressheap: rows in no particular orderMySQL InnoDBemail indexemail → idprimary-key tree = the table:leaves hold full rows, sorted by id
Postgres (left): every index, including the primary key's, points at a row address in the heap — one tree descent, then one table page. InnoDB (right): the table is the primary-key tree; a secondary index stores the primary key, so a lookup by email descends two trees. The moving dot is the lookup.
  • InnoDB: the table is a B-tree, sorted by primary key — a Clustered index. The leaves hold the full rows. Looking up by primary key finds the row in the leaf: no second trip.
  • Every other InnoDB index (a Secondary index) stores the primary key value, not an address. A lookup by email finds the id in the email index, then searches the primary-key tree for that id: two tree descents.
  • Postgres: all indexes are equal. Every index, including the primary key's, points at a row address in the heap. One descent, then one heap page.
Postgres (heap)InnoDB (clustered)
Lookup by primary keyindex → heap pageone tree descent, row is in the leaf
Lookup by secondary indexindex → heap pagesecondary tree → primary-key tree
Range scan on primary keyrows may be scattered over many pagesrows are physically next to each other
A row is updatedthe new version gets a new address, so every index gets a new entry*secondary indexes only change if their own columns changed

* Unless Postgres can do a HOT update (heap-only tuple): when no indexed column changed and the new version fits on the same page, the indexes are left alone. One more reason not to index columns you update constantly.

Practitioner§07

Reading EXPLAIN

In one sentenceSQL says what you want; the Query planner decides how to get it. EXPLAIN shows you that decision, and EXPLAIN ANALYZE runs it and shows what really happened.

Two commands

EXPLAIN SELECT …;                       -- the plan + estimates. Doesn't run the query.
EXPLAIN (ANALYZE, BUFFERS) SELECT …;    -- runs it; adds real times, row counts, pages touched

EXPLAIN ANALYZE really executes the statement. For SELECT that's harmless. For UPDATE or DELETE, wrap it so the change is undone:

BEGIN;
EXPLAIN (ANALYZE, BUFFERS) DELETE FROM users WHERE deleted_at < '2025-01-01';
ROLLBACK;   -- undo: the rows come back

(BEGIN … ROLLBACK starts a transaction and throws it away; see Transactions for the details.)

A plan is a tree; read it from the inside out

Every line that names an operation (Limit, Sort, Seq Scan …) is a node: one step that produces rows. All nodes except the top one start with ->, and a node's children are indented below it. The lines without a node name (Filter:, Sort Key: …) are details of the node above them.

Execution works like a chain of "give me the next row" requests: the top node asks its child for a row, which asks its child, and so on down to a scan that actually reads pages. Rows flow upward, one at a time. So the deepest node is where the data comes from — read the plan from the bottom up.

NodeWhat it does
Seq ScanRead every page of the table (§02).
Index ScanWalk the index, fetch each matching row from the table.
Index Only ScanWalk the index; skip the table when the visibility map allows (§06).
Bitmap Index Scan → Bitmap Heap ScanFirst collect every matching row address from the index into a list of pages, then read those pages in disk order. A Bitmap scan is the middle ground for "more than a few rows, less than most".
SortSort rows in memory (or on disk if they don't fit).
LimitStop asking its child for rows after N — so the child stops early too.
Gather + Parallel …Split a scan across worker processes and merge their rows (a Parallel scan). This page turns it off for readability (§02).
Nested Loop / Hash Join / Merge JoinThree ways to combine two tables in a JOIN.

Every node carries two sets of numbers

Sort  (cost=1870.44..1871.69 rows=499 width=39) (actual time=0.991..0.993 rows=10 loops=1)
      └────────── estimates (before running) ─┘ └──────── reality (ANALYZE only) ──────┘
  • cost=startup..total — the planner's estimated Cost, in arbitrary units where reading one page sequentially = 1.0. Startup is the cost before the first row comes out; total is the cost to produce all rows. Only compare costs with each other — they aren't milliseconds.
  • rows=499 — estimated rows this node outputs. width=39 — estimated average row size in bytes.
  • actual time=0.991..0.993 — real milliseconds until the first row and until the last row.
  • rows=10 (actual) — the real row count. loops=1 — how many times the node ran (times are per loop).

Here is a complete real plan from our table. Step through it:

Reading a real plan
Step through the plan bottom-up. Then switch to the version with a better index.
Code
1Limit (cost=1870.44..1870.46 rows=10 width=39) (actual time=0.993..0.995 rows=10 loops=1)
2 -> Sort (cost=1870.44..1871.69 rows=499 width=39) (actual time=0.991..0.993 rows=10 loops=1)
3 Sort Key: created_at DESC
4 Sort Method: top-N heapsort Memory: 26kB
5 -> Bitmap Heap Scan on users (cost=8.30..1859.66 rows=499 width=39) (actual time=0.105..0.778 rows=484 loops=1)
6 Recheck Cond: (last_name = 'Kim42'::text)
7 Heap Blocks: exact=481
8 Buffers: shared hit=485
9 -> Bitmap Index Scan on users_name_idx (cost=0.00..8.18 rows=499 width=0) (actual time=0.054..0.054 rows=484 loops=1)
10 Index Cond: (last_name = 'Kim42'::text)
11 Buffers: shared hit=4
12Execution Time: 1.016 ms
Node doing the work
the whole plan
Rows passed up
—
Pages & time so far
—
The query: SELECT id, email, created_at FROM users WHERE last_name = 'Kim42' ORDER BY created_at DESC LIMIT 10 — the 10 newest users named Kim42. The only useful index is (last_name, first_name). Each named line is a node (all but the top one start with ->); children are indented under their parent. Parents ask their children for rows, so the data starts at the deepest node — we start at the bottom.
1 / 6

Estimated vs actual rows: the number to check first

The planner picks plans using its guesses of how many rows each step returns. Those guesses come from Table statistics: for every column, Postgres samples the table and stores the row count, the most common values and their frequencies, and a histogram of the rest. ANALYZE refreshes them (autovacuum runs it in the background as the table changes).

If the data changes faster than the statistics, the guesses go wrong — and so does the plan. An experiment: change 1 million rows to country = 'IS' (which used to match 510 rows), and ask before statistics catch up. (We switched autovacuum off for this table first — ALTER TABLE users SET (autovacuum_enabled = false) — so the background job couldn't fix the statistics behind our back. count(*) simply counts the matching rows.)

UPDATE users SET country = 'IS' WHERE id <= 1000000;
EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM users WHERE country = 'IS';
--  Index Only Scan using users_country_idx on users  (cost=0.43..263.10 rows=398 …)
--                                                    (actual … rows=1000407 loops=1)
--    Heap Fetches: 1000105
--  Execution Time: 191.999 ms

ANALYZE users;   -- refresh statistics, run the same query again
--  Bitmap Heap Scan on users  (cost=11702.56..88778.14 rows=995887 …)
--                             (actual … rows=1000407 loops=1)
--  Execution Time: 145.678 ms

The planner expected 398 rows and got 1,000,407 — off by 2,500×. It chose a plan built for a handful of rows (and the visibility map was stale too, so the "index only" scan visited the table a million times). After ANALYZE, the estimate is within 0.5%, the plan changes, and the query gets ~25% faster (192 → 146 ms). On a table that doesn't fit in memory, or inside a join where the bad estimate picks the wrong join method, the gap is usually far bigger. (We put the data back afterwards: from §08 on, 'IS' is 510 rows again.)

Practitioner§08

Why the planner skips your index

In one sentenceAn index is fast at finding a few rows. When a query matches a big share of the table, following the index means jumping all over the table — and simply reading it front to back is cheaper. The planner knows this.

Selectivity: what fraction of rows match?

A condition's Selectivity is the fraction of rows it matches. Our users.country column is deliberately lopsided:

ConditionRows matchedShare of table
email = 'user4242424@example.com'10.00002%
country = 'IS'5100.01%
country = 'VN'349,1877%
country IN ('US','IN','BR','NG')3,649,31073%

(IN ('US','IN',…) is shorthand for country = 'US' OR country = 'IN' OR ….)

With an index on country, Postgres used it for 'IS', used it (as a bitmap scan, explained below) for 'VN' — and refused to use it for the 73% query, choosing a Seq Scan.

Why: random reads vs sequential reads

Two facts drive the decision:

  1. Matching rows are scattered. Rows with country = 'VN' aren't stored together; 7% of rows turn out to touch nearly every page of the table. Using the index still means visiting ~54,000 pages — plus the index itself. The grid below shows where the matching rows live.
  2. Jumping around costs more than reading in order. Reading pages one after another lets the disk and the OS read ahead. Jumping to a random page each time doesn't. Postgres's default cost settings say a random page read costs 4× a sequential one (random_page_cost = 4, seq_page_cost = 1) — a rough rule from spinning-disk days that many teams lower to ~1.1 on SSDs.
Where do the matching rows live?
A random sample of 1,000 of the table's 54,121 pages (≈ 92 rows each). A coloured cell has at least one matching row; brighter = more matches.
pages with ≥ 1 match: 100.0%matches per such page: ~6.4→ index-driven reads ≈ 54,121 pages
It visits only coloured pages, left to right, row by row.
7% of rows, but almost every page has one. Any plan reads nearly the whole table.

So for each query the planner estimates the pages and their kind, multiplies by those costs, and picks the cheapest plan. Race the plans yourself, with the real page counts from our table:

Page-read race
Pick how much of the table the query matches. Each lane replays a real measurement of one plan.
SELECT * FROM users WHERE country = 'IS'— 510 rows (0.01%)
Seq Scanreads every page
pages read: 0 / 54,121
Bitmap scan via index~1 table page per matching rowplanner's choice
pages read: 0 / 512
Bar length = pages read. Lanes finish in proportion to the real execution time.
Press start. Which lane do you expect to win?

To get the numbers for the losing plans, we turned the winning plan off for one session (SET enable_seqscan = off). Forcing an Index Scan for the 73% query read 219,543 pages — four times the table's size, because it keeps going back to pages it has already visited — and took 901 ms instead of 630 ms.

Bitmap scans: the middle ground

Between "a few rows" and "most of the table" Postgres uses a Bitmap scan. It reads the index first and builds a bitmap — a list of "page 7, page 912, page 4,051 …" with one bit per table page. Then it reads those pages in physical order, each one once. That turns random jumps into a forward sweep that skips pages without matches.

For country = 'IS' (510 rows) it read 512 pages. For country = 'VN' (7%) the bitmap ended up marking every table page — 54,349 page reads (the whole table plus ~230 index pages), slightly more than the Seq Scan's 54,121. Yet it ran faster (232 ms vs 306 ms). Two reasons: it still reads the table in physical order, so it gets the same read-ahead as a Seq Scan; and on each page it only looks at the rows the bitmap points to — about 6 per page — instead of testing all ~92 rows against the condition. Pages read is the main cost, not the only one.

Small tables: the index isn't worth opening

A table of 9 country names fits in one page. Reading that page is one read; using an index is at least two (index page + table page). So:

EXPLAIN SELECT * FROM countries WHERE code = 'VN';
--  Seq Scan on countries  (cost=0.00..1.11 rows=1 width=14)

…even though code is the primary key and has an index. On your laptop's 50-row test database, every query is a Seq Scan. Index decisions must be tested on realistic data volumes.

Practitioner§09

Queries that can't use your index (and the fixes)

In one sentenceAn index on email is sorted by email — not by lower(email), not by email cast to something else. Wrap the column in anything and the index can't help. Either rewrite the query or index the expression.

A condition the index can serve is called Sargable. The rule of thumb: leave the indexed column bare on one side of the comparison, and put any computing on the other side.

1. A function on the column

SELECT * FROM users WHERE lower(email) = 'user4242424@example.com';
--  Seq Scan on users   Filter: (lower(email) = '…'::text)   ← index on email is useless

Say someone signed up as Ann.Lee@Example.com. The index on email knows where that exact string sorts, but has no idea where its lowercase form ann.lee@example.com would go — so it can't help find it. The fix is an Expression index: index the expression itself.

CREATE INDEX users_lower_email_idx ON users (lower(email));
--  Index Scan using users_lower_email_idx   Index Cond: (lower(email) = '…'::text)

The query must use exactly the same expression (lower(email), not upper(email)) for the planner to match it.

2. Dates: compare ranges, don't convert the column

✕ Converts every row's value
-- Seq Scan: casts created_at for all 5M rows
WHERE created_at::date = '2024-03-01'
-- same problem:
WHERE date_trunc('day', created_at) = '2024-03-01'
✓ Leaves created_at bare
-- Index Scan on users_created_idx
WHERE created_at >= '2024-03-01'
  AND created_at <  '2024-03-02'

The same goes for arithmetic: WHERE id + 1 = 43 is a Seq Scan; WHERE id = 42 is an index lookup.

3. Type mismatches

If the two sides of = have different types, one of them gets converted. When it's the column that gets converted, the index is lost:

EXPLAIN SELECT * FROM users WHERE id = 42.0;    -- 42.0 is a numeric, id is a bigint
--  Seq Scan on users   Filter: ((id)::numeric = 42.0)    ← id converted on every row

EXPLAIN SELECT * FROM users WHERE id = '42';     -- an untyped literal adapts to the column
--  Index Scan using users_pkey   Index Cond: (id = '42'::bigint)

Postgres refuses outright mismatches like WHERE email = 42 (ERROR: operator does not exist: text = integer). MySQL instead quietly converts — WHERE phone = 12345 on a text phone column converts every row's phone to a number and scans the table. Pass parameters with the column's type (§10).

4. LIKE with a leading wildcard

LIKE matches patterns: % means "any characters". A B-tree is sorted from the first character, so:

ConditionCan a B-tree help?
email LIKE 'user424242%'Yes, in principle — it's the range 'user424242' ≤ email < 'user424243'
email LIKE '%@example.com'No — matches could start with anything

There's a catch for the prefix case. Whether 'user424242%' turns into a range depends on the column's Collation, the rules for sorting text. Our test database uses C.UTF-8, and even with users_email_idx in place Postgres did a Seq Scan:

EXPLAIN SELECT * FROM users WHERE email LIKE 'user424242%';
--  Seq Scan on users   Filter: (email ~~ 'user424242%'::text)     ← ~~ is how Postgres prints LIKE

The fix is to build the index with a different Operator class (or to give the column the "C" collation):

CREATE INDEX users_email_pattern_idx ON users (email text_pattern_ops);

EXPLAIN SELECT * FROM users WHERE email LIKE 'user424242%';
--  Index Scan using users_email_pattern_idx on users
--    Index Cond: ((email ~>=~ 'user424242'::text) AND (email ~<~ 'user424243'::text))
--    Filter: (email ~~ 'user424242%'::text)

text_pattern_ops tells the index to sort strings character by character, ignoring language rules — exactly the order a prefix match needs. ~>=~ and ~<~ are Postgres's names for those character-by-character >= and < comparisons. The Filter: line re-checks the actual LIKE on each entry found in the range. Note that a text_pattern_ops index can't serve ordinary <, > or ORDER BY on that column, so you may need both indexes.

For '%anything%' searches you need a different kind of index — trigram or full-text search with GIN (§12).

5. Partial indexes: index only the rows you query

Apps often "soft-delete": set deleted_at instead of removing the row, and add WHERE deleted_at IS NULL to every query. A Partial index contains only the rows matching its own WHERE:

CREATE UNIQUE INDEX users_email_active_uniq
  ON users (lower(email))
  WHERE deleted_at IS NULL;      -- only live users are in the index

This one does two jobs:

  • Uniqueness only among live users. A deleted account's email can be registered again. With user4242424@example.com already live, inserting User4242424@example.com (same email, different capitals) fails with duplicate key value violates unique constraint "users_email_active_uniq".
  • A smaller index when the condition is selective. Here 95% of users are live, so it's 184 MB instead of 194 MB — not much. An index on orders (created_at) WHERE status = 'pending', where 1% of orders are pending, would be ~1% of the full size.

The catch: the planner only uses a partial index when the query's WHERE implies the index's condition. WHERE lower(email) = $1 AND deleted_at IS NULL uses it; the same query without AND deleted_at IS NULL can't, and does a Seq Scan — unless another index fits, like users_lower_email_idx from step 1 (with that one present, Postgres uses it instead).

Practitioner§10

Measuring from Go and JavaScript

In one sentenceEverything psql shows you, your code can ask for too: run EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON), parse the JSON, and check which plan the database picked.

The two programs below do the same three things: time a lookup, read its plan, and check a second query for a full table scan. The Go one uses the standard database/sql package with the pgx driver; the JavaScript one uses node-postgres (pg). New to Go? The Go primer takes 90 seconds.

main.go
package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"os"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" driver for database/sql
)

// planNode mirrors the parts of EXPLAIN (FORMAT JSON) output we care about.
type planNode struct {
	NodeType   string     `json:"Node Type"`
	IndexName  string     `json:"Index Name"`
	PlanRows   float64    `json:"Plan Rows"`   // estimated
	ActualRows float64    `json:"Actual Rows"` // real
	SharedHit  int        `json:"Shared Hit Blocks"`
	SharedRead int        `json:"Shared Read Blocks"`
	Plans      []planNode `json:"Plans"` // child nodes
}

func explain(ctx context.Context, db *sql.DB, query string, args ...any) (planNode, error) {
	var raw []byte
	err := db.QueryRowContext(ctx, "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) "+query, args...).Scan(&raw)
	if err != nil {
		return planNode{}, err
	}
	var out []struct {
		Plan planNode `json:"Plan"`
	}
	if err := json.Unmarshal(raw, &out); err != nil {
		return planNode{}, err
	}
	return out[0].Plan, nil
}

// hasSeqScan reports whether any node in the plan tree reads a whole table.
func hasSeqScan(n planNode) bool {
	if n.NodeType == "Seq Scan" {
		return true
	}
	for _, child := range n.Plans {
		if hasSeqScan(child) { // ← walk the tree: children are plans too
			return true
		}
	}
	return false
}

func main() {
	ctx := context.Background()
	db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
	if err := db.PingContext(ctx); err != nil { // open a connection first, so we time only the query
		log.Fatal(err)
	}

	// 1. Time a real query. $1 is a parameter: the value travels separately from the SQL.
	start := time.Now()
	var id int64
	err = db.QueryRowContext(ctx, "SELECT id FROM users WHERE email = $1", "user4242424@example.com").Scan(&id)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("id=%d in %v\n", id, time.Since(start))

	// 2. Ask Postgres how it ran it.
	p, err := explain(ctx, db, "SELECT id FROM users WHERE email = $1", "user4242424@example.com")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s on %s: estimated %.0f rows, got %.0f, pages %d\n",
		p.NodeType, p.IndexName, p.PlanRows, p.ActualRows, p.SharedHit+p.SharedRead)

	// 3. A test-style guard.
	bad, err := explain(ctx, db, "SELECT id FROM users WHERE first_name = $1", "An")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("first_name query has Seq Scan:", hasSeqScan(bad))
}

Output against our 5-million-row table (both programs print the same, give or take the timing):

id=4242424 in 1.06ms
Index Scan on users_email_idx: estimated 1 rows, got 1, pages 4
first_name query has Seq Scan: true

What the numbers tell you

  • 1 ms in your code vs 0.05 ms inside Postgres. EXPLAIN ANALYZE reported ~0.05 ms of execution; the program measured ~1 ms. The difference is the round trip: sending the query, the database parsing and planning it, and the driver decoding the reply. Once a query is indexed, the round trip usually dominates — which is why running 100 small queries in a loop is slow even when each is fast (that's the N+1 problem).
  • A plan guard belongs in tests, not in request handlers. EXPLAIN ANALYZE runs the query a second time. Call it from an integration test that runs against realistic data, or from a debug endpoint — never on every request.
  • Beware ANALYZE on writes. explain(ctx, db, "DELETE …") would really delete. Wrap it in a transaction you roll back (§07).
Staff-level§11

What indexes cost

In one sentenceAn index speeds up the reads that use it and slows down every write to the table, whether or not anyone ever reads through it. Add indexes for real queries, and remove the ones nobody uses.

Every insert updates every index

Inserting one row into a table with six indexes means seven writes: the row itself, plus a new entry in each index — each in its own sorted position, each possibly causing a Page split. Updates and deletes do the same work. This is Write amplification.

Measured: copying 1 million rows of our users table into two empty tables with the same columns.

IndexesInsert 1M rowsTotal size on disk
Table Aprimary key only1.4 s104 MB
Table Bprimary key + 6 more (email, lower(email), name, name+INCLUDE, created_at, country)16.3 s352 MB (82 MB of it is the table)

11× slower inserts, and the indexes are three times bigger than the data. The exact ratio depends on the hardware and on how much memory the database has (this test server had a small 128 MB buffer cache), but the direction never changes. Indexes on random-looking values (emails, names) hurt most, because each new entry lands on a different leaf page.

They compete for memory

The database keeps hot pages in its Buffer cache. Every index you add wants its upper levels and its busy leaves in that cache too, pushing out table pages and other indexes. A rarely used 200 MB index isn't just disk space — it's 200 MB of something else that now has to come from disk.

Finding indexes nobody uses

Postgres counts how many times each index has been used to scan:

SELECT indexrelname        AS index,
       idx_scan            AS times_used,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'users'
ORDER BY idx_scan;
--  index             | times_used | size
--  users_pkey        |          0 | 107 MB
--  users_email_idx   |          1 | 194 MB
--  users_country_idx |          1 | 33 MB

(We reset the counters with SELECT pg_stat_reset(); and then ran one email lookup and one country lookup, which is why even users_pkey shows 0 although §09 used it.)

Before dropping anything with times_used = 0, check three things:

  1. Is it enforcing something? users_pkey was never scanned here, but it's what guarantees ids are unique. Unique and primary-key indexes earn their keep on every insert.
  2. How long have the counters been collecting? They count since the last statistics reset. A month-end report query might use an index twice a month.
  3. Did you check every server? Each Replica keeps its own counters. An index unused on the primary may be the one your read replicas live on.

Bloat

When rows are updated or deleted, their old index entries become dead. VACUUM cleans them, leaving free space inside pages — but pages that were split rarely merge back. After heavy churn an index can be twice the size its live data needs. That's Bloat: more pages to cache, more pages to read.

The fix is rebuilding: REINDEX INDEX CONCURRENTLY users_email_idx; (Postgres 12+) builds a fresh, compact copy without blocking writes, then swaps it in.

Building an index on a live table

A plain CREATE INDEX takes a Table lock that blocks all inserts, updates and deletes on the table until the build finishes. Reads keep working. On our table that was 3.7 seconds. On a 500 GB table in production, it can be an hour of every write timing out.

Use CREATE INDEX CONCURRENTLY instead:

CREATE INDEX CONCURRENTLY users_created_idx ON users (created_at);
DROP INDEX CONCURRENTLY users_old_idx;   -- same idea for removing one

It scans the table twice and waits for running transactions to finish, so it's slower — but writes continue the whole time. Three rules come with it:

  • It can't run inside a transaction. BEGIN; CREATE INDEX CONCURRENTLY … fails with ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block. Many Database migration tools wrap each migration in a transaction by default; check your tool's switch to turn that off for this one migration.
  • If it fails, it leaves a broken index behind. A unique index build that hits a duplicate stops with could not create unique index "users_email_uniq", and \d users then shows "users_email_uniq" UNIQUE, btree (email) INVALID. The invalid index isn't used for reads, but it is still updated on every write. Drop it (DROP INDEX CONCURRENTLY …) and retry.
  • One per table at a time, and expect it to wait for transactions that were already running when it started. It doesn't block anyone's reads or writes while it waits — but it can hang for as long as the slowest open transaction.
Staff-level§12

Staff-level: the index workflow, key choice, other index types

In one sentenceDon't index columns — index queries. Start from where the database actually spends its time, prove the fix with a plan, and keep checking what you've added.

The workflow

  1. Find the expensive queries. pg_stat_statements groups every query by shape (WHERE email = $1, whatever the value) and adds up its time:

    -- needs shared_preload_libraries = 'pg_stat_statements' and CREATE EXTENSION pg_stat_statements;
    SELECT calls,
           round(total_exec_time)       AS total_ms,
           round(mean_exec_time::numeric, 2) AS mean_ms,  -- ::numeric: round(x, 2) needs a numeric
           left(query, 60)              AS query
    FROM pg_stat_statements
    ORDER BY total_exec_time DESC
    LIMIT 10;

    Sort by total time, not mean. A 2 ms query called 5 million times a day costs far more than a 3-second report run twice.

  2. Get the real plan for the top offender with production-like parameters: EXPLAIN (ANALYZE, BUFFERS). Look for big Seq Scans, Sorts on many rows, and estimate-vs-actual gaps (§07).

  3. Design the index for the query: equality columns first, then the range or sort column (§05); INCLUDE the selected columns if it's hot enough to deserve an index-only scan (§06); a partial WHERE if the query always filters the same way (§09). Check whether an existing index can be extended instead of adding a new one.

  4. Build it safely — CONCURRENTLY, outside a transaction (§11).

  5. Verify: same query, same parameters, new plan. Then watch the table's write latency and the new index's idx_scan over the following days.

  6. Prune: every quarter or so, look for unused and duplicate indexes (an index on (a) next to one on (a, b)).

Random keys vs ordered keys

The primary key's index receives every insert. Where in that index new keys land matters a lot:

  • Sequential keys (bigint identity: 1, 2, 3 …) always go to the rightmost leaf. Only that one page is hot, it stays in memory, and Postgres splits it 90/10 so pages end up ~90% full (§03).
  • Random keys (UUID v4) land on a random leaf each time. Every leaf is equally hot, so the whole index must stay in memory or inserts start waiting on disk reads; splits happen everywhere and leave pages half-empty.
  • Time-ordered UUIDs (v7) start with a millisecond timestamp, so new ones land near the right edge like sequential keys — while still being unique across machines without coordination.

Measured: inserting 2 million keys into an empty table with only a primary key.

Primary keyInsert timeIndex sizeAverage leaf fullness
bigint 1, 2, 3 …2.5 s43 MB90%
UUID v7-style (timestamp first)5.3 s60 MB90%
UUID v4 (random)9.2 s77 MB70%

The UUIDs are bigger than a bigint (16 bytes vs 8), which explains v7's extra size. The gap between v7 and v4 is all about randomness: same size keys, 28% more index, 1.7× slower — and on a table whose index doesn't fit in memory, the difference grows much larger. In InnoDB, where the table itself is the primary-key B-tree (§06), random keys scatter the rows too.

Beyond the B-tree

B-trees handle =, <, >, ranges, prefixes and sorting on any value that sorts in a line. Some questions don't fit that shape:

Index typeBuilt forExample
GIN"Which rows contain this element?" — words in text, items in arrays, keys in JSONBWHERE tags @> '{go}' (@> = "contains"; '{go}' is an array holding 'go'), full-text search, LIKE '%foo%' with the pg_trgm extension
GiSTOverlap and nearness: shapes, map points, time ranges"restaurants within 2 km", "bookings that overlap 14:00–15:00"
BRINHuge append-only tables where the column follows the physical ordercreated_at on a 2-billion-row event log: kilobytes instead of gigabytes
HashEquality only (§04)long keys like URLs, when you never need ranges

GIN indexes are slower to update than B-trees (one row can add dozens of entries — one per word), so they're a deliberate trade, not a default.

Practitioner§13

Check yourself

Predict the answer before you click. Q1 is covered in §03, Q2 in §05, Q3 in §09, Q4 in §07, Q5 in §06 and Q6 in §08.

QUESTION 1 / 6
How tall is the tree?
-- A B-tree index on a table with 100,000,000 rows.
-- Each page holds about 300 keys. How many levels?
QUESTION 2 / 6
Which query can seek with this index?
CREATE INDEX ON orders (customer_id, status, created_at);
QUESTION 3 / 6
Why the Seq Scan?
CREATE INDEX ON users (email);

EXPLAIN SELECT * FROM users WHERE lower(email) = 'ann@x.com';
--  Seq Scan on users  Filter: (lower(email) = 'ann@x.com'::text)
QUESTION 4 / 6
What should you look at first?
->  Index Scan using orders_status_idx on orders
      (cost=0.43..52.10 rows=12 width=80)
      (actual time=0.03..1840.22 rows=480112 loops=1)
QUESTION 5 / 6
Index Only Scan… with heap fetches?
SELECT email FROM users WHERE last_name = 'Kim';
--  Index Only Scan using users_last_inc_idx on users
--    Heap Fetches: 4120
QUESTION 6 / 6
55% of rows
-- users: 5M rows. 40% have country = 'US'. There's an index on country.
EXPLAIN SELECT * FROM users WHERE country IN ('US', 'IN');   -- 55% of rows
--  Seq Scan on users
Answered 0 / 6
Foundations§14

Cheat sheet

📖
An index is a sorted copyKey columns + a pointer to each row, kept sorted. It turns "read every page" into "jump to the right page".
🌳
B-trees are wide and shallowHundreds of keys per page → 3–4 page reads for millions or billions of rows. Leaves are linked, so ranges and ORDER BY come free.
☎️
Leftmost prefixIndex (a, b, c) serves a, a+b, a+b+c. Equality columns first, range or sort column last.
🎯
Cover the hot queriesIf the index holds every selected column (INCLUDE), it's an Index Only Scan — check Heap Fetches.
🔍
Read the planEXPLAIN (ANALYZE, BUFFERS). Bottom-up. Estimated vs actual rows: 10× off → ANALYZE.
🤷
Seq Scan isn't always wrongBig share of rows, or a tiny table → reading everything in order is cheaper.
🧤
Keep the column bareNo functions, casts or math on the indexed column — or index that exact expression.
💸
Indexes cost writesEvery index slows every insert/update/delete and takes RAM. Build CONCURRENTLY; drop the unused ones.
I want to…Do this
Find rows by one column fastCREATE INDEX CONCURRENTLY t_col_idx ON t (col);
Filter by a, sort by bCREATE INDEX ON t (a, b); → no Sort step
Answer without touching the tableCREATE INDEX ON t (a) INCLUDE (b, c);
Case-insensitive lookupCREATE INDEX ON users (lower(email)); and query lower(email) = $1
Index only live rows / unique among themCREATE UNIQUE INDEX … ON users (lower(email)) WHERE deleted_at IS NULL;
Prefix search on textLIKE 'abc%' + an index with text_pattern_ops (or "C" collation)
Substring search '%abc%'GIN index with pg_trgm, or full-text search
See what the database didEXPLAIN (ANALYZE, BUFFERS) … (inside BEGIN … ROLLBACK for writes)
Find the queries worth indexingpg_stat_statements, ordered by total_exec_time
Find unused indexespg_stat_user_indexes where idx_scan = 0 (check replicas, uniqueness, reset date)
Compact a bloated indexREINDEX INDEX CONCURRENTLY … (Postgres 12+)

Classic mistakes

MistakeSymptomFix
Testing on a 50-row tableEvery plan is a Seq Scan; "indexes don't work"Test with realistic volumes, then ANALYZE
WHERE date(created_at) = …Seq Scan despite an index on created_atHalf-open range: >= day AND < next day
Index (a, b), query on bIndex ignoredAdd (b) or reorder — follow your queries
Plain CREATE INDEX on a big live tableWrites time out during the buildCREATE INDEX CONCURRENTLY, outside a transaction
Failed CONCURRENTLY build left aloneINVALID index still slows every writeDROP INDEX CONCURRENTLY, fix, retry
An index for every column "just in case"Slow inserts, bloated RAMIndex queries, not columns; prune with idx_scan
Random UUIDv4 primary keys on a hot tableBigger indexes, slower inserts as data growsbigint identity or UUIDv7
OFFSET 100000 paginationLater pages get slower and slowerKeyset: WHERE (created_at, id) > ($1, $2) ORDER BY created_at, id LIMIT n — the key must be unique, hence the id tie-breaker
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in DatabasesN+1 Queries & ORMs
Read →