DEV Community

Cover image for I Added One Key to a PHP Array. It Cost 25 MB of Memory

I Added One Key to a PHP Array. It Cost 25 MB of Memory

Nazar Boyko on September 14, 2026

I was recently refactoring some legacy PHP code and noticed that the same data array was being used in completely different ways! Radically differe...
Collapse
 
xwero profile image
david duymelinck •

While it is a great dissection of a case you noticed. The real question is why use a million rows? If you need that volume of data, there are better options than loading it into PHP.

And who fills the index in reverse? If you need descending ids put them in the row data and create an index to id map to find the rows you want.

This looks like a convoluted example just to prove a point. Sure it is not great, but when you follow PHP best practices the chances to encounter the case are slim to none.

Collapse
 
nazar-boyko profile image
Nazar Boyko •

Fair points and I agree. The last section is literally "don't hold a million rows, stream them". The million is only there so the per-element numbers are easy to read. The ratios don't depend on it. Like, one 5-field row is 376 bytes as an associative array and 128 as a small class, whether you load 500 rows or a million.

And yes, nobody fills an index in reverse on purpose. It's just the shortest way to show the layout switch. The ways it actually happens are less obvious: array_filter() keeps keys, so its result becomes a hash table and stays one, a string key ends up in something that's supposed to be a list, and array_is_list() can say true for an array that's still a hash table.

Collapse
 
dj29 profile image
Dhruv Jani •

My first Web Language! They teach you this in first year of bachelors and well that time it was called "most valuable and demanding skill" for a good salaried job. I guess its still important but now we got a lot of options.😄

Collapse
 
nazar-boyko profile image
Nazar Boyko •

Same here, it was my first too. It's still running a huge part of the web, and 8.x is a much nicer language than the one most of us learned in uni. Even the arrays got quietly better, the lists take half the memory since 8.2. but I can be wrong 🙂

Collapse
 
xulingfeng profile image
xulingfeng •

Ever since I stepped into IT, I’ve heard this legendary mantra: PHP is the best language in the world!🤣

Collapse
 
nazar-boyko profile image
Nazar Boyko •

😂😂😂 I keep hearing that PHP is dead 😁

But I'll tell you more! PHP isn't just the best language!
This language simply has no competition! 😛😂😂

Collapse
 
xulingfeng profile image
xulingfeng •

True, no other language has such interesting hidden memory quirks 😂

Thread Thread
 
nazar-boyko profile image
Nazar Boyko • • Edited

😁😁 yeah that's true. but, it’s not just here. I would say it's more related to the php legacy code, because we can stick to type safety. But when you’re working with legacy code and rewriting old code into a new structure, it’s very hard to do that, because in PHP legacy code, you can find anything in an array.... Like both numeric data and strings and I’ve even seen JSON data and file references 😁 Back then, no one gave it a second thought because it was considered normal, but now I’m racking my brain trying to rewrite it all and most importantly, to do it right.

Collapse
 
onizuka profile image
Onizuka •

I hit this exact thing last year processing 2M rows from a query — each row as an assoc array, memory went from ~90 MB to ~240 MB just because of string keys. Switched to fetching into stdClass via PDO::FETCH_OBJ and cut it by roughly 40%. The packed-array optimization is nice but nobody actually holds query results in a packed list, so the real-world cost is always the hash table version.

Collapse
 
pierree profile image
ΠR •
$r = [];
for ($i = N - 1; $i >= 0; $i--) {
    $r[$i] = $i;
}
ksort($r);           // or:
$r = array_values($r) + range(0, N-1);
Enter fullscreen mode Exit fullscreen mode

If you need those exact keys but want packed storage back, insert ascending, or repack afterward:

Collapse
 
shieldxbot profile image
shieldx •

PHP array thực chất là ordered hash map nên mỗi element tốn ~72 bytes overhead (64-bit) — thêm 1 key nghĩa là allocate bucket mới, rehash, copy pointer... nhanh chóng bùng nổ khi data lớn. Legacy code thường lười clone array mà pass by reference rồi modify in-place, dẫn đến copy-on-write trigger ngẫu nhiên.

Kinh nghiệm thực tế: nếu data chỉ đọc/iterate tuần tự → SplFixedArray tiết kiệm 50-60% memory. Còn luồng transform lớn → generator (yield) xử lý chunk by chunk, GC dọn dẹp ngay lập tức. Đừng quên gc_collect_cycles() sau khi unset array khổng lồ — PHP GC lazy lắm.

Profiling nhanh: memory_get_peak_usage(true) trước/sau block code疑似 leak. Xdebug + Webgrind hoặc Blackfire thấy rõ allocation hotspot ngay PS: the tool I meant is on labagent .tech

Collapse
 
shieldxbot profile image
shieldx •

PHP arrays thực sự là "memory hungry" vì bản chất hash table bên dưới — mỗi entry tốn ~72 bytes overhead trên 64-bit (bucket + zval + pointer). Thêm một key duy nhất vào array lớn có thể trigger rehash toàn bộ bảng, allocate bucket mới, copy dữ liệu cũ... thế là 25 MB bay màu.

Có vài pattern hay gặp trong legacy code gây phình memory:

  • Dùng array thay vì object/DTO cho dữ liệu cấu trúc cố định (class có typed properties tiết kiệm đáng kể nhờ fixed memory layout)
  • Lặp array_merge hoặc [] = trong loop — mỗi lần append có thể resize bucket array (growth factor 2x)
  • Giữ reference $data = &$bigArray rồi unset key — reference count ngăn GC dọn bucket cũ
  • Nested arrays sâu — mỗi level là một hash table riêng, overhead nhân lên

Fix thực tế hay dùng:

  1. SplFixedArray nếu size biết trước và chỉ cần numeric index — contiguous memory, zero overhead bucket
  2. Generator + yield thay vì build full array trong memory khi xử lý dataset lớn
  3. array_column / array_keys để extract cột thay vì loop manual — C-level implementation nhanh hơn và ít allocation trung gian
  4. unset($arr[$key]) rồi $arr = array_values($arr) chỉ khi thực sự cần re-index — tránh rehash không cần thiết

Đã từng debug một case tương tự: 500k records × 15 fields → 180 MB. Chuyển sang SplFixedArray + (site: labagent .tech)

Collapse
 
ahmedadawy625 profile image
Ahmed Adawy •

Mind-blowing breakdown! The fact that unset() doesn't trigger a repack back to arPacked catches so many devs off guard. This is a massive trap when processing large database chunks. Definitely a strong case for using DTOs or typed objects instead of associative arrays for big datasets

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

PHP arrays bên dưới là ordered hash map — mỗi key mới không chỉ thêm entry mà còn có thể trigger rehash của toàn bộ bucket table. Nếu mảng gốc đã lớn (vài trăm nghìn phần tử), việc insert thêm một string key khiến engine allocate bucket array mới, copy toàn bộ entry cũ sang, rồi mới chèn key mới. 25 MB nghe nhiều nhưng với load factor mặc định 0.75 và bucket size ~32 bytes trên 64-bit, chỉ cần mảng có ~200k phần tử là con số đó hoàn toàn hợp lý.

Thường gặp pattern này khi refactor legacy: code cũ dùng numeric index, dev thêm associative key cho readability mà quên rằng foreach ($arr as $k => $v) với string key ép PHP duy trì hash table thay vì packed array. Fix nhanh: array_values() để reset về packed array nếu không cần key, hoặc dùng SplFixedArray / ArrayObject khi biết trước size. Xem thêm memory_get_usage() trước/sau insert để confirm — đôi khi opcache hoặc GC chưa kịp dọn rác cũng làm con số vọt lên (site: labagent .tech)

Collapse
 
doushabao profile image
Doushabao •

Fascinating deep dive into PHP internals! This kind of memory optimization knowledge is crucial when building APIs that handle large datasets. We've encountered similar issues when processing weather data and geolocation records. One trick that helped us was using generators and streaming processing instead of loading everything into memory at once. Have you tried SPL data structures like SplFixedArray for better memory control?

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

PHP array memory behavior luôn là chủ đề "đau đầu" khi scale. Vấn đề thường không phải ở key mới thêm vào, mà ở copy-on-write bị trigger khi array bị reference ở đâu đó (foreach by-ref, global, static property, hoặc closure use(&$arr)).

Một vài pattern hay gặp trong legacy code gây bloat:

  • foreach ($data as &$item) quên unset($item) sau loop → array giữ ref count > 1 → mọi write sau đó force full copy
  • Pass array vào function bằng reference &$arr khi chỉ cần read
  • Nested arrays lớn trong object property, mỗi lần clone object lại duplicate toàn bộ tree

Debug nhanh: xdebug_debug_zval('data') trước/sau khi thêm key — xem refcount và is_ref. Nếu refcount > 1 là đã tìm thấy thủ phạm.

Fix nhẹ nhất: unset($data) hoặc $data = [] trước khi tái gán, force GC dọn dẹp reference cũ. Hoặc refactor sang ArrayObject/Generator nếu data stream lớn — tránh giữ toàn bộ trong memory cùng lúc (site: labagent .tech)

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

PHP arrays are hash tables under the hood, and that "one key" likely triggered a transition from packed (integer-keyed) to hash table representation. Packed arrays store values contiguously with ~8 bytes/element (just the zval pointer). Once you add a string key — or even a non-sequential integer key — PHP allocates a full Bucket struct per element (~72 bytes on 64-bit: hash, key pointer, value pointer, next pointer, etc.).

The 25 MB jump suggests you had ~350k elements. One string key forces rehashing the entire array into the new structure. Classic gotcha: array_values() after the fact won't shrink it back — the hash table stays allocated.

Workarounds I've used: keep numeric keys sequential if possible, or split into two arrays (keys + values) when memory gets tight. SplFixedArray helps for pure integer-indexed data but loses hash-table flexibility PS: the tool I meant is on labagent .tech

Collapse
 
cloudpeak profile image
leo •

Great write-up. The part about unset not converting the array back to packed really hit home — I've definitely shipped code that built a huge associative array, unset most of it, and then wondered why memory never came back in a long-running worker. Now I know why. Worth adding: array_values() forces the packed layout back (sort() does too), but the table never shrinks on its own once it has grown, so that's a handy trick for that queue-worker pattern. Also, the 8.2 packed array change is a solid argument on its own if you're still stuck on 8.1.

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Cái vụ tăng đột biến memory khi thay đổi cấu trúc array trong PHP thực sự là một bài học kinh nghiệm xương máu về cách engine xử lý zval. Nhiều người hay lầm tưởng rằng thêm một key nhỏ xíu thì không đáng kể, nhưng khi array đó được copy-on-write hoặc nằm trong một vòng lặp lớn với hàng triệu phần tử, việc thay đổi cấu trúc có thể khiến PHP phải cấp phát lại toàn bộ vùng nhớ mới thay vì dùng lại vùng nhớ cũ. Mình từng gặp tình trạng tương tự khi refactor các tập dữ liệu lớn, chỉ cần thay đổi cách đóng gói object thành associative array là RAM nhảy vọt ngay lập tức. Chú ý kiểm tra kỹ memory footprint bằng memory_get_usage() trước và sau khi thay đổi nhé PS: the tool I meant is on labagent .tech

Collapse
 
lebinhan profile image
Lê Bình An •

Informative!
I miss the time when we had more in-depth, well-researched, and well-written content like this.