Composite Index #

Adding an index to frequently filtered columns is a step many developers already understand. But when queries involve more than one column — which almost always happens in real applications — single-column indexes are often insufficient, or even completely unused. This is where composite indexes come in. A composite index lets the database optimize queries that filter, sort, or cover several columns at once in a single efficient B-Tree structure. But composite indexes have a non-intuitive rule: column order determines everything. The indexes (status, user_id) and (user_id, status) are fundamentally different, and choosing the wrong order can make an index completely unused. This article covers the internal workings of composite indexes, the leftmost prefix rule, how to design column order based on real queries, and when a composite index can also eliminate reads to the main table.

How It Works Internally: The B-Tree Structure of a Composite Index #

Before getting into usage rules, it’s important to understand how composite indexes are physically stored. A composite index uses the same B-Tree structure as a single-column index — the difference is that the key in every B-Tree node is an ordered combination of several columns.

For example, the composite index (user_id, status, created_at) on the orders table:

B-Tree representation of the composite index (user_id, status, created_at):
──────────────────────────────────────────────────────────────────────
  Index nodes are sorted by user_id FIRST,
  then within the same user_id, sorted by status,
  then within the same status, sorted by created_at.

  Example entries in the B-Tree (sorted):
    [user_id=1, status='active',   created_at=2026-01-01] → row ptr
    [user_id=1, status='active',   created_at=2026-01-15] → row ptr
    [user_id=1, status='inactive', created_at=2025-12-01] → row ptr
    [user_id=2, status='active',   created_at=2026-01-10] → row ptr
    [user_id=2, status='active',   created_at=2026-02-05] → row ptr
    [user_id=3, status='inactive', created_at=2026-01-20] → row ptr
    ...
──────────────────────────────────────────────────────────────────────

Direct implications:
  ✓ WHERE user_id = 1
      → Straight to the B-Tree section with user_id=1, take all
  ✓ WHERE user_id = 1 AND status = 'active'
      → Straight to user_id=1, then filter status='active'
  ✓ WHERE user_id = 1 AND status = 'active' AND created_at >= '2026-01-01'
      → Straight to the exact point, scan the range
  ✗ WHERE status = 'active'
      → Can't go directly to a specific status — statuses are scattered
        across the entire B-Tree because the first order is user_id
      → Full index scan or full table scan

This is why column order in a composite index isn’t just a convention — it reflects the physical structure of data on disk.


The Leftmost Prefix Rule: The Most Important Rule #

The most fundamental rule of composite indexes is the leftmost prefix rule: the database can only use a composite index starting from the leftmost column. Middle or right columns can’t be used directly without the columns to their left.

-- Composite index: (A, B, C)
CREATE INDEX idx_example ON orders (user_id, status, created_at);

-- ✓ Can use the index — starting from the leftmost column (user_id)
WHERE user_id = 1
WHERE user_id = 1 AND status = 'active'
WHERE user_id = 1 AND status = 'active' AND created_at >= '2026-01-01'

-- ✗ CANNOT use the index optimally
WHERE status = 'active'                         -- skips user_id
WHERE created_at >= '2026-01-01'                -- skips user_id and status
WHERE status = 'active' AND created_at >= '2026-01-01'  -- skips user_id

There’s one important nuance: if a middle column is skipped but there’s an equality condition on the left, the database can still use part of the index:

-- Index: (user_id, status, created_at)

-- Can use the index for user_id, then filter created_at outside the index
WHERE user_id = 1 AND created_at >= '2026-01-01'
-- → Uses the index for user_id=1, then scans all user_id=1 rows
--   and filters created_at at the engine level (not optimal but uses the index)
-- → Extra: "Using index condition"

-- Vs. a query using all prefix columns:
WHERE user_id = 1 AND status = 'active' AND created_at >= '2026-01-01'
-- → Far more efficient: straight to the exact point in the B-Tree

To prove it with EXPLAIN:

-- Create a table and index for demonstration
CREATE TABLE orders (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    user_id    BIGINT UNSIGNED NOT NULL,
    status     VARCHAR(20) NOT NULL,
    created_at TIMESTAMP NOT NULL,
    total      DECIMAL(15,2) NOT NULL,
    PRIMARY KEY (id),
    INDEX idx_orders_composite (user_id, status, created_at)
);

-- Query 1: uses all prefixes → very efficient
EXPLAIN SELECT id, total
FROM orders
WHERE user_id = 42 AND status = 'paid' AND created_at >= '2026-01-01';
-- type: range | key: idx_orders_composite | rows: ~150 | Extra: Using index condition

-- Query 2: skips the middle column → still uses the index but less efficiently
EXPLAIN SELECT id, total
FROM orders
WHERE user_id = 42 AND created_at >= '2026-01-01';
-- type: ref | key: idx_orders_composite | rows: ~8400 | Extra: Using index condition

-- Query 3: starts from the middle column → index unused
EXPLAIN SELECT id, total
FROM orders
WHERE status = 'paid';
-- type: ALL | key: NULL | rows: 2,500,000 | Extra: Using where

Column Order: How to Determine It Correctly #

Determining the right column order in a composite index is a design decision that must be based on the application’s real query patterns, not intuition. Several principles help.

Principle 1: Equality Columns Before Range Columns #

Columns filtered with = (equality) must always be placed before columns filtered with >, <, >=, <=, BETWEEN, or LIKE 'prefix%' (range).

-- The query to optimize:
SELECT * FROM orders
WHERE user_id = 42
  AND status = 'paid'
  AND created_at >= '2026-01-01';

-- ANTI-PATTERN: range column first
CREATE INDEX idx_wrong ON orders (created_at, user_id, status);
-- → The index can use created_at for the range, but user_id and status
--   can't be used after a range — the database is forced to scan all
--   rows in the created_at range, then filter user_id and status

-- CORRECT: equality columns first, range column last
CREATE INDEX idx_correct ON orders (user_id, status, created_at);
-- → The database goes straight to user_id=42, status='paid',
--   then scans the created_at range from that point
-- → Far fewer rows read

Visualizing the difference:

Index (created_at, user_id, status) — WRONG ORDER:
──────────────────────────────────────────────────────────
  Scan from created_at >= '2026-01-01'
  → Found: millions of rows (all orders since Jan 2026)
  → Filter user_id = 42 → sift from millions
  → Filter status = 'paid' → sift again

Index (user_id, status, created_at) — CORRECT ORDER:
──────────────────────────────────────────────────────────
  Straight to user_id = 42, status = 'paid'
  → Found: hundreds of this user's paid rows
  → Scan the created_at >= '2026-01-01' range from there
  → Maybe only 50 relevant rows

Principle 2: Higher-Selectivity Columns Earlier (Among Equality) #

If there are several equality columns, place the high-selectivity column (many unique values, few duplicates) first. This helps the database discard irrelevant rows earlier.

-- Table products: 1 million rows
-- category_id: 50 categories (low selectivity, ~20,000 rows per category)
-- brand_id: 5,000 brands (high selectivity, ~200 rows per brand)

-- ANTI-PATTERN: category_id (low selectivity) first
CREATE INDEX idx_products_wrong ON products (category_id, brand_id, status);
-- WHERE category_id = 5 AND brand_id = 1234 AND status = 'active'
-- → Starts from 20,000 rows (category_id=5), filters to brand, filters to status

-- CORRECT: brand_id (high selectivity) first
CREATE INDEX idx_products_correct ON products (brand_id, category_id, status);
-- WHERE category_id = 5 AND brand_id = 1234 AND status = 'active'
-- → Starts from ~200 rows (brand_id=1234), filters to category, filters to status
-- → Far fewer rows processed from the start

-- Note: the order in WHERE doesn't have to match the index order
-- The query planner adjusts automatically

Principle 3: Align with ORDER BY #

A composite index covering both WHERE and ORDER BY columns can eliminate the sort operation entirely — the database just scans the already-ordered index.

-- Query: the latest published articles from a specific author
SELECT id, title, published_at
FROM articles
WHERE author_id = 7 AND status = 'published'
ORDER BY published_at DESC
LIMIT 20;

-- ANTI-PATTERN: an index only for WHERE, ORDER BY not covered
CREATE INDEX idx_articles_filter ON articles (author_id, status);
-- → The database can filter with the index, but sorting published_at needs filesort
-- Extra: "Using index condition; Using filesort"

-- CORRECT: add the ORDER BY column to the index
CREATE INDEX idx_articles_full ON articles (author_id, status, published_at);
-- → The database scans the index from (author_id=7, status='published')
--   and the rows are already in published_at order
-- → No filesort
-- Extra: "Using index condition" (without filesort)

-- For DESC: in MySQL 8.0+ you can specify the sort direction per column
CREATE INDEX idx_articles_desc ON articles (author_id, status, published_at DESC);
-- → ORDER BY published_at DESC now without filesort and without a backward scan

Covering Indexes: Eliminating Main Table Reads #

A covering index is a situation where all the columns a query needs are available in the index — so the database doesn’t need to read the main table (heap) at all. This is the highest level of optimization achievable with an index.

-- The query to optimize
SELECT id, title, status, published_at
FROM articles
WHERE author_id = 7 AND status = 'published'
ORDER BY published_at DESC
LIMIT 20;

-- An index covering only the WHERE and ORDER BY columns
CREATE INDEX idx_articles_where_order ON articles (author_id, status, published_at);

-- The database still needs to read the table for the 'title' and 'id' columns
-- Extra: "Using index condition" (still a table lookup)

-- Covering index: add all SELECTed columns
CREATE INDEX idx_articles_covering ON articles (author_id, status, published_at, title, id);
-- Now all needed columns are in the index
-- Extra: "Using index" ← this is the sign a covering index is active
-- No reads to the main table at all

Visualizing the data access difference:

Without a covering index:
──────────────────────────────────────────────────────────────
  Query → B-Tree index → find the row pointer
       → Jump to the heap page → read the full row
       → Repeat for every matching row

  If 50 rows match → 50 jumps to the heap
  If the heap page isn't buffered → 50 disk I/Os

With a covering index:
──────────────────────────────────────────────────────────────
  Query → B-Tree index → all data is here
       → Read directly from the index, no heap needed

  No jumps to the main table at all
  → Far less I/O
  → Far less memory pressure
Covering indexes are very effective for frequently run pagination queries, listing endpoints with limited columns, and reports fetching the same columns repeatedly. Add SELECT columns to the index only if they’re small (integers, enums, timestamps) — never put TEXT or BLOB into a covering index.

Real Cases: Designing Composite Indexes from Queries #

Here are four common production query scenarios, complete with the thinking process for designing their composite indexes.

Case 1: Multi-Column Filtering with Pagination #

-- Query: a specific user's order list, filtered by status, sorted newest, paginated
SELECT id, product_id, total, status, created_at
FROM orders
WHERE user_id = 42
  AND status IN ('pending', 'paid')
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;

-- Analysis:
-- → user_id: equality, high selectivity → position 1
-- → status: equality (IN = multiple equality) → position 2
-- → created_at: ORDER BY → position 3 (no range in WHERE remaining)
-- → id, product_id, total: SELECT columns → add for covering

-- Optimal index:
CREATE INDEX idx_orders_user_status_created
    ON orders (user_id, status, created_at DESC, id, product_id, total);

-- Verification:
EXPLAIN SELECT id, product_id, total, status, created_at
FROM orders
WHERE user_id = 42 AND status IN ('pending', 'paid')
ORDER BY created_at DESC LIMIT 20;
-- → type: range, key: idx_orders_user_status_created
-- → Extra: Using index (covering index active, no filesort)

Case 2: Time Filtering with Status per Tenant #

-- Query: tenant transaction report within a time range, filtered by status
SELECT id, amount, type, created_at
FROM transactions
WHERE tenant_id = 'tenant-abc'
  AND status = 'completed'
  AND created_at BETWEEN '2026-01-01' AND '2026-01-31';

-- Analysis:
-- → tenant_id: equality → position 1
-- → status: equality → position 2
-- → created_at: BETWEEN (range) → position 3, must come after equality

-- ANTI-PATTERN: created_at first
-- CREATE INDEX idx_wrong ON transactions (created_at, tenant_id, status);
-- → Scans the entire January range (all tenants), then filters tenant and status

-- CORRECT:
CREATE INDEX idx_transactions_tenant_status_date
    ON transactions (tenant_id, status, created_at);
-- → Straight to tenant_id='tenant-abc' + status='completed'
-- → Scan the created_at range from that point
-- → Only reads the relevant transactions

Case 3: Soft Delete with an Active Filter #

-- Query: active products by category, sorted by name
SELECT id, name, price, stock
FROM products
WHERE category_id = 15
  AND deleted_at IS NULL
ORDER BY name ASC;

-- The deleted_at IS NULL condition is almost always true
-- (most rows aren't deleted) → very low selectivity
-- Don't put it first

-- The right index:
CREATE INDEX idx_products_category_deleted_name
    ON products (category_id, deleted_at, name);
-- → category_id first (higher selectivity)
-- → deleted_at: even though selectivity is low, it must be there so name
--   can be leveraged for sorting without filesort
-- → name: for ORDER BY name ASC

-- Note: IS NULL can be used in B-Tree indexes in MySQL and PostgreSQL
-- NULL values are stored and can be indexed

Case 4: Multi-Tenant with Status and Expiry Time #

-- Query: unread notifications for a specific user, not yet expired
SELECT id, title, type, created_at
FROM notifications
WHERE user_id = 99
  AND is_read = FALSE
  AND (expires_at IS NULL OR expires_at > NOW());

-- The expires_at column has a complex OR condition
-- For cases like this, a simple composite index already helps:
CREATE INDEX idx_notifications_user_read_created
    ON notifications (user_id, is_read, created_at DESC);
-- → The database goes straight to user_id=99, is_read=FALSE
-- → expires_at filtered at the engine after the index lookup (Using index condition)
-- → No full scan — the processed rows are already very few

Composite Index vs Multiple Single-Column Indexes #

A frequent question: is one composite index better, or several single-column indexes? The answer is almost always a composite index for queries involving several columns together.

-- Table: orders (5 million rows)
-- Dominant query: WHERE user_id = ? AND status = ?

-- Approach A: two single-column indexes
CREATE INDEX idx_orders_user_id ON orders (user_id);
CREATE INDEX idx_orders_status  ON orders (status);

-- What happens when the query runs:
-- MySQL can merge two indexes (index merge), but this is rarely optimal:
-- 1. Read all user_id=42 rows from idx_orders_user_id → 8,000 rows
-- 2. Read all status='paid' rows from idx_orders_status → 1,200,000 rows
-- 3. Intersect both sets → an expensive merge operation
-- Extra: "Using intersect(idx_orders_user_id, idx_orders_status); Using where"

-- Approach B: one composite index
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
-- → Straight to user_id=42 AND status='paid' → maybe 400 rows
-- → No merge, no intersect
-- Extra: "Using index condition"

-- Performance comparison:
-- Approach A: reads 8,000 + 1,200,000 rows, merges → slow
-- Approach B: reads ~400 rows directly → fast
MySQL sometimes chooses “index merge” when several relevant single-column indexes exist. This shows in EXPLAIN as Using intersect(...) or Using union(...). Although better than a full scan, it’s almost always slower than the right composite index. If you see an index merge in EXPLAIN, that’s a signal a composite index needs to be created.

Anti-Patterns to Avoid #

-- ✗ Anti-pattern 1: reversed column order (range before equality)
CREATE INDEX idx_wrong ON orders (created_at, user_id, status);
-- WHERE user_id = 42 AND status = 'paid' AND created_at >= '2026-01-01'
-- → The database starts from the created_at range, then filters user_id and status
-- ✓ Solution: (user_id, status, created_at)

-- ✗ Anti-pattern 2: composite indexes redundant with their prefixes
CREATE INDEX idx_a ON orders (user_id);
CREATE INDEX idx_b ON orders (user_id, status);          -- idx_a already covered
CREATE INDEX idx_c ON orders (user_id, status, created_at);  -- idx_b already covered
-- Three indexes, but idx_a and idx_b are never used because idx_c covers everything
-- Write overhead for three indexes, benefit from only one
-- ✓ Solution: drop idx_a and idx_b, keep only idx_c

-- ✗ Anti-pattern 3: too many columns in one composite index
CREATE INDEX idx_too_wide ON products
    (category_id, brand_id, status, price, name, description, stock, weight);
-- → A very large index, slow on writes
-- → The description (TEXT) column in the index is ineffective
-- ✓ Solution: only include columns actually used in queries

-- ✗ Anti-pattern 4: one composite index for queries with different patterns
-- Query A: WHERE user_id = ? AND status = ?
-- Query B: WHERE status = ? AND created_at >= ?
-- One index can't be optimal for both
-- CREATE INDEX idx_compromise ON orders (user_id, status, created_at);
-- → Query B still can't use the index because it starts from status
-- ✓ Solution: create two separate indexes matching each query's pattern

-- ✗ Anti-pattern 5: not checking EXPLAIN after creating a composite index
-- An index created but never verified whether it's actually used
-- ✓ Solution: always run EXPLAIN after creating a new index

Composite Index Design Decision Tree #

Use the following flow when designing a composite index for a new query:

flowchart TD
    Step1["STEP 1: Identify all conditions in the query"] --> Step2["STEP 2: Split columns by condition type<br>- Equality (=, IN)<br>- Range (>, <, BETWEEN, LIKE)<br>- ORDER BY"]
    Step2 --> Step3["STEP 3: Determine the order in the composite index<br>- Equality first<br>- Range after equality<br>- ORDER BY after range"]
    Step3 --> Step4["STEP 4: Consider a covering index<br>(add small SELECT columns if frequently run)"]
    Step4 --> Step5["STEP 5: Verify with EXPLAIN"]
    Step5 -->|"Problem found"| Step2
    Step5 -->|"Verification passed"| Step6["STEP 6: Check redundancy<br>(drop other indexes with the same prefix)"]

Composite Index Review Checklist #

WHEN CREATING A NEW COMPOSITE INDEX:
  □ Column order: equality before range before ORDER BY?
  □ Selectivity considered for equality columns?
  □ Verified with EXPLAIN after creating the index?
  □ EXPLAIN doesn't show unnecessary "filesort"?
  □ EXPLAIN doesn't show "index merge" (a signal a composite is needed)?
  □ No other indexes made redundant?
  □ TEXT/BLOB columns not included in the index?

WHEN AUDITING EXISTING INDEXES:
  □ Any index whose prefix is a subset of another index?
     (an idx on (A) while an idx on (A, B) also exists → drop (A))
  □ Any index never used according to sys.schema_index_statistics?
  □ Any query with "index merge" in EXPLAIN that could be consolidated?
  □ Any query with "filesort" that could be eliminated by extending an index?

Summary #

  • A composite index is stored as a sorted B-Tree — column order reflects the physical data structure. An index (A, B, C) sorts entries by A first, then B within the same A, then C within the same B.
  • The leftmost prefix rule is fundamental — the database can only use the index starting from the leftmost column. Queries skipping the first column can’t leverage the composite index at all.
  • Equality columns always come before range columns — put columns filtered with = or IN first, then range columns (>, <, BETWEEN) after. A reversed order makes the database scan too many rows.
  • Include ORDER BY columns at the end of the index — if the sort order aligns with the index order, the database doesn’t need to filesort. This eliminates Using filesort from EXPLAIN.
  • Covering indexes eliminate main table reads — add small SELECT columns to the index so the query gets Using index in EXPLAIN, meaning no heap jumps at all.
  • A composite index beats index merges — two merged single-column indexes are far slower than one right composite index. Using intersect(...) in EXPLAIN is a signal to create a composite index.
  • Redundant columns must be dropped — an index (A) is useless if an index (A, B) exists because every query using (A) can also use (A, B). Redundant indexes only add write overhead.
  • Always verify with EXPLAIN — a design that looks correct in theory might not be chosen by the query planner. EXPLAIN is the only way to prove an index is actually used.

← Previous: SQL Function Overuse   Next: COUNT() →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact