
One hundred thousand records, four fields each. As objects with declared properties they cost 143.54 bytes apiece. As associative arrays with the same four keys, 397.14.
The object is the small one, and it stays the small one at every width I could get a number for.
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,
and memory_limit is 4G.
Memory figures are memory_get_usage() deltas and were byte-identical across
all 15 rounds of every configuration, so they are printed as single values.
Timings are the median of 15 interleaved runs with three warmup rounds
discarded, taken with hrtime() inside the process, with the range given.
Read the timings relative to each other rather than as absolutes, for
the reasons the method post sets out.
Where a figure is per instance, it is the total for the run divided by the instance count, and it includes two things the instance does not contain: the packed array holding the instances, at 21.05 bytes a slot, and the engine’s object store, which is discussed below. Both are identical across the shapes being compared. Standalone object sizes are given separately and are exact.
A declared property is a slot, not an entry
The class knows its properties at compile time, so the engine numbers them once and stores the values in a plain C array inside the object’s own allocation. The name-to-slot table lives on the class and is shared by every instance.
That makes the size of an instance arithmetic:
<?php
declare(strict_types=1);
class P0 {}
class P1 { public $a; }
class P2 { public $a, $b; }
class P4 { public $a, $b, $c, $d; }
class P8 { public $a, $b, $c, $d, $e, $f, $g, $h; }
foreach (['P0', 'P1', 'P2', 'P4', 'P8'] as $class) {
$baseline = memory_get_usage();
$instance = new $class();
printf("%-3s %4d bytes\n", $class, memory_get_usage() - $baseline);
unset($instance);
}
P0 40 bytes
P1 56 bytes
P2 80 bytes
P4 112 bytes
P8 192 bytes
Forty bytes of header plus sixteen per property, rounded up to whatever bin the allocator hands out: 40 + 16 is 56 exactly, 40 + 64 is 104 and gets 112, 40 + 128 is 168 and gets 192. The header is the same one a clone of a single-property class measured at 56 bytes in this series’ first post.
Writing to those properties afterwards allocates nothing. P8 measures 192 bytes
before any of its eight properties has been assigned and 192 after all of them
have, because the slots were bought with the object.
A property with hooks occupies the same slot as a plain one, and a virtual property occupies none — which is measurable, and not the part of hooks that costs anything.
An array cannot do that, and it rounds up
The array has no class to keep a shared table on, so every array carries its own. The hashtable behind every PHP array is allocated per value, sized in powers of two, with a floor of eight.
Building both shapes one key at a time shows what that floor does:
keys assoc list
1 408 216
2 440 216
4 504 216
8 632 216
9 984 376
A list of one element costs what a list of eight costs. The associative column
grows by 32 bytes a key, which is not the bucket — it is the key string, a
zend_string for field0 and its siblings. The buckets were bought at the
floor, like the list’s slots.
Then nine keys, and both jump: the table doubles and the array pays for sixteen slots to hold nine values.
This is the part that decides the comparison, and it is the opposite of the intuition. The array is not gradually worse than the object as records grow wider. It is worst at the narrow end, because a four-field record buys a table sized for eight. The same floor turns up wherever the engine keeps a small keyed set — a closure capturing one variable pays for eight slots too.
Where the crossover is
Fifty thousand instances of the same record in three shapes, at seven widths. The class is generated so that the object and the associative array carry the same field names.
| Fields | Object | Associative array | List |
|---|---|---|---|
| 1 | 87.38 | 397.05 | 237.05 |
| 2 | 111.38 | 397.05 | 237.05 |
| 4 | 143.38 | 397.05 | 237.05 |
| 8 | 223.38 | 397.05 | 237.05 |
| 16 | 351.38 | 717.05 | 397.05 |
| 32 | 671.38 | 1,357.05 | 717.05 |
| 64 | 1,311.38 | 2,637.05 | 1,357.05 |
Bytes per instance. There is no crossover in that range. The object is between 1.8 and 4.5 times smaller than the associative array at every width, and it is smaller than the bare list at every width too — including at 64 fields, where the object’s per-field cost has had the most room to accumulate.
The object’s column is the arithmetic from the previous section plus the two container costs. The array columns are the step function.
Every live object costs eight bytes somewhere else
The object column is not quite the standalone sizes plus the holding array. Fifty thousand single-property objects cost 87.38 bytes each; the object is 56 and the array slot is 21.05, which leaves 10.33 unaccounted.
It is the object store. The engine keeps a table of pointers to every live
object, one zend_object * per handle, grown in
zend_objects_API.c with new_size = 2 * size and an
erealloc() — so it is charged to the request allocator and it does appear in
memory_get_usage(). Fifty thousand live objects need a table of 65,536
pointers, which is 10.49 bytes an object against the 10.33 the measurement left
over.
Arrays have no equivalent. It is a real cost, it is proportional to the number of live objects rather than their size, and at four fields it is 7% of what the object costs against the 176% the array costs.
Reading them
Three million reads of one field, timed inside the process, with an empty loop measured the same way to subtract:
| Read | Total | Net of the 3.023 ns loop |
|---|---|---|
| Declared property | 4.325 ns (4.316–4.371) | 1.302 ns |
readonly property |
4.355 ns (4.337–4.387) | 1.332 ns |
| List element by index | 4.521 ns (4.482–4.570) | 1.498 ns |
| Dynamic property | 4.781 ns (4.749–4.879) | 1.758 ns |
| Associative key | 7.973 ns (7.757–8.070) | 4.950 ns |
The declared property is a numbered offset into memory the object already holds, so the read is an addition. The associative key has to hash a string and walk to a bucket, and it costs 3.80 times as much.
readonly is free to read, which is worth saying because it is the modifier
people hesitate over. The check it adds is on writes.
The one property that reverses everything
Now the other direction. Undeclare a property, and the object grows the structure it was avoiding:
<?php
declare(strict_types=1);
#[\AllowDynamicProperties]
class Bag {}
$baseline = memory_get_usage();
$bag = new Bag();
printf("empty %3d bytes\n", memory_get_usage() - $baseline);
$bag->one = 1;
printf("one dynamic %3d bytes\n", memory_get_usage() - $baseline);
$bag->two = 2;
printf("two dynamic %3d bytes\n", memory_get_usage() - $baseline);
$bag->three = 3;
printf("three dynamic %3d bytes\n", memory_get_usage() - $baseline);
empty 40 bytes
one dynamic 416 bytes
two dynamic 416 bytes
three dynamic 416 bytes
The first dynamic property costs 376 bytes and the next two cost nothing, because what the first one bought was the whole properties hashtable at its floor of eight. This is the same floor the array pays, arriving in an object.
At a hundred thousand instances the shapes separate accordingly:
| Shape, four fields | Bytes per instance | Construction, 100k |
|---|---|---|
| Declared, untyped | 143.54 | 3.966 ms (3.852–4.250) |
| Declared, typed | 143.54 | 6.016 ms (5.836–6.663) |
| Declared, promoted | 143.54 | 6.459 ms (6.294–6.726) |
| Associative array | 397.14 | 4.401 ms (4.233–4.599) |
| Dynamic properties | 447.54 | 8.089 ms (7.745–8.565) |
stdClass |
447.54 | 7.580 ms (7.381–7.856) |
The object with undeclared properties is the most expensive shape in the table,
worse than the array it is usually reached for in place of. A stdClass from
json_decode() or a PDO fetch into objects lands exactly here.
There is a second bill on that row. Assigning a property a class has not declared
and has not marked with #[\AllowDynamicProperties] raises a deprecation, and
raising it is work: 100,000 such writes took 6.6 ms with the attribute present
and 16.1 ms without it, with the diagnostic suppressed at the call site so that
output was not being timed. It is still only a deprecation on 8.5 —
the severity does not change until 9.0 —
so this cost, rather than a deadline, is the reason to fix it.
Types cost time, not space
The three declared rows are byte-identical, which is itself the finding: a typed property stores its value in the same slot an untyped one does, and whatever the engine keeps the type in is not charged to the instance.
What differs is the write. Constructing 100,000 instances took 3.966 ms untyped, 6.016 ms typed and 6.459 ms with constructor promotion — 52% and 63% more than untyped, for four assignments each. That is the type check running four times per instance, and it is the price of the guarantee rather than a defect.
It is also small in absolute terms: about 20 nanoseconds an instance typed and 25 promoted, against the 254 bytes an instance the array shape would have cost.
The formula in circulation is from 2013
The reference everybody’s advice traces back to is nikic’s note of February 2013,
which gives arrays as 104 + 96*n bytes and objects as 128 + 8*n for PHP 5.4
and newer. The conclusion it drew has held for thirteen years. Neither formula
describes 8.5: an object here is 40 + 16n before bin rounding, and an array is
not linear in n at all — it is a step function with a floor.
Both corrections push the same way. The object’s base got smaller, the array’s floor stayed, and the gap at the narrow end is wider now than the old formulas predicted.
What to build
Declare the properties. That is the whole recommendation, and it is worth more
than the choice between an object and an array: the declared-property object was
the cheapest shape at every width, and the undeclared-property object was the most
expensive shape in the table. The class is what makes the difference, not the
new.
Reach for the array when the field names are not known until runtime — a row whose columns come from a query the code did not write, a payload whose keys are the data. That is not a memory decision, and paying 397 bytes for a hashtable you genuinely need is not the same as paying it for a record with four known fields.
Where objects are already the shape and memory is still the problem, count them before you shrink them. The per-object costs that do not depend on width — 40 bytes of header, 8 in the object store, 16 in whatever holds it — are about 64 bytes before a single field, so a hundred thousand small objects have a floor of roughly 6 MB no matter what you do to their properties. At that point the question is whether all of them need to be resident at once, which is a different article and a different fix.
Frequently asked
- Should I hydrate database rows into objects or arrays?
- Objects, if the fields are known at compile time. Four fields cost 143.54 bytes as a declared-property object and 397.14 as an associative array, and reading one field is 1.302 ns against 4.950. The array only stops losing if the field names are not known until runtime, and then you are paying for a hashtable either way.
- What does
- The attribute costs nothing; the dynamic property does. One undeclared property takes an instance from 40 bytes to 416, and the next seven are free inside that same hashtable. Without the attribute the write also raises a deprecation, which took 100,000 writes from 6.6 ms to 16.1 ms.
- Do typed properties use more memory than untyped ones?
- No. The same class with four untyped, four typed and four promoted properties measured 143.54 bytes per instance in all three shapes. What types cost is construction time — 52% more than untyped here, and 63% more when promoted.
- Is an object ever the more expensive shape?
- Yes, when its properties are not declared. A stdClass with four properties measured 447.54 bytes per instance against 143.54 for the declared-property class, which is worse than the associative array it is usually reached for instead of.
Written by
Alden Pike
Alden Pike writes about PHP internals, performance, architecture and production behavior — the layer beneath the frameworks. Measurements over assumptions, trade-offs over universal rules.
More about the author

