Indexes & Query Plans
53 pages · ~129 min✓ Reviewed
Builds on Subqueries & CTEs. Next up: Transactions & ACID.
Part 1 · Indexes & Query Plans
Making Databases Find Rows Fast, and Knowing Why They Don't
Most slow queries are slow for a boring reason: the database is reading far more data than it needs to. An index is a separate, ordered structure that maps column values to row locations, so the engine can jump to the rows you asked for instead of reading the whole table. On a million-row table, the difference is roughly 8,000 page reads versus about 3. But an index is not free. It costs disk space, slows every write, competes for memory, and the planner is free to ignore it whenever it judges a different route cheaper.
This chapter explains how that machinery works, so you can predict what the database will do instead of guessing. You will see how a B-tree keeps lookups shallow, why the order of columns in a composite index decides which queries it can serve, and how a covering index can answer a query without touching the table at all. Then you will learn to read EXPLAIN output: what the planner estimated, what actually happened, and where those two numbers disagree, which is usually where the problem lives.
By the end you will be able to choose an index shape from a query's filters and sort order, tell when a sequential scan is the correct answer, spot the changes that silently disable an index (a function around a column, an implicit cast, a leading wildcard), and find indexes that cost more than they give. You will also have a repeatable method: find the query that matters, read its plan, locate the node where the estimate breaks, and fix the cheapest thing first.
You need basic SQL (SELECT, WHERE, JOIN, ORDER BY) and a rough idea of what a table and a primary key are. The examples use PostgreSQL syntax, with MySQL equivalents noted where they differ. To follow along, load a table with a few hundred thousand rows, run ANALYZE on it, and try each example with EXPLAIN (ANALYZE, BUFFERS). A tiny dev table gives meaningless plans, because the planner will correctly choose a sequential scan.
Part 2 · Why Indexes Exist
What an index is, and life without one
A relational table is stored as a heap: a pile of fixed-size pages holding rows in whatever order they arrived. Nothing in that layout is sorted by value. If you ask for WHERE email = 'ada@example.com', the heap has no way to tell you which page holds that row, because insertion order says nothing about email addresses.
An index is the fix. It is a separate, ordered, redundant data structure that sits beside the table and maps column values to row locations. It is separate because it lives in its own pages on disk, ordered because its keys are kept sorted so you can search them quickly, and redundant because every fact in it already exists in the table. The database maintains it automatically on every write. You never write to an index yourself.
Without an index, the only access path is a full table scan. The engine reads every page of the heap and throws away the rows that do not match. The work is O(N) in the table size, no matter how few rows match. Finding one row among a million costs the same as finding none.
The first example mimics this in plain Python. The heap is a list of 100,000 email addresses in a scrambled order, and the scan must look at every entry because it cannot know the value is unique.
heap = [f"user{(i * 7919) % 100000:05d}@example.com" for i in range(100000)] target = "user04242@example.com" examined = 0 matches = 0 for email in heap: examined += 1 if email == target: matches += 1 print("first heap rows:", heap[:3]) print("matches:", matches) print("rows examined:", examined)
A full scan: work grows with the table, not with the answer
first heap rows: ['user00000@example.com', 'user07919@example.com', 'user15838@example.com'] matches: 1 rows examined: 100000
One matching row, 100,000 rows examined. The first three heap rows show the problem: the heap is not in email order, so no early shortcut exists.
Building the index: from 8,334 pages to about 3
Suppose a users table has 1 million rows and its heap occupies about 8,334 pages. A full scan reads all 8,334 of them to answer a one-row lookup. One statement changes that:
-- 1M users, heap = 8334 pages CREATE INDEX idx_users_email ON users (email); -- the same lookup now descends the index: SELECT * FROM users WHERE email = 'ada@example.com';
The query text does not change. Only the access path does.
The index is a balanced tree whose nodes are pages, and each node holds hundreds of sorted keys. One million entries fit in roughly three levels, so the lookup reads about 3 index pages (plus one heap visit for the row itself) instead of 8,334. The next section opens up the tree. For now, the key idea is that sorted keys let you halve the search space at every step, or better.
The next example sorts the same emails into an index of (value, heap position) pairs. It then finds the target with a binary search. This is a simplification of what a B-tree does, but it shows the shape of the saving.
import bisect import math heap = [f"user{(i * 7919) % 100000:05d}@example.com" for i in range(100000)] target = "user04242@example.com" index = sorted((email, pos) for pos, email in enumerate(heap)) keys = [entry[0] for entry in index] slot = bisect.bisect_left(keys, target) print("first sorted keys:", keys[:3]) print("found in index:", keys[slot] == target) print("heap row it points to:", heap[index[slot][1]]) print("worst-case probes:", math.ceil(math.log2(len(keys) + 1)))
Ordered entries turn a 100,000-row scan into a handful of probes
first sorted keys: ['user00000@example.com', 'user00001@example.com', 'user00002@example.com'] found in index: True heap row it points to: user04242@example.com worst-case probes: 17
| No index | With index | |
|---|---|---|
| Access path | Full table scan | Descend the index, then fetch the row |
| Work for one match | Every page in the heap | About 3 index pages plus the row |
| Growth with table size | Linear, O(N) | Logarithmic, nearly flat |
| Who keeps it correct | Nothing to keep | The database, on every write |
You rarely start from nothing. Every major engine creates an index for a primary key automatically, and a UNIQUE constraint is implemented as a unique index. That is how the database checks for duplicates quickly without scanning the table.
The trade: write cost, disk space, and what to index
An index is not free. You buy read speed with write cost and disk space. Because the index is a redundant copy of column values in sorted order, every INSERT, UPDATE of an indexed column, and DELETE has to change the table and also every index that covers the affected columns. A table with five indexes does six structural updates per inserted row. An index on a column that changes constantly taxes every write, forever.
| Side of the trade | What you pay or gain |
|---|---|
| Reads | Selective lookups, range scans and sorted output get much cheaper |
| Writes | Each INSERT, UPDATE and DELETE on an indexed column also updates the index |
| Disk | The index stores its own copy of the key values, plus tree overhead |
| Memory | Index pages compete with table pages for cache |
Whether an index pays for itself depends on two properties of the data. Selectivity is the fraction of rows a predicate matches. A predicate that matches 1 row out of 1 million is highly selective and index-friendly. One that matches 40% of the table has low selectivity, and the index usually loses, because following the index into the heap for that many rows costs more than reading the heap straight through.
Cardinality is the number of distinct values in a column. A user_id on a 1M-row table has very high cardinality, nearly one value per row. An is_deleted flag has a cardinality of 2. Low-cardinality columns tend to give low selectivity for the common value, so they make poor index candidates on their own.
| Term | Means | Example verdict |
|---|---|---|
| Selectivity | Fraction of rows a predicate matches | 1 of 1M rows: great for an index |
| Low selectivity | A large fraction matches | 40% of the table: index usually loses |
| Cardinality | Number of distinct values in a column | user_id is high, so it indexes well |
| Low cardinality | Very few distinct values | is_deleted has 2, so it is mostly dead weight |
total = 100000 rows = [{"user_id": i, "is_deleted": i % 10 < 4} for i in range(total)] for column in ("user_id", "is_deleted"): print(column, "cardinality:", len({row[column] for row in rows})) one_user = sum(1 for row in rows if row["user_id"] == 4242) deleted = sum(1 for row in rows if row["is_deleted"]) print(f"user_id = 4242 matches {one_user / total:.3%} of rows") print(f"is_deleted = True matches {deleted / total:.3%} of rows")
The same table, two very different predicates
user_id cardinality: 100000 is_deleted cardinality: 2 user_id = 4242 matches 0.001% of rows is_deleted = True matches 40.000% of rows
Putting an index on a boolean like is_deleted feels natural, but when 40% of rows share a value the planner will scan the table anyway. You still pay the write cost and the disk space for an index that never gets used.
Choosing columns, and why use is never guaranteed
Which columns deserve an index? Look at the shape of your queries, not the shape of the schema. An index helps when the engine must find, join, order or group rows by a column. It does nothing for a column that only appears in the SELECT list, because the engine reads that value after it has already found the row.
| Where the column appears | Index it? | Why |
|---|---|---|
| WHERE | Yes | Lets the engine seek straight to matching rows |
| JOIN ... ON | Yes | Finds the partner rows for each row without scanning |
| ORDER BY | Yes | Index order can supply sorted output with no sort step |
| GROUP BY | Yes | Sorted entries bring equal values together |
| Only in SELECT | No | Nothing to search or sort by |
Even a well-chosen index comes with no guarantee. The database never promises to use an index. A query planner decides at run time which access path looks cheapest, and it bases that decision on statistics about the table: row counts, distinct values and value distributions. The same query can use an index today and a full scan next month, because the statistics, and therefore the planner's estimates, changed as the data grew.
Creating an index and moving on is not enough. Wrapping the column in a function, a low-selectivity predicate, or stale statistics can all make the planner ignore it. Always check the actual plan for the query you care about, and check it again as the table grows.
A column that appears only in the SELECT list is never searched or sorted. Indexing it adds write cost and disk usage and speeds up nothing.
An index is a sorted, redundant structure the database keeps for you. It turns an O(N) scan into a few page reads for selective lookups, at the price of slower writes and extra space. Index columns used in WHERE, JOIN ON, ORDER BY and GROUP BY, favour high selectivity and high cardinality, and treat any single plan as a decision the planner may revisit.
Part 3 · B-tree Internals
The Shape of a B+tree
When a database says it has a B-tree index, it almost always means a B+tree. The tree has two kinds of node, and they do different jobs. Internal nodes hold only separator keys and pointers to child nodes. They are signposts, not data. Every real key lives in a leaf node at the bottom, next to the pointer that locates its row.
| Internal node | Leaf node | |
|---|---|---|
| Holds | Separator keys and child pointers | Real keys and row locations |
| Answers | Which child should I go to next? | Is the key here, and where is the row? |
| Count | A small fraction of all pages | Almost every page in the index |
A lookup starts at the root. At each internal node the database compares your search value with the separators, picks the one child whose range contains it, and descends. When it reaches a leaf, it either finds the key or knows the key does not exist.
Leaves are chained sideways
The leaves are also linked to each other in a doubly-linked list, in key order. This is what makes range scans and ORDER BY cheap. The database descends once to find the first matching key, then walks sideways along the leaf chain. It never climbs back up to the root and descends again.
- 1Descend onceroot to the leaf holding a
- 2Read the leafemit keys up to b
- 3Follow the next-leaf linkno re-descent
- 4Stopfirst key past b ends the scan
SELECT id, created_at FROM events WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31' ORDER BY created_at;
One descent, then a sideways walk. The rows come out already sorted, so no Sort node is needed.
One node, one page
Each node is sized to exactly one disk page. Reading a node is therefore one I/O, and the depth of the tree is the number of I/Os a lookup costs. That is why the design is built to keep the tree shallow.
| Engine | Typical page size | Consequence |
|---|---|---|
| Postgres | 8KB | One node fetch = one 8KB read |
| InnoDB (MySQL) | 16KB | One node fetch = one 16KB read, so more keys per node |
Internal nodes steer, leaves store, and the leaf chain gives you ordered traversal. Everything else in this section follows from those three facts and the one-node-one-page rule.
Depth, Lookup Cost and Staying Balanced
A page of several KB holds a lot of small entries. With a compact key and an 8-byte child pointer, one internal node can point to hundreds of children. That number is the fan-out, written f. Every level multiplies the reachable entries by f, so the tree stays very shallow.
The code below finds the smallest number of levels whose capacity (f raised to the number of levels) covers the row count. It is a simplified model, but it matches how real indexes behave.
def levels(rows, fanout): depth = 1 capacity = fanout while capacity < rows: depth += 1 capacity *= fanout return depth for rows in (1_000_000, 1_000_000_000): for fanout in (100, 300, 500): print(f"{rows:,} rows, fan-out {fanout}: {levels(rows, fanout)} levels")
1,000,000 rows, fan-out 100: 3 levels 1,000,000 rows, fan-out 300: 3 levels 1,000,000 rows, fan-out 500: 3 levels 1,000,000,000 rows, fan-out 100: 5 levels 1,000,000,000 rows, fan-out 300: 4 levels 1,000,000,000 rows, fan-out 500: 4 levels
| Rows | Typical depth | Page reads for one lookup |
|---|---|---|
| 1 million | about 3 levels | up to 3 |
| 1 billion | 4 to 5 levels | up to 5 |
This gives the lookup cost O(log_f N) page reads, where f is the fan-out. A logarithm with a base in the hundreds grows so slowly that, in practice, the cost is almost constant. A thousand-fold increase in table size adds about one level.
The root and the level below it are touched by every lookup, so they stay cached in RAM. With f around 300, the top two levels are about 300 pages, roughly 2.4MB at 8KB each. A lookup on a hot index usually pays real disk I/O only for the bottom level or two.
Splitting keeps the tree balanced
A B+tree is always balanced: every leaf sits at the same depth. It stays that way because it grows from the top, not the bottom. When an insert lands in a full node, the node splits into two halves, and a separator key is pushed up into the parent. If the parent is also full, it splits too, and the split can reach the root. Only a root split makes the tree one level taller.
def split_leaf(keys, new_key, capacity=4): keys = sorted(keys + [new_key]) if len(keys) <= capacity: return keys, None, None mid = len(keys) // 2 return keys[:mid], keys[mid:], keys[mid] left, right, sep = split_leaf([10, 20, 30, 40], 25) print("left :", left) print("right:", right) print("push up:", sep)
A leaf with room for four keys receives a fifth.
left : [10, 20] right: [25, 30, 40] push up: 25
Notice that 25 is still present in the right leaf. In a B+tree the separator is a copy used for routing, because every real key must remain in a leaf. In the other direction, deletes may leave a node nearly empty, and the database can then merge it with a sibling and remove a separator from the parent. Many engines are lazy about this, which matters for the bloat discussed on the last page.
What a Leaf Entry Points To
A leaf entry is a key plus a way to find the row. What that second part is depends on the engine, and the difference changes how lookups perform.
| Engine and index | Leaf entry holds | Cost to reach the row |
|---|---|---|
| Postgres, any index | Key + heap TID (page number and slot) | Direct heap fetch: one more page read |
| InnoDB, primary key | Key + the entire row | None: the leaf is the row |
| InnoDB, secondary index | Key + the primary key value | A second descent, this time through the PK tree |
In Postgres the table is a heap, an unordered pile of pages. Every index is a separate structure whose leaves hold TIDs that point into the heap. Finding a row costs the index descent plus one heap page read.
Clustered indexes
A clustered index is not stored beside the table. It is the table. The leaf nodes contain the full rows, sorted in key order. InnoDB and SQL Server work this way. Because the rows can only be physically sorted one way, a table has only one clustered index. In InnoDB that is the primary key.
The payoff is that a primary-key lookup ends at the leaf with the row already in hand, and a primary-key range scan reads neighbouring rows from neighbouring pages.
The secondary index penalty
A secondary index in a clustered engine cannot store a physical address, because rows move when pages split. Instead its leaves store the primary key value. Every lookup through a secondary index therefore costs two tree walks.
- 1Descend the secondary indexkeyed on email
- 2Leaf returns the primary keyfor example id = 42817
- 3Descend the clustered PK treea second walk, 3 or more page reads
- 4Row found in the PK leafthe full row lives there
Choosing a long string or composite key as the primary key of a table with many secondary indexes inflates all of them. Keep the PK narrow, such as a BIGINT or a compact binary key, unless the data truly demands otherwise.
What Order Buys You, and How an Index Rots
Because the tree is kept in sorted order, any question that can be answered by finding a position and walking along the sorted keys is cheap. Every operator in the table below fits that pattern, and the last two rows do not.
| Query form | Works? | How the tree answers it |
|---|---|---|
col = ? | Yes | One descent to the leaf |
col < ?, col > ?, BETWEEN | Yes | Descend to one end, walk the leaf chain |
col IN (1, 5, 9) | Yes | One short descent per listed value |
LIKE 'abc%' | Yes | A prefix is a range: from abc up to just before abd |
ORDER BY col | Yes | The leaves are already sorted, so no sort step |
MIN(col), MAX(col) | Yes | Read the leftmost or rightmost leaf entry |
LIKE '%abc' | No | No known starting position, so every entry must be checked |
lower(col) = ? and other functions | No | The tree is ordered by col, not by the function's output |
The prefix case works because all strings starting with ad sit next to each other in sorted order. Finding where they begin and where they end is two position searches. The sketch below uses a sorted list to show the same idea.
import bisect emails = sorted(["ada@x.com", "adam@x.com", "bob@x.com", "carol@x.com", "dave@x.com"]) lo = bisect.bisect_left(emails, "ad") hi = bisect.bisect_left(emails, "ae") print("prefix 'ad%':", emails[lo:hi]) print("suffix '%.com' must check", len(emails), "entries")
prefix 'ad%': ['ada@x.com', 'adam@x.com'] suffix '%.com' must check 5 entries
Arbitrary functions break the ordering
An index on email is sorted by the stored text. A query on lower(email) asks about a different value, and the order of the lowercased values is not the order of the stored ones. The planner cannot descend the tree for it, so it falls back to scanning every row. The same applies to date(created_at), price * 1.2 and any other function or expression wrapped around the indexed column.
-- Cannot seek on an index over (email): the function hides the column SELECT id FROM users WHERE lower(email) = 'ada@example.com'; -- Fix 1: index the expression itself (it must match the query exactly) CREATE INDEX idx_users_email_lower ON users (lower(email)); -- Fix 2: leave the column bare and move the work to the constant side SELECT id FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02';
Either index the exact expression, or rewrite the predicate so the column stands alone.
WHERE date(created_at) = '2026-01-01' silently ignores an index on created_at. Nothing errors. The query just gets slow as the table grows.
Index bloat
Indexes do not stay as tidy as a fresh build. Deleting or updating rows leaves dead entries behind, and pages that were split earlier are rarely merged back. The result is leaves that are mostly empty space. That is index bloat: more pages to read for the same data, less fan-out in effect, and more cache wasted on empty bytes.
| Tool | What it does in Postgres | Trade-off |
|---|---|---|
VACUUM | Removes dead entries and marks their space reusable for later inserts | Cheap and routine, but does not usually shrink the index file |
REINDEX | Rebuilds the index from scratch in compact form | Reclaims real space, but costs time and locking, so use the concurrent form on live tables |
VACUUM orders; REINDEX INDEX CONCURRENTLY idx_orders_created;
Routine cleanup first, a rebuild only when an index is clearly much larger than its live data.
Part 4 · Other Index Types & When B-tree Isn't Enough
Beyond the B-tree: hash, GIN, GiST and BRIN
The B-tree from the previous section is the default for good reason: it keeps keys ordered, so it answers equality, ranges, ORDER BY and prefix matches. But ordering is an assumption, and some questions are not about order at all. "Does this array contain 'sql'?", "which stores are within 2 km?" and "which rows were written last Tuesday?" each want a different structure. Postgres ships several, and other engines have their own versions.
Hash: equality only
A hash index runs the key through a hash function and uses the result to jump straight to a bucket. An equality lookup is O(1), with no tree to descend. The price is that a hash scatters neighbouring keys across unrelated buckets, so there is no range support and no ordering. WHERE id < 100, ORDER BY and MIN/MAX can't use it. A B-tree on a high-fan-out tree is already only 3-4 page reads for an equality lookup, so the hash index rarely wins by enough to justify giving up everything else it can't do.
GIN: one entry per element, not per row
A GIN (Generalized Inverted Index) works like the index at the back of a book. For a column that holds many values per row, such as an array, a JSONB document or a tsvector of words, it stores each element once, together with the list of rows that contain it. A row with ten tags therefore appears in ten index entries, instead of being one entry as in a B-tree. This is what makes containment queries fast.
CREATE INDEX idx_posts_tags ON posts USING gin (tags); CREATE INDEX idx_events_payload ON events USING gin (payload jsonb_path_ops); CREATE INDEX idx_docs_fts ON docs USING gin (to_tsvector('english', body)); SELECT id FROM posts WHERE tags @> ARRAY['sql']; SELECT id FROM events WHERE payload @> '{"type": "signup"}';
Each operator (@>, @@) is one GIN can answer; a B-tree on these columns could not.
GiST: operators a B-tree can't express
GiST (Generalized Search Tree) is a balanced tree framework where the data type decides what the internal nodes mean. For geometry, each node stores a bounding box that covers everything beneath it. This supports overlap and containment tests, nearest-neighbour ordering (ORDER BY location <-> point), and range types such as "does this booking overlap that one?". There is no single total order for 2-D points, so none of this fits a B-tree.
BRIN: tiny, but only if rows are in order
A BRIN (Block Range Index) doesn't point at rows at all. For each run of table blocks (128 pages by default) it keeps just the min and max of the column. A query skips every block range whose min/max can't contain the value, and scans the rest. The index is so small that it is often thousands of times smaller than a B-tree on the same column.
The catch is that BRIN works only when the physical row order correlates with the column. An append-only created_at on a log table is the classic fit: each block range holds a narrow, distinct slice of time. If rows are shuffled, every range's min/max spans nearly the whole domain, nothing can be skipped, and the index is useless.
CREATE INDEX idx_logs_created_brin ON logs USING brin (created_at); -- check the correlation first: near 1.0 or -1.0 is a good sign SELECT correlation FROM pg_stats WHERE tablename = 'logs' AND attname = 'created_at';
Bitmap indexes and shaping an index to your queries
Bitmap indexes: great for analytics, awful for writes
A bitmap index (Oracle, and many analytics and column-store engines) keeps, for each distinct value, a bit vector with one bit per row. For a low-cardinality column such as region or status, combining predicates is just an AND or OR of bit vectors, which is extremely fast on a read-mostly warehouse where queries are WHERE region = 'EU' AND tier = 'gold' over millions of rows.
The weakness is lock granularity. A single bitmap entry covers a large range of rows, so changing one row locks that whole entry, and concurrent writers touching different rows end up waiting on each other. That is why bitmap indexes belong in OLAP systems with batch loads and are terrible under concurrent writes in OLTP. Don't confuse them with Postgres's *Bitmap Index Scan* plan node, which is a temporary in-memory structure built during one query and has no such locking problem.
Partial index: only the rows you query
Often a query touches a small, well-defined slice of a table: the pending jobs among millions of finished ones. A partial index adds a WHERE clause to the definition, so only matching rows are indexed. The index is dramatically smaller, cheaper to maintain (rows outside the slice cost no index writes) and more likely to stay in cache.
CREATE INDEX idx_jobs_pending ON jobs (created_at) WHERE status = 'pending'; -- the query must imply the index predicate to use it SELECT id FROM jobs WHERE status = 'pending' ORDER BY created_at LIMIT 20;
If only 0.4% of jobs are pending, this index is about 250 times smaller than a full one.
Expression index: index what you actually compare
A plain index on email stores the raw email values, in raw order. WHERE lower(email) = 'ada@example.com' compares a different value, so the planner cannot use it. An expression (functional) index stores the result of the expression instead. The query's expression must match the index's expression exactly.
import sqlite3 db = sqlite3.connect(":memory:") db.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)") db.execute("CREATE INDEX idx_email ON users (email)") def seeks(sql): plan = db.execute("EXPLAIN QUERY PLAN " + sql).fetchall() return any(row[3].startswith("SEARCH") for row in plan) q = "SELECT id FROM users WHERE lower(email) = 'ada@example.com'" print("plain index seeks:", seeks(q)) db.execute("CREATE INDEX idx_email_lower ON users (lower(email))") print("expression index seeks:", seeks(q))
SQLite supports expression indexes too, so the idea can be tried with only the standard library.
plain index seeks: False expression index seeks: True
WHERE lower(email) = ? silently falls back to a full scan if only (email) is indexed. Create ON users (lower(email)), and write the query with exactly lower(email), not LOWER(TRIM(email)) or email ILIKE ? against the same index.
Unique indexes and NULLs
Unique index: a constraint and a lookup in one
A unique index is a B-tree that refuses to hold two equal keys. That makes it do two jobs with one structure: it enforces the constraint (the engine checks the index on every write) and it serves fast lookups on the same column. UNIQUE constraints and primary keys are implemented this way, so you should not add a separate plain index on the same column; it would just duplicate the work.
There is a subtlety about missing values. In most engines a NULL means "unknown", and two unknowns are not known to be equal, so NULLs are treated as distinct by a unique index. You can insert any number of rows with a NULL in a UNIQUE column. (SQL Server is a notable exception, and Postgres 15+ offers NULLS NOT DISTINCT if you want the opposite.)
import sqlite3 db = sqlite3.connect(":memory:") db.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)") db.execute("CREATE UNIQUE INDEX uq_email ON users (email)") db.execute("INSERT INTO users (email) VALUES (NULL), (NULL), ('a@x.io')") print("rows after two NULLs:", db.execute("SELECT count(*) FROM users").fetchone()[0]) try: db.execute("INSERT INTO users (email) VALUES ('a@x.io')") print("duplicate rejected:", False) except sqlite3.IntegrityError: print("duplicate rejected:", True)
rows after two NULLs: 3 duplicate rejected: True
A nullable UNIQUE column can hold many NULL rows. If "one account per email" must hold even when the email is missing, make the column NOT NULL, or use a partial unique index such as UNIQUE (email) WHERE email IS NOT NULL combined with a rule for the empty case.
Unique, partial and expression can be combined. CREATE UNIQUE INDEX ON users (lower(email)) makes e-mail uniqueness case-insensitive, and CREATE UNIQUE INDEX ON subscriptions (user_id) WHERE active allows one active subscription per user while keeping any number of cancelled ones.
Choosing a type, and choosing wrong
Comparing the main types
| Type | Best at | Cannot do | Typical fit |
|---|---|---|---|
| B-tree | Ordered, general purpose: =, ranges, ORDER BY, prefix LIKE | Containment inside values, nearest-neighbour | ~all queries; the default |
| Hash | Equality only | Ranges, ordering | Rarely worth it over a B-tree |
| GIN | Containment: arrays, JSONB, full-text | Ordering; cheap writes | Many elements per row |
| GiST | Geometry, nearest-neighbour, ranges | Exact-order scans | Spatial and range-overlap data |
| BRIN | Skipping block ranges via min/max | Seeking single rows, uncorrelated data | Huge tables with correlated columns |
The shape of the question picks the structure. Start from the operator in your WHERE clause, not from the data type of the column.
Choosing wrong makes things worse
An unusual index type is not a faster one. A GIN index is built for columns with many elements per row. Put one on a plain integer column (which needs the btree_gin extension just to be allowed) and you pay GIN's costs without any of its benefits: each row becomes a single-element posting list, lookups go through an extra layer, and writes are slower because GIN batches updates in a pending list. The result is slower and larger than the B-tree it replaced, and it still can't do ranges or ordering as well.
Before switching type, check that the query's operator matches the index, then measure with pg_size_pretty(pg_relation_size('idx')) and EXPLAIN (ANALYZE, BUFFERS) before and after. If the B-tree already answers the question, the other types only add size and write cost.
B-tree for ordered, general queries; hash only for pure equality; GIN for containment and text; GiST for geometry and ranges; BRIN for huge correlated tables. Then narrow any of them with a WHERE (partial), an expression, or UNIQUE to match exactly what you query.
Part 5 · Composite Indexes and Column Order
One index, several columns: the phone book
A composite index (also called a multi-column index) is a single B-tree whose keys are tuples of several columns. It sorts entries by the first column. Rows with equal first values are ordered by the second column, and any remaining ties are broken by the third. Nothing else about the B-tree changes: the same pages, the same descent, the same linked leaves. Only the sort key is wider.
The best mental model is a phone book sorted by (last_name, first_name). All the Smiths sit together, and inside that block the Adas come before the Beas. That layout makes two lookups cheap: all the Smiths, and Smith/Ada in particular. It makes a third lookup, every Ada in the city, no cheaper than reading the whole book. Ada entries are scattered across every surname block.
from bisect import bisect_left, bisect_right TOP = chr(0x10FFFF) book = sorted([('Smith', 'Bea'), ('Lee', 'Ada'), ('Smith', 'Ada'), ('Taylor', 'Ada'), ('Jones', 'Ada'), ('Smith', 'Cy'), ('Lee', 'Bea')]) lo, hi = bisect_left(book, ('Smith', '')), bisect_right(book, ('Smith', TOP)) print('Smith:', book[lo:hi]) lo, hi = bisect_left(book, ('Smith', 'Ada')), bisect_right(book, ('Smith', 'Ada')) print('Smith/Ada:', book[lo:hi]) adas = [row for row in book if row[1] == 'Ada'] print(f'All Adas: {len(adas)} found, {len(book)} entries examined')
A sorted list plus binary search behaves like a two-column index.
Smith: [('Smith', 'Ada'), ('Smith', 'Bea'), ('Smith', 'Cy')] Smith/Ada: [('Smith', 'Ada')] All Adas: 4 found, 7 entries examined
The two Smith lookups are binary searches that jump straight to a contiguous slice. The Ada lookup has no such slice to jump to, so it has to examine every entry. This asymmetry is the whole story behind column order.
The leftmost prefix rule
Because the sort is lexicographic, an index can only be searched efficiently by a leftmost prefix of its columns: the first column, the first two, or all of them. An index on (a, b, c) therefore serves queries that constrain a, or a and b, or a, b and c. A query that mentions only b or only c gets no seek, because matching entries are scattered throughout the tree. At best the engine scans the whole index, which is still far cheaper than the table but is no seek.
| Query on index (a, b, c) | What the index does |
|---|---|
| WHERE a = 1 | Seeks on a |
| WHERE a = 1 AND b = 2 | Seeks on a and b |
| WHERE a = 1 AND b = 2 AND c = 3 | Seeks on all three |
| WHERE b = 2 | No seek; full index scan at best |
| WHERE c = 3 | No seek; full index scan at best |
| WHERE a = 1 AND c = 3 | Seeks on a only; c is just a filter |
Writing CREATE INDEX ON t (a, b) and then querying WHERE b = ? does not give you an index lookup on b. The leading column is missing, so the engine cannot descend. If that query matters, it needs an index that starts with b.
Equality first, then range
Column order is not cosmetic, because the tree can only seek through a contiguous run of entries. The rule of thumb is: put columns compared with = first, then the column compared with a range (>, <, BETWEEN). Take WHERE status = 'paid' AND created_at > ?. With the index on (status, created_at), all paid entries sit together and are sorted by date inside that block, so the engine seeks to the cutoff date and reads to the end of the block. With (created_at, status), the entries after the cutoff are ordered by date first, and paid rows are mixed in among every other status.
from bisect import bisect_right TOP = chr(0x10FFFF) rows = [(s, d) for s in ('new', 'paid', 'void') for d in range(1, 1001)] by_status = sorted(rows) # index (status, created_at) by_date = sorted((d, s) for s, d in rows) # index (created_at, status) # WHERE status = 'paid' AND created_at > 990 lo = bisect_right(by_status, ('paid', 990)) hi = bisect_right(by_status, ('paid', 10**9)) print(f'(status, created_at): touched {hi - lo}, matched {hi - lo}') lo = bisect_right(by_date, (990, TOP)) tail = by_date[lo:] matched = sum(1 for d, s in tail if s == 'paid') print(f'(created_at, status): touched {len(tail)}, matched {matched}')
Count how many index entries each ordering has to look at.
(status, created_at): touched 10, matched 10 (created_at, status): touched 30, matched 10
Both orderings return the same 10 rows, but the wrong one reads three times as many entries and throws most of them away. With three statuses the gap is 3x. With fifty statuses it would be 50x. The cost of the wrong order grows with how many other values sit alongside the one you want.
Why a range stops the seek
The reason is mechanical. Equality on the first column narrows the search to one contiguous block, and inside that block the second column is sorted, so equality or a range on it narrows further. A range on a column leaves a span of different values for that column, and within that span the *next* column is no longer globally sorted. It is sorted only within each single value of the range column. So once the index reaches a range predicate, the columns after it can no longer be used for seeking. They can still be used to filter entries without visiting the table, or to cover the query's select list, but they cannot shrink the scanned region.
- 1status = 'paid'equality: seek to one block
- 2created_at > ?range: seek to the cutoff, read forward
- 3region = 'eu'after a range: filter only, no seek
Equality, then range, then sort
The same logic explains the classic feed query. With an index on (tenant_id, created_at), the tenant's entries form one contiguous block already sorted by date. The engine seeks to the tenant, starts at the newest entry, walks backward along the linked leaves, and stops after 20 rows. There is no sort step at all, and the work does not depend on how large the tenant is.
CREATE INDEX idx_events_tenant_created ON events (tenant_id, created_at); SELECT id, created_at FROM events WHERE tenant_id = 7 ORDER BY created_at DESC LIMIT 20;
A bounded index scan: seek once, read 20 entries backward, no Sort node in the plan.
Equality columns, then at most one range column, then sort columns. Anything beyond a range column helps with filtering or covering only, never with seeking.
Redundancy, sort direction and IN lists
One composite can replace several singles
Since an index on (a, b) is already sorted by a first, every query that a standalone index on (a) could serve works just as well on the composite. The single-column index is redundant. It adds nothing to reads and still costs a write on every insert and update, plus space in the buffer pool, so drop it.
The reverse does not hold. (a, b) does not replace an index on (b), because b is not a leftmost prefix. If you filter by a in some queries and by b in others, you need two indexes, (a, b) and (b), and each one must be justified by a real query.
| Existing index | Standalone (a) | Standalone (b) |
|---|---|---|
| (a, b) | Redundant: drop it | Still needed for WHERE b |
| (b, a) | Still needed for WHERE a | Redundant: drop it |
Teams add (a, b) for a new query and leave the old (a) in place. Both are maintained on every write and both occupy cache. Check that nothing relies on the narrow one for a different reason, such as a uniqueness constraint, and then drop it.
Sort direction counts too
An index can also hand rows back already ordered, which removes a Sort node. It can only do this for the order it was built in, or for that order read backward. An index on (a ASC, b DESC) supports ORDER BY a ASC, b DESC and its exact reverse, ORDER BY a DESC, b ASC. It does not support ORDER BY a ASC, b ASC, because walking forward gives b descending inside each a, and walking backward flips a as well.
| ORDER BY on index (a ASC, b DESC) | Sort step avoided? |
|---|---|
| a ASC, b DESC | Yes, read forward |
| a DESC, b ASC | Yes, read backward |
| a ASC, b ASC | No, a Sort node returns |
| a DESC, b DESC | No, a Sort node returns |
| b DESC | No, b is not a prefix |
IN on the leading column behaves like equality
A gotcha worth knowing: an IN (...) list on the leading column is treated like several equalities. The engine performs one seek per listed value, and inside each landing spot the next column is still sorted, so it can be seeked too. A query such as WHERE tenant_id IN (7, 9) AND created_at > ? on (tenant_id, created_at) still gets a bounded range read per tenant. A true range such as tenant_id > 7 would not allow this.
from bisect import bisect_right rows = [(t, d) for t in (7, 8, 9) for d in range(1, 101)] index = sorted(rows) # index (tenant_id, created_at) # WHERE tenant_id IN (7, 9) AND created_at > 95 touched = 0 for t in (7, 9): lo = bisect_right(index, (t, 95)) hi = bisect_right(index, (t, 10**9)) touched += hi - lo print(f'seeks: 2, entries touched: {touched}')
One seek per IN value, and created_at is still used to bound each one.
seeks: 2, entries touched: 10
Only 10 of the 300 entries are read. Tenant 8 is never visited, and for tenants 7 and 9 only the dates after the cutoff are touched. Be aware that an ORDER BY created_at across the whole IN list is a different matter, because the two tenant blocks are each sorted but not merged. The engine may need a merge step or a sort for that.
Myths, width and keeping composites lean
Cardinality-first is a myth
A common piece of folklore says to put the most selective, highest-cardinality column first. As a general rule it is wrong. What matters is the shape of the query: which columns are compared with equality, which has a range, and what the ORDER BY needs. A low-cardinality column like status makes an excellent leading column when every query filters it by equality, because that equality carves out a block in which the range or sort column is already ordered. Ordering by selectivity alone gives you a few percent here and there. Matching the query shape can turn a scan-and-filter into a bounded read, or remove a sort entirely.
Putting created_at (many distinct values) before status (a handful) because it looks more selective is exactly what makes status = 'x' AND created_at > ? read three times too many entries in the earlier example. Start from the query, not from the statistics.
Every extra column has a price
Each added column makes every index entry wider. Wider entries mean fewer keys fit in each page, so internal nodes have lower fan-out, the tree may need an extra level, and more pages compete for the buffer pool. Writes also touch the index more often, since any update to an indexed column modifies the entry. A composite should therefore contain exactly the columns that its queries use to seek, order, or filter, and no more.
| Narrow composite | Wide composite | |
|---|---|---|
| Keys per page | More | Fewer |
| Tree depth | Shallower | Possibly deeper |
| Cache footprint | Small | Larger |
| Write cost | Lower | Higher, more updates dirty the index |
| Query coverage | Only what it needs | Extra columns rarely pay off |
A composite index is a tuple-sorted B-tree. It is searched by a leftmost prefix, equality columns go first, the first range ends the seeking, and sort direction must match or be exactly reversed. The next section builds on this by putting extra payload columns into the index so the table need not be visited at all.
Part 6 · Covering Indexes & Index-Only Scans
When the Index Is the Whole Answer
A normal index lookup is a two-step trip. The engine descends the B-tree to find matching entries, then follows each entry's pointer into the table (the heap) to fetch the columns the index does not hold. Those heap visits are usually the expensive part, because matching rows are scattered across many pages and each one is a separate random read.
A covering index removes the second step. If the index contains every column the query touches (in SELECT, WHERE, ORDER BY and JOIN), the engine can answer from the index leaves alone and never opens the heap at all.
- 1Descend the B-treefind the first matching leaf entry
- 2Index has every column?SELECT, WHERE, ORDER BY and JOIN columns
- 3Yes: return from the leafno heap page is read
- 4No: fetch the heap rowone random read per matching row
Every engine supports this idea but each names it differently, so you need to recognise three labels when reading plans.
| Engine | What it is called | Where you see it |
|---|---|---|
| Postgres | Index Only Scan | the plan node in EXPLAIN |
| SQL Server | a covered query (covering index) | index properties and the execution plan |
| MySQL | Using index | the Extra column of EXPLAIN |
Whether a query is covered depends on every column it touches, not just the SELECT list. A column that appears only in the WHERE clause still has to be found somewhere, and if the index lacks it the engine must visit the heap to check it. The table below uses an index on posts (user_id, created_at, title).
| Query | Covered? | Why |
|---|---|---|
SELECT title ... WHERE user_id = 7 | yes | every column used is in the index |
SELECT title ... WHERE user_id = 7 ORDER BY created_at DESC | yes | the sort column is in the index too |
SELECT title, body ... WHERE user_id = 7 | no | body lives only in the heap |
SELECT title ... WHERE body = 'x' | no | body is touched by the WHERE clause, so rows must be fetched to test it |
SELECT count(*) ... WHERE user_id = 7 | yes | counting needs no extra column |
SELECT * ... | never | star asks for every column by definition |
Checking only the SELECT list. A query that selects covered columns but filters, sorts or joins on an uncovered one is not covered, and the heap fetches come back.
INCLUDE Columns: Payload Without Sort Order
The obvious way to cover a query is to add the missing columns to the index key. That works, but every key column becomes part of the sort order, widens every entry in the internal nodes, and shrinks the fan-out of the tree. Postgres and SQL Server offer a cleaner tool: the INCLUDE clause, which stores extra columns in the leaf entries only.
CREATE INDEX idx_orders_customer
ON orders (customer_id)
INCLUDE (total, status);customer_id is the sort key; total and status ride along in the leaf
Here the tree is still ordered by customer_id alone, so the upper levels stay narrow and shallow. The leaves carry total and status as payload, so a query that filters by customer and reads those two columns is covered.
| Key columns | INCLUDE columns | |
|---|---|---|
| Stored in | every level of the tree | leaf entries only |
| Affect sort order | yes | no |
| Can seek on them | yes | no |
| Can supply ORDER BY | yes | no |
| Can be returned from the index | yes | yes |
| Effect on tree depth | widens internal nodes | none on internal nodes |
The limits matter. Because included columns are not ordered, the engine cannot seek on them or use them to avoid a sort. They only satisfy the SELECT list (and a filter applied after the seek, which is read straight from the leaf). A query such as WHERE status = 'paid' alone gets no help from the index above.
Putting a column in INCLUDE and expecting it to be searchable or sortable. If a query needs to seek or order by it, it belongs in the key, in the right position.
MySQL's InnoDB has no INCLUDE clause, but it gives you one covered column for free. Every secondary index entry stores the primary key value so the engine can find the clustered row. That means the primary key is implicitly part of every secondary index.
-- InnoDB, with a secondary index on email SELECT id FROM users WHERE email = 'ada@example.com'; -- Extra: Using index (id is already in the index entry)
already covered without any extra column
The Visibility Map Catch
In Postgres, a covering index is necessary for an Index Only Scan but not sufficient. The index entries do not record whether a row version is visible to your transaction, so the engine must know that from somewhere. It consults the visibility map, a compact per-page bitmap marking heap pages whose rows are all visible to everyone. For an all-visible page it trusts the index and skips the heap. For any other page it falls back to fetching the heap row to check.
You see the cost in EXPLAIN ANALYZE, which reports how many times the fallback happened. A table with heavy recent writes can show an Index Only Scan node that is secretly doing a heap read for most rows.
EXPLAIN (ANALYZE, BUFFERS) SELECT title FROM posts WHERE user_id = 7; -- Index Only Scan using idx_feed on posts -- Heap Fetches: 4812 <- visibility map is stale
The fix is routine maintenance. VACUUM (or autovacuum catching up) sets the all-visible bits, after which Heap Fetches drops toward zero and the scan really does skip the heap.
VACUUM (ANALYZE) posts;
-- re-run the query: Heap Fetches: 0Trusting the plan node name. Index Only Scan with a large Heap Fetches count is barely better than a plain Index Scan, and it means VACUUM is overdue on that table.
The Win, the Price and a Feed Rewrite
The best case is dramatic. Suppose a query matches 5,000 rows spread over the heap. A plain index scan pays up to 5,000 random page reads. A covering index turns that into one descent followed by a sequential walk along adjacent leaf pages, because the leaves are a linked list in key order. Thousands of scattered reads become a short contiguous range read.
The price is paid on every write. The index is wider, so it takes more disk and more buffer-pool space, and each insert writes a bigger entry. Updating an included column used to touch only the heap; now it must also rewrite the index entry. In Postgres it also blocks the cheap HOT update, a heap-only tuple update that writes the new row version on the same heap page without touching any index (explained fully later in this chapter). Once an indexed or included column changes, every index has to be updated too.
| Plain index | Covering index | |
|---|---|---|
| Reads per matching row | index entry plus a heap page | index entry only |
| Index size | small | larger, carries payload |
| Update of a payload column | heap only (HOT possible) | heap and index |
| Needs VACUUM health | less so | yes, for the visibility map |
This is why SELECT * defeats covering by definition: no practical index holds every column of a wide table. Narrow the column list to what the page or API really needs first, and only then ask whether an index could cover it.
Keeping SELECT * and trying to cover it by adding columns to the index. You end up with a copy of the table that slows every write. Cut the column list instead.
A worked case: a user's feed shows the 20 newest post titles. The existing index is on (user_id), so each request finds the user's rows, fetches them from the heap, and sorts them. Replace it with a key that serves the filter and the order, plus the title as payload.
DROP INDEX idx_posts_user; CREATE INDEX idx_posts_feed ON posts (user_id, created_at) INCLUDE (title); SELECT title FROM posts WHERE user_id = 7 ORDER BY created_at DESC LIMIT 20;
seek on user_id, read created_at in order, take title from the leaf
The equality column comes first, the ordering column second, and the displayed column rides as payload. The engine seeks to the user, walks 20 leaf entries backwards, and returns titles with no Sort node and no heap read. Prove it by comparing buffer counts before and after, and confirm Heap Fetches is low.
Cover a query that runs thousands of times a day, whose payload columns are rarely updated, and whose current plan is dominated by random heap reads. Otherwise a plain narrow index is the better trade.
Part 7 · How the Query Planner Decides
The Planner Costs Plans, It Does Not Follow Your Query
When you send a query, you describe the result you want, not the steps to get it. The database turns that description into a plan, and the part that does this is the query planner (also called the optimizer). The planner is cost-based. It enumerates many candidate plans, estimates a cost for each one, and runs the cheapest. The order in which you wrote your joins, filters and subqueries is not a recipe it follows, so putting the selective condition first does not make the planner apply it first.
Cost is not milliseconds. It is an abstract unit assembled from a few I/O and CPU constants, and the planner only needs the units to be consistent so it can rank plans against each other. In Postgres the three that matter most are listed below. Reading one sequential page is the baseline of 1.0, and everything else is expressed relative to it.
| Constant | Default | What it prices |
|---|---|---|
seq_page_cost | 1.0 | Reading one page as part of a sequential run |
random_page_cost | 4.0 | Reading one page at an arbitrary position |
cpu_tuple_cost | 0.01 | Processing one row once it is in memory |
The core formula is simple: estimated rows x per-row cost = plan cost. A sequential scan pays for every page of the table plus CPU for every row. An index scan pays a random page read for roughly each matching row, plus CPU for those rows only. The example below applies the three defaults to a table of 10,000 pages and 1,000,000 rows. It is a simplification, since a real planner also caps repeated visits to the same page, but it shows why the winner flips as more rows match.
SEQ_PAGE = 1.0 RANDOM_PAGE = 4.0 CPU_TUPLE = 0.01 pages = 10_000 rows = 1_000_000 seq = pages * SEQ_PAGE + rows * CPU_TUPLE print(f'seq scan: {seq:.2f}') for matched in (10, 10_000, 250_000): idx = matched * RANDOM_PAGE + matched * CPU_TUPLE print(f'index scan, {matched:>7} rows: {idx:.2f}')
Toy cost model using the Postgres default constants
seq scan: 20000.00 index scan, 10 rows: 40.10 index scan, 10000 rows: 40100.00 index scan, 250000 rows: 1002500.00
Ten matching rows make the index scan about 500 times cheaper, so the planner uses it. At 10,000 matching rows (only 1% of the table) the index plan already costs twice the sequential scan, because each match is a random read. The planner is not being lazy when it ignores your index here. It is doing the arithmetic.
Two queries that mean the same thing get the same plan, however they are written. If you want a different plan, change what the planner can see: the indexes, the statistics, or the cost constants.
Statistics, Row Estimates and Stale Numbers
The formula needs a row count, and the planner cannot count rows by running the query, because that would be the very work it is trying to price. It relies on statistics gathered ahead of time: the total row count, the number of distinct values per column, a most-common-values (MCV) list with each value's frequency, and a histogram that divides the remaining values into equal-population buckets. These are collected by ANALYZE in Postgres and ANALYZE TABLE in MySQL, and Postgres also refreshes them in the background through autovacuum.
-- Postgres: refresh statistics for one table ANALYZE orders; -- MySQL ANALYZE TABLE orders; -- Postgres: what the planner knows about a column SELECT n_distinct, most_common_vals, most_common_freqs FROM pg_stats WHERE tablename = 'orders' AND attname = 'status';
Collect and inspect statistics
For status = 'shipped' the planner looks the value up in the MCV list and multiplies its frequency by the table's row count. For a range such as created_at > '2026-01-01' it reads the histogram. The result is the estimated rows that feed the cost formula. This is why a bad row estimate is the single most common cause of a bad plan: every later decision (which index, which join method, which join order) is priced from that number, so a wrong estimate makes the wrong plan look cheapest.
The classic way estimates go wrong is stale statistics. Imagine you bulk-load 10 million rows into a table that was nearly empty when ANALYZE last ran. The planner still believes the table holds a few hundred rows. At that size a nested loop is genuinely the cheapest join, so it picks one, and the query then runs it across 10 million rows. The plan was correct for the table the planner thought it had.
| What the planner believes | What is true | Result | |
|---|---|---|---|
| Rows in table | about 500 | 10,000,000 | Nested loop looks cheap |
| Pages in table | about 5 | about 80,000 | Seq scan looks trivial |
After ANALYZE | 10,000,000 | 10,000,000 | Hash join or index plan chosen |
After a bulk load, a big delete, or a restore, run ANALYZE before you judge the plan. A plan that is slow right after a load is often just a plan built on old numbers.
A second source of bad estimates is correlated predicates. For WHERE city = 'Paris' AND country = 'France', the planner estimates each condition separately and multiplies the fractions, because it assumes the columns are independent. They are not: every Paris row is in France, so the second condition removes nothing. Multiplying two small fractions gives a much smaller number than the truth. The example uses 2 million rows where 0.5% are Paris and 2% are France.
rows = 2_000_000 paris = 10_000 france = 40_000 estimate = paris * france // rows actual = paris print(f'independence estimate: {estimate} rows') print(f'actual rows: {actual}') print(f'off by a factor of {actual // estimate}')
Independence assumption: multiply the two fractions
independence estimate: 200 rows actual rows: 10000 off by a factor of 50
An estimate of 200 rows against 10,000 real ones is enough to tip the planner into a nested loop or the wrong index, exactly as stale statistics do. There are two fixes. The first is extended statistics: in Postgres, CREATE STATISTICS tells the planner to track how columns relate, so it stops multiplying them blindly. The second is to reshape the query so the redundant predicate disappears, for example by filtering on city_id alone when the city already implies the country.
CREATE STATISTICS addr_city_country (dependencies) ON city, country FROM addresses; -- statistics objects are filled in by ANALYZE ANALYZE addresses;
Teach Postgres that country depends on city
Disk Speed, Joins and Hints
The default random_page_cost = 4.0 encodes an old fact: on a spinning disk, jumping to a random page means moving a physical head, so it costs several times more than reading the next page in order. On an SSD that gap mostly vanishes. If your data lives on SSD or NVMe and the setting is still 4.0, the planner overprices every index scan and reaches for sequential scans too often. Lowering it to about 1.1 makes index scans correctly attractive again.
-- try it in one session first SET random_page_cost = 1.1; EXPLAIN SELECT * FROM orders WHERE customer_id = 42; -- then make it permanent for a fast tablespace ALTER TABLESPACE fast_ssd SET (random_page_cost = 1.1);
Tell the planner the disk is an SSD
Access paths are not chosen in isolation. For a query with joins, the planner decides join order and join method in the same search where it picks scans, because the cheapest scan for a table depends on how that table is used in the join. An index changes which joins are even viable. A nested loop with an indexed inner table is cheap when the outer side is small, a merge join becomes free when indexes already supply sorted input, and a hash join needs neither.
| Join method | Needs | Wins when | Disaster when |
|---|---|---|---|
| Nested loop | An index on the inner side | The outer side is tiny | The outer side is large |
| Hash join | Memory for a hash table | Inputs are big and unindexed | The hash spills to disk |
| Merge join | Both inputs sorted | Indexes already give the order | A sort must be added first |
Sometimes you still cannot get the plan you want. Most databases offer planner hints for that case. MySQL has FORCE INDEX, Oracle has comment hints such as INDEX, and Postgres has no hints in core but offers them through the pg_hint_plan extension. A hint pins one plan, and it keeps that plan after the data changes, long after the plan has stopped being the right one.
-- MySQL SELECT * FROM orders FORCE INDEX (idx_orders_customer) WHERE customer_id = 42; -- Oracle SELECT /*+ INDEX(o idx_orders_customer) */ * FROM orders o WHERE customer_id = 42; -- Postgres with pg_hint_plan /*+ IndexScan(o idx_orders_customer) */ SELECT * FROM orders o WHERE customer_id = 42;
The same intent in three dialects
A hint treats the symptom. If the planner chose badly, the cause is usually a wrong row estimate, so run ANALYZE, add extended statistics, or fix random_page_cost first. Keep hints for the rare case where the statistics are right and the plan is still wrong.
Cached Plans and Parameter Sniffing
Planning costs time, so applications often avoid repeating it. A prepared statement is parsed once and then executed many times with different parameter values, and the database may reuse the plan between executions. That is a good trade until the plan was built for an unrepresentative value. This problem is called parameter sniffing: the plan is shaped by the first value it sniffed, and every later value pays for that choice.
Suppose customer_id = 1 is a huge customer with 2 million orders, while most customers have a dozen. Planned for the huge customer, the query picks a sequential scan, which is right for that value. Reused for a customer with 12 orders, it scans the whole table to find 12 rows. The reverse also happens: a plan built for a tiny customer uses an index scan and then crawls when the huge customer arrives.
Postgres handles this with the plan_cache_mode setting, available since version 12. In the default mode, auto, the first few executions each get a custom plan built for the actual parameter value. After that the server may switch to a generic plan that ignores parameter values, if it is not estimated to cost much more than the custom plans. Setting plan_cache_mode to force_custom_plan makes every execution re-plan with the real value. This costs a little planning time and removes the sniffing risk. Setting it to force_generic_plan does the opposite and always reuses one plan.
PREPARE orders_for (int) AS SELECT * FROM orders WHERE customer_id = $1; -- plan each execution for its own parameter value SET plan_cache_mode = force_custom_plan; EXECUTE orders_for(1); -- huge customer: seq scan is fine EXECUTE orders_for(7342); -- tiny customer: index scan, re-planned
Prepared statement with per-value planning
If the same query is fast in your console and slow from the application, check whether the application uses prepared statements. Run the query with the slow parameter value, compare EXPLAIN against the cached plan, and consider force_custom_plan for that workload.
Accurate statistics, cost constants that match your hardware, and indexes that make cheap joins possible. Provide those and the planner usually picks well. When it does not, measure the estimate against reality before you reach for a hint.
Part 8 · Reading EXPLAIN Output
Estimate or Execute: Two Kinds of EXPLAIN
A query plan is the planner's written answer to the question "how will I get these rows?". EXPLAIN prints that answer. Plain EXPLAIN only plans the query. It never runs it, so every number is an estimate built from table statistics. EXPLAIN ANALYZE plans the query and then actually executes it, and it adds the real timings and real row counts next to the estimates.
| EXPLAIN | EXPLAIN ANALYZE | |
|---|---|---|
| Runs the query? | No | Yes, to completion |
| Numbers shown | Estimates only | Estimates plus measured values |
| Cost to you | Almost free | As slow as the query itself |
| Side effects | None | Any write really happens |
| Use it for | Quick look at the chosen shape | Finding where the plan is wrong |
Because EXPLAIN ANALYZE executes the statement, running it on an UPDATE or DELETE changes your data. The fix is to open a transaction, analyze inside it, and roll back. You get the real timings and the table stays untouched.
BEGIN; EXPLAIN ANALYZE DELETE FROM orders WHERE created_at < '2024-01-01'; ROLLBACK;
The delete runs for real inside the transaction, then ROLLBACK undoes it.
Pasting EXPLAIN ANALYZE UPDATE ... into a production console with no BEGIN. The rows are changed and committed, and you only wanted to look at the plan. Triggers also fire, and locks are held while it runs.
Read the tree inside-out
A plan is a tree, drawn with indentation. Each node takes rows from its children, does its job, and hands rows to its parent. The most indented nodes run first, so you read the plan from the bottom up and from the deepest level outward. The top line is the final step, not the first.
EXPLAIN SELECT * FROM orders WHERE customer_id = 7 ORDER BY created_at LIMIT 20;
Limit (cost=18336.50..18336.55 rows=20 width=44) -> Sort (cost=18336.50..18339.00 rows=1000 width=44) Sort Key: created_at -> Seq Scan on orders (cost=0.00..18334.00 rows=1000 width=44) Filter: (customer_id = 7)
Here the Seq Scan is the deepest node, so it runs first and filters the table. Its rows go up into Sort, and the sorted stream goes up into Limit, which keeps the first twenty.
- 1Seq Scan on ordersdeepest, runs first
- 2Sortconsumes the scan's rows
- 3Limittop node, emits the result
Anatomy of a Plan Line
Every node line packs four numbers into one pair of parentheses. Once you can split them apart, plans stop looking like noise.
Seq Scan on orders (cost=0.00..18334.00 rows=1000000 width=44)
One node, estimates only.
| Part | Reads as | Unit |
|---|---|---|
cost=0.00 | Startup cost: work before the first row can come out | Abstract cost units |
..18334.00 | Total cost: work to produce the last row | Abstract cost units |
rows=1000000 | Estimated number of rows this node outputs | Rows |
width=44 | Estimated average size of one output row | Bytes |
Cost is not milliseconds. It is a unit built from the planner's constants for page reads and per-row CPU work, so it only makes sense when comparing plans for the same query. width is the size of the row after the node's projection, so selecting fewer columns shrinks it.
Startup cost and why LIMIT cares
Startup cost is the work that must finish before the node can hand over its first row. A sequential scan can emit a row right away, so its startup cost is 0. A Sort cannot emit anything until it has consumed every input row. A Hash node must build the entire hash table first. Both show a large startup cost, close to their total.
This explains the planner's behavior with LIMIT. If you only want 20 rows, a plan that streams rows from an index already in the right order pays almost nothing up front and can stop early. A plan that scans and sorts everything pays its full startup cost for 20 rows. The planner sees this and prefers low-startup plans when a small limit is present.
| Node | Startup cost | Why |
|---|---|---|
| Seq Scan | 0 | Emits rows as it reads pages |
| Index Scan | Tiny | One descent of the tree, then rows stream |
| Sort | Close to total | Needs all input before the first output row |
| Hash (build side) | Close to total | The whole table must be hashed before probing |
What ANALYZE adds
With ANALYZE each node gets a second set of parentheses holding what really happened.
Index Scan using idx_items_order on items (cost=0.43..8.45 rows=1 width=32) (actual time=0.021..3.140 rows=998 loops=12)
Estimate on the first line, measurement on the second.
actual time is startup..total in milliseconds, rows is the real count, and loops is how many times the node ran. The catch is that both the time and the row count are averages per loop. A node on the inner side of a nested loop runs once per outer row, so you must multiply by loops to get its real contribution.
nodes = [
('Seq Scan on orders', 3.140, 12),
('Index Scan on items', 0.021, 1000),
]
for name, per_loop_ms, loops in nodes:
print(f'{name}: {per_loop_ms:.3f} ms x {loops} loops = {per_loop_ms * loops:.1f} ms')Total time is per loop, so multiply.
Seq Scan on orders: 3.140 ms x 12 loops = 37.7 ms Index Scan on items: 0.021 ms x 1000 loops = 21.0 ms
Judging a node by its printed actual time alone. A node showing 0.021 ms looks harmless, but with loops=1000 it costs 21 ms. The same goes for rows: 998 rows with 12 loops means roughly 12,000 rows in total.
Spotting Trouble: Statistics, Filters and I/O
Once estimates and actuals sit side by side, the plan tells you what is wrong. Three signals are worth learning first.
Estimate versus actual
Compare rows= in the estimate with rows= in the actual part of the same node. When they are close, the planner understood your data. When they are far apart, such as an estimate of rows=1 against an actual of rows=90000, the planner chose its plan on false beliefs. That mismatch is the smoking gun for stale or missing statistics. A planner that expects one row happily picks a nested loop, and then discovers that it must run 90,000 times.
Nested Loop (cost=0.43..16.48 rows=1 width=60) (actual time=0.040..412.900 rows=90000 loops=1)
rows=1 against rows=90000: the statistics are wrong.
Rows Removed by Filter
Some nodes print an extra line, Rows Removed by Filter: N. It means the database read N rows and threw them away because they failed a condition it could not use to seek. A large N next to a small rows= is wasted I/O. It usually points to a missing index, or to an index whose column order does not match the query, so the condition became a filter instead of a seek.
Seq Scan on orders (actual time=0.018..412.300 rows=42 loops=1) Filter: (customer_id = 7) Rows Removed by Filter: 999958
One million rows read to keep 42.
The I/O story: BUFFERS
Cost numbers only approximate disk work. To see the real thing, ask for buffer counts with EXPLAIN (ANALYZE, BUFFERS). Each node then reports shared hit (page found in the cache) and shared read (page fetched from disk or the OS). Two plans can have similar times on a warm cache while one reads ten times more pages, and that one will be slow on a cold cache.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 7;
Index Scan using idx_orders_customer on orders (actual time=0.030..0.210 rows=42 loops=1) Index Cond: (customer_id = 7) Buffers: shared hit=38 read=6
| Signal | What it means | Typical response |
|---|---|---|
| Estimate far from actual | Planner reasoned from wrong statistics | Run ANALYZE; add extended statistics if columns are correlated |
| Rows Removed by Filter is large | Rows read and discarded | Index on the filter column, or fix column order |
| High shared read | Pages came from disk | Narrow the read, cover the query, or check memory |
| High loops on inner node | Nested loop repeating work | Check the outer estimate, consider another join |
Plans from a 1,000-row dev table prove nothing. Row counts, buffer hits and even the node types change with size, so read plans on production-shaped data.
MySQL Dialect and Machine-Readable Plans
MySQL prints a flat table instead of an indented tree, but the questions are the same: what access path, which index, how many rows. EXPLAIN gives one row per table in the query. A real EXPLAIN ANALYZE that executes the query and reports timings exists since MySQL 8.0. Before that you only get estimates.
EXPLAIN SELECT id, total FROM orders WHERE customer_id = 7;
id select_type table type key rows Extra 1 SIMPLE orders ref idx_orders_customer 42 NULL
| Column | What to look for |
|---|---|
type | How rows are found. const is a single-row lookup by primary or unique key, ref is an index lookup that may return several rows, range is a slice of an index, index is a full scan of the whole index, and ALL is a full table scan |
key | The index actually chosen; NULL means none |
rows | Estimated rows to examine |
Extra | Using index means the query is covered by the index alone (good). Using filesort means an extra sort pass. Using temporary means a temporary table was built |
Read type from best to worst: const, then ref, then range, then index, then ALL. A column showing ALL on a large table, or Using filesort and Using temporary on a hot query, is where to start looking. Using index is the one Extra value you want to see.
- 1constone row by key
- 2refindex lookup
- 3rangeslice of an index
- 4indexwhole index scanned
- 5ALLwhole table scanned
JSON output for diffing in CI
The text format is built for human eyes. If you want a program to inspect the plan, use EXPLAIN (FORMAT JSON) in Postgres, which returns the same tree as nested objects with fields such as Node Type, Total Cost and Plan Rows. A CI job can save the plan for important queries, compare it with the previous run, and fail the build when a node type changes, say from Index Scan to Seq Scan, or when the estimated cost jumps by a large factor.
EXPLAIN (FORMAT JSON) SELECT * FROM orders WHERE customer_id = 7;
Plain EXPLAIN keeps CI cheap and deterministic; add ANALYZE only against a safe copy of the data.
EXPLAIN estimates and EXPLAIN ANALYZE measures, and it really runs your statement. Read the tree from the deepest node up, split each line into startup..total cost, rows and width, and multiply actual time by loops. Wide gaps between estimated and actual rows point to statistics, Rows Removed by Filter points to the index, and BUFFERS shows the real I/O.
Part 9 · Plan Node Zoo
Scan nodes: Seq, Index, Index Only
Every EXPLAIN plan is a tree built from a small set of node types. Learn the dozen or so you will meet constantly and any plan becomes readable. The leaves of the tree are always scan nodes, which decide how rows get out of a table. We start there.
Seq Scan: read everything, in order
A Seq Scan (a full table scan in other engines) reads every heap page from start to finish and tests each row against the filter. It never touches an index. Each page is cheap because the disk and the OS read-ahead love sequential access. It is the correct choice when the query wants a large fraction of the table, or when the table is only a few pages. It becomes the wrong choice when a handful of rows match in a huge table, because you still pay to read all of it.
EXPLAIN SELECT * FROM orders WHERE status <> 'cancelled';
Most rows match, so reading everything is the cheapest plan
Seq Scan on orders (cost=0.00..18334.00 rows=940000 width=44) Filter: (status <> 'cancelled'::text)
Index Scan: descend, then visit the heap per row
An Index Scan descends the B-tree to the first matching entry, then for every match follows the stored TID to the heap and fetches the row. The index narrows the search to a few leaf entries, but the heap visits are random I/O: one visit per matching row, and neighbouring index entries can point at far-apart pages. That is why it shines on a few rows and fades as the match count grows.
Index Scan using idx_users_email on users (cost=0.42..8.44 rows=1 width=72) Index Cond: (email = 'ada@example.com'::text)
Index Only Scan: never leave the index
If every column the query needs lives in the index leaves, the engine can skip the heap entirely. That node is an Index Only Scan. The number to check under EXPLAIN ANALYZE is Heap Fetches. A value of 0 means the index really answered the query alone. A large value means pages are not marked all-visible and the engine is still going to the heap, so VACUUM is overdue.
Index Only Scan using idx_posts_user_created on posts (actual time=0.03..0.9 rows=5000 loops=1) Index Cond: (user_id = 7) Heap Fetches: 0
| Node | What it reads | I/O pattern | Look for |
|---|---|---|---|
| Seq Scan | Every heap page | Sequential | Rows Removed by Filter |
| Index Scan | Index path, then one heap row per match | Random | Many matches means many heap visits |
| Index Only Scan | Index leaves only | Mostly sequential in leaves | Heap Fetches: 0 |
Bitmap scans: the middle ground
Between a few random heap hits and reading the whole table sits a third strategy. A Bitmap Index Scan walks the index and collects the TIDs of all matches into an in-memory bitmap, without touching the heap. Then a Bitmap Heap Scan sorts that bitmap by physical page and reads each needed page exactly once, in order. Random access becomes ordered access, and a page holding twenty matches is read once rather than twenty times.
- 1Bitmap Index Scanwalk the index, collect matching TIDs
- 2Build bitmapone bit per heap page or row
- 3Bitmap Heap Scanvisit each page once in physical order
Bitmap Heap Scan on orders (cost=212.40..9120.11 rows=18000 width=44) Recheck Cond: (customer_id = 42) -> Bitmap Index Scan on idx_orders_customer (cost=0.00..207.90 rows=18000 width=0) Index Cond: (customer_id = 42)
Combining indexes with BitmapAnd and BitmapOr
Because bitmaps are just sets of locations, they can be merged. BitmapAnd keeps the pages present in both inputs, and BitmapOr keeps those present in either. This is how two separate single-column indexes cooperate on one query: each produces its own bitmap, the engine combines them, and the heap is read once for the result. It also makes an OR across two indexed columns workable, where a single composite index could not help.
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'open';
Bitmap Heap Scan on orders Recheck Cond: ((customer_id = 42) AND (status = 'open'::text)) -> BitmapAnd -> Bitmap Index Scan on idx_orders_customer Index Cond: (customer_id = 42) -> Bitmap Index Scan on idx_orders_status Index Cond: (status = 'open'::text)
Recheck Cond, and when the bitmap goes lossy
Seeing Recheck Cond on a Bitmap Heap Scan is normal, not a warning. The bitmap keeps exact row locations while it fits in work_mem. If it grows too large, the engine degrades it to remember only whole pages (a lossy bitmap). Then every row on those pages must be re-tested against the condition, which is what the recheck does. EXPLAIN ANALYZE shows this as Heap Blocks: exact=… lossy=…. Many lossy blocks suggest raising work_mem.
Treating Recheck Cond as proof the index is being misused. It is printed on every bitmap heap scan; only a large lossy block count deserves attention.
Join nodes: Nested Loop, Hash, Merge
Join nodes sit above scans and combine two inputs. The planner picks among three algorithms, and each is the right answer in a different situation. The names tell you the mechanics, so the decision is mostly about input sizes and available indexes.
Nested Loop: excellent when small, catastrophic when not
A Nested Loop takes each row from the outer input and probes the inner side for matches. The total work is roughly outer rows multiplied by the cost of one probe. That makes it excellent when the outer side is tiny and the inner side has an index on the join key, because each probe is a few page reads. It is catastrophic when the outer side is large, or when the inner side has no index and each probe becomes a full scan of the inner table.
Take 3 outer rows joined to an indexed inner table: 3 probes at about 3 page reads each is roughly 9 reads. Now take 1,000,000 outer rows joined to an inner table with no index on the key: that is 1,000,000 full scans of the inner table, an effectively endless query. A stale row estimate (the planner believed the outer side had 3 rows) is the classic way a plan lands in the second case.
Nested Loop (actual time=0.05..0.21 rows=12 loops=1) -> Index Scan using users_pkey on users (actual rows=3 loops=1) Index Cond: (id = ANY ('{1,2,3}'::int[])) -> Index Scan using idx_orders_user on orders (actual rows=4 loops=3) Index Cond: (user_id = users.id)
Notice loops=3 on the inner node: it ran once per outer row. In ANALYZE output always multiply the inner node's time by its loops. A huge loops count on a slow inner node is the signature of a nested loop gone wrong.
Hash Join and Merge Join
A Hash Join builds an in-memory hash table from the smaller input, then streams the larger input past it, probing for each row. No index is needed, which makes it the workhorse for big unindexed joins. The cost is memory. If the hash table exceeds work_mem, the join splits into batches and spills to disk, shown as Batches: 4 (or any value above 1). A Merge Join requires both inputs sorted on the join key and walks them in lockstep. It is nearly free when indexes already deliver that order, and expensive when it must add Sort nodes first.
| Join | Needs | Good when | Warning sign |
|---|---|---|---|
| Nested Loop | An index on the inner join key | Outer side is tiny | Large loops count on a slow inner node |
| Hash Join | Memory for the smaller side | Big inputs, no useful index | Batches above 1 means disk spill |
| Merge Join | Both sides sorted on the key | Indexes already supply the order | Explicit Sort nodes feeding it |
Blaming a nested loop on its own. It is the right plan for 3 outer rows and a disaster for 3 million; the fix is usually better statistics or an inner-side index, not forbidding the join type.
Sort spills and parallel nodes
Sort: when work_mem runs out
A Sort node orders its input in memory when it fits in work_mem, using a quicksort. When it does not fit, the engine writes sorted runs to temporary files and merges them. EXPLAIN ANALYZE reports this plainly. A line such as Sort Method: external merge Disk: 24MB means work_mem was exceeded and the sort went to disk. You have two ways out: raise work_mem for that session or query, or give the planner an index that already supplies the order so the Sort node disappears altogether.
Sort (actual time=410.2..520.8 rows=500000 loops=1) Sort Key: created_at Sort Method: external merge Disk: 24MB -> Seq Scan on events (actual rows=500000 loops=1)
SET work_mem = '64MB'; -- session only, then re-run EXPLAIN ANALYZE -- or remove the sort entirely: CREATE INDEX idx_events_created ON events (created_at);
Two fixes: more memory, or an index that provides the order
The same memory limit governs hash tables, which is why Batches: 4 on a Hash Join and Disk: 24MB on a Sort are two views of the same problem. Raising work_mem blindly is risky, because the limit applies per node and per connection; a plan with several sorts and hashes can use many multiples of it.
Gather and Parallel Seq Scan
When you see Gather at the top of a subtree with Parallel Seq Scan beneath it, the engine launched worker processes. Each worker scans a share of the table's pages, and Gather collects their output into one stream. The plan shows Workers Planned and, under ANALYZE, Workers Launched. This changes the scan-versus-index comparison: a seq scan split across 8 workers can genuinely beat an index scan that must do random heap visits on one core, especially when a big fraction of the table matches.
Gather (cost=1000.00..52000.00 rows=900000 width=44) Workers Planned: 8 -> Parallel Seq Scan on orders (cost=0.00..40000.00 rows=112500 width=44) Filter: (total > 20)
Scans decide how rows leave a table, joins decide how two streams combine, and Sort and Gather are the nodes that expose memory limits and parallelism. Read bottom-up, check loops and spill lines, and compare estimated rows with actual rows.
Seeing Parallel Seq Scan and concluding an index is missing. With a large result fraction and several workers, the parallel scan may be the fastest plan available.
Part 10 · Seq Scan vs Index Scan: The Real Trade-off
Sequential I/O against random I/O
The usual framing, 'an index scans some rows and a seq scan scans all of them', is the wrong comparison. Both plans end up reading pages of the heap. What differs is the access pattern. A seq scan walks the heap front to back, which is exactly what disks, OS read-ahead and caches are built for. An index scan takes a pointer from a leaf entry and jumps to that heap page for one row, then takes the next pointer and jumps again, in index order rather than disk order.
Per page, a sequential read is roughly 4-10x cheaper than a random one. Postgres encodes the low end of that range in its default cost constants: seq_page_cost = 1.0 against random_page_cost = 4.0. So the real question is never 'how many rows do I touch?' but 'how many pages do I touch, and in what order?'
Why 10% of the rows can mean 100% of the pages
A heap page holds many rows, often around a hundred for a narrow table. If the matching rows are scattered through the heap, a 10% match means that almost every page contains at least one hit. The index scan therefore visits nearly every heap page anyway. It does so once, at random, one row at a time, and a page that was evicted from cache in between may even be read twice. A seq scan reads the same pages in a single ordered pass and wins by a wide margin.
Heap pages: with 10% of rows matching and scattered, every page holds a hit
The small model below makes the effect concrete. It assumes a table of one million rows at 100 rows per page, so 10,000 pages. A seq scan costs one unit per page. An index scan costs four units per page it fetches. The 'scattered' column uses the expected number of distinct pages hit when the matching rows are spread at random. The 'clustered' column assumes the matching rows sit next to each other, so only the fraction of pages that holds them is read.
ROWS = 1_000_000 PER_PAGE = 100 PAGES = ROWS // PER_PAGE SEQ, RANDOM = 1, 4 def scattered_pages(matches): # expected distinct pages hit when matches land on random pages return round(PAGES * (1 - (1 - 1 / PAGES) ** matches)) print(f"{'matched':>7} {'seq scan':>8} {'scattered':>9} {'clustered':>9}") for permille in (1, 10, 50, 100, 250): matches = ROWS * permille // 1000 scattered = scattered_pages(matches) * RANDOM clustered = PAGES * permille // 1000 * RANDOM print(f"{permille / 10:>6}% {PAGES * SEQ:>8} {scattered:>9} {clustered:>9}")
Cost in page-cost units for the same query, depending on where the matching rows live
matched seq scan scattered clustered 0.1% 10000 3808 40 1.0% 10000 25284 400 5.0% 10000 39732 2000 10.0% 10000 40000 4000 25.0% 10000 40000 10000
Read the 10% row first. The scattered index scan touches all 10,000 pages and pays the random price for each, so it costs four times what the seq scan costs. The clustered index scan reads only 1,000 pages and costs 4,000, far below the seq scan. The only difference between those two columns is where the matching rows physically live, which is covered under physical correlation below. Also notice that this crude model lets the scattered index scan lose already at 1%. Real planners do better than that because of caching and bitmap scans, which later parts of this section cover.
Where the crossover sits
Put those effects together and a rule of thumb emerges. The point where a seq scan starts to beat the index-based plans usually falls somewhere between 5% and 25% of the table, and it is a range because it depends on the data and the hardware, not a constant.
| Rows matched | Usual winner | Why |
|---|---|---|
| Under about 5% | Index scan | Few pages touched, random cost is small in absolute terms |
| About 5-25% | Bitmap heap scan | Too many rows for one-by-one jumps, too few for a full pass |
| Over about 25% | Seq scan | One ordered pass beats visiting most pages at random |
| A few hundred rows in total | Seq scan, always | The whole table fits in a page or two |
| What changes | Effect on the crossover |
|---|---|
| Narrow rows (many per page) | Even a small fraction hits every page, so the index loses earlier |
| Wide rows (few per page) | Matches rarely share a page anyway, so the index holds on longer |
| High physical correlation | Matches are adjacent, so the crossover moves far to the right |
| SSD instead of spinning disk | Random reads cost little more than sequential ones, so the crossover moves right |
Assuming an index is faster because it 'reads fewer rows'. The cost is in pages and in the order they are read. An index scan that lands on every page at random is slower than the seq scan you were trying to avoid.
Physical correlation and CLUSTER
Correlation is the statistic that decides which column of the cost table above you are in. When Postgres runs ANALYZE, it records for each column how closely the sorted order of its values matches the physical order of the rows in the heap. The number is called correlation and lives in pg_stats. It runs from -1 to 1, where values near either end mean the heap is almost perfectly ordered (ascending or descending) and values near 0 mean the order on disk has nothing to do with the column.
SELECT attname, correlation FROM pg_stats WHERE tablename = 'orders' AND attname IN ('created_at', 'customer_id');
| attname | correlation |
|---|---|
| created_at | 0.998 |
| customer_id | 0.012 |
Illustrative values for a typical orders table
The reason for those two values is how the table was filled. Orders are appended as they arrive, so created_at follows the physical order almost exactly. Customers place orders at random moments, so rows for one customer end up spread across the whole heap.
| Correlation | Layout on disk | What an index range scan reads |
|---|---|---|
| Near 1.0 or -1.0 | Heap follows the column order | A few adjacent pages, close to a sequential read |
| Near 0 | Matching rows scattered | About one random page per matching row |
The planner takes this into account when it costs an index scan. A 5% range on created_at can therefore be a cheap index scan, while a 5% selection on customer_id of the same size is better served by a bitmap scan or a seq scan. Same table, same selectivity, very different best plan.
CLUSTER: manufacturing correlation
If a column matters enough, you can force the heap into its order. CLUSTER rewrites the whole table in the order of a chosen index, which pushes the correlation of that column to nearly 1. Run ANALYZE afterwards so the planner sees the new statistics.
CLUSTER orders USING idx_orders_created; ANALYZE orders;
The cost is steep. CLUSTER takes an ACCESS EXCLUSIVE lock, so reads and writes on the table block until it finishes, and it needs enough free disk space to hold a second copy of the table and its indexes. It is also a one-time rewrite, not a property of the index. New rows are placed wherever there is room, and updated rows get a new version elsewhere, so the order decays as the table changes.
- 1CLUSTER runsheap rewritten in index order, table locked
- 2Correlation near 1.0range scans read adjacent pages
- 3Inserts and updatesnew row versions land wherever there is space
- 4Correlation drifts downindex scans slowly return to random reads
- 5Re-run CLUSTERor accept the drift
Treating CLUSTER as a setting that keeps the table ordered. It does not. On a write-heavy table the benefit fades, and re-running it means another full lock, so it fits best on tables that are loaded once and then mostly read.
Small tables, LIMIT, and the bitmap middle
A small table always wants a seq scan
Take a table of a few hundred rows that fits in one or two pages. A seq scan reads those two pages and is done. An index scan must read the root page, a leaf page, and then the heap page for each match, which is as many pages as the whole table or more. No selectivity makes the index worthwhile here. When the planner ignores your index on such a table, it is correct and the right response is to leave it alone.
EXPLAIN SELECT * FROM countries WHERE code = 'FR'; -- Seq Scan on countries (cost=0.00..5.75 rows=1 width=40) -- Filter: (code = 'FR'::text) -- 300 rows, 2 pages: 2 * 1.0 page cost + 300 * 0.01 row cost + filter work
Illustrative plan for a 300-row table that has an index on code
LIMIT changes the arithmetic
Everything so far assumed that the query needs all of its matching rows. A LIMIT breaks that assumption. If an index already delivers rows in the requested order, the executor can walk the index, fetch the first 20 heap rows and stop. A plan without that index has to read the whole table, sort everything, and only then hand back 20 rows. The first plan does work proportional to the limit; the second does work proportional to the table.
- 1Descend to the last leafa handful of page reads
- 2Walk leaves backwardsentries are already in order
- 3Fetch 20 heap rows20 random reads at most
- 4Stopthe rest of the table is never read
- 1Seq scan everythingevery page, every row
- 2Sort all rowsnothing can be returned before this ends
- 3Return the first 20after all the work is done
EXPLAIN SELECT * FROM events ORDER BY created_at DESC LIMIT 20; -- Limit (cost=0.43..3.10 rows=20 width=64) -- -> Index Scan Backward using idx_events_created on events -- (cost=0.43..133420.55 rows=1000000 width=64)
Illustrative plan. The Limit node's total cost is a small slice of the index scan's total
The planner can see this because of startup cost: the index scan can produce its first row almost immediately, while the sort cannot produce anything until it has consumed its input. One caveat applies. If the query also has a filter that rarely matches, the ordered walk may have to read far more than 20 rows before it finds 20 that qualify, and a bad estimate of that filter is a classic cause of a LIMIT query that is slower than expected.
Bitmap heap scan: the middle range
Between the two extremes sits a plan that takes the best part of each. A bitmap index scan first reads the index and collects the locations of all matching rows without touching the heap. The bitmap heap scan then visits the heap pages in physical order, once per page, and checks the matching rows on each one. Random jumps become one forward sweep over only the pages that matter.
- 1Bitmap Index Scancollect row pointers from the index
- 2Sort by heap pagepointers become an ordered set of pages
- 3Bitmap Heap Scanvisit each page once, in order
- 4Recheck Condre-test rows when the bitmap is lossy
This is why the 5-25% band belongs to bitmap scans. Each page is read at most once, never revisited, and in an order that looks much more like a sequential pass. The planner also lowers the per-page price as the fraction of the table it fetches grows, which is why a bitmap plan degrades gracefully towards a seq scan instead of falling off a cliff. The same mechanism lets BitmapAnd and BitmapOr combine two separate indexes before the heap is touched.
EXPLAIN SELECT * FROM orders WHERE customer_group = 12; -- Bitmap Heap Scan on orders (cost=1240.50..9800.20 rows=62000 width=48) -- Recheck Cond: (customer_group = 12) -- -> Bitmap Index Scan on idx_orders_group -- Index Cond: (customer_group = 12)
Illustrative plan for a filter matching about 6% of an uncorrelated column
Side by side, and checking the planner
Here are the two basic access paths next to each other. Neither is the better one; each has a shape of workload it fits.
| Seq scan | Index scan | |
|---|---|---|
| I/O pattern | Sequential | Random |
| Write overhead | None, there is no index to maintain | Every insert, update and delete also updates the index |
| Parallelism | Easy to split: workers take ranges of pages | Harder to split: the walk follows one index order |
| Cost grows with | Table size | Rows matched |
| Row order | None | Provides the index order |
The decision the planner makes can be written down as a few questions. Asking them in this order is also a good way to sanity-check a plan you did not expect.
When you disagree with the planner
Sometimes EXPLAIN ANALYZE shows a seq scan you did not expect. Before you change anything, ask the planner what the index plan would have cost. Turn seq scans off for your session only, run the same query again, and compare. The setting does not forbid seq scans; it adds a very large penalty to them, so the planner uses the index plan whenever one is possible. Compare the cost and the actual time of the forced plan with the original, not the penalized seq scan figure.
SET enable_seqscan = off; -- this session only EXPLAIN ANALYZE SELECT * FROM orders WHERE total > 90; RESET enable_seqscan; -- always reset
A diagnostic, run on production-shaped data
| Plan | Estimated cost | Actual time |
|---|---|---|
| Seq Scan (the planner's choice) | 18334 | 210 ms |
| Index Scan (forced) | 61200 | 940 ms |
In this example the forced index plan is both estimated and measured to be worse, so the planner was right and you can stop. If the forced plan had been faster in reality while its estimate looked higher, the planner's numbers are off. That points to stale statistics (run ANALYZE), to a random_page_cost of 4.0 on a machine with SSDs (a value near 1.1 fits better), or to correlation that the planner cannot see. Wrapping the experiment in a transaction with SET LOCAL is another safe way to keep the change from outliving the test.
Leaving an enable_* toggle on, in a config file, a role setting or a connection pool, because it fixed one query. It changes the plan of every query it touches, including the many where a seq scan is the right answer. Never leave these on in production.
The toggles are a diagnostic, not a fix. Use them to confirm what the planner chose, then fix the cause: statistics, the cost constants, the query, or the index.
Part 11 · When an Index Hurts
Every Index Is a Write Tax
Everything so far has treated an index as a gift: pay some disk, get faster reads. The bill arrives on the write side. An index is a second copy of part of your table, kept in sorted order, and the database must keep that copy correct on every change. A table with 8 indexes does 9 structural updates per INSERT: one for the heap (the table itself) and one B-tree insertion for each index.
The count of structural updates understates the cost, because each of those updates is also written to the WAL (Postgres) or redo log (MySQL, Oracle). A single logical row insert becomes nine page modifications and nine sets of log records, all of which must be flushed to disk and shipped to replicas.
The small program below turns that into throughput. It assumes a steady 2,000 inserts per second and counts the structural updates the storage engine has to perform as secondary indexes are added.
inserts_per_second = 2000 for secondary in (0, 2, 5, 8): structural = 1 + secondary print(f"{secondary} secondary indexes: {structural} structural updates per INSERT, {inserts_per_second * structural} per second")
The heap counts as one update, each index adds one more.
0 secondary indexes: 1 structural updates per INSERT, 2000 per second 2 secondary indexes: 3 structural updates per INSERT, 6000 per second 5 secondary indexes: 6 structural updates per INSERT, 12000 per second 8 secondary indexes: 9 structural updates per INSERT, 18000 per second
Updates and deletes pay too. A DELETE marks entries dead in every index, and an UPDATE that changes an indexed column removes the old entry and inserts a new one in that index. Postgres has one important shortcut here, and extra indexes make it harder to use.
HOT updates and why more indexes break them
A HOT update (heap-only tuple) lets Postgres write the new row version on the same heap page and leave every index alone, because the existing index entries still lead to the right page. It only works when no indexed column changed and the new tuple fits on the same page. Each extra index widens the set of columns that count as indexed, so more ordinary updates fail the first test and fall back to the full, expensive path.
INDEXED = {"email", "status", "created_at"}
def update_kind(changed, indexed, page_free, new_size):
if set(changed) & indexed:
return "index entries rewritten"
if new_size > page_free:
return "index entries rewritten (page full)"
return "HOT, indexes untouched"
print("last_seen, room on page ->", update_kind(["last_seen"], INDEXED, 200, 120))
print("status changes ->", update_kind(["status"], INDEXED, 200, 120))
print("last_seen, page is full ->", update_kind(["last_seen"], INDEXED, 60, 120))
INDEXED.add("last_seen")
print("last_seen, newly indexed ->", update_kind(["last_seen"], INDEXED, 200, 120))A model of the two HOT conditions, not Postgres itself.
last_seen, room on page -> HOT, indexes untouched status changes -> index entries rewritten last_seen, page is full -> index entries rewritten (page full) last_seen, newly indexed -> index entries rewritten
Indexing a column that is updated all the time, such as last_seen or a counter, because one dashboard filters on it. The last line above is the result: every update to that column now misses HOT and rewrites index entries, for every row, forever.
Memory, Planner Time and Dead Weight
Indexes compete for RAM
Indexes are not only on disk. To be useful they have to be cached in the buffer pool, the same memory that holds your hot table pages. Index pages that get touched are pulled in and compete for space. Even a rarely-used index still gets its pages loaded on every write, so it keeps evicting table pages your real queries need. The cost shows up as a lower cache hit ratio and more physical reads everywhere, not as a slow query you can point at.
Low-cardinality columns
An index pays off when it narrows the search to a small fraction of the table. A boolean has two values, and a status column with three values splits the table into three huge groups. Asking for one group returns a third or half of the rows, and at that fraction a sequential scan wins, so the planner ignores the index. You still pay for it on every write.
| Column | Distinct values | Typical index value |
|---|---|---|
| is_deleted | 2 | Dead weight, planner seq scans |
| status (3 values) | 3 | Dead weight, unless one value is very rare |
| country | about 200 | Marginal, depends on skew |
| user_id | one per user | Excellent |
| one per row | Excellent |
The exception is a skewed column where you only ever query the rare value, for example 0.4% of jobs that are still pending. Do not index the whole column then. Use a partial index that stores only the rows you search for, which keeps it tiny and means most writes never touch it.
-- dead weight: indexes every row to find none of them cheaply CREATE INDEX idx_jobs_status ON jobs (status); -- only the rare rows are stored, most writes skip it CREATE INDEX idx_jobs_pending ON jobs (created_at) WHERE status = 'pending';
The planner pays too
Every index on a table is a candidate access path. At planning time the optimizer has to cost each plausible one, and with joins it multiplies those choices across tables and join orders. A table with dozens of indexes adds measurable planning time to every query that touches it, which hurts most for short queries that run thousands of times a second.
Indexing every boolean flag and enum "just in case". The planner will seq scan anyway, so you get none of the read benefit and all of the write, memory and planning cost.
Random Keys, Redundant and Unused Indexes
Random UUID primary keys
A B-tree keeps keys in sorted order, so a new key must land in the leaf where it belongs. With a sequence or a timestamp, new keys always go to the right-most leaf, which stays in cache and fills up neatly. With a random UUID (v4), each insert lands on an arbitrary leaf somewhere in the tree. That leaf is likely not in memory, so it must be read first. When it is full it splits, leaving two half-empty pages. The result is write amplification across the whole tree, a bloated index and poor cache use. In InnoDB the damage is bigger, since the primary key is the table and every secondary index also stores the key.
| Sequence or UUIDv7 / ULID | Random UUIDv4 | |
|---|---|---|
| Where inserts land | Right-most leaf, always hot | Any leaf, mostly cold |
| Page splits | Rare, pages fill completely | Frequent, pages left about half full |
| Pages touched per write | Few, cached | Many, scattered |
| Range scans on recent rows | Adjacent pages | Scattered pages |
| Key size | 8 bytes (bigint) or 16 bytes | 16 bytes |
The fix is to keep the uniqueness of a UUID but add a time-ordered prefix. UUIDv7 and ULID both start with a timestamp, so new keys sort after old ones and behave like a sequence. Recent Postgres versions ship a uuidv7() function, and libraries exist for most languages. If you do not need globally unique ids generated outside the database, a plain bigint identity column is smaller still.
-- scattered inserts, splits across the whole tree CREATE TABLE events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), payload jsonb); -- time-ordered, appends at the right edge CREATE TABLE events (id uuid PRIMARY KEY DEFAULT uuidv7(), payload jsonb);
Redundant and duplicate indexes
Because of the leftmost-prefix rule, an index on (a, b) can answer everything an index on (a) can. The narrow one is subsumed: it costs writes, space and cache, and buys nothing. Exact duplicates happen too, usually when two people add the same index under different names, or an ORM and a migration both create it.
CREATE INDEX idx_orders_cust ON orders (customer_id); CREATE INDEX idx_orders_cust_date ON orders (customer_id, created_at); CREATE INDEX idx_orders_cust_again ON orders (customer_id, created_at); -- idx_orders_cust is covered by idx_orders_cust_date -- idx_orders_cust_again is an exact duplicate: drop both extras
The rule only works in one direction. (a, b) does not replace an index on (b), and it does not replace a UNIQUE index on (a), which enforces a constraint. Check before dropping.
Unused indexes pile up
Indexes get added during incidents and almost never removed. After a few years of changed queries, a table carries indexes that nobody has used in months. Postgres counts index scans for you, and MySQL exposes a ready-made view.
-- Postgres: indexes never scanned since stats were reset SELECT schemaname, relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_stat_user_indexes WHERE idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC; -- MySQL (sys schema) SELECT * FROM sys.schema_unused_indexes;
Dropping every index with idx_scan = 0 without checking. The counter restarts when statistics are reset, it is kept per server so a replica that serves reports has its own numbers, and indexes that back a PRIMARY KEY, UNIQUE or foreign key constraint may show zero scans while still doing their job. Look at a long window of data, and check replicas, first.
Loading, Building and Keeping a Budget
Bulk loads: drop, load, rebuild
Loading millions of rows into a table with live indexes means inserting each row into each tree one at a time, in arbitrary order, with a log record for every step. Building an index from scratch is far cheaper: the database sorts all the keys once and writes the tree bottom-up in packed pages. For a large initial load or a big backfill, the usual result is that dropping secondary indexes, loading, then recreating them is several times faster than loading with the indexes live.
- 1Drop secondary indexeskeep the primary key and constraints you need
- 2Load the dataCOPY or batched INSERTs
- 3Rebuild the indexesone sort and bottom-up build each
- 4Run ANALYZErefresh statistics for the planner
DROP INDEX idx_events_user; DROP INDEX idx_events_created; COPY events FROM '/data/events.csv' WITH (FORMAT csv); CREATE INDEX idx_events_user ON events (user_id); CREATE INDEX idx_events_created ON events (created_at); ANALYZE events;
This only makes sense when the table is offline or can tolerate slow queries while the indexes are missing. On a live system serving traffic, dropping indexes is not an option.
Adding an index to a live table
A plain CREATE INDEX takes a lock that blocks writes to the table for as long as the build runs. On a large table that can be minutes, and every INSERT, UPDATE and DELETE queues behind it. Postgres offers CREATE INDEX CONCURRENTLY, which builds the index without blocking writes by scanning the table more than once. MySQL has online DDL, and external tools such as pt-online-schema-change or gh-ost do the same job for large tables.
CREATE INDEX CONCURRENTLY idx_orders_cust_date ON orders (customer_id, created_at); -- if it failed, look for the leftover SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid; DROP INDEX CONCURRENTLY idx_orders_cust_date; -- then run the CREATE again
The trade-off is that the concurrent build is slower and can fail partway, for example on a uniqueness violation or a cancelled session. It then leaves behind an INVALID index. It is not used for queries, but it is still maintained on every write, so it costs you without helping you.
Running CREATE INDEX CONCURRENTLY, seeing an error, and walking away. The invalid index stays and keeps taxing writes. Always check for indisvalid = false afterwards, drop the leftover and retry.
A budget for each table
On a write-heavy OLTP table, treat anything past about 5 indexes with suspicion. This is a heuristic, not a limit: a read-mostly reporting table can carry many more. What matters is that every index can be tied to a real query, and that the query gains more than the writes lose.
An index is a trade: faster reads for slower writes, more memory, fewer HOT updates and more planning work. Keep the ones you can justify with a real query, and remove the ones that are unused, redundant, low-cardinality or leftover from a failed build.
Part 12 · Common Mistakes That Silently Disable an Index
Hiding the Column from the Index
An index is a sorted copy of one column's values (or several columns' values). The planner can only use it when the query compares the stored value itself against something. The moment the query compares a *transformed* version of the column, the sorted order of the raw values says nothing about the order of the transformed ones, and the planner has no choice but to read every row. Nothing errors and nothing warns you. The query just gets slower as the table grows.
The first three mistakes are all versions of this: something happens to the column before the comparison. The test to apply is simple. Is the column standing alone on one side of the operator, with the same type as the thing on the other side?
Wrapping the column in a function
YEAR(created_at) produces a new value for every row, and the index on created_at is not sorted by that value. The database would have to compute the function for each row to find the matches, which is a full scan. Rewrite the filter as a half-open range on the bare column, and the index can seek straight to 2026-01-01 and read forward until it passes 2027-01-01.
-- Scans the whole table: the function hides created_at SELECT id FROM orders WHERE YEAR(created_at) = 2026; -- Seeks: the bare column is compared with constants SELECT id FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
Use a half-open range (>= start, < next start) so no boundary row is lost or double counted.
If you genuinely cannot avoid the function, you can index the function's result instead. The fix is an index on that exact expression, covered under Case-insensitive search below. The expression in the query must match the one in the index definition.
Arithmetic on the column
Arithmetic is just another function. price * 1.2 > 100 forces the database to multiply every price before comparing. Algebra is on your side: move the math to the constant side so the column stands alone.
-- Cannot seek on price SELECT id FROM products WHERE price * 1.2 > 100; -- Same rows, index-friendly (100 / 1.2 = 83.333...) SELECT id FROM products WHERE price > 100 / 1.2;
The right-hand side is a constant, so it is computed once per query, not once per row.
Writing WHERE YEAR(created_at) = 2026 or WHERE price * 1.2 > 100 because it reads naturally. The index on that column is silently ignored and the query scans the whole table. Keep the bare column on one side and put all the computation on the other.
Implicit type casts
When the two sides of a comparison have different types, the database converts one of them. If it converts the column, you have the same problem as a function call, because the conversion is applied to every row. MySQL is the classic offender. Comparing a varchar column to a bare number makes MySQL convert each stored string to a number, so an index on that column cannot be used.
-- phone is VARCHAR(20) with an index SELECT id FROM users WHERE phone = 5551234; -- every row cast, full scan SELECT id FROM users WHERE phone = '5551234'; -- index seek
Match the literal's type to the column's type. Quote string values.
The same thing happens across joins when the two columns differ in character set or collation. If orders.email is utf8mb4 and customers.email is utf8mb3, MySQL must convert one side to compare them, and the converted column loses its index. Mixed integer widths are not the concern people think. MySQL compares INT with BIGINT natively, so that join keeps its index.
| Comparison | What the database does | Fix |
|---|---|---|
varchar column = number literal | Converts every stored string to a number, so no seek | Quote the literal: '5551234' |
Join on columns with different character sets or collations (utf8mb3 vs utf8mb4) | Converts one side per row, so that side's index is unusable | Give both columns the same character set and collation |
| Foreign key column declared differently from the key it references | Invites exactly the mismatches above | Declare foreign keys with the identical type, character set and collation as the parent column |
Passing an application parameter of the wrong type, such as an integer ID bound to a string column, or joining tables whose text columns were created with different collations. The query returns correct results, so nothing alerts you. Only the plan shows the full scan.
Searches and Conditions an Index Cannot Answer
The next group is not about transforming the column. The query shape itself asks something a sorted structure cannot answer cheaply. A B-tree finds values by descending from the root, comparing against separator keys. If the question gives no starting point for that descent, there is nothing to seek on.
Leading wildcard
LIKE 'term%' has a known prefix, so the tree can jump to the first entry starting with term and read forward. LIKE '%term' begins with an unknown, so there is no place to start. It is the phone-book problem: you can find everyone named Smith, but not everyone whose name ends in -son. Two real remedies exist when you truly need this search.
- Trigram index (
pg_trgmin Postgres): indexes every three-character fragment of the text, so substring and suffix matches can use it. - Reverse-column index: store or index the reversed string, then search
LIKE 'mret%'for the suffixterm. This only helps suffix search, not matching in the middle.
-- Postgres: substring and suffix search via trigrams CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_users_email_trgm ON users USING gin (email gin_trgm_ops); SELECT id FROM users WHERE email LIKE '%@example.com';
Case-insensitive search
WHERE lower(email) = 'ada@x.com' is a function call on the column, so a plain index on email is skipped. Give the planner something that matches the query. One option is an expression index on exactly lower(email). Another is a citext column, or a case-insensitive collation, so the plain comparison is already case-insensitive and a normal index works.
-- The expression must match the query text exactly CREATE INDEX idx_users_email_lower ON users (lower(email)); SELECT id FROM users WHERE lower(email) = 'ada@x.com'; -- seeks SELECT id FROM users WHERE upper(email) = 'ADA@X.COM'; -- does not
An index on lower(email) cannot answer upper(email). The two expressions are different.
OR across different columns
Suppose you have an index on (a, b) and run WHERE a = 1 OR b = 2. Rows matching only b = 2 are scattered through the whole index, so a single seek cannot collect them. You have two good ways out. Give each column its own index and let the planner combine them with a bitmap OR, or rewrite the query as two index-friendly queries joined with UNION ALL.
-- Each branch can use its own index SELECT id FROM t WHERE a = 1 UNION ALL SELECT id FROM t WHERE b = 2 AND a IS DISTINCT FROM 1;
The second branch excludes rows the first already returned, so UNION ALL does not duplicate them.
NOT IN and <>
status <> 'done' or id NOT IN (...) describe almost the whole table, minus a few rows. An index is only worth using when it picks out a small fraction of rows. When the answer is "nearly everything", reading the table sequentially is cheaper, and the planner will choose a seq scan. It is right to do so. Do not try to force an index here. If the real need is a small set, say so positively instead, for example status IN ('new', 'active') or a partial index on the rare value.
Forcing an index with a hint or enable_seqscan = off for a NOT IN or <> query, then finding it slower. The planner's seq scan was the correct plan because the predicate matches most of the table.
Skipping the leading column of a composite index
A composite index on (a, b) is sorted by a first, and by b only within each value of a. A query filtering on b alone gets no useful ordering: the matching entries are spread across every a group. At best the database scans the entire index and filters. Either put the column you always filter on first, or add a separate index for the query that filters on b.
| Query against index (a, b) | Can the index seek? | Why |
|---|---|---|
WHERE a = 1 | Yes | Leftmost column is constrained |
WHERE a = 1 AND b = 2 | Yes, on both | Whole prefix is constrained |
WHERE b = 2 | No, full index scan at best | The leading column is missing |
WHERE a = 1 OR b = 2 | No single seek | The b branch cannot use the sort order |
Sorting in an order the index does not provide
An index can hand back rows in its own order, or exactly the reverse of it, without sorting. With an index on (a ASC, b DESC), both ORDER BY a ASC, b DESC and ORDER BY a DESC, b ASC are free. But ORDER BY a ASC, b ASC is neither, because the second column would need to be read in the opposite direction within each group. A separate Sort node appears in the plan and the whole result must be sorted before the first row is returned.
CREATE INDEX idx_t_a_b ON t (a ASC, b DESC); -- Free: matches the index, or its exact reverse SELECT * FROM t ORDER BY a ASC, b DESC; SELECT * FROM t ORDER BY a DESC, b ASC; -- Needs a Sort node: mixed directions do not match SELECT * FROM t ORDER BY a ASC, b ASC;
Creating the index with one direction on each column and then writing ORDER BY with a different combination. The WHERE clause may still use the index, but the ordering no longer does, so you pay for a sort on every call.
Pagination and Production Reality
OFFSET pagination
LIMIT 20 OFFSET 100000 sounds like "jump to row 100,000". The database cannot do that. Entries in an index have no row numbers, so it must walk and discard the first 100,000 entries before it reaches the 20 you want. Page 1 is fast, page 5,000 is slow, and the cost climbs linearly with the offset.
Keyset pagination (also called the seek method) replaces the row count with a bookmark: the sort values of the last row you showed. The next page asks for rows strictly before that bookmark, so the index can descend straight to it. The cost stays constant no matter how deep the user goes. Include a unique tiebreaker such as id, or rows sharing the same timestamp will be skipped or repeated.
-- Degrades with depth: walks 100,020 entries SELECT id, created_at FROM posts ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 100000; -- Constant cost: seeks straight to the bookmark SELECT id, created_at FROM posts WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20;
The two ? values are created_at and id of the last row on the previous page. An index on (created_at, id) serves both the filter and the order.
The sketch below counts the entries each approach touches over 200,000 sorted ids. The offset version must walk past everything before its page. The keyset version uses a binary search, which stands in for the index descent, and then reads just the page.
import bisect rows = list(range(1, 200001)) # ids in index order def offset_page(offset, limit): return rows[offset:offset + limit], offset + limit def keyset_page(after_id, limit): start = bisect.bisect_right(rows, after_id) return rows[start:start + limit], limit page, walked = offset_page(100000, 20) print('offset walked:', walked) page2, walked2 = keyset_page(100000, 20) print('keyset walked:', walked2) print('same rows:', page == page2)
offset walked: 100020 keyset walked: 20 same rows: True
Both approaches return the same rows. Keyset's one trade-off is that users move forward and backward through pages rather than jumping to an arbitrary page number, which is what infinite scroll and "next" buttons need anyway.
Keysetting on a column that is not unique, such as created_at alone. Two rows with the same timestamp straddling a page boundary will be skipped or shown twice. Always add a unique column, like id, to the sort and to the comparison.
Assuming the index exists in production
Indexes are created by migrations, and migrations get skipped, fail halfway, or get applied by hand on one environment and not another. A query that is fast on your laptop proves only that your laptop has the index. Before you start debugging a slow query, check what the production table really has.
\d orders -- Postgres: columns, indexes, constraints SHOW INDEX FROM orders; -- MySQL: one row per indexed column
Also look for an INVALID index in Postgres, left behind by a failed CREATE INDEX CONCURRENTLY. It exists but is never used.
Quick triage for a slow query
When a query that should use an index does not, walk this list in order. Each step takes seconds to check and rules out one of the mistakes above.
| Mistake | Symptom in EXPLAIN | Fix |
|---|---|---|
| Function or arithmetic on column | Seq Scan, filter shows the expression | Bare column vs constant, or an expression index |
| Implicit cast or collation mismatch | Seq Scan or type ALL | Match types, quote literals, align collations |
| Leading wildcard | Seq Scan with the LIKE filter | Trigram index or reverse-column index |
| OR across columns | Seq Scan or a full index scan | UNION ALL or two indexes with bitmap OR |
| Missing leading column | Full index scan or Seq Scan | Reorder columns or add a second index |
| Mismatched sort order | Sort node above the scan | Build the index in the order the query sorts |
| Deep OFFSET | Large number of rows removed or skipped | Keyset pagination |
An index answers questions about the stored values in their stored order. If the query transforms the column, hides its type, loses the leading prefix, asks for nearly everything, or sorts differently, the index cannot help. Read the plan to see which.
Part 13 · A Working Diagnostic Method
Find the query, then rank by total time
Index tuning goes wrong when it starts from a hunch such as "the orders table feels slow". This section gives a fixed order of work that starts with evidence and ends with evidence. It has eight steps, plus two checks on what an index costs once it exists.
- 11. Find the querypg_stat_statements or slow log
- 22. Rank by total timecalls x mean, not worst run
- 33. EXPLAIN (ANALYZE, BUFFERS)on production-shaped data
- 44. Find the estimate gapestimated vs actual rows
- 55. Try ANALYZEcheapest fix first
- 66. Shape the indexequality, range, sort, INCLUDE
- 77. Re-run and comparetime and buffers
- 88. Check idx_scana week later
Step 1: find the query, don't guess
The database already records which statements cost the most. In Postgres, the pg_stat_statements extension keeps a row per normalised statement, with call count and cumulative execution time. Sort it by total_exec_time and the top rows are where your server actually spends its effort.
SELECT query, calls, total_exec_time, mean_exec_time FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
The ten statements that consume the most database time
MySQL has the same information in two places. The slow query log captures statements above a time threshold, and performance_schema keeps aggregated per-statement statistics. Whichever engine you use, the principle is the same: let the server tell you what is expensive.
Step 2: optimize total time, not the worst single run
The slowest single execution is the one people notice, but it is rarely the one that matters. What matters is calls multiplied by mean time. A 5ms lookup that runs 2 million times a day uses far more database time than a 3 second report that runs once an hour.
queries = [
('lookup_user', 5, 2_000_000),
('hourly_report', 3000, 24),
]
ranked = sorted(queries, key=lambda q: q[1] * q[2], reverse=True)
for name, ms, calls in ranked:
secs = ms * calls / 1000
print(f'{name:<14} {ms:>5} ms x {calls:>9,} = {secs:>9,.0f} s')Total time per day for each query
lookup_user 5 ms x 2,000,000 = 10,000 s hourly_report 3000 ms x 24 = 72 s
The small query costs about 140 times more database time. Shaving 1ms off it saves more than making the report instant would. This is why the ranking comes before any EXPLAIN.
Tuning the query that someone complained about instead of the query at the top of total_exec_time. A slow report that runs once a day is a poor target next to a cheap query called millions of times.
Read the plan and fix the estimate
Step 3: explain on production-shaped data
Once you have a target, run it with EXPLAIN (ANALYZE, BUFFERS). The plan is only as meaningful as the data under it. On a dev table with 1,000 rows the planner correctly decides that a sequential scan is cheapest, so the plan tells you nothing about production. Use a restored copy, a replica, or a data set with the same row counts, value distribution and physical layout.
BEGIN; EXPLAIN (ANALYZE, BUFFERS) SELECT id, total FROM orders WHERE customer_id = 4821 AND status = 'open' ORDER BY created_at DESC LIMIT 20; ROLLBACK;
ANALYZE really executes the statement, so wrap anything that writes in a transaction you roll back
BUFFERS adds the shared hits and reads for each node. These are the I/O the query actually did, and they are a steadier measure than the cost numbers, which are only estimates.
Step 4: find where estimate and actual diverge most
Every node in an ANALYZE plan shows rows= for the estimate and rows= again inside the actual section. Scan the plan for the node where those two numbers are furthest apart. That subtree is where the planner's picture of your data is wrong, and wrong estimates lead to wrong join orders and wrong scan choices above it.
Nested Loop (cost=0.43..812.10 rows=3 width=44) (actual time=0.05..2210.40 rows=88000 loops=1) -> Index Scan using idx_orders_customer on orders (rows=3) (actual rows=88000 loops=1)
An estimate of 3 rows against 88,000 actual is the smoking gun
Remember that actual time is per loop. If a node shows loops=12, multiply by 12 for its real cost. Ignore the parts of the plan whose estimates are close, even if they look long, and focus on the one subtree that is off.
Step 5: try ANALYZE before adding an index
A large estimate gap usually means the statistics are stale or too coarse, for example after a bulk load. Refreshing them costs seconds and adds nothing to maintain, while a new index taxes every write for as long as it exists. So try the cheap fix first.
ANALYZE orders;
-- then re-run the EXPLAIN and see whether the gap closedMySQL equivalent: ANALYZE TABLE orders
If the gap closes and the plan becomes fast, you are done and no index was needed. If two columns are correlated, such as city and country, plain ANALYZE will not fix the estimate, and CREATE STATISTICS on those columns is the next thing to try. Only when the estimates are sound and the plan is still slow do you design an index.
Adding an index to repair a plan that was bad only because of stale statistics. The index may even be ignored, and you now pay its write cost for nothing.
Build, verify and weigh the index
Step 6: write the index to match the query shape
Read the query's predicates in a fixed order. Columns compared with equality come first, because they let the index seek to one narrow slice. Then the single range column, because a range ends the seek. Then the columns in the ORDER BY, so the rows come out already sorted and no Sort node is needed. Finally, INCLUDE the payload columns that only appear in the SELECT list. They ride along in the leaf pages without widening the sort key.
| Part of the query | Goes in the index as | Why |
|---|---|---|
| status = 'open' (equality) | Leading key column | Seeks to one slice |
| created_at > ... (range) | Next key column | Seeking stops after a range |
| ORDER BY created_at DESC | Same or following key column | Removes the Sort node |
| SELECT title | INCLUDE (title) | Avoids heap fetches, keeps the key narrow |
CREATE INDEX CONCURRENTLY idx_posts_status_created
ON posts (status, created_at)
INCLUDE (title);Equality, then range or sort, then the payload
CONCURRENTLY avoids a long write lock on a live table, at the price of a slower build. If the build fails it can leave an INVALID index behind that you must drop.
Step 7: re-run and compare numbers, not shapes
After the index exists, run the same EXPLAIN (ANALYZE, BUFFERS) again. A plan that now says Index Scan instead of Seq Scan is not proof of improvement, because an index scan can be slower on a poorly correlated table. Compare the actual time and the buffer reads before and after. If you built a covering index, look for Index Only Scan with Heap Fetches: 0, and run VACUUM if the number is high.
| Before index | After index | |
|---|---|---|
| Plan node | Seq Scan | Index Only Scan |
| Execution time | 412 ms | 0.9 ms |
| Shared buffers read | 48,210 | 6 |
Step 8: confirm the index is used a week later
One good plan in your session does not prove that production traffic uses the index. Check again after a week of real load. The counter idx_scan in pg_stat_user_indexes records how many times each index has been scanned.
SELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE relname = 'posts' ORDER BY idx_scan;
A count of 0 after a full week means the index is dead weight; drop it
Watch for hidden regressions
An index belongs to the table, not to one query. A new index gives the planner another path to cost for every query on that table, and it may now choose that path for a query you never looked at, sometimes wrongly. After adding one, re-check the other heavy statements on the same table in pg_stat_statements, and compare their mean times before and after.
Weigh the size against memory
Every index occupies disk and, more importantly, competes with table pages for the buffer pool. Measure it rather than assuming it is small.
SELECT pg_size_pretty(pg_relation_size('idx_posts_status_created'));
Compare the result with your buffer pool budget
A 6 GB index on a server with an 8 GB buffer pool will push hot table pages out of memory and can slow queries that have nothing to do with it. A wider key from extra columns or INCLUDE payload makes the index bigger, so only include what the query needs.
Rank by total time, find the node where estimated and actual rows diverge, try ANALYZE first, build the narrowest index that matches the query shape, then prove it with buffer reads now and idx_scan later.
Part 14 · Summary & Cheat Sheet
What to Remember About Index Structure
An index is a separate, ordered, redundant copy of some columns, kept next to the table and updated by the database for you. The B+tree is the default because one ordered structure serves equality lookups, ranges and ORDER BY. Internal nodes hold only separator keys. All real entries sit in the leaves, which are linked sideways, so a range scan descends once and then walks along the leaves. Fan-out is in the hundreds, so a million rows need about 3 levels and a billion need 4 to 5. The upper levels stay cached in RAM, so a lookup costs about 3 to 4 page reads.
| Query need | What the B+tree does | Cost |
|---|---|---|
WHERE email = 'x' | Descends root to leaf | About 3 to 4 page reads |
WHERE created_at BETWEEN a AND b | Descends once, then follows leaf links | One descent plus the pages in the range |
ORDER BY created_at LIMIT 20 | Reads leaves already in order | No Sort node, stops after 20 rows |
WHERE lower(email) = 'x' | Cannot help unless the index is on lower(email) | Full scan |
Composite indexes: order is the main decision
A composite index behaves like a phone book sorted by last name, then first name. You can find every Smith, or Smith/Ada, but you cannot find every Ada without reading the whole book. So the column order decides which queries the index can seek. Put the equality columns first (IN counts as equality). Then add at most one range column. Then add the columns you sort by. Once the index reaches the range column, seeking stops, and later columns can only filter or cover.
-- WHERE tenant_id = ? AND created_at > ? ORDER BY created_at DESC LIMIT 20 CREATE INDEX idx_feed ON posts (tenant_id, created_at);
Equality column first, then the range and sort column: a bounded scan with no Sort node.
The leftmost prefix rule follows from this. An index on (a, b, c) serves queries that filter on a, on a, b, or on a, b, c. It never helps a query that filters only on b or only on c. Because of this, (a, b) makes a separate (a) index redundant, but it does not replace an index on (b).
| Query on index (a, b, c) | Result |
|---|---|
WHERE a = 1 | Seeks on a |
WHERE a = 1 AND b = 2 | Seeks on a and b |
WHERE a = 1 AND c = 3 | Seeks on a only, filters c |
WHERE b = 2 | No seek; at best a full index scan |
WHERE c = 3 | No seek; at best a full index scan |
Putting the highest-cardinality column first is folklore, not a rule. Match the equality, range, sort shape of the query. Extra columns also widen every entry, which means fewer keys per page and a deeper tree.
Covering indexes
A covering index holds every column the query touches, so the engine never visits the heap. Use INCLUDE for payload columns that are only read. They live in the leaf but not in the sort key, so the key stays narrow and the tree stays shallow. In Postgres an index-only scan still consults the visibility map, so run VACUUM. Otherwise you will see Heap Fetches above 0 and lose the benefit. SELECT * defeats covering by definition.
CREATE INDEX idx_posts_feed ON posts (user_id, created_at) INCLUDE (title); EXPLAIN (ANALYZE, BUFFERS) SELECT title FROM posts WHERE user_id = 7 ORDER BY created_at DESC LIMIT 20; -- expect: Index Only Scan, Heap Fetches: 0
Key = equality then sort; payload in INCLUDE.
Reading Plans and Choosing a Scan
EXPLAIN shows the plan the planner estimates. EXPLAIN ANALYZE actually runs the query and measures it. The planner chooses by cost, using statistics for row counts, distinct values and histograms. So a wrong row estimate usually causes a bad plan. Read the plan from the most indented node upward, and compare rows= in the estimate with rows= in the actual figures. A node that estimated 1 row and returned 90,000 is where the plan went wrong.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 42; -- Look for: -- rows=1 (estimate) vs rows=90000 (actual) -> stale or missing statistics -- Rows Removed by Filter: large -> missing or misordered index -- actual time is PER loop: multiply by loops= -- Sort Method: external merge / Batches > 1 -> spilling past work_mem
Wrap EXPLAIN ANALYZE on INSERT, UPDATE or DELETE in BEGIN ... ROLLBACK, because it really executes.
| Scan | Wins when | Why |
|---|---|---|
| Seq Scan | Large share of the table matches (roughly over 25%), or the table is small | One sequential pass is cheaper than many random reads |
| Index Scan | High selectivity (roughly under 5%), or ORDER BY ... LIMIT | Few random heap visits, and it can stop early |
| Bitmap Heap Scan | The middle range (roughly 5 to 25%) | Collects row locations, then reads each heap page once in physical order |
| Index Only Scan | The index covers the query and the visibility map is fresh | The heap is skipped |
The real contrast is sequential I/O against random I/O, not scanning everything against scanning a part. An index scan on 10% of the rows can touch nearly every heap page, one at a time, and lose to a single sequential pass. The exact crossover also moves with row width, physical correlation and the storage medium. On SSDs, lowering random_page_cost from 4.0 toward 1.1 lets the planner price index scans more fairly.
SET enable_seqscan = off in a session shows what the index plan would cost, which confirms whether the planner was right. Then RESET it. These toggles are a diagnostic and never a production fix.
The Costs and the Silent Killers
Every index taxes three things. It taxes writes, because each insert, update or delete also updates every index and writes extra WAL, and indexed-column updates defeat HOT updates. It taxes RAM, because index pages compete with hot table pages in the buffer pool. It taxes the planner, which has more candidate paths to cost on every parse. Justify each index with a real query. On a write-heavy table, be suspicious beyond about five.
SELECT indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_stat_user_indexes WHERE idx_scan = 0; -- drop candidates; re-check over a full business cycle first
MySQL equivalent: sys.schema_unused_indexes.
Dead weight shows up in a few forms: indexes on boolean or three-value columns (the planner scans the table anyway), duplicates under different names, and narrow indexes fully covered by a wider one. Random UUID primary keys also split pages across the whole tree, so prefer UUIDv7, ULID or a sequence. Build new indexes with CREATE INDEX CONCURRENTLY so writes are not blocked.
The four silent index killers
Most index failures are not missing indexes. They are queries written in a way that stops an existing index from being used, and the database does not warn you.
| Killer | Breaks the index | Fix |
|---|---|---|
| Function on the column | WHERE YEAR(created_at) = 2026 | created_at >= '2026-01-01' AND created_at < '2027-01-01', or an expression index that matches exactly |
| Cast or arithmetic | price * 1.2 > 100, or a varchar compared to an integer | price > 100 / 1.2, and compare with the column's own type |
| Leading wildcard | LIKE '%term' | LIKE 'term%', or a pg_trgm index for infix search |
| OFFSET pagination | LIMIT 20 OFFSET 100000 walks 100,020 rows | Keyset: WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20 |
An index that exists locally is not evidence. Check production with \d table or SHOW INDEX FROM table. After the change, check idx_scan again a week later. An index the planner never picks is pure write cost.
ORDER BY a ASC, b ASC cannot use an index on (a ASC, b DESC). Only that exact order and its full reverse come free. Any other order brings back a Sort node.
The Method and the Cheat Sheet
Two rules hold the method together. Fix the statistics before you add the index, and fix the query before you force the plan. Adding an index to compensate for stale statistics leaves you with a permanent write cost for a one-time problem. Pinning a plan with hints hides a query that is written in a form the planner cannot use. Work through the steps below in order and stop at the first one that solves the problem.
- 1Slow in aggregate?Rank by total_exec_time in pg_stat_statements, not by the worst single run
- 2EXPLAIN (ANALYZE, BUFFERS)On production-shaped data, not a 1,000-row dev table
- 3Find the estimate/actual gapThe subtree where rows diverge most is the problem
- 4Run ANALYZEAdd CREATE STATISTICS for correlated columns
- 5Reshape the queryUnwrap columns, fix casts, switch to keyset pagination
- 6Add the narrowest matching indexEquality, range, sort, then INCLUDE; verify with BUFFERS and idx_scan
CREATE INDEX CONCURRENTLY idx_jobs_feed ON jobs (status, created_at) INCLUDE (title); -- equality, then range or sort, then INCLUDE the payload SELECT pg_size_pretty(pg_relation_size('idx_jobs_feed')); -- weigh the size against your buffer pool
The index shape template, built without blocking writes.
After adding an index, run EXPLAIN ANALYZE again and compare timings and buffer reads. A new plan shape alone proves nothing. Also check that other queries on the same table did not regress.
Cheat sheet
| Topic | Rule |
|---|---|
| B+tree | Serves =, ranges and ordering in 3 to 4 page reads |
| Column order | Equality columns, then one range column, then sort columns |
| Leftmost prefix | (a, b, c) never helps a query on b or c alone |
| Covering | INCLUDE the payload, then VACUUM so index-only scans skip the heap |
| EXPLAIN | EXPLAIN estimates; EXPLAIN ANALYZE measures; the row gap is where bad plans start |
| Seq scan | Wins on large result fractions and small tables |
| Index scan | Wins on high selectivity and LIMIT |
| Bitmap heap scan | Owns the middle range |
| Cost of indexes | Every index taxes writes, RAM and the planner |
| Cleanup | Drop whatever idx_scan = 0 says nobody uses |
| Silent killers | Functions on the column, casts, leading wildcards, OFFSET pagination |
Fix the statistics before you add the index, and fix the query before you force the plan.
Part 15 · Check yourself
Quiz
Work through each question before you open the answer. They ask you to predict what a planner does or to spot what is wrong, not to repeat definitions.
A table has an index on (tenant_id, created_at). Which of these queries can seek on the index, and which can only scan it in full, if it uses it at all?
- Q1 seeks on both columns: equality on the leading column, then a range on the second.
- Q2 skips the leading column, so the index can only be scanned in full, if it is used at all. This is the leftmost prefix rule.
- Q3 still seeks on both, because an IN list on the leading column behaves like equality.
- Q2 needs its own index with
created_atfirst.
-- Q1 WHERE tenant_id = 7 AND created_at > now() - interval '1 day' -- Q2 WHERE created_at > now() - interval '1 day' -- Q3 WHERE tenant_id IN (7, 9) AND created_at > now() - interval '1 day'
This query is slow even though created_at is indexed. What is the bug, and what is the fix?
- The column is wrapped in a function, so the B-tree ordering on raw
created_atvalues cannot be used to seek. - Rewrite it as a range on the bare column:
created_at >= '2026-01-01' AND created_at < '2027-01-01'. - The alternative is an expression index on
EXTRACT(YEAR FROM created_at), but the expression must match the query exactly.
SELECT id FROM orders WHERE EXTRACT(YEAR FROM created_at) = 2026;
You see this node in EXPLAIN ANALYZE. Roughly how much time did it really take, and what does the row mismatch tell you?
- The actual time is per loop, so the real cost is about 3.140 ms × 12 ≈ 37.7 ms.
- The planner expected 1 row and got 998 per loop. That gap is the smoking gun for stale or missing statistics.
- The first fix to try is
ANALYZEon the table, before adding or changing any index.
Index Scan using idx_items_cart on items (cost=0.43..8.45 rows=1 width=32) (actual time=0.021..3.140 rows=998 loops=12)
A query matches about 30% of a 5-million-row table. An index on the filtered column exists, yet EXPLAIN shows a Seq Scan. Is the planner wrong?
- Almost certainly not. At roughly 30% of the table, an index scan would read nearly every heap page, but at random, and lose to one sequential pass.
- To confirm, run
SET enable_seqscan = offin a session, compare the cost of the index plan, thenRESETit. - The toggle is a diagnostic only. Never leave it on in production.
A feed endpoint uses LIMIT 20 OFFSET 100000 ordered by created_at DESC, and it gets slower every month. Why, and what replaces it?
- OFFSET still walks 100,020 index entries and throws the first 100,000 away, so the cost grows with the page number.
- Keyset pagination seeks straight to the last row seen, so the cost stays constant.
- Pair it with an index on
(created_at, id)so the order comes from the index with no Sort node.
WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20
Summary
- An index is an ordered, redundant structure; a B+tree serves equality, ranges and ORDER BY in about 3 to 4 page reads.
- Composite order is the biggest lever: equality columns first, then one range column, then sort columns, and (a, b, c) never helps a query on b or c alone.
- Covering indexes skip the heap: put payload in INCLUDE to keep the key narrow, and VACUUM so index-only scans really do skip it.
- EXPLAIN estimates and EXPLAIN ANALYZE measures; the gap between estimated and actual rows is where bad plans start.
- Seq scan wins on big result fractions and tiny tables, index scan on high selectivity and LIMIT, and bitmap heap scan owns the middle.
- Every index taxes writes, RAM and the planner, so drop whatever
idx_scan = 0shows nobody uses. - Functions on the column, implicit casts, leading wildcards and OFFSET pagination silently disable indexes; fix statistics before adding an index, and fix the query before forcing a plan.