What the message actually says
Three things, and only three:
- Which character surprised the parser. The “token” named in the message.
- Where it was. Position N, counted in characters from zero, across the entire document including newlines — not a line number, and not an offset within a line.
- That the parser had no legal way to continue. Nothing about why, and nothing about where the real mistake is.
That last point is the one that wastes the most time. The position is where the parser noticed the problem, which is frequently not where you made it. A missing comma is reported at the value that follows the gap. An unclosed brace is reported at the end of the file, possibly thousands of characters away. An unclosed string swallows everything after it, so the error lands at whatever character finally violated the string rules.
Rule of thumb: when the reported position looks perfectly fine, the mistake is before it. Read backwards from the caret, not forwards.
The same error, worded four ways
The wording depends entirely on which JavaScript engine you are running, and V8 changed its messages substantially in 2023. Recognising the dialect tells you where you are:
| Engine | Typical message | Gives you |
|---|---|---|
| V8, older Chrome < 118, Node < 20 |
Unexpected token } in JSON at position 27 | Character offset only |
| V8, current Chrome, Node, Edge, Deno |
Expected property name or '}' in JSON at position 27 (line 3 column 5) | Offset and line/column, and a much better description |
| SpiderMonkey Firefox |
JSON.parse: expected property name or '}' at line 3 column 5 of the JSON data | Line and column, no offset |
| JavaScriptCore Safari, Bun |
JSON Parse error: Expected '}' | Often no position at all |
Current V8 also emits several messages that are not “unexpected token” at all and are far more diagnostic when you read them carefully:
Unexpected token 'o', "[object Obj"... is not valid JSON— you passed an object, not a string. See cause 13 below.Unexpected end of JSON input— the document is empty or truncated. Cause 2, and a full page on every trigger.Unterminated string in JSON at position N— a quote was opened and never closed. Cause 10.Unexpected non-whitespace character after JSON at position N— two documents concatenated. Cause 9.Bad control character in string literal in JSON at position N— a real newline or tab inside a string. Cause 11.
How to find position N
The offset is only useful once you can turn it into a place in the file. Four ways, cheapest first:
- Paste the document into the box above. You get the line, the column, the character index and the offending line with a caret under it. That is what this page is for.
- Slice it in the console. Given the raw text in a variable:
Theconsole.log(JSON.stringify(text.slice(N - 40, N + 40)));JSON.stringifywrapper matters — it renders a carriage return as\r, a tab as\tand a byte order mark as\ufeff, instead of printing them as nothing at all. - Convert the offset to a line number.
const line = text.slice(0, N).split('\n').length; const col = N - text.lastIndexOf('\n', N - 1); console.log(`line ${line}, column ${col}`); - Jump to the byte in an editor. In Vim,
:goto N. In VS Code, the status bar shows the offset when you enable it, or useCtrl/⌘Gafter converting to a line with the snippet above. Careful: N counts UTF-16 code units, so a document containing emoji or other astral-plane characters will drift from a byte-based editor offset.
And before any of that, print the raw text. Not the parsed object, not a variable your
framework has already processed — the exact string you handed to
JSON.parse:
const text = await response.text();
console.log(response.status, response.headers.get('content-type'));
console.log(JSON.stringify(text.slice(0, 200)));
const data = JSON.parse(text); // now the failure is explainable
In more than half of real cases this single change ends the investigation, because it reveals that the body was never JSON to begin with.
The causes, in the order you should check them
1. The response is HTML, not JSON
Message: Unexpected token < in JSON at position 0, or in
current V8, Unexpected token '<', "<!DOCTYPE "... is not valid JSON.
Position 0 with a < is unambiguous: the very first character was an angle
bracket, so the body is HTML or XML. Something between your code and the API returned a
page instead of data:
- A 404 or 500 page rendered by the web server, not the application.
- A 502/504 from a load balancer, reverse proxy or CDN.
- A login redirect — the session expired and the framework served the sign-in page with a 200 status.
- A captive portal on hotel or conference wifi intercepting the request.
- A dev-server fallback: an unmatched
/api/...path falling through toindex.html, which is why this is epidemic in single-page-app development. - A WAF or bot-protection interstitial.
Every one of these, with worked examples and a full checklist, is covered on the dedicated page for this exact message.
Fix: check the status and content type before parsing, and log the body when the check fails.
const res = await fetch(url);
const body = await res.text();
if (!res.ok || !(res.headers.get('content-type') || '').includes('json')) {
throw new Error(`Expected JSON, got ${res.status} ${res.headers.get('content-type')}: ` +
body.slice(0, 200));
}
return JSON.parse(body);
2. The body is empty
Message: Unexpected end of JSON input, with no position at
all — the parser ran out of input before it found anything.
Causes: a 204 No Content response (correct behaviour for a successful
DELETE, and it has no body by definition); a 304 Not Modified; a HEAD request;
a body already consumed by an earlier .text() or .json() call
(a fetch Response body can only be read once); or a request that failed before
the server wrote anything.
Fix: treat an empty body as a valid outcome rather than a parse failure.
if (res.status === 204 || res.headers.get('content-length') === '0') return null;
const text = await res.text();
return text ? JSON.parse(text) : null;
3. A trailing comma
Message: Unexpected token } in JSON at position N, or in
current V8, Expected double-quoted property name in JSON at position N.
{
"host": "db.internal",
"port": 5432,
}
{
"host": "db.internal",
"port": 5432
}
JSON has never allowed a comma after the last member. It feels legal because JavaScript, Python, Rust, Go and every modern linter allow it — specifically to keep diffs clean when a line is appended. JSON does not, and never will; the format is frozen by design.
Fix: delete the comma. If a template or string-concatenation loop is generating the JSON, stop doing that — build a value and serialise it with your language’s JSON library, which cannot produce a trailing comma.
4. Single quotes
Message: Unexpected token ' in JSON at position N.
{'name': 'Ada'}
{"name": "Ada"}
JSON strings use double quotes, full stop. This usually arrives from a JavaScript object
literal that was pasted rather than serialised, or from Python’s
str(dict) / repr() output, which is not JSON even though
it looks close.
Fix in Python: use json.dumps(d), never str(d).
Going the other way, if you have already got a Python-repr string, ast.literal_eval
parses it safely; eval does not, and never should be used on data from
elsewhere.
5. Unquoted property names
Message: Unexpected token n in JSON at position N, or
Expected property name or '}'.
{name: "Ada", age: 36}
{"name": "Ada", "age": 36}
In JavaScript, {name: "Ada"} is a perfectly good object literal. In JSON,
every key must be a double-quoted string, without exception — including keys that
look like identifiers, and including numeric keys, which must be written
"1".
6. NaN, Infinity, undefined, None, True
Message: Unexpected token N in JSON at position N, or in
Safari, JSON Parse error: Unexpected identifier "NaN".
| Not JSON | Where it comes from | Write instead |
|---|---|---|
| NaN | A float division that went wrong upstream | null |
| Infinity, -Infinity | Overflow, or a deliberate sentinel | null |
| undefined | JavaScript string concatenation | null |
| None, True, False | Python str(dict) | null, true, false |
| nil | Ruby, Go, Lisp | null |
JSON has no way to express not-a-number or infinity. Python’s json.dumps
will happily emit NaN and Infinity by default, producing output
that no JavaScript client can read. Pass allow_nan=False to make it raise
instead, and decide explicitly what those values should become.
7. Comments
Message: Unexpected token / in JSON at position N.
JSON has no comment syntax — Douglas Crockford removed it deliberately, because
people had started using comments to carry parsing directives. Neither //,
/* */ nor # is legal.
Fix: if you control the format, use JSON5, JSONC (what VS Code settings
files use), YAML or TOML. If you are stuck with JSON, some people add a
"_comment" key — ugly, but valid. If you must strip comments
programmatically, use a real tokenizer such as strip-json-comments or
jsonc-parser; a regular expression will eat the // inside every
URL in the file.
8. A byte order mark
Message: Unexpected token � in JSON at position 0, or
an inscrutable message naming a character that is not visibly there.
A UTF-8 BOM is the three bytes EF BB BF at the start of a file, appearing as
the single character U+FEFF. Windows tools write it by default: Notepad, PowerShell’s
Out-File and > redirection, Excel’s CSV/JSON export,
Visual Studio. It is invisible in every editor. It is legal in a UTF-8 file and rejected by
every conforming JSON parser.
Fix:
# Python — the "sig" codec consumes a BOM if present, and tolerates its absence
data = json.load(open('file.json', encoding='utf-8-sig'))
# JavaScript
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
# Shell — check for one
head -c 3 file.json | xxd # "efbbbf" means there is a BOM
# Editors: VS Code status bar → "UTF-8 with BOM" → switch to "UTF-8"
Paste a BOM-prefixed document into the tool above and it names it explicitly rather than pointing at a character you cannot see.
9. Two documents concatenated
Message: Unexpected non-whitespace character after JSON at
position N, or Unexpected token { in JSON at position N where N is just
past a closing brace.
{"id": 1, "event": "login"}
{"id": 2, "event": "logout"}
That is NDJSON, also called JSON Lines: one complete document per line. It is an excellent format for logs and streaming, and it is not valid JSON, deliberately — a consumer can handle each line as it arrives without waiting for a closing bracket that may be gigabytes away.
Fix: parse line by line.
const rows = text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
Or, to inspect the whole file in the tool above, wrap it:
'[' + lines.join(',') + ']'.
The same message with no newline between the documents usually means a response was retried and both attempts were written to the same buffer, or two log writers shared a file descriptor without locking.
10. A truncated document
Message: Unexpected end of JSON input, or
Unterminated string in JSON at position N.
The document simply stops. Causes are almost always about transport rather than syntax:
a stream consumed before it finished; a fixed-size read buffer; a socket timeout mid-body;
a curl interrupted; a database column too narrow (a VARCHAR(255)
holding a JSON blob is a classic); a log line cut by a line-length limit; or a
Content-Length that disagrees with what was actually sent.
Fix: compare the length of what you received against the
Content-Length header, and check whether the last character is the closing
bracket you expect. The tool above tells you how many brackets are still open, which is a
good signal of how much is missing.
11. A real newline or tab inside a string
Message: Bad control character in string literal in JSON at
position N, or Unexpected token in JSON at position N.
{"note": "line one
line two"}
{"note": "line one\nline two"}
JSON has no multi-line string. Every character below U+0020 must be escaped:
\n, \r, \t, \b, \f, or the
general \u00XX form. This shows up whenever JSON is assembled by string
concatenation and a value happens to contain a newline — a user comment, a stack
trace, a multi-line address.
Fix: stop concatenating. JSON.stringify escapes control
characters correctly and takes no thought. If you have a broken document already,
the escaping page converts raw text into a correct string
literal.
12. A stray backslash — usually a Windows path
Message: Bad escaped character in JSON at position N, or
Unexpected token U in JSON at position N.
{"path": "C:\Users\new\test.txt"}
{"path": "C:\\Users\\new\\test.txt"}
Inside a JSON string a backslash always begins an escape sequence, and only
\" \\ \/ \b \f \n \r \t \uXXXX are legal. \U is not an escape, so
the parser rejects it. Worse, \n in \new is valid and
silently becomes a newline, so a path can parse successfully and be wrong.
The same trap catches regular expressions ("\d+" must be
"\\d+"), LaTeX and Windows UNC paths.
13. You passed an object, not a string
Message: Unexpected token 'o', "[object Obj"... is not valid
JSON — the giveaway is the letters [object Obj in the message.
JSON.parse coerces its argument to a string first. An object becomes
"[object Object]", whose second character is o. So the error is
telling you that you parsed something already parsed.
Fix: most HTTP clients parse for you. fetch does not
(await res.json() does), but Axios, jQuery and most SDKs return a parsed
object. Calling JSON.parse on that is the mistake. Similarly,
JSON.parse(JSON.parse(x)) is only correct if x is genuinely
double-encoded — which happens more often than it should when a JSON string is stored
in a JSON field.
14. Double-encoded JSON
A related shape worth recognising:
"{\"id\": 1, \"name\": \"Ada\"}"
That is a valid JSON string whose contents happen to be JSON. Parsing it once
gives you the inner text, not an object. This happens when a producer calls
json.dumps twice, when a JSON document is stored in a string column and
re-serialised on the way out, or when a message queue wraps the payload.
Fix: parse twice if the value really is double-encoded, but fix the producer — double encoding wastes bytes, defeats schema validation and confuses every consumer. The escaping page unwraps one layer so you can see what is inside.
15. Numbers that are not JSON numbers
| Invalid | Why | Write instead |
|---|---|---|
| 01234 | Leading zeros are forbidden | "01234" |
| .5 | Must start with a digit | 0.5 |
| 5. | A decimal point needs a digit after it | 5.0 |
| +5 | No leading plus | 5 |
| 0x1F | No hexadecimal | 31 |
| 1_000 | No digit separators | 1000 |
| 10n | No BigInt literal | "10" |
The leading-zero rule catches real data constantly: zip codes, phone numbers, ISBNs, account numbers, German postcodes. None of these are quantities — you never add two zip codes — so they belong in strings, which also protects them from being reformatted or losing their leading zero somewhere downstream.
16. Characters that look right but are not
Text that has been through a word processor, a PDF, a chat client or a corporate email system may contain characters that render like ASCII and are not:
- Curly quotes —
“U+201C and”U+201D instead of". Word, Google Docs, Slack and iOS substitute these automatically. - Non-breaking space — U+00A0 instead of U+0020. Common in text copied from HTML and from PDFs.
- Zero-width space U+200B, invisible entirely, often introduced by copy-paste from a rendered web page.
- Ideographic space U+3000, from CJK input methods.
- An en dash where a minus sign belongs.
The tool above names the exact code point when it finds one of these, which is the difference between a five-second fix and half an hour of staring at a line that looks perfect.
Quick reference
| What you see | Most likely cause |
|---|---|
| Unexpected token < at position 0 | HTML error page or login redirect, not JSON |
| Unexpected end of JSON input | Empty body (204/304) or truncated response |
| Unexpected token } / Expected double-quoted property name | Trailing comma |
| Unexpected token ' | Single-quoted string — JavaScript or Python repr |
| Expected property name or '}' | Unquoted key, or a trailing comma |
| Unexpected token N / Unexpected identifier "NaN" | NaN, None, True, undefined |
| Unexpected token / | A comment |
| Unexpected token at position 0, invisible character | Byte order mark |
| Unexpected non-whitespace character after JSON | NDJSON, or two documents concatenated |
| Unterminated string in JSON | Truncated, or an unescaped quote inside a value |
| Bad control character in string literal | A real newline or tab inside a string |
| Bad escaped character | A Windows path or regex with single backslashes |
| Unexpected token 'o', "[object Obj"... | You parsed something already parsed |
How to stop hitting this error
- Never build JSON with string concatenation or a template. Build a value in your language and serialise it. Every trailing-comma, quoting and escaping bug in this page disappears the moment you do.
- Check the status and content type before parsing. Two lines that turn “unexpected token <” into “the API returned 502”.
- Log the raw body on failure. Wrap
JSON.parsein a helper that catches, includes the first two hundred characters of the input in the error, and rethrows. - Validate JSON files in CI.
jq emptyover every.jsonfile in a pre-commit hook costs milliseconds. - Use a format that allows comments if you want comments. Fighting JSON’s strictness in a hand-edited config file is a losing game; JSON5, JSONC, YAML and TOML all exist for that reason.
Frequently asked questions
Is position N a line number?
No. It is a character offset from the start of the document, counting from zero and including every newline. On a minified single-line document position 41,208 is genuinely 41,208 characters in. Paste the document above to get a line and column instead.
Why does the error point at a character that looks correct?
Because the parser reports where it could no longer continue, not where the mistake was made. A missing comma is detected at the start of the next value; an unclosed brace at the end of the file; an unclosed string at whatever character finally broke the string rules. Always read backwards from the reported position.
Position 0 — but the file looks like it starts with a brace.
Then there is something invisible before it. In order of
likelihood: a UTF-8 byte order mark, a zero-width space, or leading whitespace that is
actually U+00A0. Run head -c 3 file.json | xxd — if it prints
efbbbf, that is your BOM. Pasting the file above will name whichever it
is.
My JSON works in Postman but fails in code. Why?
Postman shows you the response it received after following
redirects and with its own headers, including an Accept header that may make
the server answer differently. Your code may be hitting an authentication redirect that
Postman’s stored cookie avoids, or requesting without
Accept: application/json and getting the HTML representation of the same
resource. Log the status, the content type and the first 200 characters of the body from
the failing code path — not from Postman.
Can I just make the parser more forgiving?
You can — JSON5, json5 in npm, or Python’s
demjson will accept comments, trailing commas and single quotes. Think
carefully about where the leniency lives. Accepting sloppy input from a config file a
human maintains is reasonable. Accepting it from a network peer means your service and its
neighbours now disagree about what a valid message is, which is precisely the kind of
parser mismatch that turns into a security advisory.
Why does Python produce JSON that JavaScript cannot read?
Two reasons. str(dict) and repr(dict) are
not JSON — they use single quotes and None/True. Always use
json.dumps. And json.dumps itself emits NaN,
Infinity and -Infinity for those float values by default, which
no JavaScript client accepts. Pass allow_nan=False to make it raise instead
of emitting something unreadable.
Does an unclosed string really cause an error far away?
Yes, and it is the most misleading case there is. A missing closing quote means the parser reads everything after it — braces, commas, other keys — as ordinary characters inside one very long string. The error surfaces at the first character that violates a string rule, such as a raw newline, or at the end of the file. If the reported position makes no sense at all, look for an unbalanced quote well before it.
Is it safe to paste a failing production payload into this page?
Into this one, yes: it has no upload endpoint, no storage and no share link, and everything runs in your tab. That is not true of hosted formatters generally — in November 2025 two of the most popular ones were found to have been exposing years of stored submissions, credentials included. The details, with sources.
Related pages
- “Unexpected token < … position 0”Your API returned HTML, not JSON
- “Unexpected end of JSON input”Empty bodies, truncation and double-reads
- “JSON.parse: unexpected character”The Firefox and Safari wording, decoded
- JSON formatterPretty-print, tree view and statistics
- JSON validatorWhat valid JSON means, precisely
- Escape & unescape stringsFix backslash and newline problems
- JSON minifierStrip whitespace and measure the saving