A by-reference foreach leaves a reference in every element

Walking an array with foreach ($rows as &$row) wraps every element in a zend_reference. On a million integers that is 32 MB the loop never gives back.

Alden Pike9 min read

Everyone knows a by-reference foreach leaves a reference behind in $row. It also leaves one in every element of the array.

<?php

declare(strict_types=1);

$baseline = memory_get_usage();
$rows = range(0, 999_999);
printf("build            %+d bytes\n", memory_get_usage() - $baseline);

$baseline = memory_get_usage();
foreach ($rows as $row) {
}
printf("read-only walk   %+d bytes\n", memory_get_usage() - $baseline);

$baseline = memory_get_usage();
foreach ($rows as &$row) {
}
unset($row);
printf("by-reference walk %+d bytes\n", memory_get_usage() - $baseline);
build            +16793680 bytes
read-only walk   +0 bytes
by-reference walk +32000032 bytes

Both loop bodies are empty. One of them costs nothing and the other costs roughly twice the array, permanently — the unset($row) that every article recommends ran before that last reading and released none of it.

How these numbers were taken

PHP 8.5.10, NTS, arm64, Homebrew build, on an Apple M4 Pro laptop with 24 GB and 12 logical cores, running macOS and not quiesced. OPcache and the JIT are off for every figure, and memory_limit is raised so the million-element cases fit. This is the same machine and the same version as the post on what a variable holds, so the figures here and there are directly comparable.

Memory figures are single exact readings rather than medians: repeated runs of each script produced byte-identical output, so there is no variance to report. Timings are the median of 15 interleaved runs with three warmup runs discarded, taken with hrtime() inside the process, and the range is given with each one. Read the timings as relative to each other rather than as absolutes for your hardware, for the reasons that make absolute timings untransferable.

Every slot becomes a reference

A by-reference foreach has to hand the loop body something the body can write through. It does that by converting each element in turn into a PHP reference — the element’s slot stops holding the value and starts holding a pointer to a zend_reference struct that holds the value instead. The loop then binds $row to that struct.

What the loop does not do is convert the element back on its way to the next one. The array keeps every reference it made:

<?php

declare(strict_types=1);

$walked = [1, 2];
foreach ($walked as &$value) {
}
unset($value);
debug_zval_dump($walked);

$untouched = [1, 2];
debug_zval_dump($untouched);
array(2) packed refcount(2){
  [0]=>
  reference refcount(1) {
    int(1)
  }
  [1]=>
  reference refcount(1) {
    int(2)
  }
}
array(2) packed refcount(3){
  [0]=>
  int(1)
  [1]=>
  int(2)
}

Both arrays hold the same two integers. In the walked one, each of them sits inside a box. That box is 32 bytes — a refcounted header, one zval of its own, and one word for the property-type sources the engine tracks — which is the price of binding a single reference measured in the first post of this series, and the arithmetic of the opening measurement is that price times a million:

bytes per element
range(0, 999_999) 16,793,680 16.79
after foreach ($rows as &$row) 48,793,712 48.79
the difference 32,000,032 32.00

That is 2.905 times the array it started as. The layout is not what changed — the packed layout that stores values and derives keys from position survives the walk, and debug_zval_dump() still prints packed above. The same million integers converted to the hashed layout measure 41,943,120 bytes, so a by-reference walk is the more expensive of the two things that can happen to a packed array.

The wrapping is idempotent. A second by-reference foreach over the same array allocated zero further bytes, because there was nothing left to wrap.

The corruption is the smaller half

The behavior everyone writes about is the one you can see in the data:

<?php

declare(strict_types=1);

$names = ['ada', 'grace', 'alan', 'edsger'];

foreach ($names as &$name) {
    $name = strtoupper($name);
}

foreach ($names as $name) {
}

print_r($names);
Array
(
    [0] => ADA
    [1] => GRACE
    [2] => ALAN
    [3] => ALAN
)

edsger is gone. After the first loop, $name is still bound to the reference sitting in the last element, so the second loop does not read into a fresh variable — it assigns each value in turn through that reference, into slot 3. The last write before the loop ends is the second-to-last value, which is why ALAN appears twice. Adding unset($name) between the loops restores EDSGER, because it breaks the binding without touching the reference the array holds.

This is documented behavior rather than a bug, and it is not scheduled to change: the RFC that proposed unwrapping the reference after the loop targeted PHP 8.2 and its status is Withdrawn. The unset() is permanent advice.

It is also the half of the problem that announces itself. A wrong name in an array shows up in a test; 32 bytes per element does not.

What puts the array back

The obvious candidate does not work. array_values() on an array whose keys are already a sequential list hands the same array back with its reference count incremented, so the references come back with it:

Applied to a walked array Result still wrapped
array_values($rows) yes
array_merge([], $rows) yes
sort($rows) yes
array_slice($rows, 0) no
array_map(fn, $rows) no
array_filter($rows, fn) no
unserialize(serialize($rows)) no
$copy = $rows; $copy[0] = 1; no

array_slice($rows, 0) is the cheapest of the working ones and returned 32,000,000 of the 32,000,032 bytes the walk had taken. The remaining 32 is one more reference, allocated once rather than per element and held by something other than the array; I did not chase it into the C source. The last row of the table is the interesting one: separating a walked array produces an unwrapped copy, so copy-on-write is not broken by any of this. Two names still share until one writes, a function that takes the array by value still gets an isolated copy, and every semantic in the first post of this series still holds. What the walk changed is the footprint and nothing else.

Which is worth stating plainly, because “the array is now full of references” sounds like it should be a correctness problem and is not.

Where the wrapping is a rounding error

Thirty-two bytes per element is a flat charge, so what decides whether it matters is what the elements themselves cost. A million bare integers are 16.79 bytes each and the charge nearly triples them. Fifty thousand associative rows of three fields are not:

built                                     21,844,288 bytes   436.89 b/row
after foreach ($rows as &$r) { ... }       1,600,000 bytes    32.00 b/row
growth                                          7.32%

Seven percent on a result set is not a reason to restructure a loop. The million-element list is, and so is any long-lived array that gets walked this way on every request in a worker.

The time side does not argue against the loop either. Adding one to every element of a 1,048,576-element array, three ways:

median range
foreach ($rows as &$row) { $row++; } 7.425 ms 7.116–7.504
foreach ($rows as $i => $row) { $rows[$i] = $row + 1; } 7.816 ms 7.679–8.011
array_map(static fn (int $x): int => $x + 1, $rows) 15.981 ms 15.627–16.406

The by-reference loop is the fastest of the three. What it costs is charged afterwards, to every read of the array that follows: a full indexed scan measured 4.835 ms clean against 5.079 ms walked, and a foreach read 3.245 ms against 3.420 ms, so about 5% on each subsequent pass, with the ranges clear of each other on the second measurement.

What an ampersand is actually for

None of the above is an argument against references on function parameters, which is a different mechanism with a different bill. Passing a 1,048,576-element array into a function and writing one element to it:

Call Per call
readsOnly(array $rows) 11 ns
readsOnlyByRef(array &$rows) 12 ns
writesOneByRef(array &$rows) 14 ns
writesOne(array $rows) 1,797.615 µs

A factor of 128,401 between the last two rows, and the reason is the one this series started with: the by-value parameter shares the array with the caller, so its first write separates and pays for the whole thing. The ampersand does not make the call cheaper — it removes the second holder, so there is nothing to separate from. A by-reference parameter also did not wrap the caller’s elements on this build; only the foreach does that.

The folklore that says otherwise has a version number on it. The advice that you should never use references to speed up passing large arrays lives in a user-contributed note on the references page, and the note next to it that explains the mechanism describes PHP 4. The widely cited demonstration is a gist measuring an unused $ref = &$array binding making 10,000 no-op calls take 0.5552 seconds against 0.0022 without it — a factor of roughly 250, on PHP 5.5. I ran the same shape on 8.5.10:

10,000 calls of noop(array $rows) median relative
no reference bound 0.119 ms 1.000x
unused $ref = &$rows 0.114 ms 0.955x
second name $b = $rows 0.119 ms 0.996x

There is nothing there. The penalty was real when it was measured, and it belonged to a representation PHP no longer uses: in PHP 5 the reference flag lived inside the zval, so a value could not be shared between a variable that is a reference and one that is not and the copy was forced. PHP 7 moved the reference into a struct of its own, which is the 32-byte box above, and passing by value has read through it ever since. A codebase still avoiding references for that reason is carrying a decision nobody re-measured.

What this changes in code you write

Grep for as &$ and look at what happens to the array afterwards. A by-reference foreach over a list you mutate and discard costs nothing worth naming. The same loop over an array that survives the request — a cache warmed at boot, a lookup table held by a worker, anything a long-running process keeps — added 32 bytes per element on this build and kept them. array_slice($rows, 0) after the loop gives them back.

Keep the unset() regardless of the memory. It is not tidiness and it is not about the footprint at all; it is the line that decides whether the next loop over that variable reads the array or writes into it, and the RFC that would have made it unnecessary was withdrawn.

Judge a parameter’s ampersand on what the function does, not on how big the argument is. If the function writes to the array and the caller wants the write, the reference is the mechanism for that and it saved a full separation here. If the function only reads, it buys a nanosecond of nothing: 11 ns by value against 12 by reference on a million-element array. The size of the argument was never the variable that mattered — as the opening measurement of this series put it, the bill is charged to the write, and the ampersand only decides who receives it.

Frequently asked

Does unset($row) after the loop fix the memory as well as the corruption?
No. It fixes the corruption and nothing else. The references are held by the array, not by $row, so unsetting the loop variable released zero bytes on this build. What released them was rebuilding the array with array_slice($rows, 0).
Is foreach ($rows as &$row) slower than the alternatives?
It was the fastest of the three ways I measured to add one to every element of a 1,048,576-element array — 7.425 ms against 7.816 ms for writing back by index and 15.981 ms for array_map(). It costs memory, not time, on the write itself; reading the array afterwards cost about 5% more.
Does this apply to a list of database rows, or only to huge integer arrays?
The charge is 32 bytes per element whatever the elements are, so it is dilution that decides. On 50,000 associative rows of three fields it added 1,600,000 bytes to 21,844,288 — 7.32%. On a million bare integers it nearly tripled the array.
Is the ampersand on a function parameter the same problem?
No. A by-reference parameter did not wrap the caller's elements on this build; only the foreach did. Writing through one is the case where the ampersand earns its keep.
Share

Written by

Alden Pike

Alden Pike writes about PHP internals, performance, architecture and production behavior — the layer beneath the frameworks. Measurements over assumptions, trade-offs over universal rules.

More about the author

Related posts

Arrow keys to move, Enter to open.