Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 02 / 09 35 MIN

Measuring first

Query Logging and Profiling

Turn on WordPress query logging safely on a staging copy, read the log the right way round, and tell the query that runs most from the query that costs most.

You have a benchmark that repeats, and a page somebody has timed at 120 ms of server time. Something inside that request is spending it. WordPress will tell you what it ran, and MySQL will tell you which statements were slow, and the two answers disagree in a way worth understanding before you optimise anything.

Do all of this on a staging copy. Query logging changes the thing it measures, and on a busy production site it changes it enough to matter. If you only have production, read the section on the slow query log first, because that one is designed to run in production and this one is not.

SAVEQUERIES records a row per query, in memory, until the request ends

WordPress keeps a log of every query it runs, and the only thing stopping it is a constant. Add this to wp-config.php above the line that says /* That's all, stop editing! */:

define( 'SAVEQUERIES', true );

With that defined, wpdb starts a timer before each query and calls log_query() after it, which appends to the public property $wpdb->queries. Each entry is a five element array, and the field order is not obvious from reading it, so keep this table next to you:

IndexHolds
[0]The SQL, as sent, with placeholders already substituted.
[1]Time in seconds, as a float.
[2]Comma-separated list of the calling functions, from get_caller().
[3]Unix timestamp of the start of the query.
[4]Custom data. Empty by default, and filled by the log_query_custom_data filter.

The time in [1] wraps the whole call through wpdb, which includes WordPress's own work around the statement. Treat it as a ranking signal between queries, not as MySQL's execution time. When the two disagree, MySQL is right about MySQL.

What SAVEQUERIES costs

Every logged query is retained until the request ends, so the cost scales with query count. On the lab install for this lesson (WordPress 7.0.7, PHP 8.5.6, MySQL 9.6), the same loop of 20,000 identical indexed lookups peaked about 16 MB higher with SAVEQUERIES on than with it off, roughly 800 bytes per logged query. The real danger is not the average request, which runs a few dozen queries and pays a few tens of kilobytes. It is the request that runs five thousand.

Two habits keep this from biting. Never leave SAVEQUERIES defined in a production wp-config.php, and when you turn it on for a diagnosis, turn it off when the diagnosis ends. If you need the constant on while you work, put it behind a check for a query parameter or an environment variable so that ordinary traffic does not pay for it.

Read the log the right way round, or you will optimise the wrong query

A raw dump of $wpdb->queries is sorted by execution order and is useless at a glance. Nobody can eyeball four hundred rows and find the expensive one. You need two aggregations, and they answer different questions.

Group identical statements together, normalising the numbers and string literals so that two runs of the same template share a bucket, then sort twice:

  • By count to find the query that runs most often. This is how you find an N+1 loop, where a template fetches one row per post.
  • By total time to find the query that costs most. This is how you find the one statement that needs an index, or needs not to run at all.

The two lists overlap less than people expect. Here is a real example from the lab, using two deliberately shaped endpoints. The first runs one indexed lookup per post in a loop; the second runs a single query that cannot use an index.

?shapedemo=frequent   4023 queries logged   0.359301 s of query time
  4000x   350.229 ms   97.5%   SELECT meta_value FROM wp_postmeta
                                 WHERE meta_key = '?' AND post_id = N LIMIT N

?shapedemo=expensive    24 queries logged   0.029555 s of query time
     1x    11.774 ms   39.8%   SELECT post_id, meta_key FROM wp_postmeta
                                 WHERE meta_value LIKE '%needle%'

Read those two blocks together. The loop runs 4,000 iterations at 88 microseconds each. Every one of them is fast, every one of them is on an acceptable index, and together they are 97.5% of the query time in that request. The scan runs once, takes 12 ms, and would never be fixed by an index on the loop's pattern if the loop were removed instead.

The frequency list is the one you read first, because a query that runs 4,000 times is usually a template that should ask once and cache. The cost list is the one you read second, because a query that runs once and takes 12 ms is a table that needs a different shape, and no amount of caching will fix a full scan you have to run.

The slow query log only sees individual queries, never the total

MySQL has its own logging, and it is a different instrument. It records statements that took longer than long_query_time to execute, which defaults to 10 seconds. Lower it first.

The variables that matter, all settable at runtime with SET GLOBAL or in the server's configuration file:

VariableWhat it does
slow_query_logOn or off. Off by default on most installs.
slow_query_log_fileWhere the entries go when log_output is FILE.
long_query_timeThe threshold, in seconds, with microsecond precision. 0.05 is a reasonable starting point.
log_queries_not_using_indexesAlso logs statements that used no index, however fast. Useful for a short audit and noisy for long.
min_examined_row_limitSuppresses entries that examined fewer than this many rows, which trims the noise.
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.05;
SET GLOBAL log_queries_not_using_indexes = OFF;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

Setting it to zero logs everything, which is a way to see the workload once and not a way to run a site. And a warning that costs people an afternoon: if you delete the log file while the server has it open, the server keeps writing to the deleted file and you will stare at a file that never grows. Truncate it instead.

Now the part that surprises people. On the same lab install, with long_query_time set to 0.005, a request that ran 4,023 queries and spent 450 ms of wall clock time produced this many slow log entries:

mysql> SHOW GLOBAL STATUS LIKE 'Slow_queries';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Slow_queries  | 8     |
+---------------+-------+

mysql> SHOW GLOBAL STATUS LIKE 'Slow_queries';   -- after 4,023 queries
| Slow_queries  | 8     |

Zero new entries, because every one of those 4,023 statements executed in under 5 ms. The slow query log is per statement. It cannot tell you that a query shape ran 4,000 times, and it cannot tell you that your request spent 350 ms in total doing something individually fast. That is a structural limit of the tool, not a configuration mistake.

So use each log for what it measures. WordPress query logging gives you count and total across a whole request. MySQL's slow log gives you the statements that are individually expensive regardless of who ran them, including work from cron. When the slow log is empty and the site is still slow, the problem is cumulative or it is not in the database at all.

EXPLAIN tells you which index MySQL chose and how much it read

Once a query is on your cost list, the next question is what MySQL did with it. EXPLAIN answers that without running the query. Take a statement you recognise from a WordPress log, such as the meta scan above, and prefix it:

EXPLAIN SELECT post_id, meta_key
FROM wp_postmeta
WHERE meta_value LIKE '%needle%';
+----+-------------+-------------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table       | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | wp_postmeta | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 4203 |    11.11 | Using where |
+----+-------------+-------------+------------+------+---------------+------+---------+------+------+----------+-------------+

Four columns carry most of the information. type is the access method, and ALL means a full table scan: MySQL read every row. key is the index it actually used, and NULL here means none. rows is its estimate of how many rows it had to examine, which is the number to compare against the number of rows you wanted. Extra carries the warnings, and Using filesort or Using temporary in it means MySQL is doing work after reading the rows, usually because the sort or the grouping had no index to follow.

The same statement with a usable index looks different in the two columns that matter:

EXPLAIN SELECT post_id FROM wp_postmeta WHERE meta_key = 'campaign_source';

| type | possible_keys | key      | rows | Extra       |
| ref  | meta_key      | meta_key |    1 | Using where |

That is why the distinction from earlier matters. WordPress's default schema gives wp_postmeta an index on meta_key and an index on post_id. It has no practical index on meta_value, which is a longtext column, so any query that filters on the value scans the whole table. If you have ever wondered why a plugin's search feature is slow on a large site, that is the reason, and no amount of object caching fixes the first uncached run.

Classic EXPLAIN gives you estimates. Newer MySQL versions also support EXPLAIN ANALYZE, which runs the statement and reports actual rows and actual time alongside the estimate. That is the version to reach for when the estimate looks fine and the query is still slow, because the gap between estimated and actual rows is usually the whole story:

-> Filter: (wp_postmeta.meta_value like '%needle%')  (cost=425 rows=467)
    (actual time=5.07..5.07 rows=1 loops=1)
    -> Table scan on wp_postmeta  (cost=425 rows=4203)
        (actual time=0.0337..4.23 rows=4203 loops=1)

Both EXPLAIN and EXPLAIN ANALYZE work the same way from a WordPress context: the optimiser does not care that WordPress generated the statement, so run it yourself against the database. What you cannot do is run it inside a request without changing the request. Profile first, then take the SQL out and examine it on its own.

type of index is the middle case worth recognising: an index was used, but it was read end to end, so it costs almost what a full table scan costs. Running EXPLAIN on the priming query from the lab shows exactly that, and it is why the statement is the costliest one in the request even though WP_Query only ran it once:

EXPLAIN SELECT post_id, meta_key, meta_value FROM wp_postmeta
WHERE post_id IN (1,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,25,
26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50)
ORDER BY meta_id ASC;
+----+-------------+-------------+------+---------------+---------+---------+------+------+----------+-------------+
| id | select_type | table       | type | possible_keys | key     | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------------+------+---------------+---------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | wp_postmeta | index| post_id       | PRIMARY | 8       | NULL | 7407 |    60.52 | Using where |
+----+-------------+-------------+------+---------------+---------+---------+------+------+----------+-------------+

Seven thousand four hundred and seven rows examined on a table with 7,329 rows, to return the meta for the posts in the list, using an index on post_id that the optimiser chose to read end to end instead of seeking. That is the kind of finding that turns a template argument into a database argument, and you only get it by looking at the plan.

The limitation you should state out loud

Every instrument here changes what it measures. SAVEQUERIES adds memory proportional to query count and slows the request. The slow query log is cheaper but still writes per qualifying statement, and log_queries_not_using_indexes on a busy site can produce more log than traffic. EXPLAIN ANALYZE executes the statement, so on a table being written to at the same time it can block.

None of that makes the numbers useless. It makes them numbers from an instrumented request, which is fine as long as you use them to rank problems against each other and not as your latency baseline. Take the baseline with the instruments off, using the benchmark from lesson 1.

Lab 35 minutes

On a staging copy, define SAVEQUERIES and write a must-use plugin that, on shutdown, writes the contents of $wpdb->queries to a JSON file. Use the five fields, and normalise the SQL by replacing string literals and numbers with placeholders so that repeats of the same template land in one bucket.

Load your front page, then a single post, then an archive. For each, print the five query shapes with the highest count and the five with the highest total time, with the count and total next to each.

Then answer: which shape is on both lists, and which is only on one? For the shape that is top by count but not by cost, is the fix to make it faster or to make it run less often? Now write the two MySQL statements you would run next, and explain what you expect type and rows to show.

Show the solution and the reasoning

The plugin has one job and one place to do it. shutdown is late enough that the page has finished querying and early enough that the array is still in memory. The normalisation is what makes the output readable: without it, a loop that varies only by post ID produces four hundred buckets of one row each, and the frequency list is meaningless.

<?php
/**
 * Plugin Name: Query Shape Log
 */

add_action( 'shutdown', function () {
    global $wpdb;

    if ( ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES ) {
        return;
    }

    $shapes = array();

    foreach ( (array) $wpdb->queries as $entry ) {
        $sql = preg_replace( '/\s+/', ' ', $entry[0] );
        $sql = preg_replace( "/'[^']*'/", "'?'", $sql );
        $sql = preg_replace( '/\b\d+\b/', 'N', $sql );

        if ( ! isset( $shapes[ $sql ] ) ) {
            $shapes[ $sql ] = array( 'count' => 0, 'time' => 0.0 );
        }

        ++$shapes[ $sql ]['count'];
        $shapes[ $sql ]['time'] += (float) $entry[1];
    }

    uasort( $shapes, fn( $a, $b ) => $b['time'] <=> $a['time'] );

    file_put_contents(
        WP_CONTENT_DIR . '/uploads/query-shapes.json',
        wp_json_encode( $shapes, JSON_PRETTY_PRINT )
    );
}, 9999 );

Two details in that plugin are load bearing. The 9999 priority puts it after anything else registered on shutdown, so the log is complete. And the normalisation replaces numbers after strings, because replacing numbers first would put a ? inside a string literal and change the shape of the statement.

On the lab install, the front page runs 22 queries and the answer to the first question is blunt. The most frequent shape is a single options lookup, which runs four times, and the costliest single statement is WordPress priming post meta for the ten posts in the loop:

SELECT post_id, meta_key, meta_value FROM wp_postmeta
WHERE post_id IN (23,22,21,20,19,18,17,16,15,14) ORDER BY meta_id ASC
   1x   13.701 ms   42.5% of query time in that request

That query is on the cost list and nowhere near the top of the frequency list. It runs once per request because WP_Query primed its caches, and it is expensive because it reads roughly 1,400 rows to return sixty. The fix is not to call it less often, because it is already called once. The fix is a schema or access pattern question, and it belongs to module 2.

For the frequency leader, four executions of a single-row options lookup, the answer goes the other way: the statement itself cannot be made meaningfully faster, so the only lever is running it less often, which means the layer above it should hold the value. That is an object cache question, and it is module 3.

The next two statements, in the order you would run them:

EXPLAIN SELECT post_id, meta_key, meta_value FROM wp_postmeta
WHERE post_id IN (23,22,21,20,19,18,17,16,15,14) ORDER BY meta_id ASC;

EXPLAIN SELECT option_value FROM wp_options WHERE option_name = 'blogname' LIMIT 1;

Expect the first to show a rows estimate in the thousands, roughly equal to the size of the table, with Using where in Extra. On the lab install it came back as type=index on PRIMARY rather than type=range on post_id, which makes the point: an index scan of the whole table looks respectable in the key column and costs about as much as a full scan. Expect the second to show type of const or ref on the option_name key with rows of 1. If the first one comes back with type=ALL and a NULL key, the index is missing or the query was rewritten into something that cannot use it, and either is worth knowing before you touch the template.

One thing this lab will not tell you: how much of the request's wall time those queries represent. Query time is not request time, and a page that spends 120 ms running 22 queries that total 19 ms is spending its time somewhere else. Lesson 3 is about the part of the picture that neither log can see.

Progress

Mark this lesson done and it stays marked in this browser.