PostgreSQL · lesson 2 of 7
Intermediate
6- 1 EXPLAIN vs EXPLAIN ANALYZE
- 2 Indexing Strategy: When an Index Actually Helps
- 3 JSONB Containment Queries Need a GIN Index, Not a B-tree
- 4 VACUUM Doesn't Shrink Tables — VACUUM FULL Does, at a Cost
- 5 A Real Deadlock, and Why Lock Order Fixed It
- 6 The WAL Durability Tradeoff: 0.30s vs 0.02s for the Same 200 Inserts
Also part of PostgreSQL
7Also part of: Databases
Indexing Strategy: When an Index Actually Helps
Indexing a low-selectivity column barely moves the needle — 13ms to 13ms — while indexing a high-selectivity one drops a query from 14ms to 1ms. Selectivity, not the presence of an index, is what determines the payoff.
By SkillByExample · Published
An index doesn’t make a query fast by existing — it makes a query fast when the column is selective enough for the index to actually narrow the search.
Baseline: no index on genre
From the previous lesson, filtering books by genre with no index costs a full table scan:
Seq Scan on books (cost=0.00..6338.00 rows=40380 width=118) (actual time=0.006..13.769 rows=40000 loops=1)
Filter: (genre = 'mystery'::text)
Rows Removed by Filter: 160000
Execution Time: 14.749 ms
genre has only 5 distinct values, so any one value matches roughly 40,000 of the 200,000
rows — 20% of the table.
Indexing a low-selectivity column
CREATE INDEX idx_books_genre ON books(genre);
EXPLAIN ANALYZE SELECT * FROM books WHERE genre = 'mystery';
CREATE INDEX
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on books (cost=453.24..4795.99 rows=40380 width=118) (actual time=1.591..12.086 rows=40000 loops=1)
Recheck Cond: (genre = 'mystery'::text)
Heap Blocks: exact=3838
-> Bitmap Index Scan on idx_books_genre (cost=0.00..443.14 rows=40380 width=0) (actual time=1.222..1.223 rows=40000 loops=1)
Index Cond: (genre = 'mystery'::text)
Planning Time: 0.490 ms
Execution Time: 13.053 ms
(7 rows)
14.7ms down to 13.1ms — barely a dent. The planner switched to a Bitmap Heap Scan (build a
bitmap of matching pages from the index, then read exactly those pages), which is cheaper than
a naive row-by-row Index Scan when many rows match, but it still has to touch 3,838 heap
pages (Heap Blocks: exact=3838) to fetch 40,000 rows. The index didn’t fail — the column just
isn’t selective enough for any index to help much.
Indexing a higher-selectivity column
CREATE INDEX idx_books_author ON books(author);
EXPLAIN ANALYZE SELECT * FROM books WHERE author = 'Author 42';
CREATE INDEX
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on books (cost=7.50..1171.18 rows=398 width=118) (actual time=0.086..0.994 rows=400 loops=1)
Recheck Cond: (author = 'Author 42'::text)
Heap Blocks: exact=400
-> Bitmap Index Scan on idx_books_author (cost=0.00..7.40 rows=398 width=0) (actual time=0.053..0.053 rows=400 loops=1)
Index Cond: (author = 'Author 42'::text)
Planning Time: 0.605 ms
Execution Time: 1.059 ms
(7 rows)
1.06ms. author has 500 distinct values, so any one value matches roughly 400 rows — 0.2%
of the table, a hundred times more selective than genre. Same index type, same table, same
kind of query — the only thing that changed is how selective the filtered column is, and that
alone is the difference between a 13ms query and a 1ms one.
Practice
1. Compute the selectivity ratio for both indexed columns and confirm it against the plan output.
SELECT
(SELECT count(DISTINCT genre) FROM books)::float / count(*) AS genre_selectivity,
(SELECT count(DISTINCT author) FROM books)::float / count(*) AS author_selectivity
FROM books;
genre_selectivity | author_selectivity
--------------------+--------------------
2.5e-05 | 0.0025
(1 row)
author’s ratio is 100x higher than genre’s — exactly matching the 100x difference in query
time observed above (13ms vs. 1ms). Selectivity isn’t a vague concept here; it’s a number that
predicts the payoff.
2. Drop the genre index and confirm the plan reverts to a full Seq Scan.
DROP INDEX idx_books_genre;
EXPLAIN ANALYZE SELECT * FROM books WHERE genre = 'mystery';
DROP INDEX
QUERY PLAN
-------------------------------------------------------------------------------------------------------------
Seq Scan on books (cost=0.00..6338.00 rows=40380 width=118) (actual time=0.021..24.435 rows=40000 loops=1)
Filter: (genre = 'mystery'::text)
Rows Removed by Filter: 160000
Planning Time: 0.941 ms
Execution Time: 25.643 ms
(5 rows)
Back to the original Seq Scan plan, and the same order of magnitude as before the index
existed — confirming the index was never contributing much for this column, not that dropping
it caused a regression.
3. Check how much disk space the author index actually uses.
SELECT pg_size_pretty(pg_relation_size('idx_books_author'));
pg_size_pretty
----------------
1368 kB
Roughly 1.4MB for 200,000 rows — a real, measurable cost that every future INSERT into
books also has to pay by updating this index, which is the tradeoff behind “don’t index
everything.”
Next: JSONB and full-text — the index types that don’t fit the plain B-tree model at all.