SQL Indexes
An index is a sorted lookup structure that lets the database find rows without reading the whole table, like the index at the back of a book. The right index turns a query that scans millions of rows into one that reads a handful. The wrong ones waste disk space and slow down every write. This lesson shows how to create indexes and, with EXPLAIN, how to prove MySQL uses them.
- What an index is and how a B-tree lookup works
- Create, list and drop indexes
- Design composite indexes with the leftmost-prefix rule
- Recognise queries that cannot use an index
How an index works
Without an index, WHERE city = 'Pune' makes MySQL read every row and test it: a full table scan. An index on city keeps the city values sorted, each with a pointer to its row, in a balanced tree (B-tree). MySQL walks down the tree to the first ‘Pune’ and reads only the matching entries.
| Full scan | Index lookup | |
|---|---|---|
| Rows read for 1 match in 1,000,000 | ~1,000,000 | a few |
| Cost grows with | table size | log of table size |
InnoDB always has one index already: the primary key, which also stores the rows themselves in key order.
See the difference with EXPLAIN
EXPLAIN shows how MySQL plans to run a query (EXPLAIN lesson). Look at type, key and rows:
EXPLAIN SELECT first_name FROM employees WHERE city = 'Pune';| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | employees | NULL | ALL | NULL | NULL | NULL | NULL | 12 | 10 | Using where |
type = ALLmeans a full table scan.key = NULL: no index is used.rows = 12: MySQL expects to read every row.
CREATE INDEX
CREATE INDEX idx_emp_city ON employees (city);EXPLAIN SELECT first_name FROM employees WHERE city = 'Pune';| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | employees | NULL | ref | idx_emp_city | idx_emp_city | 123 | const | 3 | 100 | NULL |
CREATE INDEX idx_emp_city ON employees (city)builds a sorted index of thecitycolumn.- Now
type = refandkey = idx_emp_city: MySQL looks up ‘Pune’ in the index. rows = 3: only the matching rows are read.
List and drop indexes:
CREATE INDEX idx_emp_city ON employees (city);SELECT index_name, column_name, non_uniqueFROM information_schema.statisticsWHERE table_schema = 'company' AND table_name = 'employees'ORDER BY index_name, seq_in_index;| INDEX_NAME | COLUMN_NAME | NON_UNIQUE |
|---|---|---|
| dept_id | dept_id | 1 |
| idx_emp_city | city | 1 |
| PRIMARY | emp_id | 0 |
DROP INDEX idx_emp_city ON employees; removes it. The foreign key on dept_id created its own index automatically.
Unique indexes
A unique index also enforces that no two rows share a value (UNIQUE):
CREATE UNIQUE INDEX uq_customer_email ON customers (email);Composite indexes and the leftmost prefix
An index can cover several columns. Its entries are sorted by the first column, then the second, like a phone book sorted by last name, then first name:
CREATE INDEX idx_orders_cust_date ON orders (customer_id, order_date);EXPLAIN SELECT order_id FROM orders WHERE customer_id = 1 AND order_date >= '2025-02-01';| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | index | idx_orders_cust_date | idx_orders_cust_date | 7 | NULL | 1 | 100 | Using where; Using index |
The index can be used for the leftmost columns:
| WHERE clause | Uses (customer_id, order_date)? |
|---|---|
customer_id = 1 | yes |
customer_id = 1 AND order_date >= '2025-02-01' | yes, both columns |
order_date >= '2025-02-01' | no (skips the first column) |
CREATE INDEX idx_orders_cust_date ON orders (customer_id, order_date);EXPLAIN SELECT order_id, status FROM orders WHERE order_date >= '2025-02-01';| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | ALL | NULL | NULL | NULL | NULL | 1 | 100 | Using where |
- The query filters only on
order_date, the second column of the index. - Like finding everyone named “Rahul” in a phone book sorted by last name, the sorted order does not help to jump to the matching rows.
type = ALL,key = NULL: a full table scan. (If the query asked only for columns that are in the index, MySQL could still scan the whole index instead of the table, shown astype = index, but that is a scan too, not a lookup.)
Put the column you filter with = first and the range column (>, BETWEEN, LIKE 'x%') last.
When an index cannot be used
| Pattern | Why | Better |
|---|---|---|
WHERE YEAR(hire_date) = 2022 | function on the column | hire_date >= '2022-01-01' AND hire_date < '2023-01-01' |
WHERE email LIKE '%@gmail.com' | leading wildcard | store the domain in its own column |
WHERE phone = 9845012345 (phone is text) | type conversion of every row | WHERE phone = '9845012345' |
WHERE a = 1 OR b = 2 | one index cannot serve both | two indexes (index merge) or UNION |
CREATE INDEX idx_emp_hire ON employees (hire_date);EXPLAIN SELECT first_name FROM employees WHERE YEAR(hire_date) = 2022;EXPLAIN SELECT first_name FROM employees WHERE hire_date >= '2022-01-01' AND hire_date < '2023-01-01';| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | employees | NULL | ALL | NULL | NULL | NULL | NULL | 12 | 100 | Using where |
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | employees | NULL | range | idx_emp_hire | idx_emp_hire | 4 | NULL | 2 | 100 | Using index condition |
- The first plan wraps the column in
YEAR(), so the sorted index is useless:type = ALL. - The second expresses the same condition as a range on the raw column:
type = rangeusingidx_emp_hire.
On tiny tables like these, MySQL may choose a full scan even when an index exists, because reading 12 rows is cheaper than using the index. The plans above show the behaviour you will see on real-sized tables.
Covering indexes
If an index contains every column the query needs, MySQL answers from the index alone without touching the table. EXPLAIN then shows Using index in Extra:
CREATE INDEX idx_orders_cust_date ON orders (customer_id, order_date);EXPLAIN SELECT customer_id, order_date FROM orders WHERE customer_id = 1;| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | ref | idx_orders_cust_date | idx_orders_cust_date | 4 | const | 3 | 100 | Using index |
The cost of indexes
- Every
INSERT,UPDATEandDELETEmust also update every index on the table. - Indexes take disk space and memory.
- So: index the columns used in
WHERE,JOIN ... ONandORDER BYof frequent queries, and drop indexes nobody uses (sys.schema_unused_indexeslists them).
Quick quiz
1. What does type = ALL in EXPLAIN mean?
Every row is read.
2. An index on (customer_id, order_date). Which WHERE can use it?
The leftmost column must be in the condition.
3. Why can’t WHERE YEAR(hire_date) = 2022 use an index on hire_date?
Rewrite it as a range on the raw column.
4. What is a downside of having many indexes?
Each index must be maintained on every change.
Index FAQ
What is an index in SQL?
A data structure (usually a B-tree) that keeps column values sorted with pointers to the rows, so the database can find matching rows without scanning the whole table.
When should I create an index?
On columns that appear often in WHERE, JOIN conditions and ORDER BY of important queries, especially on large tables. Confirm with EXPLAIN that it is used.
What is a composite index?
An index on several columns. It can be used for conditions on its leftmost column(s), so column order matters.
Do indexes slow down inserts?
Yes, a little per index, because each one must be updated. That is why you should not index every column.