Same JSON, Four Tools, Four Results: How to Choose a JSON Formatter
Bottom line: Formatting is not lossless. Fed the same JSON with a leading zero, a BOM, or a 20-digit integer, Python, Node, jq and json.tool disagree: some raise errors, others silently rewrite the data. Ten self-run samples across four paths produce a selection table and a copy-paste strict validation script.

Bottom line: the most consequential difference between JSON formatters is not how many spaces they indent, but that the same input can either raise an error or silently change your data. Running one boundary-value sample through a candidate tool teaches you more than reading its feature list.
People assume formatting is just "re-indenting". In reality every path re-interprets the text with its own parser. When a parser is lenient, an error that should have been thrown becomes a silent rewrite of the data, while the page still shows a reassuring green "Formatted successfully".
1. The observation: one input, four paths, four results
The test ran on Python 3.11.1, Node v22.13.1 and jq 1.7. Ten short, hand-built JSON strings were fed over standard input, never written to disk, with default configuration. The four paths were json.loads, JSON.parse, jq -c . and python -m json.tool. In the table ERR means an exception was raised and processing stopped; SILENT means no error was reported but the output no longer equals the input.
| Sample | python json.loads | node JSON.parse | jq -c . | python -m json.tool |
|---|---|---|---|---|
S1 {"a":1,"b":[1,2]} |
OK | OK | OK | OK |
S2 {"a":1,} |
ERR | ERR | ERR | ERR |
S3 {'a':1} |
ERR | ERR | ERR | ERR |
S4 {"a":01} |
ERR char 6 | ERR position 6 | SILENT {"a":1} |
ERR |
S5 {"a":1 /*x*/} |
ERR char 7 | ERR | ERR | ERR |
S6 \ufeff{"a":1} |
ERR utf-8-sig | ERR position 0 | SILENT {"a":1} |
ERR |
S7 {"id":9007199254740993} |
OK faithful | SILENT 9007199254740992 | OK faithful | OK |
S8 {"a":1,"a":2} |
SILENT {"a":2} |
SILENT {"a":2} |
SILENT {"a":2} |
OK |
S9 {"a":1 |
ERR char 6 | ERR | ERR EOF | ERR |
S10 <html>502</html> |
ERR char 0 | ERR Unexpected token '<' | ERR | ERR |
Four of the ten samples produced "no error but changed data": S4, S6, S7 and S8. If all you trust is the green success badge, there is a four-in-ten chance that what you hold is no longer the original payload. Worse, none of these cases appears in any syntax-error list: the file opens, copies and exports perfectly well. The fault lives in the content, not in the syntax.
2. Why: syntax and semantics are two different layers
RFC 8259 is explicit about this. Section 1.2 notes that ECMA-404 permits practices the RFC recommends avoiding for maximum interoperability; Section 4 says object names SHOULD be unique but does not prescribe what to do with duplicate keys; Section 6 discusses numbers only in terms of interoperability guidance. ECMA-404 goes further and declares itself "agnostic to the semantics of numbers", specifying the representation of a number sequence but not how a language stores it. The standards deliberately leave a large boundary area to implementers.
Formatting therefore splits into two layers: the syntax layer decides whether the text is legal, and the semantic layer decides what value the text becomes. The first has a standard answer; the second does not. jq reading 01 as 1 and skipping a leading BOM is a semantic choice. Node pushing a 20-digit integer into an IEEE 754 double is a runtime choice. All four paths keeping the last value of {"a":1,"a":2} is a shared convention among mainstream parsers. None of these violates "the input could be parsed", yet each one changes the data.
How to fix JSON parse errors works on the syntax layer, translating an error message into coordinates. This article deals with the step before it: if the tool never raised an error, you cannot even get coordinates.
3. Four kinds of silent rewrite, one by one
Isolating the four silent samples makes the changes obvious — and each is small enough to miss on a quick scan:
{"a": 01} // leading zero: jq outputs {"a":1}
\ufeff{"a": 1} // BOM: jq skips the first three bytes
{"id": 9007199254740993} // 20-digit integer: JSON.parse outputs ...992
{"a": 1, "a": 2} // duplicate key: all four paths keep {"a":2}
Leading zeros and BOM are "the input was already invalid, but the tool was forgiving". 01 is illegal in standard JSON (RFC 8259 Section 6 forbids leading zeros), and a BOM must not appear at the start of JSON text; both Python and Node reject them, while jq continues. That tolerance is convenient when cleaning logs and catastrophic during contract validation: upstream believes it sent invalid data that needs fixing, while downstream already holds a result that looks perfectly normal.
The large integer is "the input is fully valid, but the runtime cannot hold it". 9007199254740993 is a legal JSON number, but it exceeds 2^53-1, so JSON.parse can only represent 9007199254740992. Python's int and jq both preserve the original. If that field is an order number or a snowflake ID, a changed last digit means the books no longer reconcile.
Duplicate keys are "the spec does not say, so each implementation chooses". All four paths silently take the last value, with no warning anywhere. RFC 8259 only says names SHOULD be unique, so this is not a bug — but it is the hardest class of problem to trace when concatenating configs or merging patches.
4. Error reporting: who gives you a position, who gives you one line
The six failing samples also differ sharply in how much information they provide. Python's JSONDecodeError exposes msg, lineno, colno and pos together. Node v22 reports position N (line 1 column M) plus a snippet of the offending source. python -m json.tool reuses Python's information. jq gives only parse error: ... at line N, with no character offset.
On minified single-line JSON, line and column numbers are nearly useless — everything is on line 1. What you need is a character offset. The script below normalises validation into "error + offset + context":
import json, sys
def strict_check(text: str) -> None:
try:
json.loads(text)
print("OK")
except json.JSONDecodeError as e:
print(f"ERR {e.msg} | char {e.pos} | line {e.lineno} col {e.colno}")
lo, hi = max(0, e.pos - 30), min(len(text), e.pos + 30)
print("...", text[lo:hi].replace("\n", "\\n"), "...")
sys.exit(1)
strict_check(open(sys.argv[1], encoding="utf-8").read())
It is stricter than jq . (leading zeros and BOM are rejected) and it hands you e.pos. How to convert line 1 column 23611 into a byte offset, and why character offsets differ from byte offsets by a factor of three in CJK documents, is covered in Debugging Minified JSON; it is not repeated here.
5. The fix: split formatting into "strict validation" + "pretty printing"
The conclusion is simple: do not let one "Format" button carry both validation and beautification. First use a strict parser to decide legality, then decide how to lay out the text. One command isolates the first step:
python3 -m json.tool input.json > /dev/null && echo "syntax OK" || echo "syntax invalid"
python3 -c "import json,sys; d=json.load(open(sys.argv[1],encoding='utf-8')); print(type(d).__name__, len(d))" input.json
The first step answers only "can it be parsed"; the second checks "is the parsed structure what you expected". If the payload contains large integers, read it with Python or jq rather than JSON.parse. If the source may carry a BOM, open it with encoding='utf-8-sig' explicitly instead of hoping a tool skips it. If keys may repeat, scan for them before parsing, because none of the four paths will warn you. Writing these into CI is exactly the pipeline described in Editors, Command Line, and Automation Toolchains, with one extra layer of data-fidelity assertions.
6. How to choose: a selection table for the four paths
| Path | Strength | Risk | Best for |
|---|---|---|---|
python json.loads / json.tool |
Strict, gives line/column and character offset, integers keep precision | Rejects leading zeros and BOM, encoding must be handled explicitly | Contract validation, CI, log triage |
node JSON.parse |
Native in the browser, error includes a source snippet | Silently truncates large integers, errors on BOM | Front-end debugging, in-browser tools |
jq -c . |
Pipeline-friendly, fastest result | Leading zeros and BOM pass silently, no character offset | Ops pipelines, quick field extraction |
Browser tools /parse, /editor |
Tree view, key sorting, copy-path, no install needed | Behaviour depends on the page's parser, so verify it once first | Manual inspection, ad-hoc troubleshooting |
The deciding question is not "which has more features" but "will it lie about my data". For payloads carrying order IDs, amounts or device IDs, prefer Python or a tool that explicitly states fidelity. For pure structural debugging that is never persisted, a browser tool is the least effort. This site's /parse performs in-browser syntax validation and formatting, which suits "see what it looks like" — but for large integers, re-check with the script above.
7. Privacy is only one of the boundaries
"Will my data be uploaded" is the most common question about online tools, but it is independent of "will my data be changed". A local, offline-capable tool can still truncate a 20-digit integer; conversely, a strictly erroring online tool may POST the original text to a server. Verify the two properties separately — neither substitutes for the other. The privacy FAQ gives three checks you can finish in two minutes; this article supplies the other half: use the sample list below to confirm the tool does not silently rewrite values.
8. A copy-paste self-check list
Save S1–S10 as cases.txt, one per line, paste them into any candidate tool, and check three things: do the failing samples actually fail, does a failure report a concrete position, and does the output of a passing sample match the input byte for byte. Pay special attention to S4, S6, S7 and S8 — a single SILENT row means that path cannot be used alone for data validation.
Add three engineering constraints: put strict validation in a pre-commit hook instead of discovering a mismatched integer in production; write files as UTF-8 without BOM across languages; and keep the raw response body from third parties, using the formatted result for reading only, never for write-back. The pre-commit setup in the editor/CLI article connects directly to this checklist.
FAQ
Does a successful format mean the JSON is fine?
No. S4, S6, S7 and S8 all "passed" formatting, yet leading zeros, BOM, a 20-digit integer and a duplicate key were rewritten or swallowed. Formatting only proves some parser accepted the text; it does not prove the parsed values equal what you sent. Assert critical fields value by value.
Why does jq accept leading zeros and a BOM that Python rejects?
jq has its own lexer: it is lenient about numbers like 01 and skips a leading BOM, whereas Python and Node follow RFC 8259 strictly. This is not a version bug but the trade-off between a tolerant and a strict parser. Tolerant parsers save work during cleaning; contract validation requires strict ones.
How do I stop a large integer becoming 9007199254740992?
JavaScript's Number is an IEEE 754 double with a safe integer limit of 2^53-1. Reading a 20-digit ID with JSON.parse necessarily truncates it. Store such IDs as strings, or use Python int, jq, or a BigInt-aware parser. If the front end truly needs a number, replace that field with a string before parsing.
Online tool or local command line — which should I use?
Split by sensitivity and purpose: for inspecting structure or finding a field, the browser is fastest; for pipelines and fidelity assertions, use python -m json.tool plus a strict checker. In both cases run S1–S10 first, confirm the errors and outputs match expectations, then decide which class of data you are willing to feed in.