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.
Table of Contents
- 1. What Copy-on-Write actually means for arrays
- 2. The internal structure of zend_array
- 3. Reference counting and the moment of the real copy
- 4. Function calls and Copy-on-Write together
- 5. Explicit references and how they defeat Copy-on-Write
- 6. Nested arrays and partial copies
- 7. Making Copy-on-Write visible in practice
- 8. Common pitfalls in practice
- 9. Copy-on-Write in direct comparison
- 10. Summary
- 11. FAQ
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?
2Does $b = $a cost memory for large arrays?
3Does passing to a function trigger a copy?
4How does a reference defeat Copy-on-Write?
5Why does writing cost more than reading?
6What happens with several write operations?
7Why use foreach by reference with caution?
8Does this also apply to nested arrays?
9How do I make Copy-on-Write visible?
10How do I avoid unnecessary copies?
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