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
| Configuration | Peak memory |
|---|---|
| Core only | 51 MB |
| Theme only | 50 MB |
| Plugins, no theme | 181 MB |
| Everything | 503 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.
- It calls
get_pages(), which loads every published page with its full content. That is 137 MB just to show page titles. - For each page, it checks
isset( $v->slug ).WP_Posthas noslugproperty (it is calledpost_name), so PHP callsWP_Post::__isset(). That runsmetadata_exists(), which loads all post meta for the page to look for a custom field namedslug. 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
| Change | Peak PHP memory |
|---|---|
| Before | 503 MB |
| Database cleanup (revisions, orphaned postmeta) | 503 MB (no change) |
| Shrink Elementor per-page custom CSS | 453 MB |
Fix 1: skip isset() on WP_Post | 295 MB |
| Fix 1 + Fix 2 | 184 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_LIMITonly hides the problem. Every request on this site was using 500 MB. - Use
--skip-pluginsand--skip-themeswith WP-CLI to narrow down the cause without touching the live site. - A tracer on the
allhook, 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