SQL-Engineering-Handbook

03 — Composite Indexes

Introduction

Most production queries filter on more than one column. A composite (multi-column) index can serve several of these queries at once — but only if its column order matches how the query filters. This file covers that ordering rule in depth, since getting it wrong is one of the most common indexing mistakes in production systems.

Learning Objectives

Business Motivation

A support dashboard queries orders by customer, then by date, then by status:

SELECT * FROM orders
WHERE customer_id = 88291
  AND order_date > '2026-01-01'
  AND status = 'completed';

Three single-column indexes exist as an option, but MySQL can typically use only one index per table per query block efficiently (barring index merge, which has its own overhead). A single composite index ordered (customer_id, order_date, status) can serve this exact filter pattern in one seek — this is the entire motivation for composite indexes.

Why This Exists

A composite index is sorted first by its first column, then by its second column within each value of the first, and so on. This nested sort order means the index can only be searched efficiently starting from its leftmost column — you cannot binary-search a structure sorted by (customer_id, order_date) using only order_date, for the same reason you can’t look up a phone book by first name when it’s sorted by last name.

Production Use Cases

Architecture Discussion

INDEX (customer_id, order_date, status)

Sorted as:
  customer_id=4471,  order_date=2026-01-02, status='completed'
  customer_id=4471,  order_date=2026-01-05, status='pending'
  customer_id=4471,  order_date=2026-01-09, status='completed'
  customer_id=88291, order_date=2026-01-01, status='completed'
  customer_id=88291, order_date=2026-01-03, status='cancelled'
  customer_id=91002, order_date=2026-01-01, status='pending'

Notice: within customer_id, rows are sorted by order_date; within each (customer_id, order_date) pair, by status. This is why the index is only directly searchable starting from customer_id.

Syntax

CREATE INDEX idx_orders_customer_date_status
    ON orders (customer_id, order_date, status);

Syntax Breakdown

Visual Explanation — The Leftmost Prefix Rule

Leftmost prefix rule checklist

INDEX(customer_id, order_date, status)

✓ WHERE customer_id = X                                   -- uses col 1
✓ WHERE customer_id = X AND order_date = Y                -- uses cols 1-2
✓ WHERE customer_id = X AND order_date = Y AND status = Z -- uses all 3
✗ WHERE order_date = Y                                    -- skips col 1
✗ WHERE status = Z                                        -- skips cols 1-2
✗ WHERE order_date = Y AND status = Z                     -- skips col 1

ASCII Diagram

                    customer_id (sorted)
                    /        |        \
              4471        88291       91002
              /  \          |  \
        order_date      order_date  ...
        (sorted within  (sorted within
         customer_id     customer_id
         =4471)           =88291)

Each level of sort only exists within the level above it — you must enter the tree from the top (leftmost column) to benefit from any of it.

Execution Flow

  1. Optimizer inspects the WHERE clause for a contiguous prefix of the composite index’s column list, starting from column 1.
  2. It seeks to the matching prefix range in the index.
  3. Any trailing columns of the index that also appear as equality filters in the query are used to narrow the seek further within that range.
  4. Any query column not covered by the prefix falls back to a filter applied after retrieval (or the index isn’t used at all for that predicate).

Engineering Notes

Equality columns should generally precede range columns in a composite index. (customer_id, status, order_date) for a query filtering customer_id = X AND status = 'completed' AND order_date > Y lets the index narrow by two equalities before applying the range — placing order_date earlier would end the useful sort narrowing at the first range predicate, since everything after a range column in the index can’t be used for further seeking within that same lookup.

Performance Notes

Storage Considerations

A composite index stores all indexed columns’ values at every leaf entry, not just the first — a 3-column composite index on large VARCHAR columns can be significantly larger than a single-column index on the same table.

Optimizer Notes

MySQL 8.0+ can sometimes use index merge to combine two separate single-column indexes for a query, but this is generally more expensive than a single well-ordered composite index and should be treated as a fallback the optimizer reaches for, not a design strategy to rely on.

ANSI SQL Notes

As with all index structures, composite index behavior is implementation-specific; the standard has no concept of “leftmost prefix” since it doesn’t define physical access paths at all.

MySQL Notes

PostgreSQL Notes

SQL Server Notes

Oracle Notes

Edge Cases

Best Practices

Anti-patterns

Common Mistakes

Interview Questions

  1. Given INDEX(a, b, c), which of these queries can use the index, and how much of it: WHERE a = 1, WHERE b = 2, WHERE a = 1 AND c = 3, WHERE a = 1 AND b = 2 AND c = 3?
  2. Why should equality-filtered columns generally precede range-filtered columns in a composite index?
  3. You have three single-column indexes and query performance is still poor on a query filtering all three columns together. What would you investigate?

Summary

A composite index is one physical structure, sorted by its columns in declared order, nested left to right. The leftmost prefix rule follows directly from that physical sort: the index is only searchable starting from its first column, and a well-designed composite index should mirror actual query filter patterns — equality columns first, then at most one range column, then any trailing columns needed for ordering or covering.

Practice

See 12_PRACTICE_PROBLEMS.md, Intermediate section, Problems 4–7.

Further Reading

See resources/documentation.md.