Memory Management for Performance
Measure a PHP script's real memory footprint with memory_get_usage()/memory_get_peak_usage(), understand why large arrays are expensive, and apply generators and streaming to keep both request-scoped scripts and long-running workers from exhausting memory.
PHP's memory_limit exists because a script that keeps allocating without bound will eventually take down the server it runs on. In a typical PHP-FPM request this rarely matters in practice — the request finishes in milliseconds and every byte it allocated is reclaimed the instant the process moves on to the next request. That safety net disappears in two very common situations: a script that processes a genuinely large amount of data (an import of a million-row CSV, a report over years of transactions), and a long-running worker or daemon process that never gets the automatic reset a short-lived request gets. This lesson is the practical, applied side of PHP memory management — measuring usage, avoiding unnecessarily large arrays, using generators and streaming to process data lazily, and the specific habits long-running workers need. (For how memory actually works underneath — zvals, reference counting, and the garbage collector for circular references — see the dedicated "PHP Memory Model" lesson; this one is about measuring and reducing usage in real applications, not internals.)
Measuring memory with memory_get_usage() and memory_get_peak_usage()
memory_get_usage() returns the number of bytes PHP has allocated for your script right now, and memory_get_peak_usage() returns the highest that figure ever reached during the script's lifetime — even if usage has since dropped back down. Both accept an optional boolean argument (true) that reports the "real" size of memory blocks the allocator reserved from the operating system, rather than the amount actually in use within those blocks; without it, PHP reports emalloc()'d memory, which is a good approximation for spotting leaks in your own code but can understate what the process is really holding.
The peak figure is the one that actually matters for sizing memory_limit and for capacity planning — a script that averages 20 MB but spikes to 400 MB while sorting a large array needs headroom for that spike, not for the average. Sprinkling memory_get_usage() calls before and after suspect operations (loading a file, building a large array, running a report query) is a cheap, dependency-free way to find exactly which line is responsible for a memory blow-up, before reaching for a full profiler like Xdebug or Blackfire.
• memory_get_usage(true) reports real, OS-allocated memory; without true it reports memory actually in use by PHP's allocator. • memory_get_peak_usage() never decreases during a script — it's the high-water mark, which is what you size memory_limit against. • Bracket a suspect block of code with before/after calls to localize exactly where memory grows. • Logging peak usage per request/job is a lightweight way to catch regressions before they become production outages.
Why large arrays are expensive
A PHP array element is not free the way a raw C array slot is. Every value stored in an array is wrapped in PHP's internal zval structure, and the array itself is a hash table with bucket overhead for each key — so an array of one million integers routinely costs on the order of tens of megabytes, not the roughly 8 bytes per int you'd expect from a packed native array. Arrays of objects are heavier still: each object adds its own header and property storage on top of the array's own per-element overhead, and copy-on-write means that passing a large array into a function and mutating it there can silently duplicate the whole structure in memory.
The practical consequence is that "just load it all into an array and loop over it" — the natural first instinct — is exactly the pattern that runs out of memory on large datasets, even when the *logic* only ever needs one row at a time. The fix is almost never a bigger memory_limit; it's restructuring the code so it never needs the whole array in memory at once.
• Every array element carries zval + hash-bucket overhead — a large array costs far more than "element size × count". • Arrays of objects are more expensive than arrays of scalars, per element. • Copy-on-write can duplicate a large array in memory the moment it's modified inside a function. • If your logic only ever touches one item at a time, it usually doesn't need the whole collection in memory at once.
Generators: lazy, constant-memory processing
A generator function uses yield instead of return to produce a sequence of values one at a time, on demand, instead of building the whole sequence up front. Calling a generator function doesn't run its body immediately — it returns a Generator object, and each step through a foreach (or explicit ->current()/->next() call) resumes the function exactly where it left off, computes the next value, and pauses again. Because only the current value (plus whatever small amount of state the function needs) is ever held in memory, a generator that produces a million rows uses roughly the same memory as one that produces ten.
This makes generators the natural replacement for any function that would otherwise build and return a large array purely so a caller can loop over it once: reading a huge file line by line, transforming rows from a database cursor, or generating a sequence of computed values. The caller's code barely changes — it's still a foreach — but the memory profile changes from "proportional to dataset size" to "constant".
• yield pauses a function and hands back one value; the function resumes from that exact point on the next pull. • A Generator holds only its current position and local state, not the whole sequence — memory stays flat as the dataset grows. • Swap 'build an array and return it' for 'yield each item' anywhere a caller only loops over the result once. • Generators can also receive values back via yield (two-way communication), though the memory win alone is usually reason enough to reach for one.
Streaming large datasets instead of loading them fully
The same lazy-loading idea applies outside of generators, to I/O itself. file_get_contents() reads an entire file into a single string before your code sees any of it — fine for a small config file, disastrous for a multi-gigabyte export. fopen() plus fgets() or fgetcsv() in a loop reads and processes the file one line (or one row) at a time, so memory usage depends on the longest single line, not the file's total size. The same principle applies to HTTP responses (stream a large download to disk instead of buffering it in a string) and to database results (use an unbuffered/cursor-based query instead of fetching every row into an array when you're only going to iterate once).
Output works the same way in reverse: writing a large CSV or JSON export directly to php://output (or a file handle) as rows are produced, rather than building the entire response body as one giant string and returning it at the end, keeps memory flat regardless of how many rows the export contains.
• file_get_contents() loads the whole file at once; fopen()+fgets()/fgetcsv() streams it line by line. • Use unbuffered/cursor-style database queries when you only need to iterate a large result set once, not hold it all. • Stream large HTTP downloads to a file handle instead of buffering the whole body in a string. • For exports, write each row to php://output as it's produced instead of concatenating one giant string in memory.
Memory in long-running workers
A PHP-FPM request is memory-forgiving by construction: no matter how sloppy the code is, every byte it allocated is reclaimed the moment the process finishes handling that one request. A queue worker, a Swoole/ReactPHP server, or any script sitting in a while (true) loop processing job after job does not get that reset — it's the same PHP process, with the same memory arena, running for hours or days. Anything that accumulates a little bit per iteration — appending to a static array used as an ad hoc cache with no eviction, keeping database result sets or file handles open longer than needed, or building up log buffers that are never flushed — quietly grows the process's memory footprint until it hits the limit and crashes or gets OOM-killed, often hours into a shift when it's hardest to reproduce.
Good worker hygiene mirrors what a request boundary gives you for free: unset() large local variables as soon as you're done with them, call gc_collect_cycles() periodically if the workload creates circular references (which the reference-counting collector alone won't free), avoid unbounded static/singleton caches (or cap them with an eviction policy), and log memory_get_usage()/memory_get_peak_usage() per iteration so a slow leak shows up in monitoring long before it becomes an outage. The most reliable production technique, though, is simply not fighting the leak forever: configure the worker supervisor (Supervisor, systemd, a queue library's own setting) to restart the worker process after N jobs or after a memory threshold, which caps the blast radius of any leak to a single restart cycle.
• A short-lived FPM request frees everything at the end automatically; a long-running worker keeps the same memory arena across every job it processes. • Static caches with no eviction policy, unclosed resources, and growing log/result buffers are the classic slow leaks in worker loops. • unset() large variables you're done with, and call gc_collect_cycles() periodically if your data structures can form circular references. • Log memory_get_usage()/peak per iteration so a leak is visible in monitoring before it causes a crash. • Restart worker processes periodically (max jobs / max memory in the supervisor) as a pragmatic cap on any leak you haven't found yet.
Code Examples
Bracketing the array creation with two memory_get_usage(true) calls shows exactly how much memory that one line cost, and memory_get_peak_usage(true) confirms the high-water mark for the whole script so far — the number you'd actually use to size memory_limit or to compare against a previous version of this code. Expected result: Before: 0.50 MB | After: 40.13 MB | Peak: 40.13 MB $peak = memory_get_peak_usage(true);
squaresArray() must allocate storage for all one million values before the caller can even start looping — memory grows in proportion to $count. squaresGenerator() computes and yields one value at a time, so the foreach loop's peak memory stays flat regardless of how large $count is; swapping the array-returning version out for the generator is usually a one-line change at the call site.
yield $i * $i;
Common mistake: Calling squaresArray(10_000_000) to sum a sequence you only iterate once forces PHP to hold ten million values in memory simultaneously for no reason — the generator produces the identical sum using a small, constant amount of memory.
fgetcsv() reads and parses exactly one row per call, so memory usage depends on the length of a single row, not on how many rows the file contains — this function computes a total over a multi-gigabyte CSV with the same tiny memory footprint it would use on a ten-row file. The alternative, file(path) or file_get_contents() followed by explode/str_getcsv, would load the entire file into memory before processing a single row.
while (($row = fgetcsv($handle)) !== false) { Common mistake: Using file('orders.csv') to get an array of every line, then looping over that array, loads the whole file into memory up front — on a large export this is often the single line that pushes a script past memory_limit, even though the loop body itself only ever needs one line at a time.
This loop treats a leak it hasn't found yet as an expected fact of long-running life: it logs memory on a regular cadence so a slow climb shows up in monitoring, proactively collects circular-reference garbage, and — most importantly — exits cleanly after a bounded number of jobs or once memory crosses a ceiling, so a process supervisor (Supervisor, systemd, Kubernetes) restarts it with a clean memory arena before any leak becomes an outage.
if ($processed >= MAX_JOBS_BEFORE_RESTART || memory_get_usage(true) > MEMORY_CEILING_BYTES) {
Common mistake: Writing a worker as an unconditional while (true) loop with no restart condition means the only thing standing between the process and an eventual out-of-memory crash is that you've already found and fixed every leak — restarting periodically caps the damage from the ones you haven't.
<?php
declare(strict_types=1);
$before = memory_get_usage(true);
$data = range(1, 1_000_000); // a plain array of one million ints
$after = memory_get_usage(true);
$peak = memory_get_peak_usage(true);
printf(
"Before: %.2f MB | After: %.2f MB | Peak: %.2f MB\n",
$before / 1024 / 1024,
$after / 1024 / 1024,
$peak / 1024 / 1024
);<?php
declare(strict_types=1);
/** Builds the whole sequence in memory before returning it. */
function squaresArray(int $count): array
{
$result = [];
for ($i = 1; $i <= $count; $i++) {
$result[] = $i * $i;
}
return $result;
}
/** Yields one value at a time — never holds the full sequence. */
function squaresGenerator(int $count): Generator
{
for ($i = 1; $i <= $count; $i++) {
yield $i * $i;
}
}
$before = memory_get_usage(true);
$total = 0;
foreach (squaresGenerator(1_000_000) as $square) {
$total += $square;
}
$peakWithGenerator = memory_get_peak_usage(true);
printf("Sum: %d | Peak with generator: %.2f MB\n", $total, $peakWithGenerator / 1024 / 1024);<?php
declare(strict_types=1);
function totalRevenue(string $path): float
{
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException("Unable to open {$path}");
}
$total = 0.0;
fgetcsv($handle); // skip header row
while (($row = fgetcsv($handle)) !== false) {
// $row = [orderId, customer, amount]
$total += (float) $row[2];
}
fclose($handle);
return $total;
}
echo totalRevenue('orders.csv'), PHP_EOL;<?php
declare(strict_types=1);
const MAX_JOBS_BEFORE_RESTART = 500;
const MEMORY_CEILING_BYTES = 256 * 1024 * 1024; // 256 MB
$processed = 0;
while (true) {
$job = fetchNextJob(); // blocks/polls a queue backend
if ($job === null) {
usleep(100_000);
continue;
}
handleJob($job);
unset($job); // release this iteration's memory promptly
$processed++;
if ($processed % 50 === 0) {
gc_collect_cycles(); // reclaim any circular references
error_log(sprintf(
'worker: processed=%d peak_mb=%.1f',
$processed,
memory_get_peak_usage(true) / 1024 / 1024
));
}
if ($processed >= MAX_JOBS_BEFORE_RESTART
|| memory_get_usage(true) > MEMORY_CEILING_BYTES) {
error_log('worker: exiting for scheduled restart');
exit(0); // supervisor restarts a fresh process
}
}Why this matters in production
The two failure modes this lesson targets look identical from the outside — a process that dies with 'Allowed memory size exhausted' — but need opposite fixes. A one-off script that OOMs on a big CSV import almost always needs streaming/generators, because the whole problem is a single oversized array. A queue worker that OOMs after running fine for six hours almost always needs the worker-hygiene checklist (periodic gc_collect_cycles(), memory logging, and a supervisor-enforced restart), because the problem is slow accumulation across many small, individually-fine iterations, not one big allocation. Diagnosing which one you're looking at starts with memory_get_usage()/peak logged at the right granularity — per request for the first case, per N jobs for the second.
$rows = file('huge_export.csv'); foreach ($rows as $line) { /* process one line */ }
$handle = fopen('huge_export.csv', 'rb'); while (($line = fgets($handle)) !== false) { /* process one line */ } fclose($handle);
file() reads the entire file into an array of lines before your loop runs a single iteration, so memory usage scales with the whole file's size even though each iteration only ever needs the current line. fopen()/fgets() reads one line at a time, keeping memory usage flat regardless of file size.
Why does memory_get_peak_usage() matter more than memory_get_usage() for sizing a script's memory_limit? A. memory_get_usage() is always inaccurate and should never be used B. Peak usage captures the highest point the script reached even if usage later dropped, which is what determines whether the script would have hit the limit C. memory_get_peak_usage() is faster to call so it's preferred for performance reasons D. They always return the same value, so it doesn't matter which one you use
B. Peak usage captures the highest point the script reached even if usage later dropped, which is what determines whether the script would have hit the limit — A script's current usage can drop after a large temporary structure is freed, but if it briefly exceeded memory_limit at its peak the script would have fatally errored at that moment — peak usage is the figure that actually determines whether the limit was safe.
A generator function processes one million rows but never returns an array. What does this mean for memory usage compared to a function that builds and returns a one-million-element array? A. The generator uses roughly the same memory because PHP still processes all one million rows either way B. The generator uses much less peak memory because it only holds the current value and its own local state, not the whole sequence, at any one time C. The generator uses more memory because yield adds overhead per call D. There is no difference unless the values are objects instead of scalars
B. The generator uses much less peak memory because it only holds the current value and its own local state, not the whole sequence, at any one time — A generator suspends and resumes, computing and handing back one value at a time; unlike an array-returning function, it never needs to hold all one million values simultaneously, so peak memory stays roughly constant instead of growing with the row count.
Why is a memory leak more dangerous in a long-running queue worker than in a typical PHP-FPM web request handling the same code? A. PHP-FPM requests are immune to memory leaks entirely B. A worker keeps the same process (and memory arena) alive across many iterations, so small per-iteration leaks accumulate over hours, while an FPM request's memory is fully reclaimed the instant each request finishes C. Queue workers use a different, buggier version of the PHP memory allocator D. memory_get_usage() doesn't work correctly inside worker loops
B. A worker keeps the same process (and memory arena) alive across many iterations, so small per-iteration leaks accumulate over hours, while an FPM request's memory is fully reclaimed the instant each request finishes — A short FPM request gets memory reclaimed automatically at the end of every request regardless of code quality, but a worker process is the same PHP process for potentially days, so anything that accumulates a little per iteration (an unbounded cache, an unclosed resource) keeps growing until it exhausts memory — which is why periodic restarts and per-iteration logging matter specifically for workers.
You have a report() function that calls file_get_contents() on a 2 GB transaction log, json_decode()s the whole thing into an array, and sums a 'total' field across every entry. Rewrite it to stream the file instead, using a generator that yields one decoded JSON line at a time (the file is newline-delimited JSON, one object per line), and confirm with memory_get_peak_usage(true) that peak memory no longer scales with the file's size.
function readEntries(string $path): Generator { $handle = fopen($path, 'rb'); while (($line = fgets($handle)) !== false) { $line = trim($line); if ($line === '') { continue; } yield json_decode($line, true); } fclose($handle); } function report(string $path): float { $total = 0.0; foreach (readEntries($path) as $entry) { $total += (float) $entry['total']; } return $total; }
Replace file_get_contents()+json_decode() with fopen()+fgets() in a loop, json_decode() each line individually inside a generator function that yields the decoded array, and have the caller's foreach sum $entry['total'] as it iterates.