Skip to main content
PProfileMatch

Composite and Covering Indexes: Column Order in Database Indexing

Data AnalyticsLesson 2 of 45 min read

What you'll learn

  • Picture a composite index as one list sorted by the first column, then the next
  • Apply the leftmost-prefix rule to predict whether an index will be used
  • Order columns as equality first, then range or sort
  • Recognise a covering index and weigh its cost

On this page

  1. A composite index is one sorted list with tie-breakers
  2. Nine queries, nine plans
  3. Reading the results
  4. Choosing the column order
  5. Covering indexes: never visiting the table
  6. Common mistakes
  7. How this shows up in projects and interviews

Here is a plan that surprises people. The table has an index that includes slot_date, the query filters on slot_date alone, and the database still reads every row. Nothing is broken; the index simply is not shaped for that question. This lesson uses a clinic's appointment table to show how column order decides which queries a composite index can serve, then how to go one step further and avoid reading the table at all.

A composite index is one sorted list with tie-breakers

An index on (clinic_id, slot_date) is not two indexes. It is a single list sorted by clinic_id first, and within equal clinics by slot_date. Everything about column order follows from picturing that list.

Sorted by clinic, then date
(clinic_id, slot_date) in order (row ids made up)

 clinic  slot_date     row
   1     2025-01-03    8841
   1     2025-01-04    5519
   ...
   3     2025-06-13      77
   3     2025-06-14    9012   <- seek here, read on
   3     2025-06-14   15432
   3     2025-06-15     301
   ...
   4     2025-01-02    7260

All of clinic 3's dates sit together, in date order. That makes "clinic 3 on 14 June" one seek followed by a short read, and "clinic 3 in June" one seek followed by a slightly longer read. But the 14 June entries are scattered across the list, one cluster per clinic, so "any clinic on 14 June" has no single place to seek to.

Nine queries, nine plans

The script creates 20,000 appointments, then asks SQLite how it would run nine queries as indexes are added. EXPLAIN QUERY PLAN does not run the query; it prints the strategy. Run it yourself, since the plan text is the evidence.

clinic_plans.py
import random
import sqlite3

random.seed(11)
db = sqlite3.connect(":memory:")
db.execute("""
    CREATE TABLE appointments (
        id           INTEGER PRIMARY KEY,
        clinic_id    INTEGER NOT NULL,
        doctor_id    INTEGER NOT NULL,
        slot_date    TEXT    NOT NULL,   -- 'YYYY-MM-DD'
        status       TEXT    NOT NULL,
        patient_name TEXT    NOT NULL
    )""")
db.executemany(
    "INSERT INTO appointments VALUES (?,?,?,?,?,?)",
    [(i, random.randint(1, 5), random.randint(1, 40),
      f"2025-{random.randint(1, 12):02d}-{random.randint(1, 28):02d}",
      random.choice(["booked", "booked", "booked", "done", "cancelled"]),
      f"patient{i}") for i in range(1, 20001)])
db.execute("ANALYZE")

def show(label, sql):
    print(f"-- {label}")
    for row in db.execute("EXPLAIN QUERY PLAN " + sql):
        print("   ", row[3])

day = "WHERE clinic_id = 3 AND slot_date = '2025-06-14'"
show("1. no index", f"SELECT patient_name FROM appointments {day}")

db.execute("CREATE INDEX idx_clinic_date ON appointments (clinic_id, slot_date)")
show("2. index (clinic_id, slot_date)", f"SELECT patient_name FROM appointments {day}")
show("3. leading column skipped", "SELECT patient_name FROM appointments WHERE slot_date = '2025-06-14'")
show("4. leading column only", "SELECT patient_name FROM appointments WHERE clinic_id = 3")
show("5. equality, then range", "SELECT patient_name FROM appointments WHERE clinic_id = 3 AND slot_date >= '2025-06-01' AND slot_date < '2025-07-01'")
show("6. ORDER BY the next column", "SELECT patient_name FROM appointments WHERE clinic_id = 3 ORDER BY slot_date")
show("7. ORDER BY an unindexed column", "SELECT patient_name FROM appointments WHERE clinic_id = 3 ORDER BY doctor_id")

db.execute("CREATE INDEX idx_clinic_date_status ON appointments (clinic_id, slot_date, status)")
show("8. covering: only indexed columns", f"SELECT slot_date, status FROM appointments {day}")
show("9. one non-indexed column added", f"SELECT slot_date, patient_name FROM appointments {day}")

Output

-- 1. no index
    SCAN appointments
-- 2. index (clinic_id, slot_date)
    SEARCH appointments USING INDEX idx_clinic_date (clinic_id=? AND slot_date=?)
-- 3. leading column skipped
    SCAN appointments
-- 4. leading column only
    SEARCH appointments USING INDEX idx_clinic_date (clinic_id=?)
-- 5. equality, then range
    SEARCH appointments USING INDEX idx_clinic_date (clinic_id=? AND slot_date>? AND slot_date<?)
-- 6. ORDER BY the next column
    SEARCH appointments USING INDEX idx_clinic_date (clinic_id=?)
-- 7. ORDER BY an unindexed column
    SEARCH appointments USING INDEX idx_clinic_date (clinic_id=?)
    USE TEMP B-TREE FOR ORDER BY
-- 8. covering: only indexed columns
    SEARCH appointments USING COVERING INDEX idx_clinic_date_status (clinic_id=? AND slot_date=?)
-- 9. one non-indexed column added
    SEARCH appointments USING INDEX idx_clinic_date_status (clinic_id=? AND slot_date=?)

Reading the results

QueryWhat happenedWhy
1SCANNo index exists, so every row is read.
2SEARCH using both columnsEquality on the leading column, then on the second: one precise seek.
3SCAN againOnly slot_date is filtered. The leading column is missing, so there is no starting point.
4SEARCH on clinic_id onlyA leading-column prefix is enough to use the index.
5SEARCH with a slot_date rangeEquality, then a range on the next column, is the ideal shape.
6No sort stepWithin one clinic the index is already ordered by slot_date, so ORDER BY is free.
7USE TEMP B-TREE FOR ORDER BYdoctor_id is not in the index, so SQLite finds rows, then sorts them separately.
8COVERING INDEXEvery column the query needs lives in the index.
9INDEX (not covering)patient_name forces a trip back to the table for each match.

Choosing the column order

Start from the queries you must make fast, then work down this list.

  1. Put columns compared with = first. They fix a position in the sorted list.
  2. Put the one range column (>, <, BETWEEN) or the ORDER BY column after the equality columns. Anything after a range column can still filter inside the index but cannot narrow the seek.
  3. If two orders are both legal, prefer the one that also serves another query through its prefix. An index on (clinic_id, slot_date) also answers every clinic_id-only query, so a separate clinic_id index is redundant.
  4. Do not decide by guessing which column has more distinct values. That matters when columns are interchangeable, but the shape of your queries matters more.

Covering indexes: never visiting the table

Queries 8 and 9 differ only in the output column. In 8, status is part of the index, so the engine reads the answer straight from index pages and never opens the table. SQLite labels this COVERING INDEX. PostgreSQL calls the same idea an Index Only Scan, and MySQL shows Using index in the Extra column.

One nuance: if you select only id, the query is covered even without listing id in the index. In SQLite the row id is stored in every index entry, and MySQL's InnoDB likewise stores the primary key in secondary indexes. PostgreSQL and SQL Server let you attach non-key columns with INCLUDE (column), which makes a column available for covering without affecting sort order. MySQL and SQLite have no INCLUDE; you add the column to the key instead.

Common mistakes

  • Reversing the column order. Symptom: you created (slot_date, clinic_id) and the query filtering on clinic_id plus a date range reads far more index entries than expected; in PostgreSQL the EXPLAIN ANALYZE row counts on the index node are large.
  • One single-column index per column instead of a composite. Symptom: the planner picks one of them and filters the rest row by row, or in PostgreSQL combines them with a bitmap step that still costs more than one well-ordered index.
  • Wrapping the leading column in a function. Symptom: WHERE lower(clinic_name) = ... falls back to a scan although clinic_name is indexed; index the expression instead where your database supports it.
  • Adding a covering column to a very wide key. Symptom: the index file is nearly as large as the table and inserts slow down with no read gain.

How this shows up in projects and interviews

Composite index design is the standard follow-up to this query is slow. A good answer names the query's equality columns, its range or sort column, and the columns it returns, then proposes one index and says which other queries it also serves. Interview prompts like design an index for the clinic's calendar page test exactly the leftmost-prefix reasoning above. In practice, check the existing indexes first: the best change is often extending one rather than adding another. See Advanced SQL for the query shapes that rely on these orderings, such as window functions with ORDER BY.

Try it yourself

A support team runs two queries all day: WHERE team_id = ? AND status = 'open' ORDER BY created_at DESC LIMIT 20 and WHERE team_id = ? AND created_at >= ?. Propose one composite index that serves both. Create a tickets table in SQLite, add your index and confirm with EXPLAIN QUERY PLAN that neither query needs a temp B-tree.

Show a hint

Both queries start with an equality on team_id. Decide where status and created_at go, remembering that an ORDER BY can only use the index if the rows come out already sorted.

Key takeaways

  • A composite index is one sorted list, so column order decides which queries can seek into it.
  • A query that skips the leading column cannot use the index to seek; it scans.
  • Equality columns first, then one range or ORDER BY column, matches how the sorted list is read.
  • A covering index answers a query without touching the table, but widens every entry and slows writes.

Test yourself

A trick from this lesson's ride: Phone book rule: surname first, so you cannot look up by first name alone. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between a composite index and multiple single-column indexes?

A composite index is one sorted structure over several columns, so a query that filters on them in order can seek once and read a tight range. Separate single-column indexes each sort by one column only, so the database usually picks one and filters the rest row by row. Some engines can merge several single-column indexes, but one well-ordered composite index is generally cheaper.

Does column order matter in a composite index?

Yes. The index is sorted by the first column, then the second within it, so queries can only seek on a leftmost prefix. An index on (clinic_id, slot_date) serves clinic_id alone and both columns together, but not slot_date alone. Put equality columns before the range or sort column.

What is a covering index?

A covering index contains every column a query needs, so the database answers from the index alone and never reads the table rows. It is reported as COVERING INDEX in SQLite, Index Only Scan in PostgreSQL, and Using index in MySQL. It speeds reads but makes the index larger, which slows writes.