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

- Published: 2026-08-30
- Tags: memory, zend-vm, hashtable
- Source: https://elephantphp.com/blog/what-a-php-variable-actually-is/
- Language: en-US
- Author: Alden Pike

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

```text
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](/blog/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](/blog/what-happens-when-php-runs-your-code/), 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](/blog/php-arrays-are-not-arrays/) stores the value and derives the key
from the position. Filling one with a million values of four different types:

```text
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
<?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.

```text
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:

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

```text
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:

```text
$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:

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