Your JSON parser is rounding your IDs and not telling you
A user reported that our own tool was changing his SIM card numbers. He was right. Here is the bug, why every JavaScript parser has it, and the fix — which is not the one most people reach for.
A user emailed to say our JSON viewer was changing his data. He pasted a SIM inventory export, and the ICCID column came back with the last few digits replaced by zeros. His first assumption was that we were truncating long values to make the table fit.
We were not. But he was right that the number had changed, and the cause is a bug that almost every JSON tool on the internet has — including, at that point, most of ours. If you write JavaScript and you have never hit this, it is worth ten minutes, because it fails in the worst possible way: silently, with no error, on data that looks fine afterwards.
Open a console and watch it happen
This is not a subtle edge case you need a special setup to reproduce. Paste this into any browser console:
JSON.parse('{"iccid": 8901240544102066246}').iccid
// 8901240544102066000 <-- the last three digits are goneNo exception. No warning. The parser returns an object, the object has the key you asked for, and the value is wrong. Anything downstream — a table, a database write, a report — now carries a number that was never in the file.
The really unpleasant part is what happens on the way back out:
JSON.stringify(JSON.parse('{"iccid": 8901240544102066246}'))
// '{"iccid":8901240544102066000}'A read-then-write round trip — the thing every formatter, minifier and "save" button does — has rewritten the file. The output is still valid JSON. It still parses. It just says something different from what it said before, and nothing in the pipeline is in a position to notice.
Where the missing digits go
JavaScript has one numeric type for this, and it is a 64-bit binary floating-point value — a double, as specified in ECMA-262 and described in MDN's note on number encoding. Of its 64 bits, 53 are available for the significand, which means integers are exact only up to 253 − 1:
Number.MAX_SAFE_INTEGER
// 9007199254740991
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992 <-- asked for ...93, got ...92Past that boundary the doubles get sparse — there simply is no double that equals 9007199254740993, so the parser hands back the nearest one that exists. In practice you get about 15 to 16 reliable digits. An ICCID is 19 or 20 digits (that length is set by ITU-T E.118), so it never stood a chance.
Notice what the JSON spec has to say about this: nothing binding. RFC 8259 §6 defines the grammar for a number and then explicitly warns that implementations vary in the range they can represent, recommending you stay inside double precision if you care about interoperability. So this is not JavaScript violating the spec. The spec quietly told you not to do it.
The same file, one column ruined and one fine
This is what makes the bug so slippery in review. Here is a two-field subscriber record. Both values are digits. Only one survives.
{
"imsi": 234150999912345,
"iccid": 8901240544102066246
}JSON.parse(raw).imsi
// 234150999912345 <-- 15 digits, exact
JSON.parse(raw).iccid
// 8901240544102066000 <-- 19 digits, wrongA test fixture with short IDs passes. Production data with long ones corrupts. That gap between the two is where this bug lives, and it is why it usually gets found by a customer rather than by a test suite.
The identifiers this actually hits, in roughly the order we have seen them come up:
- SIM and device identifiers — ICCID at 19–20 digits, IMSI at 15, IMEI at 15. The ICCIDs go every time.
- Database bigints. A 64-bit primary key exceeds 253 once the table gets large enough, so the failure arrives months after launch.
- Distributed-system IDs — snowflake-style keys are 64-bit by design and are past the boundary from the very first one issued.
- Account, card and reference numbers, which are frequently 16 to 19 digits.
- Zero-padded codes. A different symptom, same root cause:
007becomes7, because a number has no concept of a leading zero.
The fix depends on what you do next
Here is the part that took us longest to get right, and the reason this post exists rather than a one-line "use strings". There is no single correct fix, because there are two different jobs and they need opposite answers.
| What you are doing | What the parser should return | Why |
|---|---|---|
| Displaying it, or converting to another format (a table, CSV, XML, YAML) | The digits, as a string | Nothing renders a value it cannot hold. A string of the exact digits is what the reader needs to see. |
| Re-emitting JSON (formatter, minifier, validator, save) | A marker that serialises back to a bare number | A string would add quotes and change the document's type. JSON itself has no precision limit — only JavaScript does. |
Get that backwards and the failure is loud in one direction and silent in the other. Use the string approach in a formatter and you have quietly retyped every long integer in someone's document:
// input
{ "iccid": 8901240544102066246 }
// output — digits preserved, but it is a string now
{ "iccid": "8901240544102066246" }That is better than corrupting the value, but it is still not what the user asked for. They pressed Format, not Change My Schema. A consumer with a strict schema will reject the result.
We hit exactly this on the table editor, and I want to describe the mistake because the reasoning that produced it sounded convincing at the time. Editing a single cell re-serialised the whole document, which re-quoted every long integer in it — including the hundreds the user had never touched. We wrote it off as unavoidable: you cannot tell a 19-digit number from a 19-digit string that someone deliberately quoted, so you have to pick one.
If you would rather not build this, lossless-json is a well-maintained library that parses to a value carrying its original text and stringifies back to exactly what came in. BigInt solves the arithmetic half of the problem but not the parsing half — by the time a BigInt could be constructed, JSON.parse has already rounded the value.
Two traps waiting after you fix the parser
The type check you forgot about
Grep your serialisers for typeof value === 'number' before you assume the parser swap is enough. Those branches decide whether to add quotes, or which column type to use, and a preserved integer now arrives as a string or a wrapper object. The branch flips, and your careful fix emits the wrong shape.
Excel undoes it in the download
This one cost us a second round. You can fix the parser, render the full 19 digits correctly on screen, export to a spreadsheet — and the downloaded file has rounded them again. Excel applies its own 15-significant-figure limit when it reads a cell that looks numeric. The cell has to be marked as text in the export itself:
<td style="mso-number-format:'\@'">8901240544102066246</td>The lesson generalises past Excel: verifying one step of a pipeline is not verifying the pipeline. We closed one converter as safe because the decode was correct, and the corruption turned out to be in the normalisation on the very next line.
The hidden parse in your HTTP client
Even with a lossless parser wired in, one JSON.parse can still be running where you did not put it. fetch's response.json() is a JSON.parse. Angular's HttpClient parses the body for you unless you ask for responseType: 'text'. Both round your integers before your code ever sees the response.
# fetch
const raw = await (await fetch(url)).text();
const data = losslessParse(raw);
# Angular HttpClient
this.http.get(url, { responseType: 'text' })We found this in our own share-link storage, which parsed and re-stringified on both save and read. Fixing only the save path stored the right digits and then rounded them again on the way back out — so the bug survived a fix that looked complete.
YAML has exactly the same bug
If you moved to YAML to escape this, you did not:
yaml.load('iccid: 8901240544102066246').iccid
// 8901240544102066000Same root cause — the integer resolver produces a JavaScript number. What makes it worse is that the answer is language-dependent. Python reads that identical line and gives you 8901240544102066246, exactly right, because Python integers are arbitrary precision. One YAML file, two services, two different values, and no error on either side. There is more on YAML's habit of reinterpreting values in the YAML gotchas post.
How to check your own pipeline in five minutes
- Find the longest numeric ID in your real data. If it is 16 digits or more, assume it is affected until you prove otherwise.
- Send it through the full round trip — API, parser, storage, export — and compare the digits at the far end against the digits you started with. Compare as strings; comparing as numbers will happily tell you two different IDs are equal.
- Grep for
JSON.parse,response.json(),Number(,parseInt(andparseFloat(on any path that touches user data. - Check the export as well as the screen. Open the downloaded file, not the preview.
The one rule that covers all of it
A converter may change format. It may never change a value. Output that holds a different number than the input is worse than an error, because an error stops the pipeline and this does not — it reports success and hands the wrong data to the next system along.
- JavaScript numbers are doubles. Integers are exact to 253 − 1, about 15–16 digits.
JSON.parse,Number(),parseInt()andparseFloat()all round past that, silently.- If an identifier is not something you do arithmetic on, transmit it as a string. It looks wrong the first time and it is correct.
- Rendering a value and re-serialising it need different fixes. Decide which job you are doing before you pick one.
- Check the whole pipeline, including the spreadsheet export and your HTTP client.
If you are choosing a format for a system that carries large identifiers, JSON vs XML covers why XML sidesteps this by treating everything as text until a schema says otherwise. And if you are just trying to read a file someone sent you, start with how to open a JSON file.