Database Performance

WordPress Slow Database Queries:
How to Speed Them Up

Your site loads in 4 seconds. You've cached everything. Images are optimized. The database is still the bottleneck.

Why Slow SQL Queries Are WordPress's Hidden Bottleneck

WordPress runs dozens of SQL queries per page load. Most complete in under 1ms. But it only takes one slow query—a single 800ms JOIN on wp_postmeta—to make your entire page feel broken.

The Pattern

PageSpeed says 95. Users say "it's slow." The frontend is fast. The server takes 2+ seconds before it even starts sending HTML.

The worst part: WordPress doesn't log slow queries by default. There's no warning, no dashboard alert. Queries degrade silently as your database grows—and you only notice when users start complaining.

What Causes Slow WordPress Database Queries

1. WooCommerce Postmeta JOINs

WooCommerce stores product data in wp_postmeta as key-value pairs. Filtering products by price, stock, or attributes requires multiple JOINs on an unindexed table:

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 pm1.meta_key = '_price' AND pm1.meta_value BETWEEN 10 AND 50
AND pm2.meta_key = '_stock_status' AND pm2.meta_value = 'instock'

With 10,000 products and 500,000 postmeta rows, this query can take 2–5 seconds without proper indexing.

2. Unindexed meta_key Lookups

The default wp_postmeta table only has an index on post_id. Any query filtering by meta_key + meta_value does a full table scan:

SELECT post_id FROM wp_postmeta
WHERE meta_key = '_thumbnail_id'
-- Full table scan on 500K+ rows

3. LIKE Queries on Large Text Columns

Search plugins and admin searches often run LIKE '%term%' on post_content. This bypasses every index and scans the full column:

SELECT ID FROM wp_posts
WHERE post_content LIKE '%shipping policy%'
-- Scans every row, every time

4. COUNT Queries with Complex WHERE

Admin list tables run COUNT queries to show pagination. Combined with taxonomy filters and meta queries, these can be surprisingly expensive on large sites.

How to Find Slow Queries in WordPress

  1. Enable SAVEQUERIES — Add define('SAVEQUERIES', true); to wp-config.php. WordPress will log every query with timing and the calling function. Check $wpdb->queries after page load.
  2. Check MySQL slow query log — If you have server access: SET GLOBAL slow_query_log = 'ON'; and SET GLOBAL long_query_time = 0.5; to catch queries over 500ms.
  3. Use EXPLAIN on suspect queries — Prefix any slow query with EXPLAIN to see the execution plan. Look for type: ALL (full table scan) and rows: counts in the hundreds of thousands.
  4. Profile a specific page — Use Query Monitor or add SAVEQUERIES temporarily. Sort by execution time. The top 3 queries usually account for 80% of your TTFB.
  5. Check during peak traffic — Slow queries that are "fine" with 10 concurrent users become catastrophic at 100. Test under load, not just in your dev environment.

Reading an EXPLAIN Plan on a Real WordPress Query

EXPLAIN is the closest thing MySQL has to a debugger. You prefix any SELECT with it and MySQL tells you how it plans to execute the query - which tables it scans, which indexes it uses, how many rows it expects to touch. Here's the WooCommerce filter query from above, with a typical ORDER BY added, the way a real shop page runs it:

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 pm1.meta_key = '_price' AND pm1.meta_value BETWEEN 10 AND 50
  AND pm2.meta_key = '_stock_status' AND pm2.meta_value = 'instock'
ORDER BY p.post_date DESC LIMIT 20;

On a store with ~800k postmeta rows, the plan comes back looking like this:

idselect_typetabletypepossible_keyskeyrowsExtra
1SIMPLEpm1ALLpost_id, meta_keyNULL812441Using where; Using temporary; Using filesort
1SIMPLEpeq_refPRIMARYPRIMARY1Using where
1SIMPLEpm2refpost_id, meta_keypost_id4Using where

The first row is the whole problem. Here's how to read it:

  • type: ALL - a full table scan. MySQL reads every row in wp_postmeta and checks the WHERE condition against each one. On 812k rows, at every page load. This is the single worst access type EXPLAIN can show you.
  • key: NULL - no index was used. Note that possible_keys lists meta_key, so an index exists - MySQL just refused it. Why? Because '_price' matches a huge share of the table (every product has one), so the optimizer decided a scan is cheaper than an index that filters almost nothing. The index isn't missing, it's useless for this query.
  • Using temporary - MySQL has to build an internal temp table to hold intermediate results before it can sort them. On large result sets that temp table spills from memory to disk, and your query time goes from milliseconds to seconds.
  • Using filesort - the ORDER BY can't be satisfied by any index, so MySQL sorts the result set manually. The name is misleading - it doesn't always hit disk - but combined with 812k scanned rows it means sorting a massive pile of data on every request.

The fix is an index the optimizer actually wants: one that filters on meta_key AND meta_value together, so '_price' + the value range narrows to a few thousand rows instead of matching half the table.

ALTER TABLE wp_postmeta
ADD INDEX wpmt_key_value (meta_key(191), meta_value(32));

Run EXPLAIN again and the first row changes completely:

idselect_typetabletypepossible_keyskeyrowsExtra
1SIMPLEpm1rangepost_id, meta_key, wpmt_key_valuewpmt_key_value2874Using where
1SIMPLEpeq_refPRIMARYPRIMARY1Using where
1SIMPLEpm2refpost_id, meta_key, wpmt_key_valuewpmt_key_value2Using where

type: range means MySQL walks only the index entries between the two price bounds - 2,874 rows instead of 812,441. That's a 280x reduction in work before the query even touches the sort. The equality lookup on pm2 gets type: ref off the same index, at 2 rows per product. A filesort may still show up in Extra, but sorting 20 candidate rows is noise; sorting 800k was the outage.

That's the whole skill: run EXPLAIN, look at type, key, and rows for each table, and fix the row doing the most work. WP Multitool's Slow Query Analyzer runs this exact analysis for you - locally, on every slow query it catches - and tells you which index to add.

WordPress Query Performance Benchmarks

Not all slow queries are equal. Here's what to target:

<50ms
Per query (healthy)
<30
Queries per page
<200ms
Total DB time

If any single query takes over 100ms, it's worth investigating. If your total database time exceeds 500ms, your users feel it—even with page caching.

How to Speed Up WordPress Database Queries (Practical Fixes)

Finding the slow query is half the job. One rule before any of it: optimize the query, not the symptom. Don't just add indexes blindly - understand why the query is slow. Sometimes the fix is restructuring data (custom tables instead of postmeta). Sometimes it's avoiding the query entirely (transient caching for expensive aggregations). Here's what actually makes queries faster, in the order I'd try it.

1. Add the Indexes Your EXPLAIN Plan Is Asking For

The index the EXPLAIN walkthrough above lands on is the one I add first - a composite index on wp_postmeta covering both columns WordPress meta queries filter on:

ALTER TABLE wp_postmeta
ADD INDEX wpmt_key_value (meta_key(191), meta_value(32));

Covering meta_key and meta_value together lets MySQL narrow both conditions in one index walk. In the walkthrough that turned type: ALL into type: range and cut the rows examined from 812,441 to 2,874. The (191) and (32) prefix lengths keep the index compact while still covering real lookups. Don't add indexes on faith: run EXPLAIN first, then add the one the plan is missing.

One aside: you'll see a single-column index on meta_value recommended around the web. It can't help a query that filters on meta_key and meta_value together - the composite index above covers that case, so start there.

2. Speed Up WooCommerce Product and Order Queries

The WooCommerce postmeta JOINs covered earlier are the classic case: every meta_query condition adds a JOIN on a 4-column EAV table. Stop filtering on postmeta when you can denormalise. For hot fields you filter on constantly - price, stock, rating - copy the value into a purpose-built lookup table with proper columns and indexes. WooCommerce ships wc_product_meta_lookup for exactly this reason; use it, or build the same pattern for your own fields.

3. Stop LIKE '%term%' and Unbounded WP_Query Calls

A leading wildcard can never use a B-tree index - MySQL scans every row, always. Add a FULLTEXT index on post_content and query with MATCH() AGAINST(), or move search off the posts table entirely to a dedicated search plugin or an external engine. Anything beats scanning longtext.

Never query with posts_per_page => -1. Unbounded queries work fine with 200 posts and fall over at 20,000. Set a real limit and paginate, or batch with offsets in cron jobs. The slow query you can't reproduce locally is usually one of these hitting production-sized data.

And avoid meta_query OR forests. Nested OR relations across several meta keys force MySQL into broad scans and temporary tables. Prefer one targeted query, a denormalised flag column, or two cheap queries you merge in PHP over one monster meta_query.

4. Cache Aggregates and Kill N+1 Meta Loops

Start with object caching. Redis or Memcached stores query results in memory - repeated queries hit the cache instead of MySQL. This is the single highest-impact change for most sites.

Next, cache expensive aggregates yourself. COUNT() over complex WHEREs, "posts this month" widgets, term counts - these don't need to be fresh per request. Compute them once, store in a transient or the object cache, refresh on a schedule or on save_post. A 900ms COUNT that runs once an hour costs nothing.

Then trim what WP_Query fetches. If you only need IDs, say so: 'fields' => 'ids' skips loading full row objects. 'no_found_rows' => true drops the SQL_CALC_FOUND_ROWS pass when you don't paginate. 'update_post_meta_cache' => false and 'update_post_term_cache' => false skip the cache-priming queries when you won't touch meta or terms - but leave meta-cache priming ON when you will read meta in a loop, because that single priming query is what saves you from one get_post_meta() query per post.

Finally, cut the query WordPress runs before all others. Every request starts by loading all autoloaded options before your first query even fires. Fixing autoloaded options bloat speeds up every page, not just the slow ones.

5. What "Faster" Looks Like (Benchmarks After Each Fix)

Apply one fix at a time and re-measure after each: EXPLAIN plus a timing check. The targets are the benchmarks above - under 50ms per query, under 200ms total database time per page. In the EXPLAIN walkthrough, one composite index cut the scanned rows from 812,441 to 2,874; that's the scale of change a correct index makes. If a fix doesn't move the EXPLAIN plan or the timing, revert it and move to the next one.

Slow Query Log vs Query Monitor vs Automated Detection

There are three ways to catch a slow query. They answer different questions:

Approach Server access needed Runs continuously Captures stack trace Suggests index fixes Production-safe
Manual (SAVEQUERIES / MySQL slow log) Yes - my.cnf or SET GLOBAL rights The MySQL log can, but nobody reads it until something breaks No - you get the SQL, not the PHP that ran it No - you run EXPLAIN and interpret it yourself SAVEQUERIES no (memory overhead on every request); the MySQL log yes, with a sane threshold
Query Monitor plugin No No - shows the current page load, for logged-in admins only Yes - full caller stack, its best feature No Meant for development. Fine to install in prod, but it only sees requests you make while looking at it - it won't catch the 3am spike
WP Multitool Slow Query Analyzer No - works on shared hosting Yes - logs every query over your threshold, on real traffic, around the clock Yes - the plugin or theme file that fired the query Yes - runs EXPLAIN locally and points at the missing index Yes - designed to run on live sites with minimal overhead

Query Monitor is genuinely good at what it does - I use it too. It just answers a different question. It tells you what this page load did while you watch; it can't tell you what your site did last Tuesday under load. That's the gap continuous detection fills.

Why Slow Queries Keep Coming Back

Finding slow queries once isn't enough. They come back:

  1. Plugin updates introduce new queries or change existing ones
  2. Your database grows—a query fast at 10K rows is slow at 500K
  3. New content types add postmeta and taxonomy rows
  4. WooCommerce sales accumulate order data in the same tables

Manual SAVEQUERIES checks are tedious and easy to forget. If slow queries are also making your WordPress admin feel slow, the problem is compounding. You need continuous monitoring that catches regressions the moment they happen—not after users report slowness.

Slow WordPress Queries: FAQ

How do I find slow queries in WordPress?
Three ways: enable the MySQL slow query log with a long_query_time around 0.5s (needs server access), install Query Monitor and inspect page loads while logged in, or run a monitoring plugin that logs slow queries continuously on real traffic. The first two catch queries you happen to trigger yourself; only continuous monitoring catches the slow queries your visitors hit when you're not looking.
How do I speed up WordPress database queries?
You don't need a rewrite - most fixes are targeted changes. Run EXPLAIN on the slow query first: it tells you whether MySQL is scanning the whole table (type: ALL, key: NULL). Most WordPress slow queries are fixed by adding the right index, usually a composite one on wp_postmeta covering meta_key and meta_value. Beyond indexes: put an object cache (Redis or Memcached) in front of MySQL, stop unbounded queries like posts_per_page => -1, replace LIKE '%term%' searches with FULLTEXT, cache expensive counts, and fix autoloaded options bloat, which adds latency to every query before it even runs.
What is a good database query time in WordPress?
Individual queries should stay under 50ms - most well-indexed queries run in under 5ms. A typical page should need fewer than 30 queries and spend under 200ms in the database total. If a single query takes over 500ms, it's worth an EXPLAIN; over 1s and it's actively hurting your TTFB on every uncached load.
Does page caching fix slow database queries?
No - it hides them. Cached visitors get fast HTML, but every cache miss, logged-in user, cart page, and admin request still pays the full query cost. The slow query is still there, still burning CPU, and it resurfaces the moment traffic spikes or the cache purges. Caching is worth having, but it's a layer on top of a fixed database, not a substitute for one.

Stop Guessing. Start Logging.

WP Multitool's Slow Query Analyzer logs every query over your threshold, captures the full stack trace, and suggests specific index fixes. No server access required.

Get WP Multitool Backend Performance Guide