What a PHP variable actually is

A zval is sixteen bytes, a value union and a type tag. What that tag says decides whether an assignment copies anything, what a reference costs, and which variable pays.

Alden Pike10 min read

Copying a million-element array into a second variable moves memory_get_usage() by zero bytes. Writing one element to either variable moves it by 16,793,680 — the whole array, charged to whichever name wrote first.

<?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();
$copy = $rows;
printf("second name  %+d bytes\n", memory_get_usage() - $baseline);

$baseline = memory_get_usage();
$copy[0] = 1;
printf("first write  %+d bytes\n", memory_get_usage() - $baseline);

$baseline = memory_get_usage();
$copy[1] = 1;
printf("second write %+d bytes\n", memory_get_usage() - $baseline);
build        +16793680 bytes
second name  +0 bytes
first write  +16793680 bytes
second write +0 bytes

Two writes, one bill. Nothing about the array changed between them, and nothing in the source distinguishes them. What changed was a number that sits next to the array rather than inside either variable, and the variables themselves are the place to start, because they are smaller than they look.

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 below, and memory_limit is raised to 2G so the million-element cases fit — except in the one place further down that names a different limit, which is the point of that measurement. The rest of the archive was measured on 8.5.9 on the same machine.

Memory figures are single exact readings rather than medians: two consecutive runs of the whole 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. Where a table times one statement out of a sequence, the clock brackets that statement alone and the setup around it runs untimed. Read the timings as relative to each other, not as absolutes for your hardware — the reasons are in benchmarking PHP without lying to yourself.

Sixteen bytes, whatever you put in them

A userland variable is a zval, and on a 64-bit build it is sixteen bytes: an eight-byte union holding the value, a four-byte word carrying the type tag and its flags, and four more bytes the engine reuses for bookkeeping such as the next entry in a hash collision chain. The union is one machine word wide, so an integer or a float lives directly inside it, while a string, an array or an object is a pointer to something else.

Nothing in there is the variable’s name. The compiler assigns each named local a numbered slot in the call frame, which is the compiled variable an opcode dump prints as CV0, and the sixteen bytes are what that slot holds.

That the slot never changes size is measurable from userland, because a packed array is a row of zvals and nothing more — the layout behind every PHP array stores the value and derives the key from the position. Filling one with a million values of four different types:

appended 1,000,000 integers                    16.79 bytes/element
appended 1,000,000 floats                      16.79 bytes/element
appended 1,000,000 booleans                    16.79 bytes/element
appended 1,000,000 nulls                       16.79 bytes/element

Four types, one figure, to the byte. It is 16.79 rather than 16.00 because the array allocated capacity for 1,048,576 elements and a header on top; storing 1,048,576 integers instead of a million produced the identical 16,793,680 bytes, which is 1,048,576 slots of sixteen bytes plus 16,464 bytes of table.

Null is the case worth pausing on. null is a type tag with no payload, and it still costs a full slot, because the slot is what an array element is. The same holds for true and false: the engine has separate type tags for them, so a boolean does not even use the value union.

The count lives with the value, not with the variable

One byte of that four-byte word is the type tag and one carries flags. A single flag there decides everything above: whether the thing the union points at is reference counted. Integers, floats, booleans and null are not, because there is nothing to point at. Strings, arrays and objects are, and the count for them lives in an eight-byte header on the pointed-to structure — not in the variable.

debug_zval_dump() prints that header when there is one:

<?php

declare(strict_types=1);

$number = 42;
$alsoNumber = $number;
debug_zval_dump($number);

$name = 'elephant';
debug_zval_dump($name);

$built = strrev('tnahpele');
debug_zval_dump($built);

$rows = range(1, 3);
debug_zval_dump($rows);

$alsoRows = $rows;
debug_zval_dump($rows);

Each array dump also prints its three elements; those lines are replaced by an ellipsis below.

int(42)
string(8) "elephant" interned
string(8) "elephant" refcount(2)
array(3) packed refcount(2){ ... }
array(3) packed refcount(3){ ... }

The integer prints no count at all, under two names, because a copy of an integer is a copy of eight bytes and there is nothing to share. The string literal prints interned instead of a count: a string that appears literally in the source is deduplicated into the interned buffer and lives until the request ends, so the engine has no reason to count references to it. Only the string built at runtime carries a number.

Two artifacts of the method are worth naming before you use it. debug_zval_dump() receives its argument by value, which adds one to every count it prints — the runtime-built string under one name reads refcount(2). And an array written as a literal is held by the compiled literal table as well, so $rows = [1, 2, 3] reads one higher than the range(1, 3) above — and [1, 2, $x], which the compiler cannot fold into a literal, reads the same as range(). Read the deltas, not the absolute values.

The write pays, not the assignment

Assigning a refcounted value increments its count and stores the same pointer. Writing to it does something else first: if the count is above one, the engine separates — allocates a new copy, decrements the count on the old value, and performs the write on the copy. This is why the second write in the opening measurement was free. The first one had already brought the count back to one.

The engine has no idea which variable is the “original”, so neither should you. Assigning $copy = $rows and then writing to $rows charges the 16,793,680 bytes to $rows. Whoever writes first pays, and with three names sharing one array, the first two writes each pay in full and the third gets it free.

The gap between the two paths is not subtle:

Timed statement 131,072 elements 1,048,576 elements
$copy = $rows; and unset($copy); together 3.353 ns (3.019–3.694) 3.299 ns (3.048–3.411)
$sole[0] = $i; with no other holder 3.632 ns (3.311–3.813) 3.556 ns (3.329–3.921)
$copy[0] = 1; while $rows still holds it 135.229 µs (131.262–143.856) 1,163.179 µs (1,140.960–1,226.146)
unset($copy); on the copy that write made 73.982 µs (70.830–75.539) 598.305 µs (585.449–624.754)

The first two rows do not move with the array size, because incrementing a counter and writing into a slot you already own are both constant-time operations. The last two do: eight times the elements cost 8.60 times the separation and 8.09 times the release. The same statement, $copy[0] = 1, is either 3.6 nanoseconds or 1.16 milliseconds here — a factor of roughly 327,000, decided by a number the source code cannot see.

The fourth row is the half that is easy to forget. Separating allocates a second array, and something has to give it back; at a million elements that release costs another 598 µs, so one shared write bills about 1.76 ms in total, at two different moments.

Function arguments are assignments

A by-value parameter is bound the same way, which is why passing a large array into a function is free and reading it stays free:

readOnly(array $rows)        resident            0   peak              0
writes(array $rows)          resident            0   peak     16,793,256
writesByRef(array &$rows)    resident           32   peak              0
writesByRef() again          resident            0   peak              0

The middle row is the one that catches people. writes() copied the array, wrote one element to the copy, and dropped the copy on return — so the resident figure was unchanged before and after, and the only evidence the copy existed is the peak. It reads 424 bytes short of the array’s own 16,793,680 because the peak was already sitting exactly that far above the resident figure when the call started; the copy is the whole array.

An application that reports memory_get_usage() after a request is reporting what survived, not what was allocated, and a function that quietly doubles peak memory for the duration of a call is invisible in that number. memory_get_peak_usage() is the one that saw it.

The copy is real enough to be fatal. Under a 24M memory_limit, with nearly eight megabytes of headroom at the moment of the call:

<?php

declare(strict_types=1);

function writes(array $rows): int
{
    $rows[0] = 1;

    return $rows[0];
}

$rows = range(0, 999_999);
printf("resident before the call  %s bytes\n", number_format(memory_get_usage()));
writes($rows);
resident before the call  17,276,352 bytes

Fatal error: Allowed memory size of 25165824 bytes exhausted (tried to allocate 16777224 bytes) in limit.php on line 7

The absolute path is replaced by the file name above; nothing else is edited. Line 7 is $rows[0] = 1, and the 16,777,224 bytes it asked for are the array it was handed, copied so that one element could change.

A reference is a third kind of thing

The writesByRef() row in that table is where the ampersand shows its price. A PHP reference is not a flag on a variable and not a second pointer to the array. It is a different type of value: the variable’s type tag changes to reference, and the union points at a zend_reference struct that holds the refcounted header, one zval of its own, and one word for the property-type sources the engine tracks for typed properties. Eight plus sixteen plus eight is 32, which is exactly what binding one costs:

$r = &$a                                                    32 bytes
$b = $a   (while $a is referenced)                           0 bytes
$b[0] = 1                                           16,793,680 bytes
$a[0] = 1  (through the reference)                           0 bytes

The second line is worth reading twice. Binding a reference does not stop the array from being shared: $b still got it for nothing, and $b’s first write still paid the full separation. What the reference bought was the third line’s mirror image — after $b separated, $a was the sole owner again and its own write cost nothing.

So the ampersand does not remove the copy. It moves the writes down to a value the variable no longer holds directly, behind a 32-byte box every access has to step through. It is the right tool when a function must modify the caller’s variable, which is a semantic requirement rather than a memory one. Reaching for it because an array is large is reaching for the wrong lever, and this post has not measured what it does to a foreach that references its element.

Objects are handles, and clone is shallow

Objects use the same machinery to reach the opposite behavior. The variable holds a pointer to one zend_object, and assigning copies that pointer, but nothing ever separates:

new Batch(range(0, 999999))                         16,793,736 bytes
$two = $one                                                  0 bytes
$two->rows[0] = 1                                            0 bytes
clone $one                                                  56 bytes
$three->rows[0] = 2  (after clone)                  16,793,680 bytes

$two->rows[0] = 1 allocated nothing and $one->rows[0] reads back as 1. Two names, one object, and a write through either is visible through both — the count went up, and no copy was ever going to be made. That is the whole difference between “share until written” and “share forever”, and in the source both are spelled $b = $a.

Both ends of that block agree on the object’s own size. Batch holding the array cost 16,793,736 bytes against the array’s 16,793,680, and clone costs 56 bytes on this class — which is the size of a zend_object carrying its header, handle, flags, class entry, handler table, property hash pointer and one property slot. The clone is shallow: the new object’s rows property points at the same array, and the 16,793,680 bytes only appear when something writes to it.

What this changes in code you write

Look at the write, not the assignment. Handing an array to another variable, a function or a constructor is a counter increment whatever the array holds, and the search for the allocation ends at the first line that modifies a value more than one name is holding. That line is usually not the one in the profiler’s memory column, because by the time the profiler samples, the copy has been made and the counter is back to one.

Read the peak alongside the current figure. memory_get_usage() after a request tells you what is still held; memory_get_peak_usage() is the number that records a copy created and destroyed inside one call, and a memory_limit that fires on a request whose reported usage looks fine is describing the gap between those two readings.

Before adding an ampersand for performance, print the count. debug_zval_dump() on the variable, minus one for the call itself, tells you whether anything is actually shared. If the count is one, there is no copy to avoid and the reference only adds a struct and an indirection. If it is above one, the question is which name writes first — and that is a question about your code’s order of operations, not about the size of the array.

Frequently asked

Does passing a large array to a function copy it?
Not on its own. A function that only reads its array parameter moved neither the resident figure nor the peak on this build. A function that writes one element to it moved the peak by 16,793,256 bytes and the resident figure by nothing at all, because the copy was freed when the call returned.
Is an ampersand faster for large arrays?
It was not free here. Binding a reference allocated 32 bytes, which is the size of the zend_reference struct, and it does not remove the copy. A referenced array is still shared with any other variable that was assigned from it, and the first write by either of them still separates.
Do objects copy on write like arrays?
No. An object assignment copies a handle, so both names address one object and a write through either is visible through the other. clone allocates a new object header, measured at 56 bytes, and the arrays inside it stay shared until something writes to them.
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.