Skip to main content
Developer Tools

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.

By 8 min read
Article title card. A pair of large curly braces with an amber warning marker dropped between them, with the line: the parser knows where it broke

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

A four-line JSON object with a trailing comma at the end of line 3. The error message reports position 51, line 4 column 1, which is the closing brace, while the comma that caused it sits on the line above
The parser is not being coy. A comma promises another key, and the brace is where that promise came due.

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.

Six broken JSON inputs with the exact error each produced, grouped into one family that reports position, line and column, and a second family that quotes the payload back with no position
Measured on Node 24.18. The second family is where the naive regex returns NaN.

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

  1. Read the expectation in the message, then look left from the position.
  2. Check the last character. Not } or ] means truncation, and you are done here.
  3. Grep for ,\s*[}\]]. Trailing commas remain number one.
  4. Check the first three bytes for ef bb bf.
  5. Grep for NaN, Infinity and undefined. 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.

  1. 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.

  2. 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.

  3. 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.

Related articles

Article title card. A large pair of amber curly braces, with the line: one format, three dialects
Developer Tools Guide

The Developer's JSON Guide: Everything Worth Knowing in 2026

The spec, the dialects that disagree, the numbers that quietly break, and the security advice that points at the wrong recursion. With the cluster map.

The words JSON, JSONC and JSON5 stacked in large white type on a dark background, framed by a pair of oversized curly braces
Developer Tools

JSON vs JSON5 vs JSONC: What Each Parser Actually Accepts

Three formats, one file extension, and no agreement on what counts as valid. Measured against the real parsers, including two that disagree while being the same library.

Article title card. A pair of curly braces containing an amber double slash, with the line: the extension is not the format
Developer Tools

Why tsconfig.json Allows Comments and package.json Does Not

Both files end in .json. One accepts comments and trailing commas, the other throws. The difference is not the extension - it is which parser reads the file.