Debugging, Site Health and Troubleshooting
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
When a WordPress site breaks, the cause is almost always one of a short list: a plugin or theme that throws a fatal
error, a database the site cannot reach, PHP running out of memory, a wrong URL or HTTPS setting, or two extensions that
do not get along. WordPress has built-in tools for each: debugging constants in wp-config.php, a fatal error handler
with a recovery mode for administrators, and the Site Health screen. This page explains how each tool works and then
gives a runbook for each common failure, using the Lumen Press site and its lumen-catalog plugin as the example.
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), PHP 8.4 (WordPress 7.1 requires PHP 7.4 at minimum and recommends 8.3+), MariaDB 11.8 and
WP-CLI 2.12.0, and checked on 2026-10-28. Every output block below
comes from the official wordpress:7.1.3-php8.4-apache and wordpress:cli-php8.4 images, with Mailpit receiving
mail. WordPress 7.2 (in beta, due in December 2026) changes nothing described here so far. All constants mentioned here
are documented in full on wp-config.php Reference; this page covers how
they behave when something is wrong.
Where errors go
A PHP error in WordPress can end up in four places, and knowing which one is in use saves time:
-
PHP’s own error log (
log_errorsanderror_loginphp.ini;/dev/stderrin the official Docker image, sodocker compose logs). Warnings and fatal errors land here even withWP_DEBUGoff. -
wp-content/debug.logor a custom file, withWP_DEBUGandWP_DEBUG_LOG. The default file is web-readable. -
The page itself, with
WP_DEBUGandWP_DEBUG_DISPLAY, ordisplay_errorsinphp.ini. Every visitor sees it. -
The admin mailbox, through the fatal error handler’s recovery mode (since 5.2).
On top of these, Site Health (Tools > Site Health) checks configuration, and plugins such as Query Monitor show errors, queries and hooks for the current request.
The debugging constants
The constants below are read in wp_initial_constants() and applied by wp_debug_mode(), both early in
wp-settings.php. They must be defined in wp-config.php before the "stop editing" line; defining them in a plugin is
too late.
WP_DEBUG
WP_DEBUG is the main switch. When it is true, wp_debug_mode() sets error_reporting( E_ALL ), so notices,
deprecations and _doing_it_wrong() messages are reported. When it is false, WordPress lowers the reporting level to
errors, warnings, parse errors and their user-level variants; notices and deprecations are not reported, but warnings
still go to PHP’s error log. _deprecated_function() and _doing_it_wrong() only trigger their messages when
WP_DEBUG is on.
If wp-config.php does not define WP_DEBUG, WordPress 7.1.3 defines it as true when WP_ENVIRONMENT_TYPE is
development or when WP_DEVELOPMENT_MODE is not empty, and as false otherwise. local and staging do not turn it
on. The official Docker image always writes define( 'WP_DEBUG', !!getenv_docker('WORDPRESS_DEBUG', '') );, so in
containers the WORDPRESS_DEBUG environment variable decides, and the environment type has no effect on it.
WP_DEBUG_LOG and where debug.log lands
WP_DEBUG_LOG only works when WP_DEBUG is true. It accepts two kinds of value:
-
true(or'1','true'): PHP’serror_logis set toWP_CONTENT_DIR . '/debug.log', normallywp-content/debug.log. -
A path string (since 5.1): errors go to that file. The directory must exist and be writable by the PHP user.
The lumen-noisy test plugin below produces one warning and one _doing_it_wrong() notice on every request. It is
only for reproducing the behaviour on a local copy.
<?php
/**
* Plugin Name: Lumen Noisy (test)
*/
add_action( 'wp_loaded', function () {
$book = array();
echo $book['lp_isbn']; // Warning: undefined array key.
_doing_it_wrong( 'lumen_noisy', 'Called too early.', '7.1.0' );
} );
With the following settings and the plugin active, a request to the home page creates the log file. Requesting the file over HTTP then returns it to anyone:
wp config set WP_DEBUG true --raw
wp config set WP_DEBUG_LOG true --raw
wp config set WP_DEBUG_DISPLAY false --raw
docker compose exec wordpress ls -l wp-content/debug.log
docker compose exec wordpress curl -s -i -H 'Host: lumenpress.test' http://localhost/wp-content/debug.log
Source: WP-CLI: wp config set
-rw-r--r-- 1 www-data www-data 1026 Oct 28 10:44 wp-content/debug.log
HTTP/1.1 200 OK
[28-Oct-2026 10:44:01 UTC] PHP Warning: Undefined array key "lp_isbn" in /var/www/html/wp-content/plugins/lumen-noisy/lumen-noisy.php on line 7
[28-Oct-2026 10:44:01 UTC] PHP Notice: Function lumen_noisy was called <strong>incorrectly</strong>. Called too early. Please see <a href="https://developer.wordpress.org/advanced-administration/debug/debug-wordpress/">Debugging in WordPress</a> for more information. (This message was added in version 7.1.0.) in /var/www/html/wp-includes/functions.php on line 6260
The log can contain file paths, SQL, email addresses and anything else a plugin passes to error_log(). On any server
reachable from the internet, either point WP_DEBUG_LOG at a path outside the web root or block the file at the web
server. Site Health checks for this: with the default path its "Debugging enabled" test reports critical ("Your site is
set to log errors to a potentially public file"), and with a path outside ABSPATH it reports recommended. Both
results were reproduced on 7.1.3. Moving the log does not delete the old wp-content/debug.log; remove it by hand.
// wp-config.php on lumenpress.test (staging): log to a private directory.
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', '/var/log/wordpress/lumen-debug.log' ); // Directory owned by the PHP user, mode 750.
define( 'WP_DEBUG_DISPLAY', false );
If the file must stay in wp-content, deny it at the web server. The Apache rule below returned 403 for
/wp-content/debug.log in the test container (Apache 2.4 syntax; Order/Deny lines from Apache 2.2 tutorials no
longer apply). The nginx equivalent, which passed nginx -t on nginx 1.29, is
location ~* /wp-content/debug\.log$ { deny all; } inside the server block.
# wp-content/.htaccess
<Files "debug.log">
Require all denied
</Files>
WP_DEBUG_DISPLAY
WP_DEBUG_DISPLAY decides whether errors are printed into the page while WP_DEBUG is on. Its default is true,
so define( 'WP_DEBUG', true ); alone prints the warning and notice shown above at the top of every page, before
<!DOCTYPE html>, for every visitor. false forces display_errors off; null leaves display_errors as php.ini set it. WordPress always turns display
off for REST, AJAX, XML-RPC and JSON requests, so an error there does not corrupt the response body, but it still appears
in the log. WP-CLI prints PHP warnings on the terminal whatever the constant says. Site Health rates
WP_DEBUG_DISPLAY with WP_DEBUG on as critical, or only recommended when wp_get_environment_type() is local or
development.
SCRIPT_DEBUG
SCRIPT_DEBUG makes WordPress load the unminified versions of core JavaScript and CSS (.js instead of .min.js).
It is useful when a stack trace in the browser console points into a core script, or when testing a patch to core
scripts. It defaults to false, except in a wordpress-develop checkout whose version string contains -src. It does
not change how plugin assets built with @wordpress/scripts are loaded; those use whatever file the plugin enqueues.
SAVEQUERIES
SAVEQUERIES makes wpdb store every query with its duration, the call stack and the start time in $wpdb->queries.
It costs memory and time on every request, so enable it only while measuring. A quick check with wp eval:
wp config set SAVEQUERIES true --raw
wp eval 'global $wpdb;
new WP_Query( array( "post_type" => "post", "posts_per_page" => 5 ) );
$q = end( $wpdb->queries );
printf( "%d queries so far\nlast: %s\ntime: %.5f s\n", count( $wpdb->queries ), $q[0], $q[1] );'
wp config delete SAVEQUERIES
Source: Code Reference: wpdb
13 queries so far
last: SELECT t.*, tt.* FROM wp_terms AS t INNER JOIN wp_term_taxonomy AS tt ON t.term_id = tt.term_id WHERE t.term_id IN (1)
time: 0.00263 s
Each entry is an array: SQL, seconds, a comma-separated caller list, start time and custom data. Query Monitor (below) reads the same information and presents it per component, which is easier than reading the array.
Recommended values per environment
| Constant | Local | Staging | Production | Notes |
|---|---|---|---|---|
|
|
|
|
Turn it on briefly in production only with display off |
|
|
path outside web root |
path outside web root, or |
Rotate the file; it grows without limit |
|
|
|
|
Never |
|
as needed |
|
|
|
|
as needed |
as needed |
|
Measurable overhead |
|
|
|
|
The fatal error handler and recovery mode
Before WordPress 5.2 a fatal PHP error in a plugin usually produced the "white screen of death": an empty page, often
with HTTP 500, and no way into the dashboard. Since 5.2 WordPress registers a shutdown function,
WP_Fatal_Error_Handler, that catches fatal errors (E_ERROR, E_PARSE, E_USER_ERROR, E_COMPILE_ERROR and
E_RECOVERABLE_ERROR; uncaught exceptions and TypeError count as E_ERROR) and replaces the blank page with a
short message and HTTP 500:
There has been a critical error on this website.
Learn more about troubleshooting WordPress.
The handler can be customised or switched off:
-
WP_DISABLE_FATAL_ERROR_HANDLERset totrue(or thewp_fatal_error_handler_enabledfilter returningfalse) turns it off. With display off, a fatal error then returns HTTP 500 with an empty body (0 bytes in the test), which is the old white screen. -
A
wp-content/fatal-error-handler.phpdrop-in can return an object that replaces the handler class. -
A
wp-content/php-error.phpdrop-in replaces the error page itself, for example with the site’s own branded template. -
The
wp_php_error_messageandwp_php_error_argsfilters change the message and thewp_die()arguments.
When WordPress sends the recovery email
On top of the error page, WP_Recovery_Mode::handle_error() decides whether to email a recovery link. In the 7.1.3
source, confirmed by the reproduction below, three conditions must hold:
-
The error’s file must be inside a plugin or theme directory. If the fatal is raised inside core (for example
add_action( 'init', 'lp_catalog_register_meta' )with a function that does not exist fails inwp-includes/class-wp-hook.php), WordPress cannot blame an extension, so no email and no pause happen. Network-activated plugins on Multisite are also excluded. -
The request must hit a protected endpoint:
wp-login.php, awp-adminscreen (not AJAX), or one of a few AJAX actions used to fix errors, or anything allowed by theis_protected_endpointfilter. A fatal error on the front end alone never sends the email. The administrator only receives it after someone opens the login page or the dashboard. -
No recovery email must have been sent within the rate limit, one day by default (
recovery_mode_email_rate_limitfilter). The time of the last email is stored in therecovery_mode_email_last_sentoption.
The email goes to the site’s admin email, or to RECOVERY_MODE_EMAIL if that constant holds a valid address (useful
when the admin email is a shared mailbox nobody reads). The recovery_mode_email filter changes its content.
Reproducing recovery mode locally
The outputs below come from the stack described on Email Delivery: the
official WordPress and MariaDB 11.8 images plus Mailpit, a local mail catcher, with a must-use plugin that sends
wp_mail() to Mailpit through phpmailer_init. The differences were the default wp_ table prefix,
WP_ENVIRONMENT_TYPE set to local, and no published port: requests were sent from inside the WordPress container with
curl -H 'Host: lumenpress.test' http://localhost/.... The site must be installed with a host that contains a dot
(wp core install --url=http://lumenpress.test ...), because wp_mail() builds the sender as wordpress@<host> and
PHPMailer rejects wordpress@localhost with "Invalid address: (From)", so no recovery email would be sent.
A broken deploy of lumen-catalog is simulated by calling a function whose file was not shipped. The call is inside a
closure in the plugin file, so the error is attributed to the plugin:
<?php
/**
* Plugin Name: Lumen Catalog
* Version: 0.1.0
* Requires at least: 6.9
* Requires PHP: 7.4
* Text Domain: lumen-catalog
*/
defined( 'ABSPATH' ) || exit;
add_action(
'init',
static function () {
// includes/meta.php, which defines this function, was missing from the deploy.
lp_catalog_register_meta();
}
);
After wp plugin activate lumen-catalog, the front end and the login page answer differently:
docker compose exec wordpress curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: lumenpress.test' http://localhost/
docker compose exec wordpress curl -s -H 'Host: lumenpress.test' http://localhost/wp-login.php
docker compose exec wordpress curl -s http://mailpit:8025/api/v1/message/latest
Source: Mailpit: API v1
500
There has been a critical error on this website. Please check your site admin email inbox for instructions.
If you continue to have problems, please try the support forums. Learn more about troubleshooting WordPress.
Subject: [Lumen Press] Your Site is Experiencing a Technical Issue
...
In this case, WordPress caught an error with one of your plugins, Lumen Catalog.
...
http://lumenpress.test/wp-login.php?action=enter_recovery_mode&rm_token=<token>&rm_key=<key>
To keep your site safe, this link will expire in 1 day. ...
Error Details
=============
An error of type E_ERROR was caused in line 16 of the file
/var/www/html/wp-content/plugins/lumen-catalog/lumen-catalog.php. Error message: Uncaught Error: Call to undefined
function lp_catalog_register_meta() in /var/www/html/wp-content/plugins/lumen-catalog/lumen-catalog.php:16
The front-end request produced only the short message and no email; the login page request produced the longer
message and the email. Opening the link sets a wordpress_rec_* cookie and shows "Recovery Mode Initialized. Please
log in to continue." After logging in, Plugins lists Lumen Catalog under Paused (1) with the error message, and the
admin bar shows Exit Recovery Mode. The pause is stored in an option named <session id>_paused_extensions and
applies only to the recovery session: in the same test, a visitor without the cookie still received HTTP 500 while
the recovery session received 200. The fix is to deactivate, roll back or repair the plugin, then exit recovery mode.
Site Health
Tools > Site Health (wp-admin/site-health.php, since 5.2) has two tabs. Users need the view_site_health_checks
capability, which WordPress grants to users who can install plugins (on Multisite, only to network administrators).
A Site Health Status dashboard widget summarises the result, and a weekly cron event,
wp_site_health_scheduled_check, re-runs the tests in the background.
Status
The Status tab runs direct tests in PHP when the page loads and async tests through the REST API
(wp-site-health/v1/tests/...). Each test returns good, recommended or critical. In 7.1.3 the core tests are:
| Kind | Tests |
|---|---|
Direct |
WordPress version, plugin versions, theme versions, PHP version, PHP extensions, PHP default timezone, PHP sessions, database server version, secure communication (SSL support), scheduled events, HTTP requests (blocked hosts), REST API availability, debugging enabled, file uploads, plugin and theme auto-updates, temporary backup directory, disk space for updates, autoloaded options size, open registration with a privileged default role (7.0), search engine visibility (6.9), opcode cache (7.0) |
Async |
communication with WordPress.org, background updates, loopback request, HTTPS status, Authorization header (unless the site is behind basic auth) |
Production only |
page cache (async) and persistent object cache (direct); only when |
The loopback test matters more than it looks: when the server cannot request its own URL, WP-Cron (unless a system cron runs it), scheduled posts and the error check of the theme and plugin file editors fail. WP-Cron and Scheduled Tasks covers the cron side.
Info
The Info tab lists the environment in collapsible sections: WordPress, directories and sizes, drop-ins, active and inactive themes, must-use and active plugins, media handling, server, database, WordPress constants and filesystem permissions. Copy site info to clipboard produces a plain-text report for support requests; fields marked private (for example database credentials) are left out of the copy.
Adding a test and an Info section
Plugins add tests with the site_status_tests filter and Info fields with the debug_information filter. The
Lumen Catalog plugin checks that its distributor stock sync (the lp_catalog_sync_stock cron event, described on
WP-Cron and Scheduled Tasks) is scheduled and not overdue, and adds
a "Lumen Catalog" section to Info:
<?php
/**
* Site Health integration of the Lumen Catalog plugin (includes/site-health.php).
*/
defined( 'ABSPATH' ) || exit;
add_filter(
'site_status_tests',
static function ( array $tests ): array {
$tests['direct']['lp_catalog_stock_sync'] = array(
'label' => __( 'Lumen Catalog stock synchronisation', 'lumen-catalog' ),
'test' => 'lp_catalog_site_health_stock_sync',
);
return $tests;
}
);
/**
* Checks that the stock sync cron event exists and is not long overdue.
*/
function lp_catalog_site_health_stock_sync(): array {
$result = array(
'label' => __( 'Book stock is synchronised on schedule', 'lumen-catalog' ),
'status' => 'good',
'badge' => array(
'label' => __( 'Lumen Catalog', 'lumen-catalog' ),
'color' => 'blue',
),
'description' => '<p>' . esc_html__( 'The distributor stock sync is scheduled and ran recently.', 'lumen-catalog' ) . '</p>',
'actions' => '',
'test' => 'lp_catalog_stock_sync',
);
$next = wp_next_scheduled( 'lp_catalog_sync_stock' );
if ( false === $next ) {
$result['status'] = 'critical';
$result['label'] = __( 'Book stock is not being synchronised', 'lumen-catalog' );
$result['description'] = '<p>' . esc_html__( 'The lp_catalog_sync_stock event is not scheduled. Deactivate and reactivate Lumen Catalog to schedule it again.', 'lumen-catalog' ) . '</p>';
} elseif ( $next < time() - HOUR_IN_SECONDS ) {
$result['status'] = 'recommended';
$result['label'] = __( 'The stock sync is overdue', 'lumen-catalog' );
$result['description'] = '<p>' . esc_html__( 'The event is more than an hour late. WP-Cron may not be running: check DISABLE_WP_CRON and loopback requests.', 'lumen-catalog' ) . '</p>';
}
return $result;
}
add_filter(
'debug_information',
static function ( array $info ): array {
$settings = get_option( 'lp_catalog_settings', array() );
$next = wp_next_scheduled( 'lp_catalog_sync_stock' );
$info['lumen-catalog'] = array(
'label' => __( 'Lumen Catalog', 'lumen-catalog' ),
'fields' => array(
'books' => array(
'label' => __( 'Published books', 'lumen-catalog' ),
'value' => (int) ( wp_count_posts( 'lp_book' )->publish ?? 0 ),
),
'distributor_url' => array(
'label' => __( 'Distributor API', 'lumen-catalog' ),
'value' => $settings['distributor_url'] ?? __( 'Not set', 'lumen-catalog' ),
'private' => true, // Left out of "Copy site info to clipboard".
),
'next_sync' => array(
'label' => __( 'Next stock sync', 'lumen-catalog' ),
'value' => $next ? gmdate( 'Y-m-d H:i:s', $next ) . ' UTC' : __( 'Not scheduled', 'lumen-catalog' ),
'debug' => $next ? $next : 'not-scheduled',
),
),
);
return $info;
}
);
A test result needs label, status, badge (label and a color such as blue, green, red, orange,
purple or gray), description, actions and test. An async test is added under $tests['async'] with a
REST URL in test and, optionally, an async_direct_test callable used when the result is computed without HTTP. The
site_status_test_result filter can change any test’s result, including core ones. In an Info field, debug is the
value used in the copied report instead of value, and 'private' => true removes the field from it.
Running tests from the command line
WP-CLI 2.12.0 has no bundled site-health command (wp help site-health reports "not a registered wp command"), but
any test can be run with wp eval once the admin class is loaded. These runs exercised the three branches of the test
above:
wp eval 'require_once ABSPATH . "wp-admin/includes/class-wp-site-health.php";
$t = WP_Site_Health::get_tests()["direct"]["lp_catalog_stock_sync"];
$r = call_user_func( $t["test"] );
printf( "%s: %s\n", $r["status"], $r["label"] );'
wp cron event schedule lp_catalog_sync_stock '-2 hours' # an overdue single event
wp cron event delete lp_catalog_sync_stock
wp cron event schedule lp_catalog_sync_stock '+1 hour' hourly
Source: WP-CLI: wp eval
critical: Book stock is not being synchronised
recommended: The stock sync is overdue
good: Book stock is synchronised on schedule
WP_Debug_Data::debug_data() (in wp-admin/includes/class-wp-debug-data.php) returns the whole Info tab as an array
keyed by section (wp-core, wp-server, wp-database, wp-constants and so on), which ended with the added
lumen-catalog section in the test.
Query Monitor and other tools
Query Monitor (version 4.1.1, tested up to 7.1.3, at the time of checking) is the usual developer panel. It adds an
admin-bar menu showing, for the current request, database queries grouped by plugin or theme with duplicates and slow
queries highlighted, PHP errors with call stacks, _doing_it_wrong() and deprecation notices, the template and
template parts used, blocks, hooks and their callbacks, enqueued scripts and styles, HTTP API calls, capability checks,
rewrite rules and transients. It also reports on REST responses through headers for authenticated requests. Its output
is only shown to users allowed to see it, but it should still be deactivated on production when not in use.
Other tools in the ecosystem:
-
Health Check & Troubleshooting (WordPress.org community plugin, version 1.7.1) was the origin of the core Site Health screen. Its remaining feature is Troubleshooting Mode: a session, only for the current user, in which every plugin is disabled and a default theme is active, while visitors still see the normal site. The plugin directory warns that it has not been tested with the last three major releases, so check it on staging before relying on it.
-
WP-CLI:
wp --skip-plugins,wp --skip-themes,wp plugin deactivate,wp db checkandwp evalwork when the dashboard does not. -
A PHP debugger such as Xdebug, described on PHP Debugging and Profiling.
Troubleshooting runbooks
The flowchart is the order in which the Lumen Press team narrows down a failure. Each box links to a runbook below.
First steps
-
Find out what changed. Most failures follow an update, a deploy, a new plugin or a hosting change. Update history is in the plugin’s changelog and, for automatic updates, in the update emails.
-
Back up before fixing.
wp db exportand a copy ofwp-content, so a wrong fix can be undone (Backups and Disaster Recovery). -
Read the logs rather than guessing:
debug.log, the PHP error log (docker compose logs wordpress, or the host’s log viewer) and the web server error log. -
Reproduce on staging whenever the production site is still up.
The critical error screen and the white screen
"There has been a critical error on this website" means the fatal error handler caught an error. An empty page
with HTTP 500 means the handler is disabled, failed itself, or the error happened before WordPress loaded it (for
example a parse error in wp-config.php).
-
Check the admin mailbox for the recovery email. If nobody has opened the dashboard since the error, open
/wp-login.phponce to trigger it (front-end errors do not send it). -
Without email, read the last fatal error from the log: it names the file and line.
-
Deactivate the extension named in the path. WP-CLI loads plugins too, so the broken plugin makes most commands fail with the same fatal error; skip loading it:
wp plugin deactivate lumen-catalog --skip-plugins wp theme activate twentytwentyfive --skip-themes # when the error is in the active themeSource: WP-CLI: wp plugin deactivate
-
Without shell access, rename the plugin’s folder over SFTP (
lumen-catalogtolumen-catalog.off); WordPress deactivates a plugin whose file it cannot find. Renamingwp-content/pluginsitself disables all plugins at once. -
If the error is in a must-use plugin or drop-in (
wp-content/mu-plugins,object-cache.php,advanced-cache.php), move the file away; these cannot be deactivated from the dashboard.
HTTP 500 Internal Server Error
A 500 with the server’s own error page, not WordPress’s, usually comes from outside PHP code:
-
A broken
.htaccess(a typo, or a directive whose module is not loaded). Rename it and save Settings > Permalinks to write a fresh one. Apache 2.2 directives such asOrder deny,allowfrom older tutorials fail on servers withoutmod_access_compat; useRequire. -
PHP-FPM unavailable, a timeout at the proxy, or PHP crashing: check the web server error log.
-
Memory exhaustion or a fatal error with the handler disabled (see the runbooks around this one).
-
Wrong file ownership or permissions after a manual upload (File Permissions and Filesystem).
-
Damaged core files after a failed update:
wp core verify-checksumslists modified or missing files, andwp core download --force --skip-contentreplaces them without touchingwp-content.
"Error establishing a database connection"
WordPress shows this message, with HTTP 500, when wpdb cannot connect. Both causes below were reproduced, with the
matching warnings from debug.log:
# Database container stopped (host does not resolve)
PHP Warning: mysqli_real_connect(): (HY000/2002): php_network_getaddresses: getaddrinfo for db failed: Name or service not known in /var/www/html/wp-includes/class-wpdb.php on line 1990
# Wrong DB_PASSWORD in wp-config.php
PHP Warning: mysqli_real_connect(): (HY000/1045): Access denied for user 'wordpress'@'172.22.0.4' (using password: YES) in /var/www/html/wp-includes/class-wpdb.php on line 1990
With WP_DEBUG and WP_DEBUG_DISPLAY on, the page adds the MySQL error and a checklist ("Are you sure you have the
correct username and password?"); with display off, visitors see only the one-line message. A wp-content/db-error.php
drop-in replaces this page, for example with a maintenance notice that sends 503 and Retry-After.
Work through the causes in this order:
-
Credentials: compare
DB_NAME,DB_USER,DB_PASSWORDandDB_HOSTinwp-config.phpwith the database (wp config get DB_HOST). After a migration,DB_HOSTis often stilllocalhostwhile the new host needs a hostname, a port (db.internal:3307) or a socket (localhost:/run/mysqld/mysqld.sock). -
Server reachable:
wp db checkuses the same constants through themariadb/mysqlclient and fails with the client’s own error, which is often clearer. -
Server running and not full: connection limits (
Too many connections), a full disk or an exceeded hosting quota. -
Corrupted tables: a different message, "One or more database tables are unavailable", points to the repair page below.
-
Compromise: a changed password in
wp-config.phpthat nobody on the team made is a sign of intrusion; follow Security Hardening.
Repairing tables and WP_ALLOW_REPAIR
wp-admin/maint/repair.php runs CHECK TABLE and REPAIR TABLE (and optionally OPTIMIZE TABLE) on the core tables
of the current site. It only works with this constant:
define( 'WP_ALLOW_REPAIR', true );
Two facts matter more than the feature itself, and both were checked on 7.1.3:
-
The page needs no login. It must work when the database is too damaged for authentication, so with the constant set, an anonymous request to
/wp-admin/maint/repair.php?repair=1ran the repair and listed "The wp_users table is okay." and so on for every table. Anyone can start repeated repairs or optimisations, which lock tables. Remove the constant as soon as the repair is done; the page itself asks for that. -
It repairs tables and nothing else. It does not reset passwords, create users or fix data. One of the older books presents it as a way back in after a lost password; that is wrong. For lockouts, see the next runbook.
With shell access, wp db check, wp db repair and wp db optimize do the same work without exposing a page. InnoDB,
the default engine, does not support REPAIR TABLE the way MyISAM did; real InnoDB corruption is handled with
innodb_force_recovery and a restore from backup.
Memory exhaustion
The error names the limit in bytes and the allocation that failed. The lumen-memtest plugin below reproduces it on a
local copy by allocating a string of the requested size:
<?php
/**
* Plugin Name: Lumen Memory Test (local only)
* Description: Reproduces memory exhaustion: request any page with ?lp_memtest=<MB>.
*/
defined( 'ABSPATH' ) || exit;
add_action(
'template_redirect',
static function () {
if ( 'local' !== wp_get_environment_type() || ! isset( $_GET['lp_memtest'] ) ) {
return;
}
$block = str_repeat( 'x', absint( $_GET['lp_memtest'] ) * MB_IN_BYTES );
printf(
"Allocated %d MB, memory_limit %s, peak %s\n",
strlen( $block ) / MB_IN_BYTES,
ini_get( 'memory_limit' ),
size_format( memory_get_peak_usage( true ) )
);
exit;
}
);
With the image’s memory_limit = 128M, ?lp_memtest=150 failed; after wp config set WP_MEMORY_LIMIT 256M it
succeeded:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 157290496 bytes) in /var/www/html/wp-content/plugins/lumen-memtest.php on line 15
Allocated 150 MB, memory_limit 256M, peak 204 MB
How the limits work in 7.1.3:
-
WP_MEMORY_LIMITdefaults to40M(64Mon Multisite) and applies to front-end requests;WP_MAX_MEMORY_LIMITdefaults to256Mand is applied bywp_raise_memory_limit()in the admin and for image processing. -
WordPress only raises PHP’s
memory_limit; with the image’s 128M, the default 40M changes nothing. Older books that describe the default as the effective limit, or give other defaults, are out of date. -
If the host forbids changing
memory_limitat runtime, both constants are ignored and WordPress uses the current value. Ask the host, or set it inphp.inior the PHP-FPM pool. -
WP-CLI runs with
memory_limit = -1in the official image, so a command can succeed where a web request fails. -
OPcache may keep serving the old
wp-config.phpfor a moment: the official image setsopcache.revalidate_freq=2, and a request sent immediately after the change still failed with the 128M limit. Wait, or reload PHP-FPM.
Raising the limit is a workaround. The usual consumers are loops over all posts ('posts_per_page' => -1), large
autoloaded options, image processing of huge uploads and runaway recursion in a filter; Query Monitor’s memory figure
and memory_get_peak_usage() around suspect code find them.
Redirect loops
"Too many redirects" (ERR_TOO_MANY_REDIRECTS) has three usual causes:
-
HTTPS terminated at a proxy or load balancer. WordPress sees plain HTTP,
FORCE_SSL_ADMINor anhttpssite URL makes it redirect to HTTPS, and the proxy forwards HTTP again. The signature is a redirect to the same URL withhttps:proxy did not send X-Forwarded-Proto: 302 -> https://lumenpress.test/wp-login.php proxy sent X-Forwarded-Proto: https 200 ->The fix is to make the proxy send
X-Forwarded-Proto: httpsand to trust it inwp-config.phponly when the proxy is the sole way in (the official Docker image’swp-config.phpalready contains this):if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) { $_SERVER['HTTPS'] = 'on'; }Source: Advanced Administration: HTTPS
-
Wrong
homeorsiteurl.wwwagainst the bare domain, orhttpagainsthttps, combined with a server redirect in the opposite direction. Check withwp option get homeandwp option get siteurl; when they are set byWP_HOME/WP_SITEURLinwp-config.php, the constants win. -
A plugin or cache. Redirect, SSL and caching plugins, or a cached
301in the browser or CDN. Test withcurl -sIL --max-redirs 5 https://lumenpress.example/rather than a browser, and purge caches after fixing.
Locked out of the dashboard
Every lockout has a WP-CLI answer that needs no email and no WP_ALLOW_REPAIR:
wp user list --role=administrator --fields=ID,user_login,user_email
wp user reset-password admin --skip-email --show-password # prints a new password once
wp user session destroy admin --all # signs the user out everywhere
wp user create recovery recovery@lumenpress.example --role=administrator --prompt=user_pass
wp plugin deactivate <security-or-2fa-plugin> --skip-plugins
Source: WP-CLI: wp user reset-password
Typical situations: the password reset email never arrives (an email delivery problem, see
Email Delivery); a security plugin has locked the IP after failed logins
(deactivate it or clear its lockout table); a lost second factor (deactivate the two-factor plugin for the user); or the
account lost its role (wp user set-role). Without a shell, the database route is a new bcrypt or phpass hash written
through phpMyAdmin, which is error-prone; prefer the host’s WP-CLI or a support ticket. Delete any temporary
administrator account afterwards.
Mixed content
After a switch to HTTPS, browsers block or flag images, scripts and styles still loaded over http://. They come
from absolute URLs stored in post content, options and metadata. Check what would change, then run it for real:
wp search-replace 'http://lumenpress.test' 'https://lumenpress.test' --skip-columns=guid --dry-run --report-changed-only
Source: WP-CLI: wp search-replace
Table Column Replacements Type
wp_options option_value 2 PHP
wp_posts post_content 3 SQL
wp_users user_url 1 SQL
Success: 6 replacements to be made.
wp search-replace unserializes PHP-serialized values (the PHP rows) so string lengths stay correct; a raw SQL
REPLACE() would corrupt them. guid is skipped because feed readers use it as a permanent identifier. Hard-coded URLs
in theme files, CSS and third-party embeds need fixing in code; the browser console names each offending URL. A
Content-Security-Policy: upgrade-insecure-requests header is a stopgap, not a fix. Older tutorials suggest SQL
UPDATE statements with REPLACE() for this; that breaks serialized options.
Plugin and theme conflicts: bisection
When no log line points to a culprit, for example a broken editor, a missing menu item or a slow page, turn extensions off and on until the problem appears. The safe sequence keeps a list of what was active:
wp plugin list --status=active --field=name --skip-plugins > active-plugins.txt
wp plugin deactivate --all --skip-plugins
wp theme activate twentytwentyfive --skip-themes # only if the problem may be the theme
Source: WP-CLI: wp plugin deactivate
--skip-plugins is not optional when one plugin throws a fatal error: without it, wp plugin deactivate --all itself
loaded the broken plugin and died with the same error. wp plugin deactivate --all does not remember the previous state,
hence the file. The script below reactivates the plugins in order and stops at the first one after which the home page
stops answering 200. Run it from the WordPress root:
#!/bin/sh
# Usage: find-culprit.sh <file-with-plugin-slugs> <url-to-check>
# Run from the WordPress root after `wp plugin deactivate --all --skip-plugins`.
# Reactivates the plugins one by one and stops at the first one that breaks the URL.
set -eu
list="$1"
url="$2"
while read -r plugin; do
wp plugin activate "$plugin" --skip-plugins --quiet
status=$(curl -s -o /dev/null -w '%{http_code}' "$url")
echo "$plugin -> HTTP $status"
if [ "$status" != "200" ]; then
wp plugin deactivate "$plugin" --skip-plugins --quiet
echo "Culprit: $plugin (deactivated again)"
exit 1
fi
done < "$list"
echo "No single plugin breaks $url: test the theme, then pairs of plugins."
Source: WP-CLI: wp plugin activate
akismet -> HTTP 200
hello -> HTTP 200
lumen-catalog -> HTTP 500
Culprit: lumen-catalog (deactivated again)
For problems an HTTP status cannot detect, replace the curl check with a manual test after each step. With many
plugins, halve the list instead: activate the first half, test, and keep the half that shows the problem; 32 plugins
need at most five rounds. A problem that only appears with two specific plugins active together shows up as "No single
plugin breaks" and needs the halving approach with both halves combined. On production, Troubleshooting Mode of the
Health Check plugin does the same for one user without affecting visitors.
Where older books are outdated
| Older advice | Current behaviour (7.1) |
|---|---|
Define |
It only checks, repairs and optimises tables, and exposes |
A fatal error always gives a white screen; the only fix is FTP |
Since 5.2 the fatal error handler shows "There has been a critical error" and emails a recovery link, provided the error is in a plugin or theme file and someone opens the dashboard or login page. |
Turn on |
On its own it also prints errors to every visitor ( |
Memory limit defaults and "set |
Defaults are 40M (64M Multisite) and 256M for |
Fix mixed content with SQL |
Breaks serialized data; use |
Apache 2.2 |
Use Apache 2.4 |