All WordPress HTML Templates Forms & Webhooks AI & Tools
WordPress

Debugging WordPress: Logs, Query Monitor and Isolating the Culprit

How to debug WordPress properly: configure the error log safely, read fatal traces, use Query Monitor’s best panels and bisect plugin conflicts fast.

A real working moment illustrating the theme of an article about debug wordpress. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

A client site was fine at 4:02pm and returning a 500 at 4:14pm. No deploy. No plugin update. The host’s “error log” in cPanel showed one line: PHP Fatal error: Allowed memory size of 268435456 bytes exhausted, with no stack trace, because the host had logerrorsmax_len clamped and the trace was truncated. That log told us the house was on fire but not which room.

Most guides on WordPress debugging stop at “set WP_DEBUG to true”. That’s step zero. The actual skill is building a signal path, from the browser request to the PHP fatal to the SQL query, that stays intact on a live site you can’t take down, then bisecting the problem in a way that converges in four steps instead of forty.

Here’s the workflow we use on maintenance clients, including the parts where the tooling lies to you.

Key Takeaways

  • Set WPDEBUGLOG to an absolute path outside the web root. The default wp-content/debug.log is publicly readable on a surprising number of hosts and will leak file paths, table prefixes and occasionally query strings containing tokens.
  • Query Monitor’s most valuable panel is not Queries, it’s Queries by Component. It attributes every millisecond of SQL to the plugin, theme or core function that fired it, turning “the site is slow” into “WooCommerce Product Add-Ons ran 412 queries on the cart page”.
  • Bisection beats inspection. Deactivating plugins in halves with WP-CLI finds a conflict in log2(n) steps: 30 plugins resolves in 5 tests, not 30.
  • Query Monitor sees nothing on cached pages, cron runs or anonymous traffic. For intermittent production bugs you need your own shutdown logger writing request context to a file, not a browser toolbar.
  • PHP 8.2 and later turn a lot of old plugin code into deprecation spam. Filter the noise at the handler level rather than switching WP_DEBUG off, or you’ll miss the one real warning in 40,000 lines.

Set up the WordPress error log before you need it

The default debug configuration is close to useless on production. It writes to a web-accessible file, prints notices into page output (breaking JSON responses), and has no rotation. Fix all three in wp-config.php, above the “That’s all, stop editing” line.

// Enable the error handler, but never render anything to the browser.
define( 'WP_DEBUG', true );
define( 'WPDEBUGDISPLAY', false );
@iniset( 'displayerrors', '0' ); // belt and braces: some hosts force this on

// Absolute path, outside the document root. WP 5.1+ accepts a path here.
define( 'WPDEBUGLOG', '/home/clientsite/logs/wp-errors.log' );

// Unminified core JS/CSS, only when you're actually chasing an admin JS bug.
define( 'SCRIPT_DEBUG', false );

// Keep the fatal error handler on in production, off locally where it hides traces.
if ( 'local' === wpgetenvironment_type() ) {
    define( 'WPDISABLEFATALERRORHANDLER', true );
}

Two details people miss. First, WPDEBUGDISPLAY set to false still lets a fatal error render, because fatals bypass the display setting on some SAPI configurations. You also want displayerrors off at the ini level. Second, if WPDEBUG is false, error_log() calls from your own code go to the PHP default log, which on managed hosts might be /dev/null. Always point the log somewhere you control.

Then rotate it. A single deprecation notice firing on every page load of a site doing 50,000 views a month will produce a multi-gigabyte wordpress error log and eventually fill the disk. We ship a logrotate entry, or on hosts without shell access, a weekly WP-Cron task that truncates the file past 20MB.

A real working moment that fits an article section about

Fatal errors, recovery mode and reading the trace properly

Since WordPress 5.2, a fatal error during plugin or theme load triggers the recovery mode email to the admin address, pauses the offending extension and lets the site stay up. Good feature. It also means the real cause is now sitting in an inbox nobody monitors, usually admin@ on a domain that forwards to a former employee.

Point it somewhere real:

define( 'RECOVERYMODEEMAIL', '[email protected]' );

When you do get a trace, read it from the bottom up, not the top down. The top frame is where PHP exploded. The frame you care about is the last one before it that belongs to a plugin you can change. A trace that goes wp-includes/class-wp-hook.php(310): apply_filters then plugins/some-crm/src/Sync.php(88) then plugins/some-crm/vendor/guzzlehttp/... is telling you the CRM plugin’s sync routine is calling HTTP during a filter and timing out. The Guzzle frame is noise.

If the trace is truncated by the host, catch it yourself with a shutdown handler in an mu-plugin:

<?php
// wp-content/mu-plugins/00-fatal-capture.php
registershutdownfunction( function () {
    $e = errorgetlast();
    if ( ! $e || ! inarray( $e['type'], [ EERROR, EPARSE, ECOMPILEERROR, EUSER_ERROR ], true ) ) {
        return;
    }
    // wpdebugbacktrace_summary gives a compact, readable call chain.
    $line = sprintf(
        "[%s] FATAL %s in %s:%d | URI: %s | UA: %s | trace: %s\n",
        gmdate( 'c' ),
        $e['message'],
        $e['file'],
        $e['line'],
        $SERVER['REQUESTURI'] ?? 'cli',
        substr( $SERVER['HTTPUSER_AGENT'] ?? '-', 0, 120 ),
        functionexists( 'wpdebugbacktracesummary' ) ? wpdebugbacktrace_summary( null, 0, false ) : '-'
    );
    error_log( $line, 3, '/home/clientsite/logs/fatals.log' );
} );

The request URI and user agent matter more than you’d think. Half the “random 500s” we investigate turn out to be a bot hitting /?s= with a 4KB search string, or a crawler following a faceted filter URL that generates a 90 second query.

Query Monitor: the panels that actually find bugs

Query Monitor is the single highest-value plugin in a developer’s toolkit and most people use maybe 20 percent of it. Install it, then install its database drop-in, which is what gives you per-component query attribution:

wp plugin install query-monitor --activate

Creates wp-content/db.php as a symlink to QM's class. Required for the Queries panel.

wp eval 'QM_DB::init();' 2>/dev/null || echo "Activate QM in wp-admin once to place db.php"

On production, restrict it to your own session by adding an authentication cookie via the QM admin bar menu, or gate it entirely:

// Only load Query Monitor for specific users, even if it's network-active.
define( 'QMDISABLED', ! inarray( $SERVER['REMOTEADDR'] ?? '', [ '203.0.113.41' ], true ) );

The panels worth your time, in order:

  • Queries by Component. Sorts total SQL time by plugin. This is where bloat becomes undeniable. We’ve opened this panel on a client site and found a “related posts” plugin responsible for 1.9 seconds of the 2.4 second page generation. That one panel has justified more plugin audits than any argument we’ve ever made.
  • Duplicate Queries. If the same SELECT runs 60 times, something is calling getpostmeta inside a loop without priming the cache. Fix with updatemetacache() or a WPQuery with updatepostmetacache => true.
  • HTTP API Calls. Shows every wpremoteget with its duration. Blocking third-party calls on the front end is the most common cause of a site that is fast for you and slow for a visitor in another region.
  • Hooks & Actions. Filtered by hook name, it tells you exactly which callbacks are attached and at what priority. Essential when two plugins fight over the_content.
  • Capability Checks. Underrated. When an editor can’t see a menu item and you can, this panel shows the exact currentusercan() call returning false and the component that asked.

Query Monitor also exposes a logging API, which beats sprinkling error_log through a template:

doaction( 'qm/debug', $orderpayload );            // dumps into the Logs panel
doaction( 'qm/start', 'crmsync' );               // start a named timer
$response = wpremotepost( $endpoint, $args );
doaction( 'qm/stop', 'crmsync' );                // shows up in the Timings panel
doaction( 'qm/error', 'CRM returned {code}', [ 'code' => wpremoteretrieveresponse_code( $response ) ] );

For AJAX and REST requests, Query Monitor writes its output into the response headers and the browser console, so open DevTools rather than looking for the toolbar. This catches the classic admin-ajax bug where a 500 is returned as a bare 0.

Isolating the culprit without taking the site down

The instinct is to deactivate plugins one at a time. Don’t. With 28 active plugins, sequential testing averages 14 page loads and you’ll lose track of state halfway through. Bisect instead: deactivate half, test, keep halving the side that still breaks. Five tests, worst case, for 32 plugins.

On a live site, use the Health Check & Troubleshooting plugin’s troubleshooting mode, which disables plugins and switches to a default theme for your logged-in session only. Visitors see the normal site. Then enable plugins back in blocks from the admin bar menu.

If you have WP-CLI and a staging copy, scripting it is faster:

#!/usr/bin/env bash

Capture the current active set so you can always restore it.

wp plugin list --status=active --field=name > /tmp/active.txt

Deactivate the first half.

head -n $(( $(wc -l < /tmp/active.txt) / 2 )) /tmp/active.txt | xargs wp plugin deactivate

Test the failing URL and show status + total time.

curl -s -o /dev/null -w "status=%{httpcode} time=%{timetotal}s\n" \ -H 'Cache-Control: no-cache' https://staging.example.com/cart/

Restore everything when you're done.

xargs wp plugin activate < /tmp/active.txt

Two caveats that bite. Some plugins run migrations or flush options on deactivation, so never bisect on production data you haven’t backed up: licence keys and scheduled events are the usual casualties. A conflict can also be a pair of plugins, in which case single-plugin bisection finds nothing and you need to test combinations. If halving isolates it to a group but no individual plugin reproduces, you’re in pair territory.

Theme code gets the same treatment. Switch to Twenty Twenty-Five temporarily. If the bug vanishes, it’s your theme or, more often on inherited sites, 400 lines of accumulated snippets in functions.php.

When the WordPress error log is empty and the bug is still real

This is the interesting case. The page is wrong, nothing is logged, Query Monitor shows a clean request. Usual suspects, roughly in order of how often we hit them:

Caching is serving a different page than the one you’re debugging

Full page cache, object cache, CDN edge cache and the browser all sit between you and PHP. If you’re logged in you’re probably bypassing the page cache entirely, which is why the bug “only happens for visitors”. Test in a private window with a cache-busting query string, and check for a x-cache or cf-cache-status response header before you believe anything.

The code path runs in cron, not in a request

WP-Cron fires on a page load but runs in a separate loopback request, so its output never reaches your toolbar. Run it in the foreground where you can see everything: wp cron event run --due-now --debug. If a hook is silently failing, this is usually where it shows.

Output is being swallowed

Something has an open output buffer, or an exception is caught in a try/catch that does nothing with it. Search the codebase for catch ( \Exception $e ) {}. Empty catch blocks in plugin code are shockingly common and they are the reason your error log is clean while the feature does not work.

It’s not PHP at all

Check the browser console before the server. A JS error in the block editor, a blocked mixed-content request, an third-party script throwing on a null element: none of these will ever appear in a PHP log. Forms are a repeat offender here. A submission “fails” because of a JS validation handler, not a server error. Moving form delivery off the WordPress mail stack to a dedicated endpoint like WebForms removes an entire class of silent failure, because you stop debugging wp_mail, SMTP plugins and shared-host sending reputation all at once.

Slow, not broken: splitting query time from PHP time

“The site is slow” needs decomposition before it’s actionable. Query Monitor’s overview gives you page generation time, peak memory and total query time. The arithmetic matters: if generation is 2.4s and query time is 0.2s, SQL is not your problem and no amount of index tuning will help. You’ve got slow PHP, which usually means blocking HTTP calls or an O(n squared) loop over posts.

For the SQL side, log the offenders rather than eyeballing them:

<?php
// wp-content/mu-plugins/10-slow-query-log.php
// Requires define( 'SAVEQUERIES', true ); Do NOT leave this on permanently,
// it adds memory overhead proportional to query count.
add_action( 'shutdown', function () {
    global $wpdb;
    if ( ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES || empty( $wpdb->queries ) ) {
        return;
    }
    foreach ( $wpdb->queries as $q ) {
        // $q[1] is duration in seconds, $q[2] is the caller stack.
        if ( $q[1] < 0.05 ) {
            continue;
        }
        error_log( sprintf(
            "[SLOW %.3fs] %s | caller: %s | uri: %s\n",
            $q[1],
            preg_replace( '/\s+/', ' ', $q[0] ),
            $q[2],
            $SERVER['REQUESTURI'] ?? 'cli'
        ), 3, '/home/clientsite/logs/slow-queries.log' );
    }
}, 999 );

A 50ms threshold is aggressive on purpose. On a healthy site you’ll get almost nothing. When a plugin starts running an unindexed metaquery against 80,000 posts, you’ll get a flood with the exact caller chain. For deeper work, Xdebug 3 in profile mode (xdebug.mode=profile, xdebug.startwith_request=trigger) plus a cachegrind viewer gives you function-level attribution. It’s a staging-only tool though: the overhead is multiples of normal runtime.

Close the loop so you don’t debug it twice

The fix is not the end of the job. Write down what the symptom looked like, what the trace said and which component owned it, because the same plugin will do the same thing after the next update. On retainer clients we keep a per-site incident log, which is the dullest and most useful document in the project folder, and it feeds directly into the scope of what the retainer actually covers.

Then turn the diagnosis into monitoring. If the cause was memory exhaustion, log peak memory on every request and alert above 70 percent of the limit. If it was an third-party API timing out, add a 5 second timeout and a logged fallback instead of letting wpremoteget hang on the default. Every incident should leave behind one piece of instrumentation that would have caught it earlier.

Frequently Asked Questions

Is it safe to leave WPDEBUG enabled on a production site?

Yes, provided WPDEBUGDISPLAY is false, displayerrors is off at the ini level, and the log path sits outside the web root. We run it permanently on most client sites because the cost of discovering an issue three weeks late is higher than the small overhead. What you must not do is leave SAVEQUERIES or Xdebug enabled, since both carry real performance and memory costs.

Why does Query Monitor show no queries at all?

The Queries panel needs the wp-content/db.php drop-in, which Query Monitor places on activation. If another plugin already owns that file (some caching and APM plugins do), QM cannot install its own and the panel stays empty. Check whether db.php exists and which plugin it belongs to, then decide which one you need more during the investigation.

How do I debug WordPress when I only have FTP and no shell access?

Edit wp-config.php to set the debug constants with a log path inside a directory you’ve protected with a .htaccess deny rule, then drop an mu-plugin file into wp-content/mu-plugins/ for any custom instrumentation. For isolating a plugin conflict, rename the plugin’s folder over FTP: WordPress deactivates missing plugins automatically and the site stays up. Rename it back when you’re done.

My error log is full of deprecation notices from PHP 8.2 and later. Should I suppress them?

Suppress the noise, not the channel. Keep logging on and either filter EDEPRECATED out via errorreporting() in an mu-plugin loaded early, or push the log through grep -v when reading it. Switching WP_DEBUG off to silence deprecations is how teams end up blind to the one genuine warning that preceded an outage.

What’s the fastest way to prove a bug is caused by the theme and not a plugin?

Switch to a default theme for your session only, using the Health Check and Troubleshooting plugin, and reload the failing URL. If the bug persists with Twenty Twenty-Five active, the theme is innocent. This takes under a minute and saves you from reading a theme’s functions.php for an hour.

If you take one thing from this: stop debugging by reading code. Build the signal path first, log it to a file you control, then bisect. The fastest engineers we work with aren’t the ones who know every WordPress internal. They’re the ones who can halve the search space four times in ten minutes and get to the exact line while everyone else is still scrolling through functions.php.

debug wordpress isolating plugin conflicts wp-cli query monitor plugin panels saveQueries slow query logging wordpress error log location wordpress fatal error recovery mode wordpress mu-plugin shutdown handler wp_debug_log absolute path