Last month I looked at a service that would not start. The config was JSON he had written by hand. He deleted one node, and the program answered with a single line, syntax error, no line number. He scanned the file three times and could not see which punctuation mark was missing. Handed to Python 3.13's json module, the same file produced one complete sentence: Illegal trailing comma before end of object: line 5 column 22 (char 93). The culprit sat at the end of line 5.
{
"upstream": "10.0.3.7:8080",
"timeout": 30,
"retries": 3,
"keepalive": true,
}
The other half is an API response squashed onto one line, two thousand characters and more packed together, where my eyes slide away while I look for one key's value.
JavaScript that runs is usually not valid JSON
JSON is a strict subset carved out of JavaScript object literals, and the four things left out are exactly the ones people write back in: single-quoted strings, bare identifiers as keys, trailing commas, and comments. I checked with Node 24: eval on ({'a': 1, /* c */ b: 2,}) returns an object, while JSON.parse rejects it from line 1 column 2, pointing at that single quote. A script on my machine runs through a forgiving JS parser; a service loading config runs through a strict JSON parser.
I typed out every broken string in the table below. Python 3.13 reports all of them on line 1, so the table only keeps the column; V8 words it differently but lands in a similar place.
| Problem | Broken snippet | Python 3.13 error |
|---|---|---|
| Trailing comma | {"a": 1,} | Illegal trailing comma before end of object: column 8 |
| Key in single quotes | {'a': 1} | Expecting property name enclosed in double quotes: column 2 |
| A commented-out line | {"a": 1 /* note */} | Expecting ',' delimiter: column 9 |
| Raw newline inside a string | | Invalid control character at: column 13 |
| Unbalanced brackets | {"a": [1, 2} | Expecting ',' delimiter: column 12 |
Two more: numbers written as NaN or with a leading zero, which Python reads and writes back happily but which blow up in the browser. And a transport mistake, Content-Type set to text/plain, which some clients do not parse automatically even though the file itself is valid. Matching brackets by eye is slow, so I let the parser report a position first: JSON Formatter gives the line and column as you type, expands with 2 spaces, 4 spaces or a tab, compresses back to one line, and its tree view folds level by level.
Reading line and column numbers, and why minifying makes them useless
Besides line and column, Python gives a zero-based char offset: the line is the number of newlines before the offset plus one, and the column is the offset minus the position of the previous newline. For a 37-byte string {"nodes":["a","b","c",],"timeout":30}, Python reports char 21, which is line 1 column 22, landing exactly on the closing bracket after the comma.
In the file at the top, the same content makes Python report line 5 column 22, pointing at the comma that should go, while V8 reports position 95, line 6 column 1, already past it. The reported position marks the first character the parser cannot continue from, and the thing to fix is usually the element before it, so I step back one when I get the numbers. Minified to one line, the line number is always 1 and the column runs into the thousands, so spreading it over several lines is the only way to make it useful. When nobody can say which line changed, reading the error helps less than running Text Diff on the two versions.
What minifying removes: a run through my own sample
I would not copy the claim that it cuts size by 30% to 50%. Minifying deletes whitespace only; not one byte of keys or values goes away. I built a sample: 191 keys, objects nested 5 levels deep, values mixing strings, integers, decimals, booleans and null, encoded by PHP into four forms and measured.
| Form | Bytes | More than minified | Share of its own size |
|---|---|---|---|
| Minified to one line | 2458 | none | none |
| Tab indent | 3815 | 1357 | 35.6% |
| 2-space indent | 4744 | 2286 | 48.2% |
| 4-space indent | 6602 | 4144 | 62.8% |
The 4144 breaks down: 3716 bytes of leading whitespace, 237 newlines, and the remaining 191 exactly equals the key count, because the beautifier adds one space after every colon while the data itself stays at 2458. Switch to tabs and leading whitespace drops to 929 bytes, one byte per tab. You can count it yourself on ten lines: 4 spaces per line, 11 newlines, 145 formatted against 94 minified, and the difference of 51 is 40 plus 11.
Those ratios hold for this sample only: the same content is about 35% larger with tabs and nearly 63% larger with 4 spaces, 1.731 times the tab version. Whether to minify depends on who reads it: config a person will edit keeps its indentation; response bodies save bandwidth through gzip, which is a different layer from whitespace; minifying helps only when a whole block has to go into code as a string, and then every newline must be written as \n, which removes one class of escaping.
Putting JSON into code as a string: escaping stacks by layer
Four characters inside the string need rewriting: a double quote becomes backslash plus double quote, a backslash becomes two backslashes, a newline becomes backslash n, and a tab becomes backslash t. The trouble is the layer count. A payload in a log and a body posted from a form are both JSON inside JSON, and every layer means another round of escaping. Take a 5-byte value wrapped three times and check it character by character:
1 layer: {"v":"a\"b\\c"}
2 layers: {"p":"{\"v\":\"a\\\"b\\\\c\"}"}
3 layers: {"w":"{\"p\":\"{\\\"v\\\":\\\"a\\\\\\\"b\\\\\\\\c\\\"}\"}"}
The backslash count goes from 3 to 11 to 31, and the strings are 15, 31 and 59 bytes; by the last line the original 5 characters are unrecognisable. Switch to a Chinese value containing ASCII double quotes and a Windows path, and the value itself is 47 bytes; at the third layer the whole string is 129 bytes with 46 backslashes, because every extra layer escapes the backslashes that are already there. By the third layer I make counting mistakes by hand, so I decode one layer, check it, and compare against the original. To pick strings like this out of a log, I match quote boundaries and paired backslashes with Regex Tester, then hand the result to a parser.
Questions that keep coming up
Q: The error only gives an offset. How do I get line and column?
The offset is a zero-based position. The line is the number of newlines before it plus one, and the column is the offset minus the position of the previous newline; with a single line, the column is the offset plus one.
Q: I only commented out one line. Why does the whole file fail?
JSON has no comment syntax. The reported position usually lands at the end of that line's value or at the start of the next key, so it looks like the value is wrong. If you need a note, make it an ordinary key.
Q: How much size does minifying actually save?
Using the numbers above, the 191-key, 5-level sample: tabs add 35.6%, 2 spaces add 48.2%, 4 spaces add 62.8%, and all of it is whitespace. A percentage with no sample behind it can be skipped.
Q: If I paste a production config in to validate it, does the content reach a server?
Files like that often carry internal addresses and keys, so I only use tools that parse entirely in the browser. The entry point linked above parses locally.
Back to that service that would not start: the change was deleting one comma at the end of line 5, and all the work was getting the parser to state the position clearly. The order is fixed now: confirm that a strict JSON parser is reading it, take the line and column and step back one element, decide indentation by who reads it, and only stack escaping when a string truly has to be embedded.