Skip to main content
ToolsBay

DEVELOPER TOOLS

How to Format JSON, and the Errors Formatting Cannot Fix

8 min read · ToolsBay editorial · Published · Updated

Just want to do it now?

Pretty-print JSON with the indentation you choose, or minify it.

Open JSON Formatter

Formatting JSON is a solved problem. Paste it somewhere, pick an indent, done. The part that costs people an afternoon is when the formatter refuses, hands back a message like Expected double-quoted property name in JSON at position 39, and the position it names has nothing wrong with it.

This is about that half. What the round trip actually does to your document, and every way a JSON file breaks that no amount of indentation will fix.

Formatting is a round trip, not a cosmetic pass

A formatter does not insert whitespace into your text. It parses the document into a value and prints that value again. In JavaScript that is two calls:

const value = JSON.parse(input);
const output = JSON.stringify(value, null, 2);

The third argument to JSON.stringify is the indent. A number means that many spaces, capped at 10 — pass 20 and you get 10. A string is used literally and also truncated to 10 characters, so "\t" gives tabs. Passing nothing gives minified output, which is the same operation in reverse.

Two things change across that round trip that people expect to survive.

The first is number formatting. 1.50 comes back as 1.5. It is the same number written canonically — the parser produced a double, and the printer wrote the shortest string that reads back to that double. 1e2 comes back as 100, and 1e21 stays 1e+21, because that is where the shortest form flips.

The second is narrow enough that most people never meet it. Object keys that are decimal integer strings get reordered:

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

That is JavaScript object property ordering, not a formatter bug — integer-like keys come first in ascending order, everything else in insertion order. But if you diff formatted output against the original expecting a whitespace-only change, that is the one place the diff will surprise you.

What minifying is actually worth

The JSON formatter shows a byte count for both sides, so you can check this yourself rather than trust a number in a blog post. Its own example document is 218 bytes formatted at two spaces and 136 minified — a 37.6% saving. On a 500-record array of small objects the gap is wider: 54,032 bytes pretty against 29,531 minified.

Now compress both with gzip, which every HTTP server between you and the client is already doing. The same pair becomes 2,836 bytes and 2,670 bytes. A 45% saving turns into 6%, because repeated runs of spaces and newlines are the single easiest thing a compressor eliminates.

So minify a request body you are pasting by hand, and minify a blob you are embedding somewhere that will not be compressed. Minifying an API response that ships over gzip buys almost nothing and costs you readable logs.

Reading the position the parser gives you

Here is a document with one mistake:

{
  "name": "ToolsBay",
  "tools": 58,
}

V8 — Chrome, Edge, Node — reports:

Expected double-quoted property name in JSON at position 39 (line 4 column 1)

The document is 40 characters long. Position 39 is the closing brace on line 4. The mistake is the comma on line 3.

That gap is the whole skill. A parser reports where it could no longer continue, not where you went wrong. After a comma inside an object the only legal next token is a double-quoted property name; the parser was still fine at the comma and became stuck one character later. Unclosed brackets behave the same way and worse. Leave a brace open on line 4 of a 400-line file and the error lands on line 400, because that is where the input ran out — the fix is above the reported line, not at it.

When only a character offset is reported and you want the line yourself, the arithmetic is short:

const before = input.slice(0, position);
const line = before.split('\n').length;
const column = position - before.lastIndexOf('\n');

Browsers disagree about what they report. V8 gives a position and a line/column. Firefox and Safari give one or the other, and word the message differently. The JSON validator normalises all of it to a line and column, and for Unexpected end of JSON input — which carries no position at all — points at the last line, since that is always where the document stopped early.

The four that are legal JavaScript

Almost every JSON failure is someone writing a JavaScript object literal. These four are fine in JavaScript and rejected by JSON, with the messages V8 gives:

{"a": 1,}          Expected double-quoted property name in JSON
{'a': 1}           Expected property name or '}' in JSON at position 1
{a: 1}             Expected property name or '}' in JSON at position 1
{ // note          Expected property name or '}' in JSON at position 4
  "a": 1 }

Single quotes and unquoted keys produce the same message at the same position, which is a hint that the parser is not guessing at intent. It reached position 1, wanted " or }, and found neither.

Comments deserve a note. JSON has none — not "discouraged", the grammar has no production for them. JSONC (what VS Code's settings.json and tsconfig.json use) and JSON5 are separate formats that add comments, trailing commas and unquoted keys back. They are useful, and they are not JSON. A file named .json that a strict parser rejects is frequently one of these.

Numbers JSON does not have

JSON's number grammar is smaller than JavaScript's. All of these fail:

{"a": NaN}         Unexpected token 'N'
{"a": Infinity}    Unexpected token 'I'
{"a": 0x1F}        Expected ',' or '}' after property value
{"a": +1}          Unexpected token '+'
{"a": 01}          Unexpected number
{"a": .5}          Unexpected token '.'
{"a": 1.}          Unterminated fractional number

No hex, no leading plus, no leading zeros, and a decimal point needs digits on both sides.

The asymmetry is worth knowing. JSON.parse rejects NaN and Infinity loudly, but JSON.stringify emits them silently as null. A number that leaves as NaN comes back as null, with no error anywhere in the chain.

Where 64-bit integers quietly lose digits

This one costs real money. JSON's grammar puts no limit on how large a number may be, but JavaScript parses every one into a 64-bit float, which represents integers exactly only up to 9,007,199,254,740,991.

JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992

JSON.parse('{"id": 1541815603606036480}').id
// 1541815603606036500

No error. No warning. The second is the shape of a snowflake ID, the scheme Twitter and Discord use, and it comes back wrong in the last three digits. Parse that same document in Python and you get 9007199254740993 exactly, because Python integers have no width limit. The same file genuinely means different things in two languages.

If you emit 64-bit IDs, send them as strings. If you consume them, do not round-trip them through JSON.parse before comparing them.

The characters you cannot see

A UTF-8 byte order mark at the start of a file fails with Unexpected token '' — a message that appears to name nothing, because U+FEFF is invisible. Strip it with input.replace(/^/, ''). Files exported from Excel and older Windows editors carry one routinely, which is a common surprise when moving data through JSON to CSV and back.

Inside strings, raw control characters below U+0020 are illegal. A literal newline or tab pasted into a string value gives Bad control character in string literal in JSON.

The escape list is closed: \", \\, \/, \b, \f, \n, \r, \t, and \uXXXX. That is all of it. \x41 and \a both give Bad escaped character in JSON, even though both are fine in JavaScript.

Duplicate keys, and which one wins

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

The last one wins in JavaScript, and in Python's json module too. But RFC 8259 only says names should be unique, and leaves the behaviour of a duplicate to the implementation. Some parsers take the first. Some reject the document. A file with duplicate keys is valid JSON that two services can legitimately read differently, and a validator that only runs the parser — this site's included — will not flag it.

The two arguments to stringify nobody uses

The second argument, the replacer, has two forms.

An array is a key allowlist. It applies at every level of the document, and it also sets the output order:

JSON.stringify({b: 2, a: 1, secret: 'x'}, ['a', 'b'])
// {"a":1,"b":2}

A function is called for every key and value; return undefined to drop that key:

JSON.stringify(obj, (key, value) => (key === 'password' ? undefined : value))

Both are a reasonable way to strip credentials before writing a log line. The array form is the easier one to get wrong, because it prunes nested keys you were not thinking about — JSON.stringify({a: {a: 1, b: 2}, b: 3}, ['a']) returns {"a":{"a":1}}, and the inner b is gone without a word.

Separately, any object with a toJSON() method controls its own output. That is why a Date serialises to an ISO string rather than an empty object: Date.prototype.toJSON exists. Your own classes can do the same.

Three more ways stringify loses data or throws, none of them obvious. undefined, functions and symbols are dropped from objects but become null inside arrays, so [1, undefined] serialises to [1,null]. A circular reference throws Converting circular structure to JSON. And a BigInt throws Do not know how to serialize a BigInt — which, given the precision problem above, is a trap you can walk into while trying to avoid one.

Tools covered in this guide