One Index, Several Columns
A composite index — sometimes called a multi-column index — is a single index built over two or more columns. You create one by listing the columns in order:
The index idx_orders_customer_status stores entries sorted first by customer_id, then by status within each customer. That ordering is the whole story — everything else about composite indexes follows from it.
The Mental Model: A Sorted Phone Book
Picture an old phone book. Entries are sorted by last name, and within each last name, by first name. That's exactly what an index on (last_name, first_name) looks like.
Some lookups are cheap, others aren't:
- "Find everyone named Patel" — easy, all the Patels sit together.
- "Find Priya Patel" — easy, jump to Patel, then scan to Priya.
- "Find everyone named Priya" — slow, you have to scan every page. Priyas are scattered across every last name.
A composite SQLite index works the same way. The first column is the primary sort key; the second column only sorts entries that share the same first-column value.
The Leftmost Prefix Rule
SQLite can use a composite index for a query only when the WHERE clause constrains a leftmost prefix of its columns. For an index on (a, b, c):
- Filtering on
a— uses the index. - Filtering on
aandb— uses the index. - Filtering on
a,b, andc— uses the index. - Filtering on
balone, orcalone, orbandc— index is not used.
You can verify this directly with EXPLAIN QUERY PLAN:
The first plan reports SEARCH events USING INDEX idx_events_user_kind_time. The second falls back to SCAN events — filtering on kind alone skips the leading user_id column, so the index is useless for that query.
Column Order Is a Design Decision
Because the leftmost prefix matters, the order you list columns in CREATE INDEX is a real choice, not a stylistic one. Two rules of thumb:
- Put the column you filter on most often first. That column unlocks the index for the widest range of queries.
- Put equality columns before range columns. SQLite can drill into the index using
=, then scan a contiguous range using<,>, orBETWEEN— but only on the last used column.
The plan shows SEARCH sales USING INDEX idx_sales_region_time (region=? AND sold_at>?). SQLite jumps straight to region = 'EU', then walks forward through the date range. Flip the column order to (sold_at, region) and the same query has to scan every row in the date range and recheck region for each.
Composite vs. Several Single-Column Indexes
A common question: should I create one index on (a, b), or two separate indexes on a and b?
For the combined filter, the composite index is faster — SQLite walks straight to the matching (project_id, state) entries. With two single-column indexes, SQLite typically picks one, uses it to narrow rows, then rechecks the other column on each matching row. It can sometimes intersect them, but the composite index is the cleaner answer when the columns are queried together.
If project_id and state are also queried independently, you may want both — the composite for the combined filter, plus a single-column index on state for queries that filter only on it.
Covering Indexes
When an index includes every column a query needs — both filter columns and selected columns — SQLite can answer the query without touching the table at all. That's a covering index, and it's the fastest a query gets.
The plan shows USING COVERING INDEX idx_invoices_cover. The query reads issued_at and total straight out of the index — notes and id aren't needed, so the table itself is never opened. Adding a column to a composite index purely to cover a hot query is a worthwhile trade when that query runs constantly.
Composite UNIQUE Constraints
Composite indexes also enforce uniqueness across column combinations. Useful when no single column is unique on its own, but the combination must be:
The third insert raises UNIQUE constraint failed: enrollments.student_id, enrollments.course_id. The same pair already exists in the index, so SQLite refuses the duplicate.
Pitfalls Worth Knowing
ORbetween non-leading columns blocks the index.WHERE a = 1 OR b = 2on an index(a, b)typically can't use the index at all — SQLite needs to consider both branches separately.- Functions on indexed columns disable the index.
WHERE lower(email) = 'x'won't use an index onemail. Index the expression instead, or normalise the data on insert. - Indexes aren't free. Every index is updated on every
INSERT,UPDATE(of indexed columns), andDELETE. Three composite indexes on a write-heavy table can dominate write cost. - Run
ANALYZEafter building indexes. SQLite's planner uses statistics gathered byANALYZEto choose between candidate indexes. Without those stats, it falls back on heuristics that aren't always optimal.
A Practical Workflow
When tuning a slow query, the loop usually looks like this:
- Run
EXPLAIN QUERY PLANon the query to see what SQLite is doing today. - If it's scanning, look at the
WHEREclause — what's the equality column? What's the range column? What's selected? - Build a composite index ordered equality-first, range-second, with selected columns appended if covering helps.
- Run
ANALYZE. - Run
EXPLAIN QUERY PLANagain. Confirm the plan changed and the index is being used. - Time the query before and after on representative data.
Skip step 6 at your peril. An index that looks correct in the plan can still be slower in practice if the table is small or the planner picks a different path.
Next: Partial Indexes
Composite indexes cover every row in the table. But often, only a small subset of rows matters — open tickets, unprocessed jobs, non-deleted records. A partial index lets you index just those rows, with a WHERE clause baked into the index itself. That's the next page.
Frequently Asked Questions
What is a composite index in SQLite?
A composite index is a single index that covers two or more columns. You create one with CREATE INDEX idx_name ON table(col_a, col_b). SQLite stores the entries sorted by col_a first, then by col_b within each col_a value — like a phone book sorted by last name, then first name.
Does column order matter in a SQLite composite index?
Yes, a lot. SQLite can use a composite index for a query only if the WHERE clause filters on a leftmost prefix of the indexed columns. An index on (a, b, c) helps queries that filter on a, on a and b, or on all three — but it can't help a query that filters only on b or only on c.
When should I use a composite index instead of separate single-column indexes?
Use a composite index when queries regularly filter or sort on the same combination of columns together. Separate single-column indexes work when each column is queried independently. Run EXPLAIN QUERY PLAN to see which index SQLite actually picks — that's the only reliable feedback.
What is a covering index in SQLite?
A covering index includes every column the query needs, so SQLite can answer the query straight from the index without touching the table. EXPLAIN QUERY PLAN shows USING COVERING INDEX when this happens. Adding extra columns to a composite index just to cover a hot query is a common optimisation.