JSON Hacks: Nine Things to Do With a Payload Before You Parse It
All of these assume the thing you care about is the text you were handed, not the object your language builds from it. That distinction is where every one of these tricks comes from.
1. Keep a 19-digit ID intact
JSON.parse("9007199254740993") returns 9007199254740992. That is not a rounding option you can switch off; IEEE-754 doubles cannot hold the input, so the value is wrong before your code sees it. Two fixes:
// keep it as a string at parse time
const o = JSON.parse(text, (k, v) =>
typeof v === 'number' && !Number.isSafeInteger(v) ? String(this.source[k].value) : v);
// or parse with a bigint-aware reader
import JSONbig from 'json-bigint'; // or lossless-json, or json-bigint's siblings
The this.source trick inside a reviver reads the raw token text and preserves the original digits. If you cannot change the parser, quote the field on the way out of your API — a string ID is the only format that survives every consumer, including Excel.
2. Make JSON diffable
Formatting differences are the reason JSON diffs are useless in code review. Canonical form:
jq -S . payload.json # -S sorts object keys at every level
python3 -m json.tool in.json out.json
Sorting keys is lossless for objects (key order is not part of JSON's meaning) and destroys nothing except your intent that order was meaningful — if it was, you were using an object as an array; fix the schema instead. Arrays never get reordered by any of these tools.
Paste two payloads into JSON Diff to get JSON-Pointer paths for exactly what changed; it reports added /user/email rather than a block of context you have to read.
3. Read one NDJSON line at a time
jq -c 'select(.level=="error") | {t: .ts, m: .msg}' app.ndjson # -c: one object per line
head -c 2000000 huge.ndjson | jq -s 'length' # count objects in first 2 MB
jq -s 'group_by(.user) | map({user: .[0].user, n: length})' log.ndjson # -s slurp when you must
-c keeps one record per line, which is the entire point of the format: grep, head, and wc -l all work without a parser. Details in the NDJSON guide.
4. Find the duplicate key that ate your data
{"a":1,"a":2} parses to {a: 2} with no warning — the last value wins and the first is gone. To find them:
jq -e 'to_entries' file.json > /dev/null # does not detect dupes; it parses after collapse
python3 -c "
import json,sys
def hook(pairs):
seen=set()
for k,_ in pairs:
if k in seen: print('DUPLICATE KEY:',k,file=sys.stderr)
seen.add(k)
return dict(pairs)
json.load(sys.stdin, object_pairs_hook=hook)" < file.json
object_pairs_hook receives every pair before the duplicate collapses, which is the only way to see them in Python. The formatter here collapses them silently too, same as every JSON.parse implementation — see patterns and gotchas.
5. Stop a float turning into 1
1.000 round-trips to 1, 1e1000 becomes null, and 1.7976931348623157e309 becomes null too — the last two are not precision loss but literal invalid output, because JSON has no infinity. If a decimal is money or a measurement with stated precision, it should be a string in your API. There is no parse-side trick that recovers a type the format never had.
6. Escape JSON inside JSON
The classic double-encode: a field whose value is itself a JSON document. Detect it, then parse twice:
const inner = typeof s === 'string' && /^[[{]/.test(s.trim()) ? JSON.parse(s) : s;
Do not "fix" it by unescaping quotes with a regex — \" inside a string is one character and a regex cannot tell it from a structural quote. The formatter double-parses when you paste an escaped payload; that is what the repair pass is and is not for (below).
7. Know where Auto-Repair will hurt you
The Auto-Repair button here runs three regex substitutions over the whole document, string values included, when the first parse fails. Measured against the shipped code, on inputs that genuinely fail to parse:
| Input (unparseable) | After repair | Result |
|---|---|---|
{name:'ada',age:36} | {"name":"ada","age":36} | fixed — this is what it is for |
{"msg":"they said 'hi'",} | {"msg":"they said "hi""} | still invalid, and the value's quotes are now unbalanced |
{"sql":"SELECT 'a' AS x",} | {"sql":"SELECT "a" AS x"} | still invalid, SQL string quoting destroyed |
{"note":"value x, } end",} | {"note":"value x} end"} | parses, but a space and a comma vanished from the value |
{"t":"a: b, c: d",} | {"t":"a: b, "c": d"} | still invalid — the key-quoter fired inside a string |
The trailing-comma and bare-key repairs are safe and useful. The single-quote repair does not know about string state, so it trades one syntax error for a subtler one and changes your text. Rule: run repair on a document that failed to parse, and re-check any string value containing ', :, or , }. Prefer a real repairer that tracks tokens — jsonrepair (from the Lightning CSS team) or best-effort-json — when the document matters.
8. Get the error position out of the browser
JSON.parse errors carry a byte offset in message (Unexpected token } at position 42) and no line number. Convert once:
const pos = +err.message.match(/position (\d+)/)?.[1] ?? -1;
const upto = text.slice(0, pos), line = (upto.match(/\n/g)?.length ?? 0) + 1;
const col = pos - (upto.lastIndexOf('\n') + 1);
console.log(`line ${line}, col ${col}: …${text.slice(pos - 20, pos + 20)}…`);
The validator here prints the offset and a 12-character window either way; the page's error line does this conversion for you.
9. Compare two payloads by shape, not by value
When the values differ every run (timestamps, IDs) but the schema must not:
jq 'walk(if type=="object" then with_entries(.key|=ascii_downcase) else . end)' a.json | jq -S .
diff <(jq -S 'paths' a.json) <(jq -S 'paths' b.json) # structure only: key paths differ
paths output is a structural diff — everything that does not change between environments disappears, and the one renamed field shows up immediately.
One more, free: never log a whole payload to "see what came in". jq 'del(.password, .token, .user.email)' at the logging boundary is cheaper than scrubbing later. Related: patterns and gotchas and the cheat sheet.