How much RAM an object really costs, and why empty never means empty
A PHP object without a single property still occupies several dozen bytes in memory, purely from the internal zend_object header. Anyone seeing unexpectedly high memory usage with thousands of objects per request usually finds the cause in the interplay of object header, property table and the size of typed properties, not in the actual payload.
Table of Contents
- 1. Why the memory profile of objects matters at all
- 2. The zend_object header in detail
- 3. The property table and the cost per property
- 4. zval size and reference counting
- 5. Typed properties and their effect on size
- 6. Inheritance, traits and shared metadata
- 7. Measuring memory usage in practice
- 8. Reducing object memory deliberately
- 9. Object types in a memory comparison
- 10. Summary
- 11. FAQ
1. Why the memory profile of objects matters at all
As long as an application only creates a few hundred objects per request, the exact memory profile of a single PHP object barely matters. But once an import script creates tens of thousands of value objects from a CSV file, an ORM hydrates thousands of entity objects for a collection, or a report job holds millions of small data points as objects, the overhead per object adds up to a real memory problem that either exceeds the memory_limit or puts the garbage collector under continuous load.
The fundamental misconception here: many developers estimate an object's memory requirement by simply adding up the size of its contained values, for example an integer at eight bytes and a short string by its character length. The real memory profile of a PHP object is significantly larger, though, because PHP maintains an internal header, a property table, and a separate zval container for every single property, independent of the actual value.
This article breaks down the memory profile of a PHP object layer by layer: from the zend_object header through the property table to the effects of typed properties. It ends with a practical guide on how to measure actual memory usage and reduce it deliberately within limits, without giving up the advantages objects have over arrays.
2. The zend_object header in detail
Every PHP object is internally represented by a C structure called zend_object. This structure contains, among other things, a reference counter (refcount), a pointer to the associated class definition (zend_class_entry), a handle for the internal object store, and pointers to handler tables that define how the object behaves during comparisons, cloning, or garbage collection. On a typical 64 bit system, this header alone occupies between 40 and 56 bytes, depending on the exact PHP version and build configuration.
The decisive point: this header exists for every single object, regardless of how many properties it has or whether it has any properties at all. An instance of a completely empty class without a single property already costs the full header overhead before a single byte of payload is added. This explains why instantiating many small, specialized objects, for instance one for every single character of a text or every single value in a very large number series, costs a disproportionate amount of memory in practice.
A second, often overlooked part of the memory profile is the shared class definition itself. The zend_class_entry with its method table, property names and type information is only held once per class in memory, regardless of how many instances exist. This structure therefore does not count toward the memory profile of a single instance, but it is quite relevant when looking at the overall memory budget of an application with many different classes.
3. The property table and the cost per property
Beyond the fixed header overhead, every declared property of an object contributes to its memory profile. PHP allocates a table for an object's properties directly following the zend_object header, the so called properties table. Every entry in this table is a zval, a fixed size container holding the property's actual value along with a type tag, regardless of whether the property holds an integer, a string, or another object.
On a typical 64 bit system, a single zval occupies 16 bytes: eight bytes for the actual value or a pointer to more complex data, and additional bytes for type information and internal flags. An object with ten typed properties therefore contributes about 160 bytes to the memory profile through the property table alone, on top of the header overhead of 40 to 56 bytes. With strings, arrays and further objects as property values, the memory requirement of the referenced structure itself adds on top of that, because in these cases the zval only contains a pointer, not the full value.
declare(strict_types=1);
// Measuring the real memory footprint of objects with many properties
final class OrderLine
{
public function __construct(
public readonly string $sku,
public readonly int $quantity,
public readonly float $unitPrice,
public readonly string $currency,
public readonly ?string $note = null,
) {}
}
$before = memory_get_usage();
// Allocate 100,000 small objects, five properties each
$lines = [];
for ($i = 0; $i < 100_000; $i++) {
$lines[] = new OrderLine(
sku: 'SKU-' . $i,
quantity: 1,
unitPrice: 9.99,
currency: 'EUR',
);
}
$after = memory_get_usage();
$perObject = ($after - $before) / count($lines);
printf("Memory per object: %.1f bytes\n", $perObject);
// Typically well above the naive sum of scalar values alone,
// because header + property table dominate for small objects
In this example, the real memory usage per object is significantly above the naive sum of scalar values, because the header and property table make up the largest share while the actual payload, a short string and three numbers, only contributes a small fraction. This ratio reverses for larger objects with long strings or nested structures, where the referenced data quickly exceeds the header overhead.
4. zval size and reference counting
The zval container is the smallest storage unit for values in PHP and appears not only in object properties but also in array elements and local variables. For the size analysis of objects it matters that a zval itself has a fixed size, regardless of the type of the value it contains. An integer, a boolean and an object pointer occupy the same amount of space in the zval, the actual difference only arises from the data referenced additionally for more complex types.
For strings, arrays and objects as property values, reference counting comes into play: the zval in the property table only contains a pointer to the actual Zend string or zend_array structure, whose reference counter gets incremented on every further assignment. This means that two objects pointing to the same unchanged string do not hold that string twice in memory, as long as neither reference modifies the value and thereby triggers a copy. This behavior is closely related to copy on write for arrays, but applies in a weaker form to strings as well.
In practice this means: objects with many properties pointing to the same unchanged string, for instance a recurring category name or a status code, cost only the zval overhead itself per additional object, not the full string length again. Only when an object modifies its own, independent copy of a string does copy on write trigger a real, additional memory allocation.
5. Typed properties and their effect on size
Since PHP 7.4, the language supports typed properties, and this has a direct effect on an object's memory profile. A typed property that has not yet received a value exists internally in a special uninitialized state that occupies less memory than a fully initialized zval. As soon as the property is assigned a value, however, it switches into the regular state and occupies the same memory as an untyped property.
The actual advantage of typed properties therefore does not lie primarily in reduced memory usage for initialized objects, but in error prevention: accessing a typed but uninitialized property throws an Error instead of silently returning null, which surfaces bugs much earlier. What is relevant for the memory profile above all is that typed scalar values like int or float continue to be stored via the full zval format in PHP, meaning PHP does not internally allocate more compact, C style structs for typed objects the way a language with a real value type system would.
A practical comparison shows the difference between an object with only public properties and a functionally equivalent associative array. An array needs, for every key, not just a zval but also a hash of the key and a bucket entry in the internal hash table, which tends to create more overhead for many small entries than the property table of an object holding the same values. Objects are therefore often the more memory efficient choice over associative arrays for many small, recurring records, even though both variants transport similar payloads.
6. Inheritance, traits and shared metadata
Inheritance affects the memory profile in two different ways. First, every additional property declared by a parent class enlarges the property table of every instance of a child class, because inherited properties are part of the same contiguous table as the child class's own properties. Second, the method table, unlike properties, remains fully shared at the class level: methods cost no additional memory per instance, regardless of how many methods a class or its parent classes define.
Traits behave similarly to properties and methods declared directly in the class when it comes to the memory profile: the PHP compiler effectively copies trait declarations into the consuming class before the class itself is compiled. Properties from a trait therefore contribute to the property table of every instance just like directly declared properties do, there is no additional overhead created purely by using a trait compared to a direct declaration.
For applications with deep inheritance hierarchies, such as in Magento or Symfony, where base classes frequently bring numerous properties for caching, event handling or configuration, this can add up quickly: a child class with five own properties, whose parent class already declares fifteen properties for internal purposes, produces instances with twenty entries in the property table, even if only five of them are actively used in the actual application code.
7. Measuring memory usage in practice
The most reliable method for measuring the real memory profile of objects in your own application is a direct comparison of memory_get_usage() before and after creating a defined number of instances, as shown in the previous code example. It matters that memory_get_usage(true) with the parameter true returns the memory actually allocated by the system including internal fragmentation, while memory_get_usage() without a parameter only shows the memory PHP reports as used, which is usually the more precise metric for pure object size analysis.
For a more detailed analysis of individual objects, the Xdebug extension function xdebug_debug_zval() is useful, showing the reference counter and internal structure of a single value, along with tools like memory_get_peak_usage() to capture peak consumption during an entire request, not just a snapshot. External profilers such as Blackfire or XHProf additionally provide a breakdown of memory usage per function call, which helps identify exactly the place in the code where an unexpectedly large number of objects is held in memory simultaneously.
A practical pitfall when measuring: the PHP garbage collector does not clean up circular references immediately, but collects them in a separate root buffer and only processes them once this buffer reaches a certain fill level. Anyone measuring memory immediately after deleting object references without explicitly calling gc_collect_cycles() beforehand may see higher memory usage than what is actually finally needed, because not yet collected circular references still occupy memory.
declare(strict_types=1);
// Force garbage collection before measuring released memory,
// otherwise circular references may still hold memory
$before = memory_get_usage();
$objects = [];
for ($i = 0; $i < 50_000; $i++) {
$objects[] = new OrderLine('SKU-' . $i, 1, 9.99, 'EUR');
}
$peak = memory_get_peak_usage();
unset($objects);
// Without an explicit collection cycle, freed memory may not
// be reflected immediately if circular references exist
gc_collect_cycles();
$after = memory_get_usage();
printf("Peak: %.2f MB, released to: %.2f MB\n", $peak / 1_048_576, $after / 1_048_576);
8. Reducing object memory deliberately
Anyone who finds after measuring that the memory profile of their own objects actually becomes a problem has several practical levers. The first and most effective one: reduce the number of objects held in memory simultaneously, instead of optimizing the size of each individual object. A generator that creates and processes objects one at a time, instead of holding an entire collection as an array in memory, often reduces peak usage by orders of magnitude without changing the object design itself.
The second lever concerns the number of properties itself. Value objects with many rarely used, optional properties can be split into smaller, deliberately composed objects so that only the data actually needed is held in memory, instead of one large object with many unused null properties. Here too: every additional property costs at least one zval entry in the property table, regardless of whether it actually carries a meaningful value in the specific use case.
A third, less obvious lever is the deliberate choice between objects and arrays for very large, homogeneous data volumes. For millions of identical, simple records, for example when processing CSV rows, a flat, typed array with a fixed structure can actually require less memory than the same number of object instances, because the fixed per object header overhead does not apply to arrays. The decision between object and array should therefore not be based purely on readability arguments for very large volumes, but also on the actually measured memory profile.
A direct comparison shows how different data structures compare in memory profile given the same payload.
9. Object types in a memory comparison
| Structure | Header overhead | Overhead per field | When it makes sense |
|---|---|---|---|
| Object, 5 properties | ~48 bytes zend_object | 16 bytes per zval | Type safety, methods needed |
| Associative array, 5 keys | ~56 bytes zend_array | zval + hash + bucket | Dynamic, unknown keys |
| Numeric array, 5 values | ~56 bytes zend_array | zval + bucket, no hash | Large, homogeneous lists |
| Generator instead of array | No object per element | One element at a time | Very large data volumes |
| Inherited base class | Header + inherited properties | Accumulates across hierarchy | Check with deep inheritance |
The comparison shows: there is no universally best structure, every choice has its own memory profile with clear advantages and disadvantages. For small, manageable object counts, the difference rarely matters; for bulk processing with hundreds of thousands or millions of instances, the right choice decides over a noticeably different memory requirement.
10. Summary
The memory profile of PHP objects consists of three parts: the fixed zend_object header of about 40 to 56 bytes, the property table with a 16 byte zval per property, and the actual memory requirement of more complex values like strings, arrays or nested objects, which are only referenced. Typed properties barely change this memory requirement in the initialized state, but they reduce the risk of errors through early exceptions on uninitialized access.
Anyone who actually wants to optimize the memory usage of their own application should first measure with memory_get_usage() and memory_get_peak_usage() instead of guessing, and then primarily address the number of objects held simultaneously, for example through generators, instead of the size of individual instances. Knowing the memory profile of individual objects is the foundation of any well grounded decision between objects, arrays and generators for large data volumes.
Memory profile of PHP objects, the essentials at a glance
zend_object header
About 40 to 56 bytes of fixed overhead per instance, regardless of the number of properties.
zval per property
16 bytes per declared property, regardless of type. Strings and objects add an extra reference on top.
Typed properties
Reduce errors through early exceptions, but barely change the size of initialized values.
Measure, don't guess
memory_get_usage() before and after object creation reliably shows the real overhead per instance.
11. FAQ: Memory Profile of PHP Objects
1How much memory does an empty PHP object occupy?
2How large is a zval in PHP?
3Do typed properties save memory?
4Objects or arrays: which is more efficient?
5How do I measure real object memory?
6Does inheritance cost additional memory?
7Do traits create memory overhead?
8Why use generators instead of arrays?
9What does gc_collect_cycles do for measurement?
10When is an array worth it over objects for large volumes?
Mironsoft
PHP memory profiling, performance audits and refactoring of data intensive processes
Got memory issues with large object volumes under control?
We analyze the memory profile of your data processing, identify unnecessary object overhead and build memory friendly alternatives with generators and deliberately reduced value objects.
Memory profiling
Measuring real overhead via memory_get_usage and peak analysis under production load
Data model review
Evaluating object versus array structures for large, homogeneous data volumes
Import and batch optimization
Converting memory intensive import and report scripts to generators and streaming