Knowing how indexes work is half the job. The other half is deciding which ones to create, and when to stop.
A method that works
- Collect the real queries: the slow query log,
performance_schema, or the queries in your code. Do not guess. - Sort them by cost: how often they run times how long they take. A 50 ms query that runs a million times a day matters more than a 5 second query that runs once.
- For each important query, note:
- the equality conditions (
=,IN), - the range condition (
>,<,BETWEEN,LIKE 'abc%'), - the sort and group columns,
- the columns selected.
- Design one index per query shape: equality columns, then sort, then range. Consider making it covering if the query is critical and narrow (Composite Indexes, Covering Indexes).
- Test with
EXPLAINandEXPLAIN ANALYZEon realistic data. - Measure again after deployment.
Always index
| Column | Why |
|---|---|
| Primary key | Automatic |
| Foreign keys (child columns) | Joins and referential checks. InnoDB creates it for you |
Columns in the WHERE of frequent queries | Fast filtering |
Columns in JOIN ... ON | Fast joins |
UNIQUE business keys (email, order number) | Integrity and lookup |
ORDER BY columns of paginated lists (with the filter columns in front) | Avoid sorting |
Usually do not index
| Column | Why not |
|---|---|
Low-selectivity columns alone (is_active, gender, status) | The optimizer will ignore them |
| Tiny tables (a few hundred rows) | A scan is as fast |
| Rarely queried columns | Pure write cost |
| Very wide text columns | Large index, little benefit. Use prefix, hash or full-text |
| Columns that are updated constantly (counters, “last seen”) | Every update rewrites the index entry |
| Every column “just in case” | See below |
The over-indexing problem
Each index slows every INSERT, UPDATE (of its columns) and DELETE, uses disk and memory in the buffer pool, and makes the optimizer’s job harder. A table with 15 indexes where 5 would do pays a constant tax on every write. Keep the smallest set that serves your real queries.
Find redundant indexes
An index is redundant if another index has the same leading columns:
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
customer_id INT NOT NULL,
status VARCHAR(10) NOT NULL,
created_at DATETIME NOT NULL,
KEY idx_customer (customer_id),
KEY idx_customer_status (customer_id, status),
KEY idx_customer_status_created (customer_id, status, created_at)
);
SELECT table_name, redundant_index_name, dominant_index_name
FROM sys.schema_redundant_indexes
WHERE table_schema = 'learn'
ORDER BY redundant_index_name;
+------------+----------------------+-----------------------------+
| table_name | redundant_index_name | dominant_index_name |
+------------+----------------------+-----------------------------+
| orders | idx_customer | idx_customer_status |
| orders | idx_customer | idx_customer_status_created |
| orders | idx_customer_status | idx_customer_status_created |
+------------+----------------------+-----------------------------+
3 rows in set
idx_customer and idx_customer_status are both prefixes of idx_customer_status_created, so the big index serves their queries too. Drop the two smaller ones (after making them invisible and checking).
Find unused indexes
The sys schema reports indexes that have not been used since the server started:
SELECT object_schema, object_name, index_name FROM sys.schema_unused_indexes WHERE object_schema = 'learn' ORDER BY object_name, index_name;
+---------------+-------------+-----------------------------+
| object_schema | object_name | index_name |
+---------------+-------------+-----------------------------+
| learn | orders | idx_customer |
| learn | orders | idx_customer_status |
| learn | orders | idx_customer_status_created |
+---------------+-------------+-----------------------------+
3 rows in set
(This list depends on the server’s activity since its last restart, so it is not compared.) Be careful: an index may be unused simply because the server restarted recently, or because it is used only in a monthly report. Observe for a full business cycle before you act. Test with an invisible index first (Invisible Indexes).
Check the index size
SELECT table_name, index_name, ROUND(stat_value * @@innodb_page_size / 1024 / 1024, 2) AS size_mb
FROM mysql.innodb_index_stats
WHERE database_name = 'learn' AND stat_name = 'size'
ORDER BY size_mb DESC;
+------------+-----------------------------+---------+
| table_name | index_name | size_mb |
+------------+-----------------------------+---------+
| orders | PRIMARY | 0.02 |
| orders | idx_customer | 0.02 |
| orders | idx_customer_status | 0.02 |
| orders | idx_customer_status_created | 0.02 |
+------------+-----------------------------+---------+
4 rows in set
(Sizes differ per server.) On big tables, this shows which indexes cost the most.
Indexes and the write path
When you insert a row into a table with six indexes, MySQL updates six B-trees. For bulk loads, you can create secondary indexes after loading the data (Fast Inserts).
Primary key choice affects everything
Because every secondary index contains the primary key, a short, ever-increasing primary key (INT or BIGINT UNSIGNED AUTO_INCREMENT) keeps all the indexes smaller and inserts fast (PRIMARY KEY, UUIDs as Keys).
Review checklist
- [ ] Every foreign key column is indexed (and the index starts with that column).
- [ ] Every frequent
WHERE/JOIN/ORDER BYhas an index, verified withEXPLAIN. - [ ] Composite indexes follow: equality, then sort, then range.
- [ ] No redundant indexes (
sys.schema_redundant_indexes). - [ ] Candidate unused indexes were tested invisible for a full cycle.
- [ ] Statistics are fresh (
ANALYZE TABLEafter big changes). - [ ] Writes on the table are not slowed too much by the number of indexes.
- [ ] Index creation is in a migration, with a comment about which query it serves.