skip to content

A PHP service decoding a partner's JSON corrupts 20-digit order IDs; why does json_decode() do that, and which flag fixes it?

level: seniorimportance: should knowfreq 30%

answer

  1. past PHP_INT_MAX the integer overflows
  2. a double keeps about 15-17 digits
  3. JSON_BIGINT_AS_STRING
  4. encoding floats uses serialize_precision
  5. JSON_NUMERIC_CHECK drops leading zeros

basics

~10 s

json_decode() turns an integer literal beyond PHP_INT_MAX into a float, which cannot hold 20 exact digits, so the ID is rounded. Pass JSON_BIGINT_AS_STRING to keep the original digits as a string.

solid answer

~40 s

JSON numbers have no size limit, but a PHP `int` is 64-bit on common builds, topping out at `PHP_INT_MAX` (9223372036854775807, 19 digits). When `json_decode()` meets an integer literal that does not fit, it decodes it as a `float`, and a double holds only about 15-17 significant digits, so a 20-digit order ID comes back rounded and no error is raised. The flag `JSON_BIGINT_AS_STRING` makes the decoder return such literals as the original digit string instead; integers that fit still come back as `int`. The robust contract is to send identifiers as JSON strings in the first place. On the encoding side, floats are written using the `serialize_precision` ini setting (default `-1`, shortest round-trippable form), and `JSON_PRESERVE_ZERO_FRACTION` keeps `10.0` from becoming `10`.

code

php · 14 lines
php
<?php
declare(strict_types=1);

$json = '{"orderId":12345678901234567890,"qty":3}';

$lossy = json_decode($json, true);
var_dump($lossy['orderId']); // float(1.2345678901234567E+19) - digits lost

$exact = json_decode($json, true, 512, JSON_BIGINT_AS_STRING);
var_dump($exact['orderId']); // string(20) "12345678901234567890"
var_dump($exact['qty']);     // int(3) - small ints unchanged

var_dump(json_encode(['total' => 10.0]));                              // {"total":10}
var_dump(json_encode(['total' => 10.0], JSON_PRESERVE_ZERO_FRACTION)); // {"total":10.0}

go deeper

for a junior

Recall that PHP ints have a maximum size and that JSON numbers larger than that come back from json_decode() as floats.

for a middle

Explain the float fallback, what JSON_BIGINT_AS_STRING changes, and how serialize_precision and JSON_PRESERVE_ZERO_FRACTION shape float output.

for a senior

Diagnose silent ID corruption at an integration boundary, fix decoding with JSON_BIGINT_AS_STRING and push for string identifiers in the contract.

for a principal

Set cross-service rules for identifiers and money in JSON contracts so no consumer depends on its parser's number range.

## The symptom A partner sends `{"orderId": 12345678901234567890}`. Your service decodes it, stores it, and later the partner reports that you referenced `12345678901234567000` or some similar neighbour. No exception, no `json_last_error()` code: the number was silently rounded during decoding. ## Why it happens JSON itself does not limit the size of a number. PHP does: - a PHP `int` is a signed 64-bit integer on 64-bit builds, so the largest value is `PHP_INT_MAX`, `9223372036854775807` (19 digits); - a PHP `float` is an IEEE 754 double, which holds integers exactly only up to 2^53 and carries roughly 15-17 significant decimal digits. The JSON scanner checks every integer literal. If it fits in an `int`, it becomes one. If it does not, the default is to parse it as a **float**, which is where the digits are lost. The 20-digit ID above exceeds `PHP_INT_MAX`, so it takes the float path. ## The fix on the decoding side `JSON_BIGINT_AS_STRING` changes only that overflow path: an integer literal too large for `int` is returned as a `string` holding the exact digits. ```php $data = json_decode($body, true, 512, JSON_BIGINT_AS_STRING | JSON_THROW_ON_ERROR); ``` | JSON literal | default result | with `JSON_BIGINT_AS_STRING` | |---|---|---| | `42` | `int(42)` | `int(42)` | | `9223372036854775807` | `int` (fits) | `int` (fits) | | `12345678901234567890` | `float`, rounded | `string "12345678901234567890"` | | `1.5` | `float(1.5)` | `float(1.5)` (fractions are unaffected) | Code that consumes the field must then accept `int|string` and should treat it as an opaque identifier. Arithmetic on large values needs an arbitrary-precision library such as `bcmath`. ## The better contract Identifiers are not quantities. Asking the partner to send `"orderId": "12345678901234567890"` removes the problem for every consumer, including ones that parse all JSON numbers as doubles. The same reasoning applies to money: amounts are safer as strings or as integer minor units than as JSON fractions. ## The encoding side `json_encode()` writes a PHP `int` exactly, so large IDs leave PHP intact; whether the receiver keeps them depends on its parser. Floats have two knobs: - **`serialize_precision`** (ini, default `-1` in `main.c` and both shipped `php.ini` files): `-1` means "the shortest representation that round-trips", so `0.1` encodes as `0.1`, not `0.10000000000000001`. Setting it to 17 brings back the long forms. - **`JSON_PRESERVE_ZERO_FRACTION`**: without it, `10.0` encodes as `10`, which a strict consumer may read as an integer. With it, the output keeps `10.0`. ## JSON_NUMERIC_CHECK: a trap in the same family `JSON_NUMERIC_CHECK` makes `json_encode()` write numeric **strings** as numbers. It looks handy for values read from a database as strings, but it also rewrites values that only look numeric: - a phone number `"0123456"` becomes `123456`, losing the leading zero; - a long numeric ID string can become a float and lose digits; - a postcode or account code changes type between records. Cast the specific fields you know are numbers instead. ## Checklist for integrating a third-party JSON API 1. Ask whether any integer can exceed 19 digits or `PHP_INT_MAX`. 2. Decode with `JSON_BIGINT_AS_STRING` if it can, and type the field `int|string`. 3. Never round-trip identifiers through `float`. 4. Avoid `JSON_NUMERIC_CHECK` on mixed data. ## Where the digits get lost | Stage | Large ID as JSON number | Large ID as JSON string | |---|---|---| | PHP `json_decode()` default | rounded float | exact string | | PHP with `JSON_BIGINT_AS_STRING` | exact string | exact string | | a consumer that parses numbers as doubles | rounded | exact | The right-hand column is the only one that is safe everywhere, which is why the contract fix beats the flag.

  • Does JSON_BIGINT_AS_STRING turn every JSON number into a string?
    No. It affects only integer literals too large for a PHP `int`. Integers that fit stay `int`, and numbers with a fraction or exponent stay `float`. That is why consumers of such a field must accept `int|string`.
  • Why is JSON_NUMERIC_CHECK risky when encoding database rows?
    It converts every numeric-looking string to a number, so values like phone numbers or codes with leading zeros lose them, and long numeric strings can become floats. Cast the fields you know are numeric instead of applying the flag to the whole row.

saying these in an interview costs you the question

  • Expecting json_decode() to raise an error when an integer overflows
  • Believing JSON_BIGINT_AS_STRING returns every number as a string
  • Storing external IDs as float after decoding
  • Using JSON_NUMERIC_CHECK on rows with phone numbers or codes
  • Thinking json_encode() drops digits from large PHP ints