Database Diagnostics

How to Read a MySQL
EXPLAIN Plan

EXPLAIN is MySQL's built-in tool for showing you exactly how it executes a query—and where it goes wrong. Here's how to read it, with real WordPress examples.

Running EXPLAIN

Take any SQL query and put EXPLAIN in front of it. MySQL will return a row for each table involved, showing how it plans to access the data.

EXPLAIN SELECT p.ID, p.post_title
FROM wp_posts p
INNER 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'
  AND pm.meta_value BETWEEN 10 AND 50;

The output is a table with columns like type, rows, key, and Extra. Each one tells you something specific about how MySQL is handling that part of the query.

The type Column (Most Important)

The type column tells you how MySQL accesses the table. This is the single most important thing to check. Ranked from best to worst:

Type Speed What It Means
const Instant Single row via primary key or unique index. The best you can get.
eq_ref Very fast One row per join via unique index. Ideal for JOINs.
ref Fast Multiple rows via non-unique index. Common and usually fine.
range Good Index scan for BETWEEN, >, < operations. Acceptable.
index Slow Full index scan. MySQL reads the entire index. Can answer the query without touching the table, but still scans everything.
ALL Terrible Full table scan. Every single row is read. If you see this on a table with more than a few thousand rows, that's a performance problem.
Rule of Thumb

If the type is ALL and the rows column shows more than 10,000—you have a problem that needs an index.

Other Columns That Matter

rows

An estimate of how many rows MySQL will examine to produce the result. Lower is better. If you expect 5 results but MySQL examines 156,000 rows to find them, the query is doing too much work.

key

Which index MySQL actually uses. If this shows NULL, no index is being used—the query is doing a full scan. Check the possible_keys column to see what indexes could have been used but weren't.

Extra

Watch for these flags in the Extra column:

  • Using filesort — MySQL can't use an index to sort, so it sorts in memory (or on disk for large results). Expensive for large result sets.
  • Using temporary — MySQL creates a temporary table to hold intermediate results. Common with GROUP BY and DISTINCT on unindexed columns.
  • Using index — The query can be answered entirely from the index without reading table data. This is good—also called a "covering index."
  • Using where — MySQL applies a WHERE filter after fetching rows. Normal, but if combined with type: ALL, it means filtering is happening after a full table scan.

Real WordPress Example: WooCommerce Product Query

Here's a query that filters WooCommerce products by price and stock status. On a store with 10,000 products:

EXPLAIN SELECT p.ID FROM wp_posts p
INNER JOIN wp_postmeta pm1 ON p.ID = pm1.post_id
INNER JOIN wp_postmeta pm2 ON p.ID = pm2.post_id
WHERE p.post_type = 'product'
  AND p.post_status = 'publish'
  AND pm1.meta_key = '_price'
  AND pm1.meta_value BETWEEN 10 AND 50
  AND pm2.meta_key = '_stock_status'
  AND pm2.meta_value = 'instock';

-- Result:
-- pm1: type=ALL, rows=156000  <-- full table scan on postmeta
-- pm2: type=ref, rows=4       <-- OK, using index
-- p:   type=eq_ref, rows=1    <-- OK, primary key lookup

The first JOIN on pm1 does a full table scan of 156,000 rows because there's no index on (meta_key, meta_value). The fix:

CREATE INDEX idx_postmeta_key_post_value
  ON wp_postmeta (meta_key, post_id, meta_value(191));

After adding this composite index, the same query uses type: ref and examines under 200 rows instead of 156,000.

Common WordPress EXPLAIN Patterns

1. wp_options Full Table Scan

The wp_options table loads all autoload='yes' rows on every page. If your autoloaded data exceeds 1MB, this single query dominates TTFB. WordPress runs this before your theme even loads.

EXPLAIN SELECT option_name, option_value
FROM wp_options WHERE autoload = 'yes';
-- type: ALL (no index on autoload column by default)

2. Postmeta JOIN Explosion

Every meta_query in WP_Query adds another JOIN on wp_postmeta. Three meta conditions = three JOINs on the same unindexed table. The query planner multiplies the row estimates, and performance collapses.

3. Taxonomy Count Queries

On sites with 50,000+ posts, the admin term count queries (wp_update_term_count) do full table scans. These run on every post save and can take 2+ seconds on large installations.

When to Optimize (and When Not To)

Not every slow query needs fixing. Focus your effort where it matters:

  1. Queries that run on every page load — These affect every visitor. Even shaving 50ms off a per-page query saves seconds at scale.
  2. Queries over 100ms — Anything under 50ms is fine. Between 50–100ms, monitor it. Over 100ms, investigate.
  3. Queries inside loops — A 10ms query called 200 times in a loop is a 2-second problem. Fix the loop, not the query.
  4. Skip: one-time admin operations — A 3-second query that runs once when you save settings? Not worth optimizing. Focus on what your visitors hit.

Automate EXPLAIN Analysis

WP Multitool's Slow Query Analyzer logs every query over your threshold, runs EXPLAIN automatically, and suggests specific index fixes—no SSH access required.

Get WP Multitool Read the Docs