How to Read a JSON Parse Error and Find the Position
A JSON parse error tells you exactly where parsing stopped — but every runtime encodes that location differently, and the field you need (position, char, Offset, colno) is easy to misread. This article is about decoding the message and converting whatever the parser gives you into the precise line, column, and surrounding bytes of the failure. We cover JavaScript V8, Python, Go, and Java Jackson, with working helpers to turn raw offsets into something you can actually look at.
Anatomy of the error message per runtime
Each parser reports a stop position — the index where it found a token it couldn't accept given the grammar state. Knowing how that index is expressed is the difference between fixing the bug in seconds and scrolling a 4,000-character blob.
JavaScript (V8 / Node / browsers)
V8 throws a SyntaxError. Two message formats are common:
SyntaxError: Unexpected token o in JSON at position 1
SyntaxError: Unexpected non-whitespace character after JSON at position 14
SyntaxError: "[object Object]" is not valid JSON
The number after at position is a zero-based character index into the string you passed to JSON.parse. "Unexpected token o" means the parser read a character (o) that is illegal in the current grammar state. The newer V8 message ("[object Object]" is not valid JSON) appears when the input itself is the literal string [object Object], which is the result of coercing an object to a string.
The position is not exposed as a structured field — you have to parse it out of error.message:
function parsePosition(err) {
const m = /at position (\d+)/.exec(err.message);
return m ? Number(m[1]) : null;
}
Python
json.loads raises json.decoder.JSONDecodeError, a subclass of ValueError:
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 5 column 13 (char 92)
Python is the most helpful here because the exception object carries structured attributes:
err.msg— the reason ("Expecting ',' delimiter").err.pos— zero-based character offset into the string (92above).err.lineno— one-based line number (5).err.colno— one-based column number within that line (13).err.doc— the full source string that was being parsed.
You never have to regex the message; read the attributes directly.
import json
try:
json.loads(payload)
except json.JSONDecodeError as e:
print(e.msg, e.lineno, e.colno, e.pos)
Go (encoding/json)
json.Unmarshal and json.Decoder.Decode return an error. For pure grammar violations the concrete type is *json.SyntaxError, which has a single useful exported field:
invalid character ',' looking for beginning of value
*json.SyntaxError.Offset is an int64 giving the byte offset (one past the offending byte, in practice) into the input. Critically, the default string form does not include the offset — you only see the generic message unless you extract the typed error. There's also *json.UnmarshalTypeError (.Offset, .Field, .Type) for type mismatches like a string where an int was expected.
Java (Jackson)
Jackson throws JsonParseException (a JsonProcessingException). The location lives in a JsonLocation:
catch (JsonProcessingException e) {
JsonLocation loc = e.getLocation();
loc.getLineNr(); // 1-based line
loc.getColumnNr(); // 1-based column
loc.getCharOffset(); // 0-based char offset (-1 if unknown)
loc.getByteOffset(); // 0-based byte offset (-1 if unknown)
}
Jackson exposes both a character offset and a byte offset, plus line/column — the most complete location of any of these runtimes.
A debugging walkthrough: locating "position 2473" in a minified payload
Suppose an API returns a ~4,000-character minified JSON body, and your code logs:
SyntaxError: Unexpected token } in JSON at position 2473
Position 2473 in a single-line minified string is invisible. The fix is to print a context window around that index so you can see the exact bytes.
JavaScript: substring window around the offset
function showJsonError(text, err) {
const pos = (/at position (\d+)/.exec(err.message) || [])[1];
if (pos == null) {
console.error(err.message);
return;
}
const p = Number(pos);
const start = Math.max(0, p - 40);
const end = Math.min(text.length, p + 40);
const before = text.slice(start, p);
const after = text.slice(p, end);
console.error(err.message);
console.error("..." + before + ">>>" + after + "...");
// caret line aligned under the offending character
console.error(" ".repeat(before.length + 3) + "^");
}
try {
JSON.parse(body);
} catch (e) {
showJsonError(body, e);
}
The window [pos-40, pos+40) is almost always enough to recognize the structural problem — a trailing comma, an unquoted key, a stray }. The caret marks the precise character the parser rejected.
Python: convert a char offset to line/column and print the line
If you have a raw pos (for example from a tool that only gives you a character index), reconstruct the line and column yourself, then print that physical line with a caret:
def locate(doc: str, pos: int) -> None:
# line/col are derived from how many newlines precede pos
line = doc.count("\n", 0, pos) + 1
line_start = doc.rfind("\n", 0, pos) + 1 # 0 if no newline before
col = pos - line_start + 1
line_end = doc.find("\n", pos)
if line_end == -1:
line_end = len(doc)
source_line = doc[line_start:line_end]
print(f"Parse error at line {line}, column {col} (char {pos})")
print(source_line)
print(" " * (col - 1) + "^")
import json
try:
json.loads(doc)
except json.JSONDecodeError as e:
# Python already gives lineno/colno, but locate() works for any raw pos
locate(e.doc, e.pos)
For a minified single-line payload there are no newlines, so line is 1 and col equals pos + 1 — and source_line is the whole blob. Slice it the same way the JS helper does if it's long:
window = source_line[max(0, col - 41):col + 39]
Go: convert a byte offset to line/column
Go gives you a byte offset and nothing else, so you must compute line/column from the raw bytes:
package main
import (
"encoding/json"
"errors"
"fmt"
)
func offsetToLineCol(data []byte, offset int64) (line, col int) {
line, col = 1, 1
limit := int(offset)
if limit > len(data) {
limit = len(data)
}
for i := 0; i < limit; i++ {
if data[i] == '\n' {
line++
col = 1
} else {
col++
}
}
return
}
func main() {
data := []byte(`{"a":1,"b":}`)
var v map[string]any
err := json.Unmarshal(data, &v)
var syn *json.SyntaxError
if errors.As(err, &syn) {
line, col := offsetToLineCol(data, syn.Offset)
lo := syn.Offset - 30
if lo < 0 {
lo = 0
}
hi := syn.Offset + 30
if hi > int64(len(data)) {
hi = int64(len(data))
}
fmt.Printf("%s at byte %d (line %d, col %d)\n",
syn.Error(), syn.Offset, line, col)
fmt.Printf("context: %q\n", data[lo:hi])
}
}
Because Go counts in bytes, offsetToLineCol iterates the byte slice directly — which is correct as long as you treat the result as a byte column, not a rune column (more on that below).
The classic gotcha: "Unexpected token o in JSON at position 1"
This specific message — token o, position 1 — almost always means you double-parsed. JSON.parse was handed a value that was already an object. JavaScript coerces an object to a string before parsing, and an object stringifies to [object Object]. The parser reads [ fine (a valid array start), then hits o at index 1 (the o in object), which is illegal.
// fetch().then(r => r.json()) ALREADY returns a parsed object
const res = await fetch("/api/user");
const data = await res.json(); // data is an object
// BAD - double parse: data -> "[object Object]" -> SyntaxError at position 1
const broken = JSON.parse(data);
// GOOD - data is already what you want
const ok = data;
The same trap hits any code path where an upstream layer parsed the JSON for you:
// BAD - value is already an array/object
JSON.parse([1, 2, 3]); // Unexpected token , ... (or position 1)
JSON.parse({ id: 1 }); // Unexpected token o in JSON at position 1
// GOOD - only parse actual strings
const value = typeof raw === "string" ? JSON.parse(raw) : raw;
In modern V8 the message has changed to "[object Object]" is not valid JSON, which names the cause more directly — but the root fix is identical: stop parsing something that isn't a string.
Edge cases that move the offset
Byte offsets vs. character positions
JavaScript and Python report character positions; Go reports a byte offset. With pure ASCII they're identical. With multibyte UTF-8 they diverge, and a context window built with the wrong unit lands in the wrong place.
{ "name": "café", "ok": }
The é is two bytes in UTF-8 but one character. In Go, *json.SyntaxError.Offset for the error after "ok": counts those two bytes, so slicing data[offset-30:offset] on the byte slice is correct (byte slice, byte offset — they match). In JavaScript and Python, positions count é as one unit, and string.slice/string indexing also work in code units/characters — so they're internally consistent too. The danger is mixing units: never feed a Go byte offset into a JS String.prototype.slice, and remember that Go's column from offsetToLineCol is a byte column, not a visual column, when the line contains multibyte text. To get a rune-accurate column in Go, decode runes:
import "unicode/utf8"
func offsetToRuneCol(data []byte, offset int64) (line, col int) {
line, col = 1, 1
for i := 0; i < int(offset) && i < len(data); {
r, size := utf8.DecodeRune(data[i:])
if r == '\n' {
line++
col = 1
} else {
col++
}
i += size
}
return
}
Capturing the typed error in Go
If you log err.Error() directly, you lose the offset entirely:
// BAD - generic message, no position
if err := json.Unmarshal(data, &v); err != nil {
log.Println(err) // "invalid character ',' looking for beginning of value"
}
// GOOD - type-assert or errors.As to reach .Offset
var syn *json.SyntaxError
if errors.As(err, &syn) {
log.Printf("%v at byte %d", syn, syn.Offset)
}
Use errors.As rather than a bare type assertion (err.(*json.SyntaxError)) — Decoder.Decode and wrapped errors (fmt.Errorf("...: %w", err)) won't satisfy a direct assertion but will satisfy errors.As.
Decoder offset semantics
For json.Decoder (streaming), the offset is relative to the start of the stream, and dec.InputOffset() gives you the byte offset of the most recently read token — useful for pinpointing errors mid-stream where *json.SyntaxError.Offset may report the position within the buffered chunk.
Trailing-data errors
Unexpected non-whitespace character after JSON at position N (JS) and Extra data: line L column C (char N) (Python) mean the JSON value parsed successfully but something followed it — two concatenated objects, a leftover comma, or a log line glued onto the payload. Here the offset points at the first byte after the valid value, so look just before position N, not at it.
Quick reference
- JavaScript:
error.message, regexat position (\d+)-> zero-based character index. - Python:
e.pos(char),e.lineno,e.colno(both 1-based),e.msg,e.doc. - Go:
errors.As(err, &*json.SyntaxError)->.Offsetis a byte offset; compute line/col yourself. - Java:
e.getLocation()->getLineNr(),getColumnNr(),getCharOffset(),getByteOffset().
Always print a context window around the reported offset before guessing — the bytes on screen beat any theory about what "should" be there.
Related Tools & Resources
Tools
- JSON Validator — paste JSON to get the exact line and column of the error
- JSON Error Analyzer — pinpoints the failing token and explains the parser's stop position
- JSON Repair — auto-fixes the structural mistakes the offset points to, like stray commas and braces
- JSON Formatter — pretty-prints minified payloads so a character offset maps to a readable line
Learn More
- Unexpected token < in JSON — the HTML-instead-of-JSON variant of a token error
- Fix "Unexpected end of JSON input" — when the offset points past the end of a truncated payload
- JSON Syntax Errors — the full catalogue of grammar violations behind these messages
- Working with JSON in Python — JSONDecodeError attributes and loads/dumps in depth
Related Articles
Formatting JSON: When It Helps, When It Hurts
A practical guide to JSON beautifiers: pretty-print for humans, minify for transport, sort keys for diffs, and the lossy gotchas of round-tripping through stringify.
Common JSON Mistakes and How to Avoid Them
The 20 most common JSON syntax errors developers make, organized by category with examples and fixes.
How to Fix 'Unexpected End of JSON Input' Error
Learn what causes the 'Unexpected end of JSON input' error and how to fix it with practical solutions for JavaScript, Python, and other languages.
How to Open JSON Files: A Practical Guide
Open, view, and troubleshoot JSON files on macOS, Windows, and Linux with real commands for jq, Python, VS Code, and browsers — plus fixes for common errors.