SQL-Engineering-Handbook

07 — Query Optimization with Indexes

Introduction

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.

Learning Objectives

Business Motivation

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.

Why This Exists

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.

Production Use Cases

Architecture Discussion

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

Production Use Cases (continued)

Syntax

-- 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';

Syntax Breakdown

Visual Explanation

Selectivity comparison: customer_id vs. status

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.

ASCII Diagram

Cost-based optimization flow

Mermaid version (renders inline on GitHub without loading the SVG) ```mermaid flowchart TD A[Query parsed] --> B[Consult table/index statistics] B --> C[Estimate cost per candidate access path] C --> D[Full scan
cost ≈ rows × scan_cost] C --> E[Index seek
cost ≈ log rows + matches × lookup_cost] D --> F[Pick lowest-cost estimate] E --> F ```
                Optimizer cost comparison
                ┌─────────────────────────┐
                │ cost(full scan)          │
                │   ≈ rows × scan_cost     │
                ├─────────────────────────┤
                │ cost(index seek)         │
                │   ≈ log(rows) + matches  │
                │       × lookup_cost      │
                └─────────────────────────┘
                  optimizer picks the lower estimate

Execution Flow

  1. Optimizer consults table/index statistics (row counts, cardinality, histograms where available) for every candidate access path.
  2. It estimates the number of matching rows (selectivity × total rows) for each candidate.
  3. It computes an estimated cost per path using engine-specific cost constants (I/O cost, CPU cost).
  4. It selects the lowest estimated cost — this is the query plan.
  5. Predicate pushdown is applied wherever possible within the chosen plan, filtering as early as the storage layer allows.

Engineering Notes

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.

Performance Notes

Storage Considerations

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 Notes

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.

ANSI SQL Notes

Optimizer behavior, statistics, and hints are entirely implementation-specific; the ANSI standard defines none of this.

MySQL Notes

PostgreSQL Notes

SQL Server Notes

Oracle Notes

Edge Cases

Best Practices

Anti-patterns

Common Mistakes

Interview Questions

  1. Define selectivity and explain why a boolean column is usually a poor indexing candidate on its own.
  2. Why can stale statistics cause a previously fast query to suddenly become slow, with no code or data schema changes?
  3. What is predicate pushdown, and how does an index seek relate to it?
  4. When would you reach for an optimizer hint, and what’s the risk of leaving one in place indefinitely?

Summary

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.

Practice

See 12_PRACTICE_PROBLEMS.md, Advanced section, Problems 6–8.

Further Reading

See resources/documentation.md.