Fixing a JSON Syntax Error in Hand-Edited Config Files
Hand-editing config files is where most JSON syntax errors actually happen. You open package.json to bump a version, add a trailing comma out of habit, drop in a // note, and suddenly npm install dies with Unexpected token } in JSON at position 412. This article walks through a real edit-and-break cycle, explains why the same comment errors in package.json but not tsconfig.json, and shows how to validate any config from the command line.
A real walkthrough: breaking package.json
Here is a package.json that someone edited by hand. They added a comment to remind themselves why a dependency is pinned, and left a trailing comma after the last dependency because that is how they write JavaScript object literals.
{
"name": "billing-service",
"version": "2.3.0",
"dependencies": {
"express": "4.19.2",
// pinned: 5.x breaks our auth middleware
"lodash": "4.17.21",
}
}
Run any npm command against this and you get something like:
npm error code EJSONPARSE
npm error JSON.parse Unexpected token "/" (0x2F) in JSON at position 78 while parsing near "...press": "4.19.2",\n // pinned: 5.x b..."
npm error JSON.parse Failed to parse JSON data.
The "position 78" is a byte offset into the file, not a line number. npm helpfully prints the surrounding text so you can find it, but plain JSON.parse does not — you get only the offset. Two separate problems are present: the // comment (strict JSON has no comments at all) and the trailing comma after "lodash": "4.17.21". The parser fails on whichever it hits first, so fixing one error often just surfaces the next.
The corrected file:
{
"name": "billing-service",
"version": "2.3.0",
"comment_lodash": "pinned: 5.x breaks our auth middleware",
"dependencies": {
"express": "4.19.2",
"lodash": "4.17.21"
}
}
The comma after the closing value is gone, and the note moved into a real string key (npm ignores unknown top-level keys, so a comment_* field is a safe place to park a note). The trailing comma after "lodash" was the second problem — strict JSON does not allow a comma before a closing } or ].
The crucial nuance: JSONC vs strict JSON
Here is what trips people up: the exact same // comment and trailing comma that crash package.json are completely legal in tsconfig.json and VS Code's settings.json. This file parses fine:
{
"compilerOptions": {
"target": "ES2022",
// we still ship to older Node in CI
"module": "CommonJS",
"strict": true,
}
}
That is not standard JSON — it is JSONC (JSON with Comments), a superset that allows // line comments, /* */ block comments, and trailing commas. The TypeScript compiler and VS Code use their own JSONC parser, not JSON.parse. RFC 8259 — the JSON spec — permits none of this. So the rule is about which program reads the file, not the .json extension:
- JSONC (comments and trailing commas allowed):
tsconfig.json,jsconfig.json, VS Codesettings.json/launch.json/keybindings.json, and.eslintrc.jsonwhen ESLint loads it (ESLint uses a JSONC-tolerant loader). - Strict JSON (no comments, no trailing commas):
package.json,package-lock.json,composer.json,.prettierrc/.prettierrc.json(Prettier's JSON config is strict), most CI and cloud config, and anything your own code reads withJSON.parse.
How to tell which you are dealing with: ask what reads the file. If a tool with its own parser (the TS compiler, VS Code, ESLint) owns it, JSONC features usually work. If a generic JSON.parse / json.load consumes it — which includes npm, pnpm, most build scripts, and any service that ships the file as data — assume strict. When in doubt, stay strict; strict JSON is valid everywhere, JSONC is not.
The specific rules that break hand-written JSON
Beyond comments and trailing commas, these are the things people type without thinking, all of which are invalid in strict JSON:
{
"single": 'no single quotes allowed',
unquoted: "keys must be double-quoted strings",
"trailing": "comma after me is illegal",
}
Every line above is a syntax error in strict JSON. Keys must be double-quoted; single quotes are never allowed for strings or keys.
Numbers are stricter than most people expect. JSON numbers must match -?\d+(\.\d+)?([eE][+-]?\d+)?. All of these are invalid:
{
"leading_dot": .5,
"trailing_dot": 1.,
"explicit_plus": +1,
"hex": 0x1F,
"leading_zero": 0123,
"not_a_number": NaN,
"infinity": Infinity
}
Write 0.5, not .5. Write 1.0, not 1.. Drop the + sign. There is no hex (0x1F must be 31), no octal-style leading zeros (0123 must be 123), and no NaN or Infinity — JSON has no way to represent non-finite numbers, which is why serializing NaN from JavaScript silently produces null.
The duplicate-key gotcha
This one is dangerous because it is valid JSON — RFC 8259 says objects with duplicate keys are allowed but the behavior is "unpredictable." In practice every mainstream parser silently keeps the last value and discards the earlier ones. No error, no warning. This bites hard during merges, when someone adds a "port": 8080 near the top of a config that already had "port": 3000 lower down.
{ "port": 3000, "host": "localhost", "port": 8080 }
JavaScript — last wins, no error:
JSON.parse('{ "port": 3000, "host": "localhost", "port": 8080 }');
// { port: 8080, host: 'localhost' }
Python — same, last wins:
import json
json.loads('{ "port": 3000, "host": "localhost", "port": 8080 }')
# {'port': 8080, 'host': 'localhost'}
Go — also last wins when unmarshaling into a map:
var m map[string]int
json.Unmarshal([]byte(`{"port":3000,"port":8080}`), &m)
// m == map[string]int{"port": 8080}
Because no parser raises an error, duplicate keys survive code review and silently change config. If you want to catch them, Python is the easiest: pass an object_pairs_hook that rejects repeats.
import json
def no_dupes(pairs):
seen = {}
for k, v in pairs:
if k in seen:
raise ValueError(f"duplicate key: {k!r}")
seen[k] = v
return seen
json.loads('{"port":3000,"port":8080}', object_pairs_hook=no_dupes)
# ValueError: duplicate key: 'port'
The BOM gotcha
A config file can look byte-for-byte correct and still throw at position 0. The culprit is a UTF-8 byte order mark — the three bytes EF BB BF that some Windows editors (older Notepad, some PowerShell Out-File calls) prepend when saving as "UTF-8 with BOM." JSON.parse does not strip it:
const text = "" + '{"ok":true}';
JSON.parse(text);
// SyntaxError: Unexpected token '', "{"ok":true}" is not valid JSON
The error points at position 0 and the file looks fine in most editors because the BOM is invisible. Check for it with file config.json (it reports "UTF-8 (with BOM) text") or hexdump -C config.json | head -1 (look for ef bb bf at the start). Fix it by re-saving as plain UTF-8, or strip it in code: in JS, text.replace(/^/, '') before parsing; in Python, open the file with encoding='utf-8-sig', which transparently removes a leading BOM.
Validating a config from the command line
Before committing a hand-edited config, validate it. Three quick options, and what each prints on failure (using the broken package.json from the top):
python3 -m json.tool package.json
# Expecting property name enclosed in double quotes: line 6 column 5 (char 78)
python3 -m json.tool gives you a line and column, which the npm offset does not — the most useful one-liner for locating the problem.
jq empty package.json
# jq: error (at package.json:6): Objects must consist of key:value pairs ...
jq empty parses the file and produces no output on success, an error with a line number on failure. jq is strict JSON, so it flags JSONC files too — handy when you want to confirm a file is portable strict JSON.
node -e "JSON.parse(require('fs').readFileSync('package.json','utf8'))"
# SyntaxError: Unexpected token / in JSON at position 78
The node form matches exactly what npm and most build tools will do, since they all use the same V8 JSON.parse. If this passes, your tooling is happy.
Validating in code with useful error context
When you want validation inside a script — a pre-commit hook, a CI step — wrap the parse and report the location. Plain JSON.parse only gives a byte offset, so compute the line and column yourself.
const fs = require('fs');
function validate(path) {
const text = fs.readFileSync(path, 'utf8').replace(/^/, '');
try {
JSON.parse(text);
console.log(`${path}: valid`);
} catch (err) {
const m = /position (\d+)/.exec(err.message);
if (m) {
const upto = text.slice(0, Number(m[1]));
const line = upto.split('\n').length;
const col = upto.length - upto.lastIndexOf('\n');
console.error(`${path}: ${err.message} (line ${line}, col ${col})`);
} else {
console.error(`${path}: ${err.message}`);
}
process.exit(1);
}
}
validate('package.json');
Python's JSONDecodeError already carries .lineno, .colno, and .pos, so you get location for free:
import json, sys
def validate(path):
with open(path, encoding="utf-8-sig") as f: # utf-8-sig strips a BOM
text = f.read()
try:
json.loads(text)
print(f"{path}: valid")
except json.JSONDecodeError as e:
print(f"{path}: {e.msg} at line {e.lineno}, column {e.colno}")
sys.exit(1)
validate("package.json")
In Go, json.Valid gives a fast boolean, and Unmarshal into a map[string]any confirms the structure and surfaces the byte offset on failure:
package main
import (
"encoding/json"
"fmt"
"os"
)
func main() {
data, _ := os.ReadFile("package.json")
if !json.Valid(data) {
fmt.Println("package.json: not valid JSON")
os.Exit(1)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
fmt.Printf("package.json: %v\n", err) // e.g. invalid character '/' looking for beginning of value
os.Exit(1)
}
fmt.Println("package.json: valid")
}
Note Go's json.Valid does not strip a BOM either — if the file starts with EF BB BF, both json.Valid and Unmarshal fail, so trim a leading BOM with bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) before checking.
Quick checklist for hand-edited config
When a config file throws a JSON syntax error, run through this in order: remove any // or /* */ comments (unless the file is genuinely JSONC), delete trailing commas before } and ], replace single quotes with double quotes, double-quote every key, fix numbers (0.5 not .5, no 0x, no leading zeros, no NaN/Infinity), check for a UTF-8 BOM at position 0, and scan for duplicate keys that a parser would silently collapse. Validate with python3 -m json.tool for a line and column before you commit.
Related Tools & Resources
Tools
- JSON Validator — paste a config file and get the exact line and column of the syntax error.
- JSON Formatter — reindent a messy hand-edited config so misplaced commas and brackets become obvious.
- JSON Repair — auto-strip trailing commas, comments, and a stray BOM from a broken config.
- JSONC to JSON — convert a commented tsconfig or settings.json into strict JSON any parser accepts.
Learn More
- JSON Comments — why
//works in tsconfig but breaks package.json, and how to add notes safely. - JSON Parse Errors — decode
Unexpected tokenmessages and map byte offsets to locations. - Common JSON Mistakes — the recurring traps behind most hand-written JSON failures.
- JSON Format Rules — the strict RFC 8259 grammar for keys, strings, and numbers.
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.