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,}).
| Runtime | HTML instead of JSON | Empty body | Trailing comma |
|---|---|---|---|
| Chrome, Edge, Node 20+ (V8) | Unexpected token '<', "<!DOCTYPE "... is not valid JSON | Unexpected end of JSON input | Expected 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 data | JSON.parse: unexpected end of data at line 1 column 1 of the JSON data | JSON.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 EOF | JSON Parse error: Property name must be a string literal |
Python json | JSONDecodeError: 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 value | MismatchedInputException: No content to map due to end-of-input | Unexpected 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:
'<'at position 0: you received HTML or XML, almost always an error page, a login page, or your ownindex.html.- "Unexpected end of JSON input": the body was empty or was cut off part-way.
'u', as in"undefined" is not valid JSON: you passed the valueundefined, or the string"undefined". A common source islocalStorage.setItem(key, JSON.stringify(value))with an undefined value, which stores the textundefinedand breaks the next read."[object Object]" is not valid JSON: you passed an object that is already parsed.JSON.parseconverts it to a string first, which produces that text.'N'or'I':NaNorInfinityleaked into the output.- A quoted token that looks empty: an invisible byte order mark (U+FEFF) at the start of the text.
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:
- A 404 or 500 page from the server, a reverse proxy, or a CDN.
- An expired session that redirects to a login page.
fetchfollows redirects, so you see a 200 status with HTML. - A single-page-app dev server or static host with a fallback rule that serves
index.htmlfor every unknown path, including a mistyped/api/route. - A relative URL that resolves against the page instead of the API host.
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:
- Trailing commas:
{"a": 1,}or[1, 2,]. - Single quotes:
{'a': 1}. Safari's message is the most direct:Single quotes (') are not allowed in JSON. - Unquoted keys:
{a: 1}gives V8'sExpected property name or '}' in JSON at position 1. - Comments:
// noteafter a value givesExpected ',' or '}' after property value.
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:
fetch's.text()and.json()strip a leading UTF-8 BOM, so network responses are rarely affected.- Node's
fs.readFileSync(path, 'utf8')keeps it. Usetext.replace(/^/, '')before parsing. - In Python, open the file with
encoding="utf-8-sig", or pass bytes tojson.loads, which detects the BOM itself.
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:
- A proxy or load balancer timing out mid-stream.
- Response size limits in an API gateway or serverless platform.
- Logging pipelines that cap line length, so a JSON log line copied from a dashboard is incomplete.
- Reading a file while another process is still writing it.
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
- Read the quoted token:
<means HTML, end of input means empty or truncated. - Check the status code and
Content-Typebefore parsing. - Read the body as text once and log the first few hundred characters when parsing fails.
- Strip a BOM when reading files from disk.
- Fix NaN, trailing commas and double encoding where the JSON is produced, not where it is consumed.
- Use the error offset to show the surrounding text instead of scrolling through the payload.