# A declared property is cheaper than an array key

> One hundred thousand four-field records cost 143.54 bytes each as objects and 397.14 as associative arrays. Where the crossover is, and what one undeclared property costs.

- Published: 2026-09-27
- Tags: memory, hashtable, benchmarking
- Source: https://elephantphp.com/blog/objects-are-cheaper-than-arrays/
- Language: en-US
- Author: Alden Pike

---
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](/blog/benchmarking-php-without-lying-to-yourself/).

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
<?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);
}
```

```text
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](/blog/what-a-php-variable-actually-is/)
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](/blog/property-hooks-and-what-the-compiler-emits/).

## 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](/blog/php-arrays-are-not-arrays/) 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:

```text
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](/blog/closures-are-objects-with-a-scope/).

## 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`](https://github.com/php/php-src/blob/PHP-8.5/Zend/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
<?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);
```

```text
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]`](https://wiki.php.net/rfc/deprecate_dynamic_properties) 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](/blog/what-actually-breaks-between-php-8-2-and-8-5/) —
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.
