You've Got a Slow Query. Now What?
You know your WordPress site has slow database queries. Maybe you spotted them in Query Monitor, or your hosting company sent you a warning. You've got a query that takes 3 seconds when it should take 30 milliseconds.
The next step is EXPLAIN. It's MySQL's built-in tool for showing you exactly how it executes a query — and where it goes wrong.
Most WordPress developers never use it. That's a shame, because it's the single most useful tool for database performance.
Running EXPLAIN
Take any SQL query and put EXPLAIN in front of it:
EXPLAIN SELECT p.ID, p.post_title
FROM wp_posts p
JOIN wp_postmeta pm ON p.ID = pm.post_id
WHERE pm.meta_key = '_price'
AND pm.meta_value > 100
AND p.post_type = 'product'
AND p.post_status = 'publish';
MySQL returns a table with several columns. Here are the ones that matter:
The Columns That Matter
type — How MySQL Accesses the Table
This is the most important column. From best to worst:
| Type | Meaning | Speed |
|---|---|---|
const |
Single row lookup by primary key | Instant |
eq_ref |
One row per join via unique index | Very fast |
ref |
Multiple rows via non-unique index | Fast |
range |
Index range scan (BETWEEN, >, <) | Good |
index |
Full index scan (reads every entry) | Slow |
ALL |
Full table scan (reads every row) | Terrible |
If you see ALL on a table with more than a few thousand rows, that's your problem. MySQL is reading every single row to find matches.
rows — How Many Rows MySQL Expects to Examine
This is an estimate, not exact. But if your query returns 10 results and MySQL examines 500,000 rows to find them, something is wrong.
key — Which Index MySQL Uses
If this is NULL, MySQL isn't using any index. That usually means there isn't one that fits, and you need to create it.
Extra — Watch for These
- Using filesort — MySQL sorts results without an index. Slow on large result sets.
- Using temporary — MySQL creates a temporary table. Often seen with GROUP BY or DISTINCT.
-
Using where — MySQL filters rows after reading them. Normal, but combined with
ALL, it means scanning everything. - Using index — MySQL can answer the query from the index alone without touching the table. This is ideal.
A Real WordPress Example
Here's a common slow query from a WooCommerce site:
EXPLAIN SELECT p.ID, p.post_title, pm.meta_value as price
FROM wp_posts p
JOIN wp_postmeta pm ON p.ID = pm.post_id
WHERE p.post_type = 'product'
AND p.post_status = 'publish'
AND pm.meta_key = '_price'
ORDER BY pm.meta_value + 0 ASC
LIMIT 20;
Typical EXPLAIN output:
+----+-------+------+---------------+------+---------+------+--------+-----------------------------+
| id | table | type | possible_keys | key | rows | Extra |
+----+-------+------+---------------+------+---------+------+--------+-----------------------------+
| 1 | p | ref | type_status | ... | 12,500 | Using where; Using filesort |
| 1 | pm | ref | post_id | ... | 156,000 | Using where |
+----+-------+------+---------------+------+---------+------+--------+-----------------------------+
What's wrong here:
-
rows: 156,000on wp_postmeta — it's scanning most of the table -
Using filesort— the ORDER BY can't use an index - There's no composite index on
(meta_key, post_id, meta_value)
The fix:
CREATE INDEX idx_postmeta_key_post_value
ON wp_postmeta (meta_key, post_id, meta_value);
After this index, the same query examines ~12,500 rows instead of 156,000 and skips the filesort.
Common WordPress EXPLAIN Patterns
The wp_options Full Scan
EXPLAIN SELECT * FROM wp_options WHERE autoload = 'yes';
WordPress core adds an index on autoload since 6.4, but older installations don't have it. If you see type: ALL here, add:
CREATE INDEX idx_autoload ON wp_options (autoload);
The Postmeta Join Problem
wp_postmeta is WordPress's most abused table. Plugins store everything there. The default indexes cover post_id and meta_key separately, but queries that filter on both often can't use them efficiently.
The Taxonomy Count Query
EXPLAIN SELECT COUNT(*) FROM wp_posts p
JOIN wp_term_relationships tr ON p.ID = tr.object_id
WHERE tr.term_taxonomy_id = 42
AND p.post_type = 'post'
AND p.post_status = 'publish';
This is usually fine with default indexes, but on sites with 50,000+ posts it can slow down. Watch for type: ALL on wp_posts.
When to Stop Optimizing
Not every query needs to be instant. Focus on:
- Queries that run on every page load (autoload, active plugins, rewrite rules)
- Queries that take more than 100ms
- Queries that run inside loops (the N+1 problem)
A query that runs once during a weekly cron job and takes 2 seconds? Not worth optimizing.
Automating This
Reading EXPLAIN output manually works, but it's tedious when you have 200 queries per page load. WP Multitool automates this — its Slow Query Analyzer captures slow queries, runs EXPLAIN on each one, assigns a health score (green/yellow/red), and suggests the exact CREATE INDEX statement when an index would help.
No external APIs. Everything runs locally on your server. Disabled modules add zero overhead.
Originally published at WP Multitool Blog.
Find what's slowing your WordPress. WP Multitool — 14 modules, $50 lifetime, zero bloat. Built by Marcin Dudek.
Top comments (0)