Debugging Malformed JSON: A Field Guide
The parse error is better than its reputation. How to read what V8 says now, the five failure modes behind most broken payloads, and the two errors that hide their position.
Everybody quotes the same error when they complain about JSON: SyntaxError: Unexpected token } in JSON at position 4217. A position, no explanation, good luck.
That message is a museum piece. Feed the same broken object to Node 24 today and you get this:
SyntaxError: Expected double-quoted property name in JSON at position 51 (line 4 column 1)
It names the thing the parser wanted, the offset, and the line and column. The error got good while the folklore about it stayed bad, and most of the debugging advice you will find is still written for the old one.
So this is a guide to reading the message you actually get, the five mistakes behind nearly every broken payload, and the one family of errors that still tells you nothing about where it happened.
Before any of it, rule out the file not being strict JSON in the first place. Configs written for VS Code or the TypeScript compiler are JSONC, and a comment or a trailing comma will fail JSON.parse while looking perfectly normal in the editor. The JSON Dialect Detector settles that in one paste.
Read the expectation, not just the offset
The position is where the grammar broke. The mistake is usually a few characters to the left, because most JSON errors are a promise made and not kept: a comma promising another element, an opening quote promising a closing one.
"Expected double-quoted property name" means something promised a property name. Scan left from the reported position until you find what did.
The five things that usually went wrong
A trailing comma
The most common failure by a wide margin. You deleted the last element and left its comma behind.
{
"name": "zeroutil",
"category": "developer",
}
JavaScript accepts it. JSON5 accepts it. Strict JSON does not.
Find them with ,\s*[}\]] in our Regex Tester . Prevent them with a formatter in a pre-commit hook, because JSON.stringify never emits one. A trailing comma is always a human-editing artefact.
Unquoted or single-quoted keys
{
name: "zeroutil",
'category': "developer"
}
Neither form is JSON, and this is what typing JSON from memory produces. The error is unusually clear here: Expected property name or '}' in JSON at position 4 (line 2 column 3), which is the n of name. In a large file, grep -nE '^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*:' will list the offending lines.
Unescaped characters inside strings
Three things must be escaped inside a JSON string: the double quote, the backslash, and every control character from U+0000 to U+001F1. That last group is the one that bites, because a literal newline inside a string is a control character.
{
"bio": "line one
line two"
}
You get Bad control character in string literal in JSON at position 20 (line 2 column 19), and position 20 is the newline itself, not the character after it. The old advice to check the line above is no longer needed for this one.
The real lesson is upstream. Build JSON with JSON.stringify or your language's equivalent, never by concatenating strings into a template, because template concatenation is how an unescaped quote gets into a payload in the first place.
NaN, Infinity, undefined
JSON.stringify turns NaN and Infinity into null. undefined is omitted when it is an object property and becomes null when it is an array element2, which is a distinction worth knowing before you go looking for a missing array slot.
So a payload containing a literal NaN did not come from JavaScript. It very often came from Python, whose json module is non-strict in both directions by default: dumps emits NaN, Infinity and -Infinity, and loads accepts them again on the way back in3. The documentation says outright that the results are not valid JSON. That symmetry is exactly why nobody notices: the payload round-trips cleanly inside Python and detonates the moment a Go or Java service reads it.
Fix it at the producer with json.dumps(obj, allow_nan=False), which raises at serialisation time instead of shipping something unparseable.
A byte order mark
Some editors and exports prepend U+FEFF. RFC 8259 forbids adding one to networked JSON, but explicitly permits a parser to ignore one rather than erroring1, so both behaviours are conformant and you cannot reason your way to which you will get.
In practice both JSON.parse and Python's json.loads reject it. Python's message is the more helpful of the two: Unexpected UTF-8 BOM (decode using utf-8-sig).
$ file config.json
config.json: Unicode text, UTF-8 (with BOM) text
$ head -c 3 config.json | xxd -p
efbbbf
Strip it with sed '1s/^\xEF\xBB\xBF//' config.json, or save as "UTF-8 without BOM" in your editor.
The errors that will not tell you where they are
Here is the part that costs people time, and the reason any position-scraping helper you copied from Stack Overflow eventually breaks.
V8 produces two shapes of message, and only one has a position in it.
The second family quotes a snippet of the payload back at you instead. For a short payload that is fine, because the snippet is the whole thing. For a large one it is a fragment with an ellipsis, and it is still enough to find the spot, because you can search for it.
function locate(text) {
try {
JSON.parse(text);
return null;
} catch (err) {
const pos = err.message.match(/position (\d+)/);
if (pos) return Number(pos[1]);
const quoted = err.message.match(/"(.+)" is not valid JSON$/s);
if (quoted) {
const at = text.indexOf(quoted[1].replace(/^\.\.\./, ''));
if (at >= 0) return at;
}
return null;
}
}
That handles both families. Tested against a payload with a NaN literal 107 characters in, the position branch finds nothing and the snippet branch lands on 107.
Once you have an offset, print around it rather than counting characters by hand:
const at = locate(payload);
console.log(payload.slice(Math.max(0, at - 80), at + 80));
An earlier version of this article recommended a bisection instead, halving the payload and re-parsing each prefix with '}]'.repeat(10) glued on to close the structure. It does not work, and I should have run it before publishing it. Almost no prefix plus ten closers is valid JSON, so the search collapses to the first byte on the first iteration. On a 4.6 KB test payload with a genuine syntax error at index 4593, it returned 1.
Bisection is the wrong instinct anyway. The parser already knows the offset and will hand it over.
When you would rather not write any JavaScript
jq reports position in the form most people find easier to act on:
$ jq . config.json
jq: parse error: Expected another key-value pair at line 3, column 1
Line and column beat a character offset for anything you are going to open in an editor. Our JSON Formatter does the same job in a browser tab and points at the character.
Diff against the version that worked
When a payload parsed yesterday and fails today, comparing the two is faster than reading either. Run both through the formatter first so structural changes surface as line changes rather than hiding inside one enormous line, then feed them to the Diff Checker , which runs entirely in the browser. That last part matters when the payload is a production response you should not be pasting into a stranger's server.
Check for truncation before you check for syntax
If the payload comes from an API and the last character is not } or ], this is not a syntax bug. The response body was cut off: a connection closed, a buffer flushed early, a timeout fired mid-serialisation. The text simply stops, often in the middle of a string, and you will get Unterminated string in JSON at position ....
No parser option fixes that. The bug is on the producer.
Five minutes, broken payload, go
- Read the expectation in the message, then look left from the position.
- Check the last character. Not
}or]means truncation, and you are done here. - Grep for
,\s*[}\]]. Trailing commas remain number one. - Check the first three bytes for
ef bb bf. - Grep for
NaN,Infinityandundefined. If you find them, go and talk to whoever generated the file.
Most broken JSON is one of those. Genuinely strange failures, deep structural damage or mixed encodings, are rare enough to deserve the full half hour when they turn up.
One thing not to do
Tools that "repair" malformed JSON by permissively reinterpreting it are guessing at what you meant, and they hide the producer bug that will send you a worse payload next week. Fix the source. The parser was right.
Sources
Every number in this article traces to a source below. Where a claim could not be sourced, it was cut rather than softened.
- Primary sourceIETF
Section 7, that the quotation mark, the reverse solidus and the control characters U+0000 to U+001F must be escaped inside strings, and section 8.1, that a parser may ignore a byte order mark rather than treating it as an error.
- Primary sourceMDN Web Docs
That Infinity and NaN are serialised as null, and that undefined is omitted when found in an object but becomes null when found in an array.
- Primary sourcePython Software Foundation
That allow_nan defaults to True so NaN and Infinity are emitted, that the decoder accepts those literals on input, and the documentation's own statement that the results are not valid JSON.
Topics
- JSON
- Debugging
- Parsers
- Regex
- Diff
Tools mentioned in this article
- JSON Dialect Detector - Tell whether a file is JSON, JSONC or JSON5 - and convert it to strict JSON.
- JSON Formatter - Format, validate and minify JSON with syntax highlighting.
- Regex Tester - Test regular expressions with live highlighting, matches and capture groups.
- Diff Checker - Compare code or text with line-by-line diff and unified output.
Get new tools by email
New tools and the occasional deep-dive, about once a month. No spam, no sharing your address, unsubscribe in one click.