Back to Indexing
Q13Question 13 of 18

How does a composite index work and what is the left-prefix rule: why put equality columns before range columns

Key Concepts
  • A composite index is a single B-tree sorted first by the leftmost column, then by the next column within equal values of the first, and so on.
  • The left-prefix rule states the index serves any query filtering a contiguous prefix of columns starting from the leftmost.
  • An index on `(a, b)` serves `WHERE a` and `WHERE a AND b`, but NOT `WHERE b` alone because b values are scattered across every a.
  • Equality columns should precede range columns because an equality pins one value and keeps subsequent columns perfectly ordered.
  • A range predicate opens a column to many values, after which the next column's values are interleaved and no longer seekable.
  • A composite index on `(a, b)` also serves `WHERE a` alone, so a separate single-column index on a is usually unnecessary.

Definition

A composite index is sorted left to right, so it serves a leftmost prefix of its columns – put equality columns first and the single range column last.
  • Composite index – A single B-tree built over an ordered list of columns, sorting rows first by the first column, then by the second within equal values of the first, and so on.
  • Left-prefix rule – The index can efficiently serve any query that filters a contiguous prefix of its columns starting from the leftmost; skipping the leading column breaks the contiguous range.
  • Equality before range – Equality columns should precede range columns because an equality pins a column to one value and keeps subsequent columns perfectly ordered for further seeking.
  • Range stops seeking – Once a range predicate is applied to a column, all columns after it are interleaved across the range and no longer seekable.
  • Prefix reuse – An index on (a, b) also serves WHERE a alone, so a separate single-column index on a is often unnecessary.

Think of the National Bank of India phone directory, sorted first by branch, then by customer surname within each branch. That is a composite index on (branch, surname). You can instantly find "everyone in the Mumbai branch" (uses the first column) or "the Sharmas in Mumbai" (uses both). But you cannot use this directory to find "all Sharmas everywhere" quickly, because the Sharmas are scattered across every branch section. That is the left-prefix rule. And if you first ask for "branches between A and M," the surnames inside that wide range are no longer in one tidy block, which is why you want the exact-match column first and the range column last.

Understanding the Concepts

What is a composite index?

A composite index is a single B-tree built over multiple columns in a defined order. The entries are sorted first by the leftmost column, then by the next column within equal values of the first, and so on.

SQL
-- National Bank of India: composite index on (account_id, created_at)
CREATE INDEX ix_txn_acct_date
    ON transactions(account_id, created_at);

-- The B-tree sorts entries like this:
-- SAV1001 | 2026-01-15
-- SAV1001 | 2026-02-20
-- SAV1001 | 2026-03-10
-- SAV2001 | 2026-01-05
-- SAV2001 | 2026-01-18
-- SAV2001 | 2026-02-25
-- SAV3001 | 2026-01-02

Within each account, transactions are already sorted by date. This is why the index perfectly serves queries that filter by account and sort by date.

What is the left-prefix rule?

The left-prefix rule states that the index can efficiently serve any query that filters a contiguous prefix of columns starting from the leftmost.

SQL
-- Index on (account_id, created_at)

-- Uses the index: filters on account_id (leftmost column)
SELECT * FROM transactions WHERE account_id = 'SAV2001';
-- Plan: Index Scan on ix_txn_acct_date

-- Uses the index: filters on both columns (full prefix)
SELECT * FROM transactions
WHERE account_id = 'SAV2001'
  AND created_at >= '2026-01-01';
-- Plan: Index Scan on ix_txn_acct_date

-- CANNOT use the index: skips the leftmost column
SELECT * FROM transactions
WHERE created_at >= '2026-01-01';
-- Plan: Seq Scan (created_at values scattered across all accounts)

Why can the index not serve a query on only the second column?

Because the B-tree is sorted by account_id first. The created_at values for different accounts are interleaved throughout the tree. There is no single contiguous block of entries for a given date range.

TEXT
Index entries (sorted by account_id, then created_at):
  SAV1001 | 2026-01-15  <-- date range scattered
  SAV1001 | 2026-02-20
  SAV2001 | 2026-01-05  <-- same date range, different account
  SAV2001 | 2026-01-18
  SAV3001 | 2026-01-02  <-- same date range, yet another account

A query for created_at >= '2026-01-01' must visit every account.
There is no contiguous block to seek to.

Why put equality columns before range columns?

An equality predicate pins a column to a single value, keeping the remaining columns perfectly ordered beneath it. A range predicate opens a column to many values, after which the next column's values are interleaved and no longer form a single sorted run.

SQL
-- Good: equality first, range last
-- Index on (account_id, created_at)
SELECT * FROM transactions
WHERE account_id = 'SAV2001'            -- equality: pins one value
  AND created_at BETWEEN '2026-01-01'   -- range: walks dates within SAV2001
                   AND '2026-03-31';
-- Plan: Index Scan (seek to SAV2001, walk date range)

-- Bad: range first, equality last
-- Index on (created_at, account_id) -- wrong order!
SELECT * FROM transactions
WHERE account_id = 'SAV2001'
  AND created_at BETWEEN '2026-01-01' AND '2026-03-31';
-- Plan: scans the entire date range, then filters by account
-- Much slower: reads all accounts' transactions in that date range
JAVA
// com.bank.service.StatementService
package com.bank.service;

import java.math.BigDecimal;
import java.sql.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public class StatementService {

    /**
     * Uses the composite index (account_id, created_at).
     * Equality on account_id, range on created_at.
     * The index serves this perfectly.
     */
    public List<String> getStatement(
            Connection conn, String accountId,
            LocalDate from, LocalDate to)
            throws SQLException {

        String sql = """
            SELECT txn_id, amount, created_at
            FROM   transactions
            WHERE  account_id = ?
              AND  created_at >= ?
              AND  created_at <  ?
            ORDER BY created_at DESC
            """;

        List<String> rows = new ArrayList<>();
        try (PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setString(1, accountId);
            ps.setDate(2, Date.valueOf(from));
            ps.setDate(3, Date.valueOf(to));
            try (ResultSet rs = ps.executeQuery()) {
                while (rs.next()) {
                    BigDecimal amt = rs.getBigDecimal("amount");
                    rows.add(rs.getLong("txn_id")
                        + " | " + amt.toPlainString()
                        + " | " + rs.getTimestamp("created_at"));
                }
            }
        }
        return rows;
    }
}

How does the composite index eliminate sorting?

Because the index is sorted by created_at within each account_id, a query with ORDER BY created_at on a specific account needs no sort step.

SQL
-- Index on (account_id, created_at)
-- ORDER BY created_at is already sorted within each account
EXPLAIN ANALYZE
SELECT txn_id, amount, created_at
FROM   transactions
WHERE  account_id = 'SAV2001'
ORDER BY created_at DESC
LIMIT 10;
TEXT
Limit
  -> Index Scan Backward using ix_txn_acct_date on transactions
       Index Cond: (account_id = 'SAV2001')
  -- No Sort node: the index provides the order

Does the composite index make a single-column index redundant?

A composite index on (a, b) serves WHERE a alone because that is a valid left prefix. So you usually do not need a separate single-column index on a.

SQL
-- Index on (account_id, created_at) already serves:
-- 1. WHERE account_id = ?                    (left prefix)
-- 2. WHERE account_id = ? AND created_at > ? (full prefix)
-- 3. WHERE account_id = ? ORDER BY created_at (prefix + sort)

-- A separate index on just account_id is redundant
-- DROP INDEX ix_accounts_only_acctid;  -- not needed

But the composite index does NOT serve WHERE created_at > ? alone. If that query is needed, you need a separate index on created_at.

What about three or more columns?

The same rules apply. For a three-column index on (a, b, c), the valid prefixes are:

SQL
-- Index on (branch_code, status, created_at)
CREATE INDEX ix_accounts_branch_status_date
    ON accounts(branch_code, status, created_at);

-- Valid prefixes:
WHERE branch_code = 'MUM001'                           -- a
WHERE branch_code = 'MUM001' AND status = 'ACTIVE'     -- a, b
WHERE branch_code = 'MUM001' AND status = 'ACTIVE'
  AND created_at > '2026-01-01'                         -- a, b, c

-- NOT valid (skips the leftmost):
WHERE status = 'ACTIVE'                                 -- b alone
WHERE created_at > '2026-01-01'                         -- c alone
WHERE status = 'ACTIVE' AND created_at > '2026-01-01'  -- b, c

What should a senior engineer know?

  • Some engines support index skip scans when the leading column has very few distinct values, but do not rely on this – design the column order for your real queries.
  • The order of columns in the index also affects ORDER BY. The index can avoid a sort only if the query's sort columns are a suffix of the used index prefix in the same direction.
  • Use EXPLAIN ANALYZE to verify that the composite index is actually being used, especially when the column order is not obvious.
SQL
-- Skip scan: only works when leading column has few distinct values
-- PostgreSQL does not support skip scan (as of v16)
-- Oracle and MySQL 8.0.13+ support it for very low cardinality

-- Example: status has only 3 values (ACTIVE, FROZEN, CLOSED)
-- Index on (status, created_at)
-- A skip scan on WHERE created_at > ? would scan 3 ranges
-- But this is unreliable – design the index order properly

What are common interview follow-up questions?

  • What is a covering index and how does INCLUDE help?
  • How does the equality-before-range rule apply to a three-column index?
  • Can a composite index serve an ORDER BY on only the second column?
  • What is the difference between a composite index and multiple single-column indexes?

Visual Overview

DIAGRAM
┌─────────────────────────────────────────────────┐
│   Composite Index and Left-Prefix Rule          │
├─────────────────────────────────────────────────┤
│                                                 │
│  Index on (account_id, created_at):             │
│                                                 │
│  B-tree sorted entries:                         │
│  ┌──────────┬────────────┐                      │
│  │SAV1001   │ 2026-01-15 │ ◄── sorted by       │
│  │SAV1001   │ 2026-02-20 │     account_id first │
│  │SAV1001   │ 2026-03-10 │     then created_at  │
│  ├──────────┼────────────┤                      │
│  │SAV2001   │ 2026-01-05 │                      │
│  │SAV2001   │ 2026-01-18 │                      │
│  │SAV2001   │ 2026-02-25 │                      │
│  ├──────────┼────────────┤                      │
│  │SAV3001   │ 2026-01-02 │                      │
│  └──────────┴────────────┘                      │
│                                                 │
│  Left-prefix rule:                              │
│  WHERE account_id = ?           ──► YES (a)     │
│  WHERE account_id = ? AND dt > ? ──► YES (a,b)  │
│  WHERE created_at > ?           ──► NO  (b only)│
│                                                 │
│  Equality before range:                         │
│  account_id = 'SAV2001' ──► pins to one block   │
│  created_at > '2026-01-01' ──► walks within it  │
│  Result: tight, contiguous range scan           │
└─────────────────────────────────────────────────┘

Key Takeaway

A composite index is a single B-tree built over an ordered list of columns, and the key insight is that it sorts rows first by the leftmost column, then by the next column within equal values of the first, and so on. Because of that sort order it obeys the left-prefix rule: an index on columns a and b can efficiently serve a query that filters a leftmost prefix – a WHERE on a, or a WHERE on a and b together – but it cannot efficiently serve a WHERE on b alone, because the b values are scattered across every a value and there is no single contiguous block to seek.

The reason you put equality columns before range columns follows from how B-tree seeking works. An equality predicate pins a column to one value and keeps every column after it perfectly ordered, so the next column can still be seeked. A range predicate opens a column to many values, and after that the following columns are interleaved across the range and no longer form a single sorted run. So for a query that filters an account exactly and a date by range, the right index order is account first and date second: seek to the account, then walk the date range within it.

At the National Bank of India the statement query filtered by account id with equality and by created-at with a range. Rajesh built the composite index as account id then created-at, and the plan showed a clean index seek that touched only that customer's rows and needed no sort. When a teammate proposed reversing the order to created-at then account id, Rajesh showed with EXPLAIN that it degraded into scanning the whole date range and filtering accounts inside it, which was far slower.

The nuance Pankaj always raises is that this same index also serves a query on account id alone, because that is a valid left prefix, so the team did not need a separate single-column index on account id. But a report that filtered only by date could not use it at all, and that was the clearest demonstration of the left-prefix rule in practice.

Remember one sentence: a composite index is sorted left to right, so it serves a leftmost prefix of its columns – put equality columns first and the single range column last.

---

Share
Use ← → arrow keys to navigate