JSON Formatter — Pretty Print, Minify, and What the Round Trip Rewrites
Paste JSON and it prints it back with your browser's own parser: pretty-printed, minified, keys sorted if you ask. It then lists every value the round trip rewrote - integers past 2^53, 1.0 and 1e3, -0, duplicate keys, reordered keys - and a failed parse comes back with the line and column.
What came back different
| Where | As it stands in the text | As it comes out |
|---|
Worked round trips
| You paste | Result box after Minify | What the round trip rewrote |
|---|---|---|
{"a":1,"a":2} | {"a":2} | the name a was written 2 times, only the last value survives |
{"hello":"world","id":9007199254740993,"ratio":1.0} | {"hello":"world","id":9007199254740992,"ratio":1} | 9007199254740993 came back as 9007199254740992, 1.0 as 1 |
[1e21,-0,1e400] | [1e+21,0,null] | all three spelled another way, and the last one changed kind |
{"10":0,"2":0,"a":0,"B":0} | {"2":0,"10":0,"a":0,"B":0} | 10, 2, a, B came out 2, 10, a, B |
What the round trip rewrites, and where it stops
Reading it and writing it back are two different jobs
A JSON text is read into a tree of values, and the tree is written out again as a new text. JSON.parse does the first, JSON.stringify does the second, and neither of them remembers the text in between: not the line breaks, not the indentation, not the order the names were typed in, not the way a number was spelled. A formatter is therefore not a re-layout of what went in. It is a fresh piece of writing from an intermediate that has no memory.
That intermediate holds six kinds of value and nothing else - object, array, string, number, true/false, null - and two of them are where the surprises live. A string keeps only the characters it decodes to, so every escape spelling you wrote is gone. A number is an IEEE-754 double: 64 bits of binary, not the digits you typed. That single fact is why some numbers come back changed.
Both halves are your browser’s own code, so what sits in the result box is exactly what a JSON.parse / JSON.stringify pair typed into a console produces, and no second implementation of JSON is deciding anything quietly. What is added is the comparison: the two texts are walked name by name and path by path, and everything the writer changed is listed in the table under the box.
Numbers are the part that can quietly change
A double has 53 bits of significand. Integers are exact up to 9007199254740992, which is 2 to the 53rd, and past that the representable values sit more than one apart - so 9007199254740993 does not exist, and reading it gives its neighbour. Measured in a current Chrome: String(JSON.parse("9007199254740993")) answers 9007199254740992. This is where a snowflake id, a nanosecond timestamp, or any 64-bit integer arriving in a JSON body loses its last few digits, and it is the reason such ids travel as strings.
The writer prints the shortest run of digits that reads back as the same double, so every spelling that was longer than the value needs gets cut, and every spelling too short to be read in fixed-point notation comes back with an exponent:
| Went in | Came out | What happened |
|---|---|---|
1.0 |
1 |
a trailing zero says nothing a double can act on |
100.000 |
100 |
same |
1e3 |
1000 |
the exponent was a way of writing the value, not part of it |
1e20 |
100000000000000000000 |
still inside the range printed in fixed point |
1e21 |
1e+21 |
from 21 digits up, the short form switches to exponential |
0.000001 |
0.000001 |
one step above the switch |
0.0000001 |
1e-7 |
below 10 to the minus 6, the same switch the other way |
-0 |
0 |
the minus sign survives in the double; printing does not show it |
123456789012345678901234567890 |
1.2345678901234568e+29 |
only about 17 significant digits survive the double |
One case changes the kind of a value rather than its spelling. A literal too large for a double is Infinity, and JSON has no spelling for infinity, so the writer emits null: both -1e309 and 1e400 come out as null. Too small becomes zero, and 1e-328 comes out 0. A count that overflows into a null pointer is a different kind of bug from a formatting one, and it is listed here like any other change.
Two names for one key
RFC 8259 says the names inside an object should be unique - a recommendation, not a rule - and JavaScript’s parser follows the permissive reading: the last one written wins and the earlier one is dropped without a trace. {"a":1,"a":2} is valid JSON and reads as {"a":2}.
Nothing in the resulting tree shows that a name was written twice, so the loss is invisible to anything working from the tree. It can only be seen by counting the text. The path is named, the number of times that name appeared is counted, and the value that survived is the one quoted.
One variety of it is easy to miss by eye, because the two names do not look alike: an escape-spelled name and a plain name are the same name once read. {"a":1,"\u0061":2} has two entries, both called a after decoding, and comes out {"a":2}. The count is taken after decoding, so that pair is caught as a duplicate too.
Which order the keys come out in
A JSON object is defined as an unordered collection, but the tree JavaScript builds has an order, and the writer follows it: names that look like array indices come first in ascending numeric order, and every other name follows in the order it was inserted. Read {"10":0,"2":0,"a":0,"B":0} and the keys come back as 2, 10, a, B - neither the order typed nor alphabetical.
Two files can therefore hold the same document and still diff, purely because one of them was last written by something in the JavaScript family. Asking for names in alphabetical order rebuilds the model with the names sorted, and the engine still hoists the index-like ones: {"a":1,"10":2,"2":3} sorted comes out 2, 10, a. That is not the choice failing; it is the one part of key order a script cannot decide. Objects where the two orders disagree are counted, and the first of them is quoted with the order asked for and the order that came out.
Where a failed parse gets its line and column
The engine’s own sentence is printed as it came, because paraphrasing an error is how details get lost. A current Chrome sends two families of it:
Expected property name or '}' in JSON at position 1 (line 1 column 2), andUnexpected non-whitespace character after JSON at position 7 (line 1 column 8)- an offset into the text, with a line and column beside it.Unexpected token '}', "…" is not valid JSON, andUnexpected end of JSON input- no position at all.
Where there is an offset, the line and column are recomputed from the text in the box and a caret is placed under the character: the offset counts from 0 while the line and column count from 1, and a pasted document brings its own line breaks. Where the sentence carries no offset, that is said plainly and the piece the engine quoted is what is shown. The quoted piece is short by design - 56 characters of message for a mistake sitting on a 400-character line - so a long line cannot flood the message.
How deep, and how big, before it stops
The cliff belongs to the engine, and the steps do not share one. Measured in a Chromium tab: JSON.parse read a document 200,000 levels deep, and the writer that emits no line breaks and no spaces wrote that same document back, while the writer that indents gave out between 4,000 and 4,400 levels and the step that puts the names in alphabetical order gave out between 10,000 and 12,000. Under Node 22.20 the identical document stopped both writers at 2,235 levels, although the parser there read the same 200,000 without complaint. Each step spends its own stack frames, so which one refuses first is a fact about the engine, its version, and how much stack was already spoken for.
The indenting writer has a second cost that shows before the stack does: the text it produces grows with the square of the depth, because every line carries every line above it. A document 4,000 levels deep is 8,001 bytes flat and 32,016,001 characters once it is indented two spaces a level.
What happens when a step gives up is stated rather than hidden: the message names the step that failed and carries its own sentence, the compact form of the same document goes in the box instead, and if the compact writer cannot manage it either the box stays empty rather than half-filling with a truncated file. When the alphabetical-order step gives up, nothing is written at all - the box stays empty. The text pasted in is never modified on the way.
Two limits belong to the comparison step, and both are about legibility rather than time:
- The walk goes character by character over both texts and stops at 2,097,152 characters - 2 MB - for either one. Past that the counts read
—and nothing is claimed about what came back different. - The table lists at most 40 rows, and the sentence above it gives the real total.
For scale: an 8,488,891-character document was read in about 80 ms and written back in about 107 ms in a live tab, so neither limit is there because the arithmetic is slow.
What never leaves this machine
Reading, comparing and writing are all done by code in this tab over the text in the box. No request is made with the content, nothing is stored, and no server is asked to do the maths. What you enter does not leave this device.
Questions
Q: Why did the last digits of my id change? It arrived as a JSON number, a JSON number becomes a double, and a double is exact only up to 2 to the 53rd. Send it as a string and the digits stay put.
Q: Is what comes out the same JSON as what went in? The same document: read again, it gives the same tree. Not the same bytes - whitespace, number spellings, key order and any duplicated name are the writer’s business now. Anything that hashes the text or diffs it character by character can disagree even when nothing semantic moved.
Q: Which indent should I pick? Whichever the reader of the file expects. Two spaces is what most JavaScript style guides ask for, four is what PEP 8 asks of Python, and a tab is the fewest bytes of the three. The size figure is the only thing the choice changes; the document is identical either way.
Q: Does sorting the keys change the meaning? No - an object is unordered by definition. It changes diffs. It cannot move the index-like names, which come out first whatever is asked.
Q: The error has no line number. Is that broken? That family of message carries no position; it quotes the offending token instead. Where a position exists in the sentence, the line, the column and the caret all get filled in.
Q: How big a file can go through? The reading and writing are the browser’s own, and the document measured above took under a fifth of a second end to end. Past 2 MB per text only the comparison step stops, and it says that it stopped; the box still holds the writer’s full output.