OwlTheEngineer
Engineering MistakesEpisode 11 · Backend

Why your database ignores the index you added

The planner never ignored it — it priced it. The two things the video could not say safely, then everything else it cut: index-only scans, partial and multicolumn indexes, trigrams, BRIN, statistics, HOT updates, bloat, and what LIMIT does to a plan.

Owl · 8 min read · 0 views
– comments
Ep 11 · Backend
60s VISUAL BREAKDOWN
Episode 11
Watch the fast visual breakdown or discuss on social:

You added the index. The query is still slow. EXPLAIN says Seq Scan, and the first theory everyone reaches for is that the database did not notice.

It noticed. The video walks six reasons a plan comes back without your index: the value you asked for is most of the table; you wrapped the column in a function; a type mismatch put a cast on it; the pattern has a leading wildcard; one side of an OR has no index; the statistics are stale. Every one is a plan on screen, and every plan was real.

The conflation to hold onto. An index is not an instruction. It is a choice offered to the planner, priced in “arbitrary units determined by the planner’s cost parameters,” conventionally one sequential page fetch as 1.0. Every plan you’ve seen is the cheapest one it could find. “Ignored” is not a state the planner has — and there’s a second conflation nearly every explainer makes, with the opposite fix. Unused is an arithmetic result: the index was usable and lost on price, and the fix is a better predicate, better statistics, or accepting the scan is right. Unusable is a syntax result: the query doesn’t mention what the index actually holds, and no statistics will change that. The tell is one line: Index Cond means the index selected the rows; Filter means rows were fetched and thrown away.

Everything below is checked against PostgreSQL 18, against a real 500,000-row orders table with a single B-tree each on status, email, created_at and user_id, at default cost settings (seq_page_cost 1, random_page_cost 4).

enable_seqscan = off is a measuring tool, not a fix

The planner doesn’t volunteer the losing prices — you get them by forbidding the winner:

SET enable_seqscan = off;
EXPLAIN (ANALYZE) SELECT * FROM orders WHERE status = 'shipped';
-- Bitmap Heap Scan  cost=5133.80..16549.97

Sequential scan priced at 11,922, bitmap scan at 16,550, plain index scan at 19,578 — the same three numbers a plan like this draws, now with their origin visible. The setting’s own description: “It is impossible to suppress sequential scans entirely, but turning this variable off discourages the planner from using one if there are other methods available” — the docs call the whole enable_* family “a crude tool, but useful.”

It is a diagnostic, not a fix. PostgreSQL’s own decision tree: force the plan you expected; if the index is still not used, “there is probably a more fundamental reason” — a function, a cast, a wildcard, the unusable branch. If forcing does use it, time the query with and without the index, because the cost estimates might not reflect reality; if they don’t, the fix is the per-row cost parameters or the statistics, not the flag. Someone who writes enable_seqscan = off into postgresql.conf has skipped every step of that and made every query in the database worse, including the ones where the scan was right. The two lines that answer half of all “the index is ignored” reports on their own: “Always run ANALYZE first,” and “It is especially fatal to use very small test data sets” — a two-page table will never look like an index candidate, no matter what you build.

idx_scan = 0 is a read counter, not a verdict

The instinct to find unused indexes and drop them is right. The evidence people use for it, on its own, is not.

SELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE relname = 'orders';
--  orders_pkey        | 0
--  orders_user_id_idx  | 0

Read that as “drop orders_pkey, it’s never scanned” and you’ve deleted the thing stopping two orders sharing an id. idx_scan counts reads; it knows nothing about what the index is for. Four reasons the zero lies: it may be backing a constraint — a unique index with no declared constraint behind it gets no protection from DROP INDEX, and enforces uniqueness on every insert while showing zero reads forever. The counter may be young — a crash, a failover, or pg_stat_reset() zeroes it, and only a clean shutdown preserves history. The reads may be happening on a replica — statistics are per-server, and an index serving every analyst report against a standby shows idx_scan 0 on the primary because the primary never ran those queries. Or it may simply be used once a quarter, which last_idx_scan (since PostgreSQL 16) at least turns into a date you can reason about. Only once you’ve checked all four does dropping become safe — with DROP INDEX CONCURRENTLY, which doesn’t take the exclusive lock a plain DROP INDEX does.

The scan that never touches the table

There’s a plan that skips fetching the row entirely, and it changes what “covered” means:

EXPLAIN (ANALYZE) SELECT email FROM orders WHERE email = 'user42@example.com';
-- Index Only Scan  cost=0.42..4.44  Heap Fetches: 0

Ask only for the column the index holds and the planner never opens the table. The two conditions: the index type has to support it (B-tree always does), and the query must reference only indexed columns. INCLUDE extends that to columns the query returns but doesn’t filter on — attaching created_at as a payload column turns a ten-row bitmap scan costing 43 into an index-only scan costing 4.6, without making it part of the sort key.

The catch is the one people meet in production. Update those ten rows and run the same query again: Heap Fetches: 20. The plan still says Index Only, and it visited the table anyway — twice. An index-only scan needs to know a row is visible to your transaction, and the index doesn’t store that; PostgreSQL keeps a visibility map, one bit per heap page saying everyone can see it, and an update clears the bit until VACUUM sets it again. So an index-only scan is a property of the table’s maintenance, not just the index — there’s “little point in including payload columns… unless the table changes slowly enough that an index-only scan is likely to not need to access the heap.”

Design the index around the query, not the column

A value that’s 92% of the table is never worth seeking through an index, so why keep those rows in the index at all? A partial index answers exactly that: “a query searching for a common value… will not use the index anyway, there is no point in keeping those rows in the index at all.”

CREATE INDEX orders_pending_created_idx ON orders (created_at) WHERE status = 'pending';
-- 560 kB, against 11 MB for the full created_at index

It answers a pending-orders screen with no Filter line at all, because every entry is a pending order by construction — and the 92% of writes that end up shipped never pay for maintaining it. The rule that trips people: the planner uses a partial index “only if the system can recognize that the WHERE condition of the query mathematically implies the predicate of the index.” status = 'pending' matches; status IN ('pending', 'cancelled') does not, even though half of it would, because “PostgreSQL does not have a sophisticated theorem prover.”

Column order matters the same way on a multicolumn index. A B-tree on (country, status) “is most efficient when there are constraints on the leading (leftmost) columns” — a phone book sorted by surname, then first name. country = 'DE' AND status = 'cancelled' opens it at one place; status = 'cancelled' alone, against only this index, is looking for everyone named Ada with no surname — full scan, unless PostgreSQL 18’s new skip scan kicks in: it opens the phone book at each distinct leading value in turn, which is nearly as good when country has ten values and still a wall when it has millions. Put the column you always filter on first; put the one with fewer distinct values first only when you filter on both regularly.

Specialized index types for the cases a B-tree can’t cover

A B-tree has no answer for LIKE '%42@example.com' — it’s ordered by prefix. pg_trgm breaks a string into overlapping three-character groups and indexes those instead, so “the search string need not be left-anchored”:

CREATE INDEX orders_email_trgm_idx ON orders USING gin (email gin_trgm_ops);
-- LIKE '%4200%'  ->  0.2ms, vs 16.7ms sequential

A substring match, mid-string, in a fifth of a millisecond — but the same index makes a suffix search like '%42@example.com' slower, not faster: every email in the table ends the same way, so nearly every trigram in the pattern appears in nearly every row, and the index reads entries covering the whole table to learn almost nothing. Trigram indexes are for text that varies, not a column with a constant suffix.

BRIN indexes store, per block range, just the minimum and maximum of a column — “most effective for columns whose values are well-correlated with the physical order of the table rows.” An id assigned in insertion order gets a 24 kB BRIN index answering a range query by skipping to the handful of ranges that could match — one five-hundredth the size of the equivalent B-tree. The same index type on a column uncorrelated with physical order reads the entire table through it, because every range’s summary matches; the planner prices that correctly, which is why it never picks BRIN there when a B-tree exists.

The write side: what an index costs on every insert

An UPDATE writes a new row version rather than changing the old one in place — naively, that means a new entry in every index on the table, even for columns no index touches. Heap-only tuples (HOT) avoid it when the update doesn’t touch an indexed column and the page has free space. On a table bulk-loaded at full packing (fillfactor = 100), there’s no free space, so almost every update misses the optimization and writes to every index regardless of what it touched. Lowering fillfactor to leave room on the page turns nearly all of those into HOT updates instead — a concrete argument against densely packing a table that gets updated constantly.

Deleting rows doesn’t shrink an index, either: “if all but a few index keys on a page have been deleted, the page remains allocated.” An archive job that deletes every other row leaves every leaf page half full at the same on-disk size. REINDEX INDEX CONCURRENTLY rebuilds it without the exclusive lock a plain REINDEX takes, building a replacement alongside the old one and swapping them — and if a concurrent build fails partway, it can leave an invalid index behind that costs every write and serves no reads, idx_scan 0 for a reason that has nothing to do with the planner. Worth checking pg_index.indisvalid after any concurrent rebuild that didn’t finish cleanly.

SYSTEM DESIGN CHECK

Test Your Understanding

Quick architectural scenario challenge based on this breakdown.

SCENARIO

A 3rd-party payment gateway starts timing out with 504 errors during peak traffic.

Q1.How should a downstream dependency failure be handled to protect upstream service availability?

Comments –