What counts as valid JSON
JSON (RFC 8259) is a much smaller language than most people assume, and nearly every "invalid JSON" error comes from importing a habit from JavaScript or YAML that JSON simply does not have.
- Strings use double quotes only.
'single'is a syntax error. So is a backtick template literal. - Object keys must be quoted strings.
{name: "x"}is valid JavaScript and invalid JSON. - No trailing commas.
[1, 2, 3,]fails. This is the single most common cause of a rejected config file. - No comments. Neither
//nor/* */. Formats that allow them — JSON5, JSONC,tsconfig.json— are extensions, not JSON. - No literal newlines inside strings. A line break must be written
\n. Tabs must be\t. - Numbers are decimal only. No hex (
0xFF), no leading+, no leading zeros, noNaN, noInfinity, no trailing decimal point. - `undefined` does not exist. Only
null. And the literals are lowercase —Trueis nottrue.
The values themselves are just six things: object, array, string, number, boolean, and null. A valid JSON document may have any of them at the top level — a bare 42 or "hello" is legal JSON, even though most APIs return an object.
Reading the error message
Browsers disagree about how they report parse failures. V8 (Chrome, Edge, Node) gives a byte offset — "Unexpected token } in JSON at position 42" — and newer versions add a line and column. Firefox reports a line and column directly. Safari often reports neither. This tool prefers the byte offset when one is available and computes the line and column from your input itself, so the number you see is always derived from the text in the box rather than trusted from a message format that varies by browser.
Read the full explanation of json formatter results ↓Show less ↑
The reported position is where the parser *noticed* the problem, which is often one token after where you made the mistake. A missing comma on line 12 is usually reported at the start of line 13, because the parser only realises something is wrong when it encounters a key where it expected a comma. Always look at the line above the one flagged.
| Message fragment | Almost always means |
|---|---|
Unexpected token } in JSON | A trailing comma before the closing brace |
Unexpected token ] in JSON | A trailing comma before the closing bracket |
Unexpected end of JSON input | Truncated data — an unclosed brace, or a response that got cut off |
Unexpected token ' in JSON | Single-quoted strings; JSON requires double quotes |
Unexpected token o in JSON at position 1 | You parsed the string "[object Object]" — something was stringified with String() rather than JSON.stringify() |
Unexpected non-whitespace character after JSON | Two JSON documents concatenated, or NDJSON parsed as a single document |
Unexpected token < in JSON at position 0 | The response is HTML, not JSON — usually an error page or a login redirect |
That last one deserves emphasis because it is the most misdiagnosed. Unexpected token < means your code asked for JSON and received <!DOCTYPE html>. The JSON is not malformed; there is no JSON. Check the HTTP status code and the Content-Type header — the HTTP headers checker will show you what the server is actually returning.
Formatting, minifying, and sorting
Pretty-printing adds whitespace, which is semantically meaningless in JSON — the parsed value is identical either way. Two spaces is the near-universal convention for configuration files and API examples; four is common in Python-adjacent codebases; tabs suit anyone who wants readers to control the visual width.
Minifying strips all insignificant whitespace. For a response served over the wire this matters less than it once did, because gzip and brotli compress repeated indentation to almost nothing — the real saving is usually 1–3% after compression, not the 20–30% the raw byte counts suggest. Minify because a system requires a single line (a Docker label, an environment variable, a DNS TXT record), not as a performance optimisation.
Sorting keys is the underrated feature here. JSON objects have no defined ordering, so two semantically identical documents can differ byte-for-byte purely by key order — which makes diff useless and produces noisy commits when a tool re-serialises a config file. Sort both sides first and the diff shows only real changes. It is also the fastest way to compare two API responses that should match.
Command-line equivalents
jq is the tool worth learning properly; python3 -m json.tool is the one guaranteed to be present.
# Pretty-print (jq indents with 2 spaces by default)
jq . response.json
jq --indent 4 . response.json
curl -s https://api.example.com/status | jq .
# Minify
jq -c . response.json
# Sort keys recursively — makes two responses diffable
jq -S . a.json > a.sorted.json
jq -S . b.json > b.sorted.json
diff a.sorted.json b.sorted.json
# Validate only: exit status 0 = valid, non-zero = invalid, with an error on stderr
jq empty config.json && echo "valid"
# No jq available? Python is everywhere
python3 -m json.tool config.json
python3 -c "import json,sys; json.load(open(sys.argv[1]))" config.json
# Extract a value rather than eyeballing the whole document
jq -r '.records[] | select(.type=="MX") | .value' zone.json
# NDJSON / JSON Lines — one document per line, so parse per line
jq -c . < events.ndjson | headjq empty is the cleanest validator: it parses the document, produces no output, and exits non-zero if parsing fails. That makes it ideal in a CI step or a pre-commit hook, where you want a fast fail rather than a formatted dump.
Where JSON shows up in DNS and mail work
DMARC aggregate reports are XML rather than JSON, but almost every tool that processes them emits JSON, and the nested record arrays are exactly the kind of structure that is unreadable minified and obvious formatted. RDAP — the structured successor to WHOIS — returns JSON directly, with the registrar, status codes, and event dates in nested objects; the WHOIS lookup presents that for you, but if you query an RDAP endpoint yourself you will want to format the response.
DNS-over-HTTPS also speaks JSON. Cloudflare and Google both expose a JSON API where the Status field carries the DNS response code (0 = NOERROR, 3 = NXDOMAIN) and Answer is an array of records with name, type, TTL, and data. Being able to read that quickly is the difference between "the domain is broken" and "the domain exists but has no records of that type" — a distinction the DNS lookup tool makes explicit.
curl -s -H 'accept: application/dns-json' \
'https://cloudflare-dns.com/dns-query?name=example.com&type=MX' | jq .
# Status 0 = NOERROR, 3 = NXDOMAIN. An empty Answer with Status 0
# means "the name exists but has no records of this type".
curl -s -H 'accept: application/dns-json' \
'https://cloudflare-dns.com/dns-query?name=example.com&type=MX' \
| jq '{status: .Status, answers: (.Answer // [] | length)}'One last habit: never build JSON by string concatenation. A domain name or an error message containing a quote or a backslash will produce invalid output, and the failure surfaces far from the cause. Serialise with your language's JSON library, which handles escaping, and use this page to check the result when something downstream rejects it.