Unexpected token in JSON at position X: causes and fixes

Published October 7, 2026

Few errors are as common, or as unhelpful at first glance, as SyntaxError: Unexpected token from JSON.parse(). The message tells you that the parser hit a character that cannot appear at that point in a JSON document. It does not tell you why that character is there, and the reason is usually somewhere other than your parsing code: a server that answered with an HTML page, a response with no body, a config file that someone edited by hand, or a payload that was cut off in transit.

This guide shows what the error looks like in each major runtime, how to work out the cause from the first offending character, how to find "position X" in a large payload, and how to write parsing code that fails with a message you can act on.

What the error looks like in each runtime

The same broken input produces very different messages depending on the parser. The table below shows three common failures: an HTML page, an empty body, and a trailing comma ({"a": 1,}).

RuntimeHTML instead of JSONEmpty bodyTrailing comma
Chrome, Edge, Node 20+ (V8)Unexpected token '<', "<!DOCTYPE "... is not valid JSONUnexpected end of JSON inputExpected double-quoted property name in JSON at position 8 (line 1 column 9)
Firefox (SpiderMonkey)JSON.parse: unexpected character at line 1 column 1 of the JSON dataJSON.parse: unexpected end of data at line 1 column 1 of the JSON dataJSON.parse: expected double-quoted property name at line 1 column 9 of the JSON data
Safari (JavaScriptCore)JSON Parse error: Unrecognized token '<'JSON Parse error: Unexpected EOFJSON Parse error: Property name must be a string literal
Python jsonJSONDecodeError: Expecting value: line 1 column 1 (char 0)Expecting value: line 1 column 1 (char 0)Illegal trailing comma before end of object: line 1 column 8 (char 7) (3.13+)
Java (Jackson)JsonParseException: Unexpected character ('<' (code 60)): expected a valid valueMismatchedInputException: No content to map due to end-of-inputUnexpected character ('}' (code 125)): was expecting double-quote to start field name

Older V8 releases, including the one in Node 18, used a shorter format that is still all over Stack Overflow: Unexpected token < in JSON at position 0. Python versions before 3.13 report a trailing comma in an object as Expecting property name enclosed in double quotes, the same message you get for single-quoted or unquoted keys, such as Expecting property name enclosed in double quotes: line 1 column 2 (char 1) for {'a': 1}.

Two details are worth knowing. In JavaScript, "position" is a zero-based index into the string you passed to JSON.parse, counted in UTF-16 code units rather than bytes. Python's char offset is also zero-based, while its line and column numbers start at 1.

Read the first bad character

The quoted token is the fastest clue. Before opening the payload, match it against this list:

Cause 1: an HTML page instead of JSON

When the error says Unexpected token '<', the response body starts with <!DOCTYPE html> or <html>. The usual sources:

Open the browser's Network panel, select the request and look at the Response tab: you will see the page you actually got. In code, the fix is to stop calling response.json() blindly. Check response.ok and the Content-Type header first, and read the body once as text so you can include it in the error:

async function fetchJson(url, options = {}) {
  const res = await fetch(url, {
    ...options,
    headers: { Accept: 'application/json', ...options.headers },
  });
  const text = await res.text();               // read once, as text

  if (!res.ok) {
    throw new Error(`HTTP ${res.status} ${res.statusText} from ${url}: ${text.slice(0, 200)}`);
  }
  if (res.status === 204 || text.trim() === '') {
    return null;                                // no content is not a parse error
  }
  const type = res.headers.get('content-type') || '';
  if (!/\bjson\b/i.test(type)) {
    throw new Error(`Expected JSON from ${url} but got "${type}": ${text.slice(0, 200)}`);
  }
  try {
    return JSON.parse(text.replace(/^/, ''));
  } catch (err) {
    throw new Error(`Invalid JSON from ${url}: ${err.message}\n${text.slice(0, 200)}`);
  }
}

The content-type test accepts application/json, application/json; charset=utf-8 and suffixed types such as application/vnd.api+json or application/problem+json. Against a test server, an HTML login page now fails with Expected JSON from http://localhost:63287/html but got "text/html": <!DOCTYPE html>... and a missing route fails with HTTP 404 Not Found. Both tell you where to look, which Unexpected token '<' does not.

Cause 2: an empty response body

Unexpected end of JSON input at position 0 means there was nothing to parse. A 204 No Content reply, a 201 Created that sends no body, a HEAD request, and a handler that crashed before writing anything all produce it. An empty string is not valid JSON (RFC 8259 requires exactly one value), so the parser is right to complain. Decide what "no body" means for each endpoint. The helper above returns null, but you may prefer to throw if the endpoint should always return data.

Cause 3: JavaScript syntax that isn't JSON

JSON looks like JavaScript object literals but is much stricter. Hand-edited files and strings built with template literals regularly include:

If a file needs comments, choose a format that supports them, such as JSONC (used by VS Code settings and tsconfig.json), JSON5 or YAML, and parse it with a matching library. Stripping comments with a regular expression breaks as soon as a string contains //, for example in a URL.

NaN, Infinity and undefined

JSON.stringify handles these safely: NaN and Infinity become null, and keys whose value is undefined are dropped. The bad tokens come from other places. Python's json.dumps writes NaN by default, so json.dumps({"ratio": float("nan")}) returns {"ratio": NaN}, which every JavaScript parser rejects. Pass allow_nan=False to make Python raise ValueError: Out of range float values are not JSON compliant: nan at the source instead. Jackson also rejects NaN by default with Non-standard token 'NaN': enable `JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS` to allow.

Cause 4: a byte order mark

Some Windows editors save UTF-8 files with a byte order mark (BOM), the bytes EF BB BF. It is invisible in most editors, so V8's message looks like Unexpected token '', "{"a":1}" is not valid JSON, where the quoted token appears empty. Python is explicit: Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0).

RFC 8259 says senders must not add a BOM and lets parsers ignore one. In practice:

Cause 5: double-encoded JSON

Double encoding happens when a server serializes an object to a JSON string and the framework then serializes that string again. The body then looks like this:

"{\"id\":1}"

That is valid JSON: a single string. Parsing it does not fail; it returns the string {"id":1}, and data.id is undefined. The error shows up later as Cannot read properties of undefined. The opposite mistake, calling JSON.parse on something response.json() already parsed, produces "[object Object]" is not valid JSON.

Fix double encoding at the source by returning the object and letting the framework serialize it once. As a temporary measure on the client, parse a second time only if the first result is a string:

let data = JSON.parse(body);
if (typeof data === 'string') data = JSON.parse(data);   // tolerate one extra layer

Cause 6: truncated payloads

If parsing fails near the end of a large document with Unexpected end of JSON input, Unterminated string in JSON at position ..., or Python's Expecting ',' delimiter, the text was probably cut off. Common culprits:

Compare the received length with the Content-Length header, or check whether the text ends with the closing } or ] you expect. No amount of client-side repair will make a truncated document trustworthy, so fetch it again or fix the limit.

How to find "position X" in a large payload

Newer V8 versions add the line and column to the message, but a minified response is one long line, so line 1 column 978 is not much help. Print the text around the offset instead:

function showPosition(text, pos) {
  const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
  let lineEnd = text.indexOf('\n', pos);
  if (lineEnd === -1) lineEnd = text.length;
  const line = text.slice(0, pos).split('\n').length;
  const from = Math.max(lineStart, pos - 60);
  const to = Math.min(lineEnd, pos + 60);
  return `line ${line}, column ${pos - lineStart + 1}\n` +
         `${text.slice(from, to)}\n${' '.repeat(pos - from)}^`;
}

try {
  JSON.parse(text);
} catch (err) {
  const m = /position (\d+)/.exec(err.message);
  if (m) console.log(showPosition(text, Number(m[1])));
}

On a 50-element minified array with one stray comma, it prints:

line 1, column 978
user35"},{"id":36,"name":"user36"},{"id":37,"name":"user37",},{"id":38,"name":"user38"},{"id":39,"name":"user39"},{"id":
                                                            ^

Firefox and Safari do not include a position in every message, so reproduce the failure in Node or with a command-line tool. Both of these report a line and column:

$ python3 -m json.tool payload.json
Illegal trailing comma before end of object: line 1 column 8 (char 7)

$ jq . payload.json
jq: parse error: Expected another key-value pair at line 1, column 9

If the payload is a single line, pretty-print a known-good copy first. A line number in formatted JSON leads you straight to the problem, and pasting the text into the JSON Validator highlights the failing line.

Defensive parsing in Python

The same rules apply on the server: check the status, check the content type, handle empty bodies, and include context when parsing fails. With requests:

import json
import requests

def get_json(url, **kwargs):
    resp = requests.get(url, headers={"Accept": "application/json"}, timeout=10, **kwargs)
    body = resp.content.decode("utf-8-sig")       # strips a BOM if present

    if not resp.ok:
        raise RuntimeError(f"HTTP {resp.status_code} from {url}: {body[:200]!r}")
    if resp.status_code == 204 or not body.strip():
        return None
    ctype = resp.headers.get("Content-Type", "")
    if "json" not in ctype.lower():
        raise RuntimeError(f"Expected JSON from {url}, got {ctype!r}: {body[:200]!r}")
    try:
        return json.loads(body)
    except json.JSONDecodeError as err:
        start = max(err.pos - 40, 0)
        context = body[start:err.pos + 40]
        raise RuntimeError(
            f"Invalid JSON from {url} at line {err.lineno}, col {err.colno}: "
            f"{err.msg}. Context: {context!r}"
        ) from err

JSONDecodeError exposes pos, lineno and colno as attributes, so you don't need to parse the message text the way you do in JavaScript. A body of {'id': 3} now fails with Invalid JSON from http://127.0.0.1:63451/bad at line 1, col 2: Expecting property name enclosed in double quotes. Context: "{'id': 3}".

Checklist

  1. Read the quoted token: < means HTML, end of input means empty or truncated.
  2. Check the status code and Content-Type before parsing.
  3. Read the body as text once and log the first few hundred characters when parsing fails.
  4. Strip a BOM when reading files from disk.
  5. Fix NaN, trailing commas and double encoding where the JSON is produced, not where it is consumed.
  6. Use the error offset to show the surrounding text instead of scrolling through the payload.
Try it: paste a failing payload into the JSON Validator to see the exact line and column of the first error, then clean it up and pretty-print it with the JSON Formatter. Both run entirely in your browser, so the payload is never uploaded.

Related articles