Query performance with EXPLAIN
A query that takes 20 ms on your laptop can take 20 seconds on a production table with ten million rows. EXPLAIN
shows how MySQL plans to run a query, so you can see whether it uses an index or scans every row.
Reading EXPLAIN output
EXPLAIN
SELECT id, total FROM orders
WHERE customer_id = 4521 AND status = 'PAID';
| Column | What to look for |
|---|---|
type |
Access method. Good to bad: const, eq_ref, ref, range, index, ALL |
possible_keys |
Indexes MySQL considered |
key |
Index actually chosen (NULL means none) |
rows |
Estimated rows examined — smaller is better |
Extra |
Hints such as Using index, Using filesort, Using temporary |
type = ALL on a large table means a full table scan. Using filesort and Using temporary mean extra sorting or
temp tables, often fixable with a better index.
EXPLAIN ANALYZE (MySQL 8.0.18+) actually runs the query and reports real timings per step, which is more reliable
than estimates.
Composite indexes and the leftmost-prefix rule
An index on (customer_id, status, created_at) is sorted by customer_id first, then status, then created_at.
MySQL can use it for conditions on a leftmost prefix of those columns.
CREATE INDEX idx_orders_cust_status_date ON orders (customer_id, status, created_at);
| Query filter | Uses the index? |
|---|---|
customer_id = ? |
Yes |
customer_id = ? AND status = ? |
Yes |
customer_id = ? AND status = ? ORDER BY created_at |
Yes, and avoids filesort |
status = ? alone |
No (skips the first column) |
Put equality columns first and range or sort columns last.
Covering indexes
If every column a query needs is inside the index, MySQL never touches the table rows. EXPLAIN shows Using index.
CREATE INDEX idx_orders_cust_total ON orders (customer_id, total);
SELECT customer_id, SUM(total) FROM orders GROUP BY customer_id; -- served from the index
Rewrites that unlock indexes
Wrapping an indexed column in a function hides it from the index.
-- Slow: function on the column forces a scan
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- Fast: a range on the raw column can use an index on created_at
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
Similarly, LIKE '%sharma' cannot use an index but LIKE 'sharma%' can, and comparing a VARCHAR column to a number
causes an implicit cast that disables the index.
A performance checklist
- Turn on the slow query log (
long_query_time = 1) to find the real offenders. - Run
EXPLAINand look attype,keyandrows. - Add or adjust an index that matches the
WHERE,JOINandORDER BYcolumns. - Select only needed columns instead of
SELECT *. - Paginate large lists with keyset pagination (
WHERE id > last_seen ORDER BY id LIMIT 50) rather than huge offsets. - Re-run
EXPLAIN ANALYZEto confirm the improvement.
Common mistakes
- Adding an index for every column separately instead of one well-ordered composite index.
- Indexing low-selectivity columns like
is_activeon their own. - Forgetting that every extra index slows down inserts and updates.
Practice
Create an orders table with 200,000 generated rows (use a recursive CTE or a script). Time a query filtering by
customer_id and month, check it with EXPLAIN, add a composite index, and record the before-and-after rows and time.