VillageSQL is a drop-in replacement for MySQL with extensions.
All examples in this guide work on VillageSQL. Install Now →
EXPLAIN and the slow query log. Start there.
Step 1: Read EXPLAIN
RunEXPLAIN before the query you want to investigate:
possible_keys — the indexes MySQL considered for this query. NULL means no relevant indexes exist on the columns in your WHERE clause.
key — the index MySQL actually chose. If possible_keys lists candidates but key is NULL, MySQL decided a full table scan was cheaper — usually a sign the statistics are stale or the table is small enough that the optimizer skipped the index.
type — the access method MySQL used. This is the most important column. ALL means a full table scan: MySQL read every row to find the ones that match. On a table with 800,000 rows, that’s 800,000 row reads for a query that might return 1. ref or eq_ref means MySQL used an index lookup, which is what you want.
key — which index MySQL chose. NULL means no index was used (see possible_keys above).
rows — MySQL’s estimate of how many rows it’ll examine. A high number relative to the number of rows you expect back is a signal that something is off.
Extra — supplementary information. Using filesort means MySQL had to sort the result set in memory or on disk because no index covered the ORDER BY. Using temporary means MySQL built a temporary table, which happens with certain GROUP BY and DISTINCT queries. Both are warning signs on large tables.
Once you’ve read the estimates, use EXPLAIN ANALYZE to see what actually happened at execution time:
EXPLAIN ANALYZE runs the query and reports actual row counts and timing alongside the estimates. If the estimate says 800,000 rows and the actual says 800,000 rows, the estimate was right and the scan is real. If the actual is much lower, the optimizer may have made a poor choice based on stale statistics.
Step 2: The Most Common Causes
Missing index
The query in the example above scans the fullorders table because there’s no index on customer_id. The fix is direct:
EXPLAIN. The type column should change from ALL to ref, and key should show your new index name.
Wrong index
MySQL sometimes picks an index that isn’t the best choice.EXPLAIN tells you which one it chose. To test whether a different index would do better, use FORCE INDEX:
rows estimate, consider whether the statistics on the original index are stale. ANALYZE TABLE orders recalculates them. If MySQL consistently picks the wrong index, you may also have an index that’s no longer useful — dropping it removes it from consideration.
Leading column problem
A composite index on(customer_id, status, created_at) is efficient when your query filters on customer_id or on customer_id and status together. It can’t be used efficiently if your query filters only on status or only on created_at, because the index is sorted by customer_id first.
status alone, add a separate index on status.
SELECT *
Naming only the columns you need isn’t just good style — it affects performance. When MySQL can satisfy a query entirely from an index (a covering index), it never touches the actual table rows.SELECT * breaks that: it forces MySQL to look up each matching row in the table to retrieve columns that aren’t in the index.
Functions on indexed columns
Wrapping an indexed column in a function usually prevents index use. MySQL can’t use a B-tree index to answer “which rows have acreated_at where YEAR() returns 2024” because the index stores raw timestamps, not computed years.
LOWER(), DATE(), arithmetic on columns, and anything else that transforms the value before comparison.
N+1 queries
Sometimes the problem isn’t a single slow query — it’s hundreds of fast ones. A common pattern: fetch a list of orders, then run a separate query for each order to get the customer name. With 200 orders, that’s 201 queries. Each one is fast, but together they add seconds. The fix is to pull the data in one query with aJOIN:
Step 3: Enable the Slow Query Log
Set this up once and leave it running — it captures queries that exceed a time threshold in the background, surfacing problems you didn’t know existed. Enable it at runtime:mysqldumpslow:
long_query_time to 0.1 or 0.5 catches queries that are fast individually but add up across many calls.
Step 4: Check SHOW PROCESSLIST
Use this when something is actively slow — a query that’s been running too long, or an application that’s hanging right now.SHOW PROCESSLIST shows every active connection and how long each query has been running:
Waiting for table metadata lock or Waiting for lock state isn’t slow because of a bad plan — it’s blocked by another query holding a lock. For more detail on lock contention, check:
TRANSACTIONS section shows which transactions are waiting and which ones are holding locks.
Quick Reference
| Symptom | Likely cause | Fix |
|---|---|---|
EXPLAIN shows type: ALL | Missing index | Add index on WHERE columns |
Extra: Using filesort | No index for ORDER BY | Add index covering sort columns |
Extra: Using temporary | Complex GROUP BY or DISTINCT | Review query or add index |
| Query fast on small table, slow on large | Full table scan | Add index |
| Many fast queries adding up | N+1 pattern | Rewrite with JOIN |
| Query slow only sometimes | Lock contention | Check SHOW PROCESSLIST and INNODB STATUS |
See also
- How MySQL indexes work — the mental model behind index selection and B-tree structure
- Covering indexes — how to write queries that never leave the index
- Reading EXPLAIN output — a deeper look at every column in the EXPLAIN plan
- Using the slow query log — configuration, rotation, and analysis with
mysqldumpslow

