Back to Blog

JSON.parse Is Not a Round Trip

September 4, 2026
Share:
JSON.parse Is Not a Round Trip

I spent a day building a JSON formatter. The obvious version is two lines: JSON.parse the text, JSON.stringify it back with an indent, render the result. That version is wrong in three ways, and all three are silent.

Paste this into any formatter and see what comes back.

{"id":7203415887654321987,"2":"b","1":"a","id":42}

Three things come back different from what you sent, and none of them show up as an error.

The Formatter I Almost Shipped

I wrote the two line version first, the way everybody does. Then I wrote a test that pasted a document in and asserted the output parsed back to the same value, which passed. Then I wrote a test that asserted the output contained the same characters, which did not.

That second test is the whole post.

JSON.parse doesn't give you back the document. It gives you a JavaScript value that the document described, and JavaScript can't describe everything JSON can. Whatever falls outside what a JavaScript object holds gets quietly rounded, reordered or dropped on the way in, and JSON.stringify can't put it back, because by then it's gone.

For a validator this matters more than it does for most code. The person is here because something is wrong and they want to look at it. Handing them a tidied up copy with the evidence taken out is a strange thing to do.


Change One: The 64-Bit ID

Run this in any JavaScript engine:

JSON.parse('{"id":7203415887654321987}') // { id: 7203415887654322000 }

The last four digits changed. Nothing threw.

JavaScript numbers are IEEE 754 doubles, and the largest integer a double can represent exactly is Number.MAX_SAFE_INTEGER, which is 9007199254740991. That is about nine quadrillion. Above it, integers start snapping to the nearest representable value, and the snapping gets coarser the higher you go.

Nine quadrillion sounds like a lot until you look at what actually flies around in an API response. Discord snowflakes are 64 bit. X post and account ids are 64 bit. Plenty of database primary keys are 64 bit because somebody sensibly reached for bigint. All of them are past the safe range.

This is why the Twitter API shipped an id_str field next to id years ago. The number was arriving in JavaScript clients as a different number, and rather than fight physics they sent the digits as a string too.

So when a formatter reformats your response and you copy the pretty version into a bug report, the id in your bug report is not the id in the system. It's close. It's off by a few hundred.


Change Two: The Key Order

JSON.stringify(JSON.parse('{"2":"b","1":"a","x":"c"}')) // {"1":"a","2":"b","x":"c"}

The first two properties swapped places.

That isn't a bug in anybody's code. It's in the ECMAScript spec. A JavaScript object keeps its properties in insertion order except for keys that look like array indices, and those come first, sorted numerically ascending. Parse into an object and you have handed your document's order to that rule.

Most of the time nobody notices, because most keys are words. Then you hit a document keyed by year, or by product code, and the pretty printed copy has quietly rearranged itself.

The place it stings is a diff. You format two API responses so you can compare them side by side, and the formatter reorders both of them the same way, which is fine. Then you compare a formatted response against the raw one from a log, and the diff lights up on lines that are identical in the source.


Change Three: The Duplicate Key

JSON.parse('{"id":1,"id":2}') // { id: 2 }

The first id is gone with no complaint from anyone.

RFC 8259 says the names in an object SHOULD be unique and then admits that parsers differ on what happens when they are not. In practice every JavaScript parser takes the last one, because it is assigning to the same property twice. Other languages do not all agree. Some take the first. Some raise. Some hand you a list.

A duplicate key is almost always a real defect further upstream. Two code paths merging into one object, or a config file where somebody pasted a block and edited half of it. You want to know. A formatter that parses into an object can't tell you, because by the time it looks there's one key sitting there and it looks fine.


What Each One Costs You

What changedWhen you noticeWhat it costs
A 64 bit id got roundedWhen a lookup on the id returns nothingAn afternoon, because the id looks right
Keys got reorderedWhen a diff lights up on identical linesTen minutes and some doubt about the diff tool
A duplicate key vanishedUsually neverThe bug stays in the config file

The middle row is annoying. The other two are the kind of thing you chase for hours because the evidence has already been cleaned up by the tool you are using to look at the evidence.


The Fix Is To Not Parse Into JavaScript

The way out is dull, which is usually a good sign. Keep the source text.

Write a parser that produces a tree of nodes, and for every number, store the digits exactly as they were written instead of a JavaScript number. Store object members as an ordered list of pairs rather than as an object. Printing then walks that tree and writes the stored text back out.

{ kind: "number", raw: "7203415887654321987" }

A number node carries raw. The formatter never calls Number() on it, so nothing rounds. An object node carries members: [{ key, value }], which is a list, so nothing reorders. Two members with the same key are two entries in that list, so the duplicate is still sitting there to be reported.

It's more code than the two line version. The library came out at 1,344 lines and about 640 of those are the parser. In exchange the formatter hands back what you handed it, and the tests can assert on characters instead of on values.


What About BigInt and the Reviver

There are two other ways at this, and both are worth knowing about.

The reviver argument to JSON.parse now gets a context object with the source text of the value it is reviving, so you can catch a big integer before it becomes a double:

JSON.parse('{"id":7203415887654321987}', (key, value, ctx) => key === "id" ? BigInt(ctx.source) : value )

There is a matching JSON.rawJSON for writing them back out. Both work in V8 today. Check your other targets before you build on them.

That solves precision. It does not solve key order or duplicates, because you still end the parse holding a JavaScript object. For a formatter you need all three, so the tree is the answer anyway.


Unexpected Token Is Not an Error Message

The other half of the day went into the failure path, which is the part people actually hit.

Here is the same engine, two broken documents, in the same Node process:

JSON.parse("[1,2,]") Unexpected token ']', "[1,2,]" is not valid JSON JSON.parse('{"a":1,}') Expected double-quoted property name in JSON at position 7 (line 1 column 8)

Same mistake, a trailing comma, and two completely different messages. One names the position and the line. One quotes the document back at you and names neither. That is a single engine on a single day. Across Chrome, Firefox and Safari the wording drifts further, so anything you build on top of the message text breaks the moment somebody opens your page in a different browser.

So a validator has to do its own parse. That's the only way it knows where it stopped.


What a Parse Error Should Actually Say

Four things, and none of them are expensive to produce once you are walking the characters yourself.

Where it stopped, as a line and a column counted from one. What it found there. What it wanted instead. And the line itself, printed, with a caret under the character.

Line 4, column 1 Trailing comma before the closing brace. 3 "name": "softery", 4 } ^

The caret is the part people react to. A line number sends you looking. A caret ends the search.

Two details are easy to get wrong. Count columns in code points, not in UTF-16 units, or an emoji earlier in the line shoves your caret one place right of the problem. And when the offending line is a minified document three thousand characters long, print a window around the column instead of the whole line.


The Errors Worth Naming

Broken JSON breaks in a small number of ways, and each one deserves a sentence in English rather than a token name.

What is in the documentWhat to say
{"a": 1,}Trailing comma before the closing brace
{name: 1}Property names must be in double quotes
{'a': 1}Strings must use double quotes, not single quotes
{"a": NaN}NaN is not valid JSON, and there is no way to write it
// commentComments are not allowed in JSON
{"a": "helloThe string that opened on line 2 was never closed
{"a": [1, 2The input ended while 2 brackets were still open

That last one is the most useful and the rarest. When somebody pastes half a log line, telling them the document is cut off beats telling them a token was unexpected at the end.


Repair, and Then Say What You Repaired

Most broken JSON arrives broken in predictable ways, because it came out of a log file, a config file or a Python print. Trailing commas. Single quotes. Unquoted keys. NaN where a number should be. True and None from a Python REPL. A byte order mark from a Windows editor. The guard prefix some APIs put in front of a response so it cannot be loaded as a script.

All of that is mechanically fixable, and every online formatter with a fix button works the same way. You press it, the document changes, and you find out what happened by reading the result.

I think that's backwards. A repair is a guess at what you meant. If a tool turns your NaN into null, that's a real change to your data, and you should hear about it in a sentence before you copy the result anywhere. So the repair pass records every change it makes and prints the list:

Removed 3 trailing commas. Rewrote 2 single quoted strings with double quotes. Turned 1 NaN value into null.

Same fix, and now you can disagree with it.


The Two Shapes That Come Out of Logs

Two inputs turn up constantly once people point a tool at real data, and neither one is valid JSON.

The first is several documents in a row, one per line, which is what a streaming API or a structured log hands you. There's no single value there, so a strict parse dies on the second one. Wrapping them into an array is almost certainly what the reader wanted.

The second is a document that is still inside its quotes, because somebody copied a field out of a log where the payload had been stored as a string:

"{\"order_id\": \"ord_8Kd92mQx\", \"paid\": true}"

That's valid JSON. It's a string. A formatter will happily hand it back as one long line with the backslashes intact, having done exactly what you asked and nothing you wanted. Unescaping it by hand is a miserable five minutes. Detecting it is a two line check: if the root is a string and its contents parse, offer to unwrap it.


How I Tested It

Writing a parser is the easy part. Trusting it isn't, and the way to trust it is a differential test.

Two lists. One of documents that are valid, one of documents that are broken, both including the awkward cases: -0, an empty string, " ", a stack of empty arrays, a thirty digit number, a lone -, 1e, .5, 0x1F. Every item goes through my parser and through JSON.parse, and the test asserts they agree on accept or reject.

for (const sample of broken) { expect(() => JSON.parse(sample)).toThrow(); expect(parseJson(sample).ok).toBe(false); }

That one loop found more real bugs than any case I thought up myself, because it isn't testing my idea of the grammar against my idea of the grammar.

The library ended up with 140 tests and the page with another 33. The repository is at 753. Coverage numbers are a poor brag, but that is roughly what it takes to promise a formatter hands back what you gave it.


What To Do About It

If you handle JSON that came from somewhere else, four things are worth doing this week.

  1. Search your codebase for ids that arrive as numbers. Anything past 9007199254740991 needs to be a string or a BigInt before it reaches a JavaScript number. If the API offers a string variant of the field, use it.
  2. Stop comparing a formatted document against a raw one. Format both sides or neither, or your diff is showing you the formatter's opinions.
  3. Add a duplicate key check wherever you load config. It is a few lines against the source text and it catches a class of bug that otherwise sits there for a year.
  4. Pick a formatter that runs in your browser. Pasting an API response into a website means pasting it onto somebody's server, and a response you are debugging usually has a token or a customer in it.

The JSON formatter and validator I built while writing this does all four things. It keeps your digits, keeps your key order, names duplicate keys with the line each one is on, gives you the line, the column and a caret when the parse fails, and repairs the usual damage while telling you what it changed. It parses in the tab, so you can pull your network cable and watch it keep working.

It's free, there's no account, and I'm not doing anything with your JSON, mostly because I never get it.


Last updated: September 4, 2026 | Reading time: 12 minutes

Written by Evgeniy Poznyak, who wrote six hundred lines of parser to avoid two lines of JSON.parse.