# PHP arrays are not arrays

> One structure serves as list, dictionary and set. Packed and hashed layouts, what each costs in memory, and when a conversion happens.

- Published: 2026-08-06
- Tags: hashtable, memory, zend-vm
- Source: https://elephantphp.com/blog/php-arrays-are-not-arrays/
- Language: en-US
- Author: Alden Pike

---
These two arrays hold the same million integers under the same million keys in
the same order, and `===` agrees they are equal. One of them occupies 16.0 MB
and the other 40.0 MB.

```php
<?php

declare(strict_types=1);

$baseline = memory_get_usage();
$packed = range(0, 999_999);
printf("packed  %s bytes\n", number_format(memory_get_usage() - $baseline));

$baseline = memory_get_usage();
$hashed = range(0, 999_999);
$hashed['total'] = 0;
unset($hashed['total']);
printf("hashed  %s bytes\n", number_format(memory_get_usage() - $baseline));

var_dump($packed === $hashed);
```

```text
packed  16,793,680 bytes
hashed  41,943,120 bytes
bool(true)
```

The second array briefly held a string key. That was enough to change how the
engine stores it, and removing the key again did not change it back.

## One structure, two layouts

The array is a single type doing the work of a list, a dictionary and a set, and
the structure underneath is a hashtable. In its general form each element gets a
bucket holding the value, the key and the key's hash, and a separate index array
maps a hash to a position among those buckets. A string key is hashed on the way
in; an integer key is its own hash.

Buckets are appended in the order elements arrive, and the index points into
that sequence rather than defining it. This is why `foreach` is predictable in a
way a dictionary in most other languages is not — iteration walks the buckets,
so it yields insertion order, not hash order and not key order.

```php
<?php

declare(strict_types=1);

$stock = ['widget' => 4, 'anvil' => 19, 'rope' => 7];
$stock['crate'] = 2;

foreach ($stock as $sku => $count) {
    echo $sku, ' ', $count, PHP_EOL;
}
```

```text
widget 4
anvil 19
rope 7
crate 2
```

That ordering guarantee is load-bearing for a great deal of PHP code, and it is
also the reason the structure cannot be a plain vector. Every element carries
the bookkeeping that makes ordering and arbitrary keys work — which is what the
40 MB above is mostly made of.

## What the packed layout drops

When the keys are integers running from zero without arriving out of order, the
engine drops to a packed layout: it stores the values in a row and derives the
key from the position. There is no bucket and no index array, so a read is an
offset rather than a hash lookup.

Measured on PHP 8.5.9, arm64, NTS, Homebrew build, on an Apple M4 Pro laptop
running macOS and not quiesced, one million elements: the packed form held 16.79
bytes per element and the hashed form 41.94, a factor of 2.5. Sixteen of those
bytes are the size of one value slot on a 64-bit build, and the rest is the key,
the hash and the index the packed form does not keep.
Both figures include capacity the array has allocated but not filled, which is
why the packed number is not exactly 16. Every memory figure below is a single
exact reading rather than a median: repeated runs return byte-identical values,
so there is no variance to report.

Per-element overhead is diluted by whatever you store in the elements. Repeating
the measurement at the same one million elements with fixed six-character strings
as the values gave 49.59 against 74.74 bytes per element — a ratio of 1.51 rather
than 2.50. The gap between the layouts did not move: 25.15 bytes, exactly where
the integers left it. At sixteen characters it is 64.79 against 89.94, a ratio of
1.39, and the gap is still 25.15. The overhead belongs to the table and is a flat
per-element charge; the ratio belongs to whatever you put in the elements.

## What ends the packed layout, and what does not

The received wisdom is that `unset()` in the middle of a list breaks packing. On
this build it does not. I applied each operation to a fresh `range(0, N - 1)`
and read the layout back from the array's footprint, where the two forms differ
by more than a factor of two:

| Operation | Layout afterwards |
|---|---|
| `unset()` at the start, middle or end | packed |
| `unset()` of half the elements | packed |
| `sort()`, `rsort()`, `usort()`, `shuffle()` | packed |
| `array_values()`, `array_map()`, `array_slice()` | packed |
| Appending an integer key immediately past the end | packed |
| Adding a string key | hashed |
| Adding a negative integer key | hashed |
| Adding an integer key far past the end | hashed |
| `ksort()`, `krsort()`, `asort()` | hashed |
| `array_filter()` that drops elements | hashed |

Two of those are worth pausing on. `ksort()` converted an array that was
*already* in key order — the sort had nothing to reorder and still cost 25 MB on
a million elements. And an integer key past the end is tolerated up to a
boundary that tracks the array's allocated capacity rather than its element
count: appending index N to a million-element list kept it packed, while index
10N converted it. Where exactly that boundary sits, and why, I did not chase
into the C source.

What `unset()` does instead is nothing at all to the footprint:

```php
<?php

declare(strict_types=1);

$rows = range(0, 999_999);
$before = memory_get_usage();

for ($i = 0; $i < 500_000; $i++) {
    unset($rows[$i * 2]);
}

printf("after unsetting half: %+d bytes, %d elements left\n", memory_get_usage() - $before, count($rows));

$rows = array_values($rows);
printf("after array_values():  %+d bytes, %d elements left\n", memory_get_usage() - $before, count($rows));
```

```text
after unsetting half: +0 bytes, 500000 elements left
after array_values():  -8388576 bytes, 500000 elements left
```

Half a million elements went away and the array released none of its memory. The
rebuild released 8 MB, because it allocated for what was left rather than for
what had once been there.

## What the layout costs depends on how you read it

Memory is charged before the first read and does not move with how you read it:
2.5x here with integers in the elements, less as the values get larger. The time
does move — it runs from a few percent to well over double, and which one you get
is decided by the access pattern rather than by the layout.

This table uses 1,048,576 elements rather than the one million above, and the
change is deliberate: the random rows index the array with a linear congruential
generator masked to the array's size, and a mask is only all-ones when the size
is a power of two. At one million, `& 999999` has twelve bits set and reaches 512
distinct slots — a working set that fits in cache and reports nothing about
random access. Same keys, same values, same insertion order, same count, 15
interleaved runs per variant, `opcache.jit=disable` throughout, and each run
prints its own footprint so the two layouts are confirmed different at the moment
they are timed. Each cell is the median of its fifteen runs, with the full range
in parentheses:

| Access pattern | Packed | Hashed | Ratio |
|---|---|---|---|
| Sequential read | 4.597 ms (4.428–4.762) | 4.865 ms (4.738–5.049) | 1.058x |
| Sequential read, four per iteration | 3.797 ms (3.629–3.863) | 4.136 ms (3.974–4.318) | 1.089x |
| Random read | 17.284 ms (16.853–18.962) | 40.554 ms (39.255–41.192) | 2.346x |
| Random read, four per iteration | 16.155 ms (15.904–17.023) | 38.195 ms (36.353–39.694) | 2.364x |
| Write | 7.276 ms (7.139–7.381) | 8.802 ms (8.560–9.573) | 1.210x |

The sequential rows sit 6% apart with ranges that barely touch, so read that as a
small effect this method can barely resolve. Quadrupling the reads per
iteration widens it to 8.9% with the ranges clear of each other, which says loop
overhead was diluting a real per-fetch difference rather than hiding nothing.

Random access is the row that matters. At 2.35x, with the two ranges nowhere near
each other and the four-per-iteration variant agreeing at 2.36x, the hashed
layout costs more than double — on a 40 MB table where almost every lookup is a
cache miss and the hashed form has one more indirection to miss on. Writes land
at 1.21x.

So a hashed list of a million integers costs 2.5x the memory, about 6% on a scan,
and well over double on scattered lookups. A pass that walks the array start to
finish barely notices; one that jumps around it pays twice. The memory is
charged either way, and memory pressure is one of the costs that
[neither OPcache nor the JIT removes](/blog/opcache-jit-and-where-time-actually-goes/).

## The alternatives and the conditions they need

`SplFixedArray` stores 16 bytes per element, the same as a packed array. Its
advantage is that it allocates the size you asked for, while an array rounds its
capacity up: at one million elements it measured 0.95x the packed array, at
1,048,576 exactly 1.00x, and at 1,048,577 — one element past a doubling —
0.50x. Against the hashed form it is smaller by 2.62x, 2.50x and 5.00x at those
same three sizes, the last because the hashed array's capacity doubles there too.
It also has a fixed length and lacks array semantics, so it fits a buffer of
known size rather than a working list.

A generator sidesteps the question. Iterating a million values through a
generator held 960 bytes at the high-water mark against 16.0 MB for the
materialized list, because only one value exists at a time. The condition is that
one pass is enough: nothing can be counted, sorted or read twice.

The third option is not building the list. A query that returns 50,000 rows so
the application can sum a column materializes 50,000 arrays to produce one
number, and the VM that walks them is doing it [one opcode at a
time](/blog/what-happens-when-php-runs-your-code/).

## What to do about it

Reach for the memory figure before the layout. `memory_get_peak_usage(true)`
against a realistic dataset tells you whether arrays are the problem at all, and
for most applications holding a few thousand rows the answer is that they are
not. The 25.15-byte gap above is the one-million figure; at a few thousand rows
capacity rounding moves it, and across sizes from 1,000 to 8,193 I measured
between 20 and 48 bytes per element. None of that buys a rewrite.

Where the layout does earn attention is a large array read out of order. That is
the one pattern above that cost more than double, so if a hot path indexes
scattered positions in a list of hundreds of thousands of elements, check whether
something converted it and whether `array_values()` puts it back.

When the number is large, count elements before optimizing bytes. Halving
per-element overhead saves half; not materializing the list at all saves
essentially the whole of it, and the measurements above put those two options
four orders of magnitude apart. Reach for `SplFixedArray` when the length is
known and fixed, for a generator when a single pass will do, and for
`array_values()` when a long-lived array has been holding capacity it stopped
needing.
