Skip to content
System nominalUTC --:--:--Articles 080

“Allowed Memory Size Exhausted” Is Almost Never a Memory Problem

The fatal names the file that ran out, not the code that filled memory. How to find the query or loop that is actually growing in memory.

Martin Kiel · @mkiel

WordPress internals

Topic
PERF
Section
Performance
Published
16 OCT 2026
Reading time
8 MIN
Words
1780W
Technical cover graphic for “Allowed Memory Size Exhausted” Is Almost Never a Memory Problem
Generated schematic — PERF series, REV 2.6.

The message on the screen is short and the message in the log is long, and neither one names the code you have to change. A store's product page dies with Allowed memory size of 268435456 bytes exhausted, the log points at wp-includes/class-wpdb.php, and the owner's instinct is that the database class is misbehaving. It is not. It is the last code that ran before the request was killed.

I reproduced this locally on WordPress 7.1.3, PHP 8.5.6, with a 48M memory limit and 284 published posts carrying about 24 MB of post_content between them. One plugin function that selects every published post at once is enough. Here is the fatal from the server log, with the local path left as it was:

PHP Fatal error:  Allowed memory size of 50331648 bytes exhausted (tried to allocate 90112 bytes) in /private/tmp/m-wperrors-lab/wordpress/wp-includes/class-wpdb.php on line 2324

The allocation that finally failed was 90 KB. The request had already consumed everything else, and the file that happened to be running was the MySQL result reader. Raising the limit to 512M would move the number in the message and change nothing about the cause.

The browser hides it, and the debug log may too

With WP_DEBUG_LOG set to true and display off, the visitor sees the standard critical error page: 2704 bytes of HTML in which the only readable sentence is "There has been a critical error on this website." That is by design and it is not the part of this article you can fix.

What surprised me is the second destination. On this install wp-content/debug.log stayed empty through the fatal, while the web server log got the full line. Memory exhaustion is raised as a core PHP fatal rather than a catchable throwable, so it reaches the server error log first. If you grep only debug.log for "Allowed memory size" and find nothing, that does not mean the fatal did not happen. Check the server log, and check that log_errors is on at all: the built-in PHP server in this lab prints errors from a php.ini where I set it explicitly.

Environment note

WordPress defines WP_MEMORY_LIMIT as 40M for a single site and 64M for multisite, but only raises PHP's limit when the ini value is changeable and lower than the constant. On most managed hosts the effective limit is whatever the host set, and phpinfo() or wp eval 'echo ini_get("memory_limit");' is the only number worth arguing about. WP_MAX_MEMORY_LIMIT defaults to 256M and is the ceiling admin-side work gets raised to.

The fatal is a symptom with three usual authors

Almost every exhausted limit in a WordPress request comes from one of three shapes, and they are distinguishable from the query log and the file name in the message.

An unbounded result set. A plugin builds a report, a sitemap, a CSV feed or a "recent orders" widget with $wpdb->get_results() and no LIMIT, or with posts_per_page => -1 through WP_Query. Every row is a PHP object in memory, and the columns nobody displays are loaded anyway unless you asked for a field list. This is the case in my reproduction, and the tell is that the failing file is class-wpdb.php.

A loop that accumulates. The query is bounded, but the plugin appends to an array inside the loop, or builds one giant string for an export. The failing file is then the plugin's own file, which is a gift: the message includes the line number, and the line number is the loop. The same shape appears as a recursive function walking a category tree without a depth limit.

One operation that needs a large buffer by design. Compressing an archive, building a PDF, unloading a ZIP, or an image operation. Here the failing file is wp-admin/includes/file.php or an image editor class, and the number in the message is close to the size of the thing being processed. This is the one class where a higher limit is sometimes the correct answer, and I will come back to it.

The three authors produce the same sentence. The file name separates the second from the others immediately; the query log separates the first.

One thing the query log will not do for you here, and it is why people stare at the MySQL slow query log and find nothing: a slow query is logged when it completes. When the request dies while reading the result set, the query never completes, never reaches the slow log, and is gone from SHOW PROCESSLIST a second later. A two-second query that returns nine rows appears reliably; the query that killed your page does not appear at all. That asymmetry is worth knowing before you conclude the database is innocent.

Find the culprit by measuring the request, not by guessing

You cannot use SAVEQUERIES here, and this is worth understanding before you try. The query log records a query after it returns. The request that dies in my reproduction never returns: it dies inside mysqli_query() while the result set is being read. On a run with SAVEQUERIES enabled and a shutdown handler printing $wpdb->queries, the log held four queries from bootstrap and no trace of the 24 MB SELECT, because the SELECT never finished. The shutdown function did run, which is how I know the array was intact.

What does work is measuring the memory delta around your own code. Wrap the suspect in a small probe, run it on staging with the memory limit left at the production value, and print the peak before and after. On the same install, the unbounded select never completed, while the same work done in batches of twenty finished with a 34 MB peak against a 48 MB limit:

$start = memory_get_peak_usage( true );
$rows  = $wpdb->get_results(
	"SELECT ID, post_title, post_content FROM {$wpdb->posts} WHERE post_status = 'publish'"
);
printf(
	"returned %d rows, peak went from %.1fMB to %.1fMB\n",
	count( $rows ),
	$start / 1048576,
	memory_get_peak_usage( true ) / 1048576
);

When the probe dies instead of printing, you have your answer and you did not have to guess. To watch the whole request rather than one function, put the same call in a shutdown handler and log the peak on every request for an hour. The endpoint that reaches 90 percent of the limit is the one to fix, and it is rarely the page you suspected.

A lower limit is a better test

Set the staging limit to half of production while you are looking. A request that touches 45 percent in production will exhaust a limit set to half, which turns a slow accumulation into an immediate fatal with the same file and line. It is the cheapest profiler WordPress has.

Fix the shape, not the number

Batching is the fix for the first two authors and it is a small change. Select IDs first, then fetch the heavy columns twenty or fifty at a time, and free each batch before the next one. On the local install the batched version processed 283 rows in four batches at a flat 34 MB peak. Nothing else about the work changed.

$last = 0;
do {
	$ids = $wpdb->get_col( $wpdb->prepare(
		"SELECT ID FROM {$wpdb->posts}
		 WHERE post_status = 'publish' AND ID > %d
		 ORDER BY ID ASC LIMIT 50",
		$last
	) );

	foreach ( $ids as $id ) {
		$last = (int) $id;
		my_plugin_process_post( $id );
	}
} while ( $ids );

Two details make this hold. The ID > %d cursor is cheaper than OFFSET, which makes the database re-read and discard every earlier row on each pass. And any write inside my_plugin_process_post() belongs in its own batch boundary: a thousand-row transaction will undo your memory saving by filling the binlog or the redo log instead.

If the request is an admin report, consider a different primitive rather than a smaller query. Action Scheduler, which WooCommerce ships, will run a batched job as a series of separate processes, so the memory ceiling is per batch and the work survives one batch failing. For a one-off export, WP-CLI does the same thing with no web server timeout at all. The point of both is that no single request has to hold the entire result.

The one case where raising the limit is right

Image work is the exception, and I want to be precise about it because the advice above otherwise reads as "never raise the number". WordPress itself raises the limit for this class of work: wp-admin/includes/file.php calls wp_raise_memory_limit( 'admin' ) before unzipping an upload, and the image editor does the same before an edit operation. Core is telling you that some operations need a bigger buffer on purpose.

What I could not reproduce on this machine is the classic image-buffer fatal. I generated a 2.9 MB JPEG at 14000 by 14000 pixels and asked the GD editor to load, resize and rotate it under a 48M limit. All three operations succeeded, and a direct imagecreatefromjpeg() followed by imagecreatetruecolor( 14000, 14000 ) left PHP's reported usage at 2 MB. On this PHP build the image buffer is not counted against memory_limit, which is not what I expected and is not a promise about your host. Treat the arithmetic as guidance rather than as a reproduction: a 6000 by 4000 RGB image is 72 MB of pixel data before any copy, so a host with a 128M limit and a thumbnail-regeneration plugin has a real ceiling, and two buffers at once is how people find it.

The honest limitation of everything above: measuring memory tells you where the request grew, not why the data set is that large. If the unbounded query is selecting 24 MB of post_content because a plugin stores serialised payloads in post bodies, the batch fix makes the page load and leaves the design in place. At some point the answer is not a smaller query; it is a table that holds only the columns the request needs, which is a migration, not a hotfix.

What to do on Monday

  1. Read the file and line in the fatal before touching any limit. class-wpdb.php means an unbounded or unbounded-in-effect query; a plugin path means a loop that accumulates.
  2. Add the memory probe to the two endpoints that page owners complain about, and leave it for a day. The peak number is more useful than any single stack trace.
  3. Replace posts_per_page => -1 and unbounded get_results() calls with a cursor and a batch size. Keep the batch size in a filter so you can tune it without a deploy.
  4. Raise WP_MAX_MEMORY_LIMIT only after you have identified an operation that needs a large buffer, and write the reason in the commit message. A limit with no reason attached to it grows forever.

Martin Kiel · @mkiel

WordPress internals

Writes about the load-bearing parts of WordPress: the options table, hook ordering, WP_Query behaviour and the database work that decides whether a plugin scales or falls over.

Continue

Related in Performance