skip to content

In PHP, when does passing or assigning a large array actually duplicate its memory, and which values are refcounted at all?

level: middleimportance: should knowfreq 32%

answer

  1. share until someone writes
  2. separation on the first write
  3. int, float, bool, null live inline
  4. interned strings and immutable arrays
  5. debug_zval_dump() shows refcount

basics

~20 s

Assigning or passing an array only shares it; PHP duplicates the table when one holder writes while others still share it. Integers, floats, booleans and null live inside the zval uncounted; strings, arrays and objects are refcounted.

solid answer

~50 s

Every PHP variable is a **zval**. Integers, floats, booleans and `null` are stored inside the zval itself, so copying them is trivial and nothing is counted. Strings, arrays and objects point to a separate structure with a **refcount**. Assigning or passing an array just increments that count; `memory_get_usage()` barely moves. The cost comes at the **first write** through a holder while the count is above one: PHP separates, copying the array's table so the writer has its own. Nested arrays and strings inside it are shared again, not deep-copied. So a function that receives a 50 MB array and only reads it costs nothing extra, while one that appends a single element briefly holds two copies and raises the peak. Objects differ: the variable holds a handle, so passing one never copies the object. String literals and constant arrays compiled by OPcache are **interned** or immutable and are not refcounted at all.

code

php · 13 lines
php
<?php
declare(strict_types=1);

$a = range(1, 1_000_000);
$base = memory_get_usage();

$b = $a;                              // shares the array
echo memory_get_usage() - $base, "\n"; // about 0

$b[] = 0;                             // first write: separation
echo memory_get_usage() - $base, "\n"; // roughly the size of the array

debug_zval_dump('literal');           // string(7) "literal" interned

go deeper

for a junior

Know that assigning an array does not copy it until one side writes, and that objects are shared through handles.

for a middle

Explain zvals, which types are refcounted, separation on first write, and why a function that modifies a received array raises the peak.

for a senior

Spot hidden separations in hot code paths, read peaks rather than averages, and avoid reference tricks that force copies of their own.

for a principal

Guide APIs towards read-only passing of large data and chunked transformations, so memory cost scales with batch size rather than dataset size.

## zvals and what they point to In PHP every variable, array element and property holds a **zval**: a small fixed-size value with a type tag. What happens on copy depends on the type: | Type | Stored | Refcounted | |---|---|---| | `int`, `float`, `bool`, `null` | inside the zval | no | | `string` | separate buffer | yes, unless interned | | `array` | separate hash table | yes, unless immutable | | `object` | separate object; the zval holds a handle | yes | Copying a scalar copies a few bytes. Copying a string, array or object copies only a pointer and increments the target's **refcount**. ## Copy-on-write for arrays and strings Arrays and strings have **value semantics**: after `$b = $a`, changing `$b` must not change `$a`. PHP implements that lazily: 1. `$b = $a;` — both point to the same array; its refcount becomes 2. No memory is allocated. 2. `$b[] = 1;` — the write sees a refcount above 1, so PHP **separates**: it duplicates the array's table for `$b`, then writes. Now memory really doubles for the table. 3. `$a` keeps the original, whose refcount drops back to 1. The duplicate is **shallow**. Elements that are themselves strings or arrays are not copied; their refcounts are incremented, and they are separated later only if written through the new array. ## Where the cost shows up in real code - **Passing to a read-only function**: free. `function total(array $rows): int` that only loops over `$rows` adds no memory. - **Modifying a parameter**: a function that sorts, appends to or `unset()`s elements of a received array separates it, so for a moment the caller's array and the function's copy coexist. With a 200 MB array, the peak becomes 400 MB. - **Returning a modified copy**: `$rows = addTotals($rows);` separates inside the function, then the caller's old array is freed when `$rows` is reassigned. The peak still includes both. - **Array functions**: most, like `array_map()` and `array_filter()`, build a new array, so input and output coexist until the input is released. - **foreach by value**: iterating does not copy the array; the loop shares it. Writing to the array inside the loop is what forces a separation. ## Values that are not counted at all - **Interned strings**: literals, identifiers and class names in compiled code are stored once and shared without counting, and with OPcache they live in shared memory across requests. - **Immutable arrays**: with OPcache, an array literal made only of constants, such as `['draft', 'sent', 'paid']`, is stored as an immutable array. Assigning it copies nothing; the first write creates a normal, refcounted copy. `debug_zval_dump()` makes this visible: it prints `refcount(n)` for counted values and `interned` for interned strings and immutable arrays. ## Objects are different An object variable holds a **handle**. Passing it to a function or assigning it to another variable shares the same object, and writes are visible to every holder; nothing is separated. Only `clone` creates a second object, and even then it is shallow: properties that hold arrays are shared copy-on-write, properties that hold objects keep pointing at the same objects. ## Strings follow the same rule Strings are also copy-on-write. Appending with `.=` to a string that only one variable holds extends it in place; appending to a string that is shared first copies it. Functions such as `substr()` or `str_replace()` return new strings, so a loop that repeatedly rewrites a large string allocates a fresh copy each time. For large text output, writing pieces to a stream is cheaper than building one huge string. ## Practical rules 1. Pass large arrays freely to functions that only read them. 2. When a function must transform a large array, build the result while dropping the input, or process it in chunks. 3. Measure with `memory_get_peak_usage()`: separation costs show up as peaks, not as steady usage. 4. Do not reach for references (`&`) just to avoid a copy; a reference to an array that is also shared by value forces a separation of its own.

  • Does passing an object to a function copy it like an array?
    No. The variable holds a handle, so the function receives a handle to the same object, and property changes are visible to the caller. Only `clone` makes a second object, and that copy is shallow.
  • Why can modifying a received array inside a function double peak memory?
    The caller still holds the array, so its refcount is above one. The first write inside the function separates it: PHP duplicates the table for the function's copy. Until the function returns and one copy is released, both exist, and the peak reflects both.

A shared photocopy on a desk: everyone reads the same sheet, and only when someone wants to write on it do they make their own copy first.

saying these in an interview costs you the question

  • Assigning an array to another variable copies it immediately
  • Passing an object to a function clones it
  • Integers and booleans carry a refcount
  • Separation deep-copies every nested array
  • Using & everywhere is the standard way to save memory