How to Debug WordPress Memory Exhausted Errors with WP-CLI

A client’s WordPress site started showing a fatal memory error every time they opened Pages → All Pages in wp-admin. The error looked like this:

PHP Fatal error:  Allowed memory size of 536870912 bytes exhausted (tried to allocate 442368 bytes) in /wp-includes/class-wpdb.php on line 2353

The usual advice is “increase the memory limit” or “clean up the database”. The site had a 1.5 GB wp_postmeta table, so a database cleanup looked like the obvious fix. It turned out cleanup would have saved 0 MB of PHP memory. This post shows how I measured where the memory actually went, using only WP-CLI and a few small PHP files.

Step 1: Read the error properly

536870912 bytes is 512 MB. The error happened inside class-wpdb.php, which means a database query returned more data than PHP could hold. Find fatal errors in the logs with:

grep -h "Fatal\|Allowed memory" error_log wp-content/debug.log | tail -20

Step 2: Measure the real PHP memory usage

This one-liner loads WordPress through WP-CLI and prints peak memory. -d memory_limit=-1 removes the limit so the command finishes instead of crashing.

php -d memory_limit=-1 $(which wp) eval 'echo round(memory_get_peak_usage()/1048576)." MB\n";'

Result: 503 MB, just under the 512 MB limit. The Pages screen needed a few MB more, which pushed it over. A normal WordPress site boots in 50–150 MB, so something was loading far too much on every request.

Step 3: Theme or plugins?

WP-CLI can skip plugins and themes for a single command, without touching the live site:

# WordPress core only
php -d memory_limit=-1 $(which wp) eval 'echo round(memory_get_peak_usage()/1048576)." MB\n";' --skip-plugins --skip-themes

# Plugins, no theme
php -d memory_limit=-1 $(which wp) eval 'echo round(memory_get_peak_usage()/1048576)." MB\n";' --skip-themes

# Theme, no plugins
php -d memory_limit=-1 $(which wp) eval 'echo round(memory_get_peak_usage()/1048576)." MB\n";' --skip-plugins
ConfigurationPeak memory
Core only51 MB
Theme only50 MB
Plugins, no theme181 MB
Everything503 MB

Neither the theme nor the plugins were heavy on their own. Together they used an extra 320 MB, so the problem was a theme and a plugin interacting.

To test plugins one by one, this loop disables each plugin in turn:

for p in $(wp plugin list --status=active --field=name); do
  printf "%-35s" "without $p:"
  php -d memory_limit=-1 $(which wp) eval 'echo round(memory_get_peak_usage()/1048576)." MB\n";' --skip-plugins=$p 2>/dev/null | tail -1
done

Step 4: Trace memory hook by hook

WordPress fires hundreds of hooks while loading. By attaching a function to the special all hook, we can record memory at every hook and print wherever it jumps. Save this as ~/memtrace.php, outside your web root:

<?php
// Prints every point where memory grows > 10 MB between two WordPress hooks
$GLOBALS['__mt'] = ['m' => 0, 'h' => 'start', 'out' => []];
function __mt() {
    $g = &$GLOBALS['__mt']; $t = current_filter(); $m = memory_get_usage();
    if ( $m - $g['m'] > 10 * 1048576 ) {
        $where = [];
        foreach ( debug_backtrace() as $f )
            if ( isset( $f['file'] ) && strpos( $f['file'], 'wp-content' ) !== false )
                $where[] = basename( dirname( $f['file'] ) ) . '/' . basename( $f['file'] ) . ':' . $f['line'];
        $g['out'][] = sprintf( "+%d MB  [%s] -> [%s]  now %d MB  %s",
            ( $m - $g['m'] ) / 1048576, $g['h'], $t, $m / 1048576, implode( ' < ', array_slice( $where, 0, 3 ) ) );
    }
    $g['m'] = $m; $g['h'] = $t;
}
$GLOBALS['wp_filter']['all'][10][] = [ 'function' => '__mt', 'accepted_args' => 1 ];
register_shutdown_function( function () {
    fwrite( STDERR, implode( "\n", $GLOBALS['__mt']['out'] ) . "\nPEAK " . round( memory_get_peak_usage() / 1048576 ) . " MB\n" );
} );

Load it with WP-CLI’s --require option, which runs the file before WordPress starts:

php -d memory_limit=-1 $(which wp) eval '1;' --require=$HOME/memtrace.php 2>&1 | grep MB

The output pointed straight at the theme’s settings plugin:

+137 MB  [query] -> [posts_results]  now 293 MB  classes/class-redux-wordpress-data.php:284 < ...

A single post query added 137 MB. Then memory grew by another 182 MB in small steps with no single big jump. When that happens, add up the growth per hook instead. Here it showed get_post_metadata being called 662 times, once for each page on the site.

Step 5: Find which code runs the heavy query

This file prints the SQL and the calling plugin or theme file for any query that returns more than 100 posts:

<?php
$GLOBALS['wp_filter']['posts_results'][10][] = [ 'accepted_args' => 2, 'function' => function ( $posts, $q ) {
    if ( count( $posts ) > 100 ) {
        fwrite( STDERR, count( $posts ) . " posts\n" . $q->request . "\n" );
        foreach ( debug_backtrace() as $f )
            if ( isset( $f['file'] ) && strpos( $f['file'], 'wp-content' ) !== false )
                fwrite( STDERR, "  " . $f['file'] . ':' . $f['line'] . "\n" );
    }
    return $posts;
} ];

The root cause: isset( $post->slug )

The site uses the Savoy theme, whose settings plugin (nm-theme-settings) bundles the Redux options framework. Redux builds a “select a page” dropdown for the theme options, and it does this on every request, front end and admin, not only on the settings screen.

  1. It calls get_pages(), which loads every published page with its full content. That is 137 MB just to show page titles.
  2. For each page, it checks isset( $v->slug ). WP_Post has no slug property (it is called post_name), so PHP calls WP_Post::__isset(). That runs metadata_exists(), which loads all post meta for the page to look for a custom field named slug. On an Elementor site, each page carries hundreds of KB of _elementor_data. Multiply that by 662 pages and you get 182 MB.

This is an easy trap to fall into: isset() on an unknown property of a WP_Post object is not a cheap check. It is a database lookup that loads all of the post’s meta into the object cache.

The fix

File: wp-content/plugins/nm-theme-settings/includes/options/redux-core/inc/classes/class-redux-wordpress-data.php

Change 1 in process_results(). Skip the property check for WP_Post objects. The dropdown looks exactly the same, because the check always returned false anyway.

- if ( is_object( $v ) && isset( $v->$secondary_key ) ) {
+ if ( is_object( $v ) && ! ( $v instanceof WP_Post ) && isset( $v->$secondary_key ) ) {

Change 2 in get_data(), case 'page':. Fetch only the ID and title instead of full page objects:

- $results = get_pages( $args );
+ global $wpdb;
+ $exclude_ids = array_filter( array_map( 'absint', $args['exclude'] ) );
+ $exclude_sql = $exclude_ids ? ' AND ID NOT IN (' . implode( ',', $exclude_ids ) . ')' : '';
+ $results     = $wpdb->get_results( "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_type = 'page' AND post_status = 'publish'{$exclude_sql} ORDER BY post_title ASC" );

Keep a copy of these changes: a plugin update will overwrite them.

Results

ChangePeak PHP memory
Before503 MB
Database cleanup (revisions, orphaned postmeta)503 MB (no change)
Shrink Elementor per-page custom CSS453 MB
Fix 1: skip isset() on WP_Post295 MB
Fix 1 + Fix 2184 MB

Why database cleanup did not help

The database had 1.5 GB of postmeta, including 384 MB of orphaned rows and hundreds of MB of revision meta. Cleaning it up is good for disk space and backups, but none of those rows were ever loaded during a request. PHP memory only depends on what the code loads, not on how big the tables are. Measure first, then clean.

Useful commands for checking database bloat

# Biggest tables
wp db query "SELECT table_name, table_rows, ROUND((data_length+index_length)/1048576) mb FROM information_schema.tables WHERE table_schema=DATABASE() ORDER BY mb DESC LIMIT 10"

# Biggest postmeta keys
wp db query "SELECT meta_key, COUNT(*) c, ROUND(SUM(LENGTH(meta_value))/1048576,1) mb FROM wp_postmeta GROUP BY meta_key ORDER BY mb DESC LIMIT 10"

# Autoloaded options (loaded on every request)
wp db query "SELECT option_name, ROUND(LENGTH(option_value)/1024) kb FROM wp_options WHERE autoload IN ('yes','on','auto-on','auto') ORDER BY LENGTH(option_value) DESC LIMIT 10"

Takeaways

  • Raising WP_MEMORY_LIMIT only hides the problem. Every request on this site was using 500 MB.
  • Use --skip-plugins and --skip-themes with WP-CLI to narrow down the cause without touching the live site.
  • A tracer on the all hook, loaded with --require, shows exactly where memory grows and which file is responsible.
  • If memory grows in many small steps, look for a loop: an N+1 query or a meta load per post.
  • Delete tracer files when you are done, and never put them inside the web root.

Back to WordPress

Leave a Comment