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

- Published: 2026-09-06
- Tags: memory, benchmarking, hashtable
- Source: https://elephantphp.com/blog/copy-on-write-and-the-cost-of-passing-arrays/
- Language: en-US
- Author: Alden Pike

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

```php
<?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);
```

```text
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](/blog/what-a-php-variable-actually-is/), 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](/blog/benchmarking-php-without-lying-to-yourself/).

## 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
<?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);
```

```text
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](/blog/what-a-php-variable-actually-is/)
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](/blog/php-arrays-are-not-arrays/)
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
<?php

declare(strict_types=1);

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

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

foreach ($names as $name) {
}

print_r($names);
```

```text
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](https://wiki.php.net/rfc/foreach_unwrap_ref) 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:

```text
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](https://www.php.net/manual/en/language.references.php),
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](https://gist.github.com/ihabunek/6375653) 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](https://www.npopov.com/2015/05/05/Internal-value-representation-in-PHP-7-part-1.html)
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.
