
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.
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

