The labs on this site need JavaScript to run. The same content is written out below.
Learn › Database › Indexes and execution plans
Composite and covering indexes
A composite index can be used only in column order
An index on several columns is sorted by the first column, then by the next column, and so on. So the WHERE conditions must start from the first column to narrow the search range, and once a range condition is reached, the columns after it cannot be used to narrow the range. When every column the query reads is in the index, so that the table does not have to be visited, the index is called a covering index for that query.
Checked against documentation and standards · The official MySQL documentation
Concepts in this lab
- Composite index and leftmost prefix
- A composite index is an index built on two or more columns together. It is sorted by the first column first and then by the second column within that, so it can be used for a search only when the conditions use its columns in order, starting from the left. An index on (a, b, c) is used for a condition on a or on a and b, but not for a condition on b alone (as an exception, in MySQL 8.0.13 and later the optimizer can use this index with Skip Scan when the leading column has few distinct values).
- Covering index
- An index that contains every column a query needs, so the result can be produced from the index alone without reading the table rows. Skipping the table access makes the read faster. In a MySQL execution plan, it appears as Using index in the Extra column.
- Clustered index and secondary index
- A clustered index stores the row data itself at the bottom of the index (the leaf nodes), so the data of the table is stored in the order of its key. In MySQL InnoDB, the primary key is the clustered index. An InnoDB secondary index holds the primary key value in its leaf nodes. After a lookup in the secondary index, that primary key is used for one more lookup in the clustered index to read the row.
- Cardinality and selectivity
- Cardinality is the number of distinct values in a column. The more distinct values there are (for example, a national ID number or an email address), the fewer rows one condition leaves and the more effective an index is. The fewer kinds of values there are (for example, gender), the more rows match a condition and the less effective an index is. Selectivity is the cardinality divided by the number of rows in the table; the closer it is to 1, the better a single condition narrows down the rows.
- Query execution plan (EXPLAIN)
- The plan that the database decides on for how to run a query. It shows which indexes are used, in what order the tables are joined, and how many rows the database expects to read. You view it by putting EXPLAIN in front of the query, and it is the starting point for finding the cause when a query is slow.
Try these
- Default settings — The three conditions match the index order, so all three columns narrow the range.
- Leaving out the first-column condition — Without a member_id condition, this index cannot narrow the range.
- Leaving out the middle-column condition — Only the first column is used, and the date condition filters entries while the index is being read.
- Range-condition column in the middle — (member_id, created_at, status). The status after the range cannot narrow the range.
- Selecting only columns in the index — The query reads only the index and never goes to the table (covering index).
- Selecting only index columns, with no first-column condition — It cannot narrow the range, but it scans the whole index instead of the table.
Running with the default settings
- The orders table has 1,000,000 rows. The index is sorted in the order member_id → status → created_at. Entries with the same first column are grouped together, within that group they are in order of the second column, and within that in order of the third column.
- Column #1 (member_id): there is the condition member_id = 7, and it is the first column of the index, so this condition narrows the range to read.
- Column #2 (status): there is the condition status = 'PAID', and the columns before it are all fixed, so this condition narrows the range to read.
- Column #3 (created_at): there is the condition created_at >= '2026-09-01', and the columns before it are all fixed, so this condition narrows the range to read.
- 50 index entries in the narrowed range are read.
- SELECT * needs columns that are not in the index too. For each of the remaining 50 entries, the query goes to the table (the clustered index) and reads the whole row.
Result of this run
- The conditions continue in index order without a gap, so all three columns narrowed the range. The order in which you write the conditions in WHERE does not matter; the column order of the index decides.
Code
CREATE INDEX idx_orders ON orders (member_id, status, created_at);
SELECT *
FROM orders
WHERE member_id = 7
AND status = 'PAID'
AND created_at >= '2026-09-01';
Assumptions of this model
This is a calculation that assumes that, in a table of 1,000,000 rows, member_id = 7 matches 0.1% of all rows, status = 'PAID' matches 50%, and the date condition matches 10%. The rules that index columns must be used from the left and that the columns after a range condition cannot narrow the search range follow the description of composite indexes in the official MySQL documentation. Filtering conditions at the index stage after the order has been broken (Using index condition) is the index condition pushdown behavior of MySQL. The real optimizer may choose a different plan depending on statistics, and here it is assumed that this index is used. This was not checked against the EXPLAIN output of a real database.
Previous lab: Indexes and full table scans · Next lab: Reading the execution plan