Notes › Tech Notes › Which values quietly change when you paste API JSON into a PHP array

Which values quietly change when you paste API JSON into a PHP array

Last week I was assembling a reconciliation fixture and pasted the order endpoint response into the converter to get a PHP array. It surfaced three days later: the log said user_id 9007199254740993, the seed said 9007199254740992, and the amount 9223372036854775807 came out as 9223372036854776000. Nothing failed, nothing warned, the values simply moved.

I read the template implementation and ran 88 cases through it. The short answer is that almost every change happens in the very first step, JSON.parse, not in the part that assembles the literal.

Where the values change: they land in a JS number first

The chain is short: read the string, JSON.parse, then build 'key' => value per type, and the number branch (template line L107) just stringifies whatever parse returned. Once a value sits inside a double, no amount of pretty indentation brings the original back. 2^53=9007199254740992 is the exact integer ceiling in JS: on the boundary it stays as it is, above the boundary there is no promise at all. The controls are the output format, the indentation and whether to add the <?php tag, and there is no big integer fidelity switch.

Value in the JSONLiteral producedjson_decode on the PHP side
90071992547409939007199254740992integer
1234567890123456789012345678901234567000double 1.2345678901234567E+19
92233720368547758079223372036854776000integer, off by 193

The last row stings most: PHP taking the same digits on its own keeps the exact value, it only collapses after passing through JS. Order numbers, snowflake ids and microsecond timestamps are usually bare numbers.

Floats follow no single rule, and infinity is computed

  • The 21 digits of 3.14159265358979323846 survive as the 16 digits of 3.141592653589793.
  • Inside 17 significant digits, both 0.30000000000000004 and 1.2345678901234567 come back character for character.
  • Trailing zeros get eaten: 1.500 becomes 1.5, -0 becomes 0, 1.5e3 becomes 1500.
  • The notation itself changes: 1e20 prints 100000000000000000000, 1e21 prints 1e+21, 1e-324 underflows to 0.

A huge exponent computes an infinity instead: 1e999 gives Infinity and -1e999 gives -Infinity. PHP has no Infinity literal and the constant is INF, and I did not run php -l in this round, so I will not claim whether that line even compiles.

null, empty containers, duplicate keys, key order

{} and [] both come out as []; switch to the PHP object mode and the empty object does not even keep the (object) prefix, so {"a":[{},[]]} yields 'a' => [ [], [], ]. Duplicate keys merge without a sound: {"a":1,"a":2,"a":3} leaves only 'a' => 3, and 19 bytes collapse into 14. Numeric looking keys get reordered, so inside {"01":"a","10":"b"} the '10' sits before the '01'. {"z":null} produces 'z' => null and does not collapse, and {"s":"007"} is neither turned into a number nor trimmed.

Chinese keys, \u escapes and quotes

Chinese is no obstacle: {"用户名称":"张三"} produces '用户名称' => '张三', and reserved words, keys with spaces and keys with dashes print as they are. \u is already resolved during the parse phase, so {"s":"\u4e2d\u6587"} comes back as 2 characters. The escaping only covers the backslash and the single quote: {"a'b":"x"} turns into 'a\'b', while a double quote falls into the single quoted string untouched.

What you copy back is not what was produced

The output area renders highlighting with innerHTML and escapes neither angle brackets nor &, yet the copy button takes textContent. {"s":"a<b>c</b>"} copies back as 's' => 'ac', while the same value is 24 characters long before it ever touches the DOM; in {"<?php":"x"} the key name disappears whole and only ' => 'x' is left. So compare the source text against the final array, not against what you pasted back.

Scale and depth: from slow to broken

Bars are elapsed milliseconds, labels are input and output bytes 1000 items 8 ms · 24790 → 54811 10000 items 67 ms · 267790 → 567811 100000 items 742 ms · 2877790 → 5877811 Output over input: 2.21, 2.12, 2.04

Figure 1: the three sizes went through one and the same template, one after another, and err was an empty string every time, so there was neither a silent failure nor a truncation warning.

Depth does not make it slow, it makes it break: depth=100 writes 20401 characters, depth=1000 writes 2004001, depth=2000 writes 8008001, and from depth=5000 there is only Maximum call stack size exceeded. Indentation adds 2 spaces per level, so the total length climbs as a square, and the usable stack depth lands between 1000 and 5000; I cannot give a threshold for nested objects, because those cases are invalid input by themselves.

Nesting depth against output characters depth=100 20401 depth=1000 2004001 depth=2000 8008001 depth=5000 → Maximum call stack size exceeded

Figure 2: every step up in depth raises the output length by roughly a square.

The failure notice is one line of English

The red box title is fixed as JSON 解析错误 and the body is simply e.message, while the output area and the statistics get cleared together. {"n":007} reports Unexpected number at position 6, {"a":1,} reports Expected double quoted property name at position 7, and {"a":1}{"b":2} reports Unexpected non-whitespace character at position 7, because only one document is read at a time. Blank input shows the empty state only, input carrying a BOM still converts, and whether the notice is localised I have no sample for. When a paste fails at position 1 right away, I doubt the source text first and run it through JSON formatting to confirm whether it is valid on its own.

How I check things now

A whole endpoint response that has to become a PHP array goes through JSON to PHP array, and every number above came out of that same template. After it, three fixed steps:

  1. Fields above 2^53 never pass JS: read the raw text in PHP, json_decode it there, and export it yourself.
  2. Once converted, compare lengths instead of appearances, because an unexplained shrink is usually the display layer having eaten something.
  3. Validate key order and empty containers on their own, the generated code hides them; to work out the nesting of the original I follow how that tree viewer post collapses and locates a node.

The object mode only adds the (object) prefix to the levels that take the object branch, the top level array and the scalars never get one, and the word stdClass does not appear anywhere in the output.

Q: Why does a 20 digit integer always change while 0.30000000000000004 stays untouched?

A: It depends on whether a double can hold the value exactly, not on how strange the digits look. An integer beyond 2^53 can only land on the nearest representable value, whereas 0.30000000000000004 is already what a double prints.

Q: {} and [] convert to the same thing, how do I know which one the source had?

A: The generated code cannot answer that: the two branches produce the same number of bytes, and object mode does not rescue an empty object either, so you have to go back to the source text.

Q: Only half of it came out, was it truncated?

A: First tell apart a missing tail from a missing middle, because angle bracket values lose their middle no matter how small the payload is. The largest sample I ran was 100000 items with 5877811 bytes of output, and that run was neither truncated nor flagged; anything bigger is unmeasured.

📎 Related articles

PNG compressed three times only lost 426 bytes: the real boundary of lossless compression 2026-10-22 How Many Times Bigger a PNG Gets After Converting From WebP: When It Is Unavoidable and How to Keep the Growth Down 2026-10-22 Three Reasons the Small Text Smudges When a Screenshot Is Shrunk to a Quarter 2026-10-22 Cropped to the Same 300x300, Why Does the Avatar Always Sit Half a Cell Low-Right 2026-10-22 Forecasting 18 months at 1.7 times a month: why the script and the sheet give two numbers 2026-10-22