Understanding Copy-on-Write in PHP Arrays
AI generated
<?php
8.4
PHP · Arrays · Memory Management
Understanding Copy-on-Write in PHP Arrays
Why copying costs almost nothing, until it actually does

An assignment like $b = $a for a large PHP array looks at first glance like an expensive copy operation, but internally it initially only costs incrementing a reference counter. Copy-on-Write postpones the actual copy to the moment one of the two variables is actually modified, and this exact mechanism decides memory usage and performance for every array hand off in PHP.

14 min read Copy-on-Write · refcount · zend_array PHP 8.x

1. What Copy-on-Write actually means for arrays

PHP arrays are officially treated as value types: an assignment like $b = $a is supposed to behave as if an independent copy of $a is created in $b, so a later change to $b does not affect the original in $a. If PHP actually created this copy physically on every single assignment, that would be noticeably slow and memory intensive for large arrays with thousands of elements, even if the copy is never modified in the end.

Copy-on-Write solves exactly this problem by postponing the physical copy to the last possible moment: the point where one of the two variables is actually modified. Until then, $a and $b internally share the same memory structure, PHP merely counts how many variables currently point to that structure. From the perspective of PHP code, the array still behaves correctly as a value type, because the shared structure remains unchanged for both variables as long as no write operation occurs.

This mechanism affects not only simple assignments but also passing arrays as function parameters, storing them in further arrays, and function return values. Copy-on-Write is therefore one of PHP's central performance mechanisms, one that usually stays invisible in everyday use but whose precise understanding becomes decisive as soon as large data volumes are passed through function calls repeatedly.

2. The internal structure of zend_array

Internally, every PHP array is represented by a structure called zend_array, which manages both ordered values and, for associative arrays, a hash table for the keys. Central to Copy-on-Write is the embedded zend_refcounted_h field, which contains a reference counter. This counter indicates how many PHP variables currently point to exactly this one zend_array instance, regardless of what variable name they appear under in the code.

When an array is assigned to a new variable, for example through $b = $a, PHP does not create a new zend_array structure. Instead, $b merely receives a pointer to the same structure that $a also points to, and the reference counter is incremented from one to two. This operation costs only a few instructions and is independent of the actual size of the array; whether it contains ten or ten million elements makes no difference for the assignment itself.

This applies both to simple assignments and to storing an array as a value in another array or as a property of an object. In all these cases PHP first only increments the reference counter of the underlying zend_array structure instead of duplicating the contained elements. Only an actual change to one of the involved variables breaks this sharing.

3. Reference counting and the moment of the real copy

As soon as one of the two variables is actually modified, for example through $b[] = 'new' or $b['key'] = 'value', PHP checks the reference counter of the underlying structure before the actual write operation. If the counter is greater than one, this means at least one other variable shares this structure, and PHP creates a full, independent physical copy of the entire zend_array structure at this point before applying the change. Only this copy is then actually modified, the original remains unchanged for all other variables.

This moment of copying is the actually expensive operation of Copy-on-Write, and its cost scales linearly with the size of the array: copying an array with a million elements noticeably costs more time and memory than an array with ten elements. As long as only reads happen, though, for example through a foreach iteration or simple value access, the reference counter remains unchanged and no copy occurs, regardless of how often it is read.


declare(strict_types=1);

$original = range(1, 1_000_000);

$before = memory_get_usage();

// No copy yet: only the refcount of the underlying zend_array increases
$shared = $original;

$afterAssignment = memory_get_usage();
printf("After assignment: %d bytes extra\n", $afterAssignment - $before);
// Typically close to zero — no physical copy has happened yet

// Now trigger an actual copy: modifying $shared forces PHP to
// duplicate the whole zend_array structure before applying the change
$shared[] = 1_000_001;

$afterMutation = memory_get_usage();
printf("After mutation: %d bytes extra\n", $afterMutation - $before);
// Now the full array size is duplicated in memory

The difference between the two measurement points in this example shows Copy-on-Write directly: after the pure assignment, hardly any additional memory is allocated; after the first write operation on $shared, memory usage nearly doubles, because now two fully independent copies of the million element array exist.

4. Function calls and Copy-on-Write together

PHP passes arrays to functions by value by default, which without Copy-on-Write would force a full copy on every function call, even if the function never modifies the passed array at all. With Copy-on-Write, exactly this does not happen: the parameter inside the function initially points to the same zend_array structure as the argument in the caller's context, the reference counter is incremented, no physical copy occurs.

Only when the function actually modifies the parameter, for example by adding or removing an element, does the same mechanism as with variable assignments apply: PHP creates a private copy inside the function's scope, the original in the caller remains untouched. This property makes pure, read only functions with array parameters practically free in terms of memory and copy cost, regardless of the size of the passed array.


declare(strict_types=1);

/**
 * Read-only function: no copy happens regardless of array size,
 * because the refcount only increases, nothing is ever written.
 */
function sumValues(array $values): int
{
    $total = 0;
    foreach ($values as $value) {
        $total += $value;
    }
    return $total;
}

/**
 * Mutating function: forces a real copy inside the function scope
 * as soon as the first write happens, caller's array stays untouched.
 */
function withAppendedTotal(array $values): array
{
    $values[] = array_sum($values); // triggers copy-on-write here
    return $values;
}

$data = range(1, 500_000);

$sum = sumValues($data);       // no copy — pure read
$extended = withAppendedTotal($data); // copy happens inside the function

// $data itself remains fully unchanged and unaffected by withAppendedTotal

This property is a central reason why type hints like array $values can be used in function signatures without additional performance concern, even when potentially very large arrays are passed. The type hint itself does not trigger a copy, only the actual write inside the function does.

5. Explicit references and how they defeat Copy-on-Write

As soon as a reference with & comes into play, for example $b = &$a or a function parameter with function foo(array &$values), the behavior changes fundamentally. A reference permanently binds two variable names to the same underlying structure, and Copy-on-Write no longer applies to this binding: a change through either of the two referenced variables becomes immediately visible through the other, no independent copy is ever created.

In practice this means: references are the right choice when a function should actually modify a large array in place without PHP creating a full copy for it, for example a sorting function that reorders the passed array directly instead of returning a sorted copy. The downside: references are harder to reason about, because a function with a reference parameter invisibly changes state in the caller's context, which makes the control flow less obvious than an explicit return value.

An often overlooked side effect: once a variable has been bound by reference anywhere in the code, it permanently loses the ability to participate in Copy-on-Write, even after the reference binding has been dissolved via unset(). PHP internally marks the underlying structure as referenceable (IS_REF), and this mark persists until the structure is fully recreated. This can lead to unexpected, permanent copies with heavy use of references, where Copy-on-Write should actually apply.

6. Nested arrays and partial copies

For multidimensional arrays, Copy-on-Write applies independently at every nesting level. If only a nested sub array is changed, PHP does not necessarily copy the entire outer array in full, but in certain cases can duplicate only the affected branch of the structure, while unchanged sibling elements continue to reference the shared structure. This behavior, however, depends strongly on the exact access pattern and should not be treated as a guarantee without measuring it for the concrete use case.

A practical example: a large, nested configuration array from which a function only reads a single, deeply nested value causes no copy, because only reading happens. If, on the other hand, a single key in a deeply nested sub array is changed, PHP has to copy at least the path from the root down to that key, because every level along the way exists as its own zend_array structure with its own reference counter.

In practice this means: for very large, deeply nested configuration structures, as they frequently occur in Magento for attribute sets or layout configuration, it pays to check specifically where writing actually happens, instead of assuming across the board that every modification copies the entire structure. A deliberate redesign that moves frequently changed values into a separate, smaller array can noticeably lower copy cost.

7. Making Copy-on-Write visible in practice

To make Copy-on-Write visible in your own code, the Xdebug function xdebug_debug_zval() is useful, which for a given variable outputs, among other things, the current reference counter. A value of refcount=1 indicates that the variable is the sole reference to its structure, a value greater than one shows that a copy would be triggered on the next write operation. This function is intended exclusively for a development environment with Xdebug installed and should not be used in production code.

Alternatively, a simple before and after comparison with memory_get_usage(), as shown in the code example in section three, gives a reliable picture of whether and when a real copy occurs, without any Xdebug dependency at all. For analysis in production environments, profilers like Blackfire or XHProf are better suited, because they break down memory usage per function call and thereby show exactly where in the code unexpected array copies actually occur.

A practical test approach for codebases with many function calls: create a large test array, send it through the relevant chain of functions, and compare memory usage before and after the entire run. If memory usage stays stable, the chain works purely read only. A sudden jump at a specific point reliably indicates a write operation and therefore a triggered copy.

8. Common pitfalls in practice

A common pitfall is the seemingly harmless use of foreach ($array as &$value) with a reference to the iteration element. After this loop finishes, $value remains a reference to the last element of the array, which leads to subtle bugs in a subsequent second foreach loop without a fresh reference declaration, where the last element gets accidentally overwritten. An unset($value) right after the referenced loop prevents this classic problem.

A second pitfall involves functions that unnecessarily pass a large array through several intermediate steps, where each step makes a small but actual modification. Every one of these modifications triggers a full copy of the entire array, even if only a single element is changed. In a processing chain with five such steps, this creates five full copies of a potentially very large array, where a single, bundled modification would have sufficed.


declare(strict_types=1);

// WRONG: five separate mutations trigger five full array copies
function processInefficient(array $orders): array
{
    $orders = addTaxField($orders);      // copy #1
    $orders = addDiscountField($orders); // copy #2
    $orders = addShippingField($orders); // copy #3
    $orders = addStatusField($orders);   // copy #4
    $orders = addTimestampField($orders); // copy #5
    return $orders;
}

// RIGHT: bundle all mutations into a single pass, one copy total
function processEfficient(array $orders): array
{
    foreach ($orders as $key => $order) {
        $orders[$key]['tax'] = calculateTax($order);
        $orders[$key]['discount'] = calculateDiscount($order);
        $orders[$key]['shipping'] = calculateShipping($order);
        $orders[$key]['status'] = 'processed';
        $orders[$key]['processed_at'] = time();
    }
    return $orders; // single copy-on-write trigger for the whole batch
}

The decisive difference in the second example: all modifications happen inside a single loop that breaks the reference count sharing only once, instead of creating a full copy of a potentially large array five times in a row.

9. Copy-on-Write in direct comparison

A direct comparison shows which operations trigger Copy-on-Write and which do not, and what that means in practice.

Operation Triggers a copy? Cost Recommendation
$b = $a (assignment) No Only refcount incremented Use freely
Array as function parameter No, unless written to refcount, no copy on read Pass large arrays without worry
$b[] = 'x' after assignment Yes Full copy, linear in size Bundle modifications
$b = &$a (reference) Never, but shares state permanently No copy cost, but side effects Only for deliberate in place updates
foreach ($a as $v) (read) No No overhead Iteration is always cheap

The comparison makes the central pattern clear: reading is practically always free with Copy-on-Write, writing triggers a copy whose cost grows linearly with array size. References bypass this behavior entirely, but pay for it with less predictable code.

10. Summary

Copy-on-Write in PHP arrays ensures that assignments, function hand offs, and storage in further structures stay practically free as long as only reading happens. The internal reference counter of the zend_array structure decides whether a physical copy is needed. Only the first actual write operation on a shared structure triggers the full copy that scales linearly with size.

Anyone who understands this mechanism can pass large arrays to read only functions without worry, but should deliberately bundle modification chains with several consecutive write operations to avoid repeated, unnecessary copies. References with & are the right tool for genuine in place updates, but permanently defeat Copy-on-Write for the involved variables and should therefore be used deliberately and sparingly.

Copy-on-Write in PHP arrays, the essentials at a glance

Assignment is practically free

$b = $a only increments the refcount of the zend_array structure, regardless of array size.

Writing triggers the real copy

Once refcount is greater than one, the first write operation creates a full, independent copy.

References defeat Copy-on-Write

$b = &$a binds variables permanently together, no copy cost, but shared state.

Bundle modifications

Combine several consecutive write operations into a single loop instead of copying repeatedly.

11. FAQ: Copy-on-Write in PHP Arrays

1What is Copy-on-Write in PHP arrays?
Postpones the physical copy to the moment of the first change. Until then variables share the same structure via a reference counter.
2Does $b = $a cost memory for large arrays?
No, only the reference counter is incremented. A real copy only occurs on actual modification of $a or $b.
3Does passing to a function trigger a copy?
Only on actual modification inside the function body. Purely read only functions cost no additional memory regardless of size.
4How does a reference defeat Copy-on-Write?
$b = &$a binds both variables permanently, changes are immediately mutually visible, an independent copy never occurs.
5Why does writing cost more than reading?
Reading does not change refcount. Writing checks it and creates a full copy if the value is greater than one.
6What happens with several write operations?
Only the first triggers the copy. After that refcount is one, further writes on this copy do not cost another copy.
7Why use foreach by reference with caution?
The reference variable persists after the loop. A second loop without unset can accidentally overwrite the last element.
8Does this also apply to nested arrays?
Yes, independently at every level. A deeply nested change can copy the path from the root down to it.
9How do I make Copy-on-Write visible?
xdebug_debug_zval() shows the reference counter directly. Without Xdebug, a memory_get_usage() comparison reliably shows if a copy happened.
10How do I avoid unnecessary copies?
Bundle several write operations into a single loop instead of passing the array through several separately mutating functions.

Mironsoft

PHP performance analysis, array processing and memory optimization

Want to find unexpected copies in your array processing?

We analyze processing chains with large arrays, identify repeated Copy-on-Write triggers and bundle modifications for noticeably lower memory usage.

Array performance audit

Identifying unnecessary copies in processing chains with memory_get_usage analysis

Refactoring modification chains

Bundling multiple write operations into a single Copy-on-Write trigger

Reference strategy

Deliberate use of references for in place updates without unpredictable side effects