Files 01-06 covered what indexes are and how to design them. This file covers how the query optimizer actually decides whether to use one — selectivity, statistics, histograms, predicate pushdown, and the hints available when the optimizer gets it wrong.
Two columns, both indexed: status (3 possible values, roughly even
distribution) and order_id (unique per row). A query filtering on
status = 'completed' and one filtering on order_id = 12345 have
indexes available for both — but the optimizer will use one and ignore
the other, and understanding why is the difference between predicting
production query performance and being surprised by it.
An index’s existence doesn’t guarantee it’s fast — its usefulness depends entirely on how well it narrows the result set for a given query. This is what selectivity and cardinality measure, and it’s the core input to the optimizer’s cost-based decision.
Cardinality: the number of distinct values in a column.
Selectivity: cardinality relative to row count — selectivity =
distinct_values / total_rows. A selectivity close to 1 means almost
every row is unique (great for indexing); close to 0 means few distinct
values relative to row count (poor for indexing in isolation).
customer_id on a 10M-row orders table: ~2M distinct customers
selectivity ≈ 2,000,000 / 10,000,000 = 0.2 -- reasonably selective
status on the same table: 3 distinct values
selectivity ≈ 3 / 10,000,000 ≈ 0.0000003 -- very poor selectivity
-- View the optimizer's estimated statistics for a table
SHOW TABLE STATUS LIKE 'orders';
-- Force a statistics refresh after major data changes
ANALYZE TABLE orders;
-- Inspect selectivity of a specific column via cardinality estimate
SHOW INDEX FROM orders WHERE Column_name = 'status';
SHOW TABLE STATUS surfaces Rows (an estimate, not exact for
InnoDB) — used by the optimizer as a baseline for scan cost.ANALYZE TABLE recomputes index cardinality estimates — essential
after bulk loads, large deletes, or any operation that shifts data
distribution significantly.SHOW INDEX’s Cardinality column is the engine’s current estimate of
distinct values for that index — directly feeds selectivity
calculations.High selectivity column (customer_id):
Index seek narrows 10,000,000 rows → ~5 rows. Clear win.
Low selectivity column (status, 3 values):
Index seek narrows 10,000,000 rows → ~3,333,333 rows.
Optimizer correctly prefers a full scan — reading a third of the
table via random index lookups costs MORE than reading it
sequentially.
Optimizer cost comparison
┌─────────────────────────┐
│ cost(full scan) │
│ ≈ rows × scan_cost │
├─────────────────────────┤
│ cost(index seek) │
│ ≈ log(rows) + matches │
│ × lookup_cost │
└─────────────────────────┘
optimizer picks the lower estimate
Statistics are estimates, sampled periodically, not recalculated on
every query. After a large bulk load, delete, or significant data skew
change, stale statistics can cause the optimizer to pick a badly wrong
plan — this is one of the most common causes of “this query used to be
fast” production incidents, and ANALYZE TABLE / VACUUM ANALYZE is
frequently the fix.
Histograms and extended statistics add metadata storage, but it is negligible compared to the index/table storage itself — there’s little reason not to maintain them on any actively queried column.
Optimizer hints (FORCE INDEX, USE INDEX, IGNORE INDEX in
MySQL; pg_hint_plan extension in PostgreSQL) let you override the
optimizer’s choice. They are an escape hatch, not a design tool — a hint
that’s correct today can become actively harmful as data grows and the
genuinely optimal plan changes, because a hint doesn’t adapt the way the
cost-based optimizer does.
Optimizer behavior, statistics, and hints are entirely implementation-specific; the ANSI standard defines none of this.
ANALYZE TABLE refreshes cardinality statistics.FORCE INDEX (idx_name) and IGNORE INDEX (idx_name) are available
directly in query syntax for testing/overriding optimizer choices.ANALYZE (often paired with VACUUM ANALYZE) refreshes planner
statistics, including histograms via default_statistics_target.pg_hint_plan is a well-known third-party
extension for cases where hints are still needed.UPDATE STATISTICS refreshes the optimizer’s statistics.OPTION (FORCESEEK), etc.) and plan guides are available
for advanced override cases.DBMS_STATS.GATHER_TABLE_STATS refreshes statistics, including
histograms./*+ INDEX(...) */ syntax) among major RDBMSs.city and zip_code correlate
heavily) can mislead cost estimates that assume column independence —
extended statistics (PostgreSQL, MySQL 8.0+) exist to address this.ANALYZE/UPDATE STATISTICS after any bulk data operation.SHOW INDEX / pg_stats.ANALYZE after a large data load and being surprised by
a sudden plan regression.WHERE clause,
without checking its selectivity first.The optimizer’s index-vs-scan decision is a cost estimate built from selectivity, cardinality, and (where available) histograms — not a fixed rule. Low-selectivity columns are poor indexing candidates in isolation; stale statistics are a common, often-overlooked cause of sudden plan regressions; and optimizer hints are a monitored escape hatch, not a permanent substitute for good index design and fresh statistics.
See 12_PRACTICE_PROBLEMS.md, Advanced section, Problems 6–8.