Data Formats (JSON, YAML, TOML, XML, CSV) Module 2 – JSON in depth
JSON Lines, JSONC, JSON5 and other variants
Read JSON Lines and JSON text sequences record by record, decide where JSONC and JSON5 comments belong, and repair near-JSON with every change listed.
What you will learn
- Read and write JSON Lines and JSON text sequences one record at a time
- Report each bad record by its line number while keeping the good ones
- Decide when to accept comments and trailing commas (JSONC, JSON5) and when to refuse them
- Repair near-JSON from logs or language models without silently guessing
Before you start
What you’ll learn · Before you start
What you will learn
- Read and write JSON Lines and JSON text sequences one record at a time
- Report each bad record by its line number while keeping the good ones
- Decide when to accept comments and trailing commas (JSONC, JSON5) and when to refuse them
- Repair near-JSON from logs or language models without silently guessing
Before you start
On this page
An export of six orders has a blank line, a trailing comma and a last order cut off halfway. Parsed as one JSON text it fails at once and gives back nothing; read as JSON Lines, it keeps the three good orders and names the three bad lines.
JSON allows one value per text and no comments, so formats built on it settle two questions: how many values share a file, and what people may write in it. You will read and write JSON Lines and RFC 7464 sequences one record at a time, split values written back to back, decide where JSONC and JSON5 belong and where only strict JSON will do, and repair near-JSON from logs and language models with every change listed.
JSON Lines puts one whole JSON value on each line
Here is the export, six lines of orders from a bookshop’s branches:
{"id": "BK-10041", "branch": "Pune", "amount_paise": 59900}
{"id": "BK-10042", "branch": "Pune", "amount_paise": 37900}
{"id": "BK-10043", "branch": "Nagpur", "amount_paise": 12500,}
{"id": "BK-10044", "branch": "Nashik", "amount_paise": 80000}
{"id": "BK-10045", "branch": "Pune", "amount_paise": 24 JSON Lines: A text format with one JSON value on each line, in UTF-8, every line ended by a line feed; also called JSONL, and close to NDJSON. Each line is parsed on its own, so a file is read one record at a time. has three rules, set out on jsonlines.org: the file is UTF-8 without a
byte-order mark, every line is a valid JSON value, and lines end with \n. A value may be anything, so null is a
record, but a blank line is not. A \r before the \n does no harm, because a parser ignores white space around a
value. A line ending after the last value is strongly recommended, not required; the extension is .jsonl, and the
site calls the media type application/jsonl not yet standardised. NDJSON is the same idea under another name: its
1.0.0 specification suggests application/x-ndjson and .ndjson, and lets a parser skip empty lines only if the
parser’s documentation says that it does.
One value per line buys four things a single JSON array lacks. A writer appends a record without touching the rest of
the file, where an array would need its closing ] moved. A reader holds one line in memory at a time. A big file can
be split at any line ending for parallel work. And a damaged line costs one record, not the file. Writing it takes one
rule, one record per json.dumps or JSON.stringify, with no indentation:
Python · lines/write_jsonl.py
# Write JSON Lines: one json.dumps per record, without indent, and a line ending after each. A line break inside a
# string is written as \n, so no record can spill onto a second line, and a new record is simply appended.
import json
def line_of(record):
return json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
records = [
{"id": "BK-10046", "note": "gift wrap\nno invoice"},
{"id": "BK-10047", "note": "पुणे शाखा"},
]
text = "".join(line_of(r) for r in records)
print(f"{len(records)} records, {text.count(chr(10))} lines:")
print(text, end="")
text += line_of({"id": "BK-10048", "note": None}) # appending rewrites nothing
print(f"after appending BK-10048: {text.count(chr(10))} lines")
# The mistake: indent= spreads every record over several lines, and no line parses on its own.
spread = "".join(json.dumps(r, ensure_ascii=False, indent=2) + "\n" for r in records)
lines = spread.splitlines()
parsed = 0
for line in lines:
try:
json.loads(line)
parsed += 1
except json.JSONDecodeError:
pass
print(f"with indent=2: {len(lines)} lines, {parsed} of them parse on their own") Output
2 records, 2 lines:
{"id":"BK-10046","note":"gift wrap\nno invoice"}
{"id":"BK-10047","note":"पुणे शाखा"}
after appending BK-10048: 3 lines
with indent=2: 8 lines, 0 of them parse on their own
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 write_jsonl.py
JavaScript · lines/write_jsonl.mjs
// Write JSON Lines: one JSON.stringify per record, without a space argument, and a line ending after each. A line break
// inside a string is written as \n, so no record can spill onto a second line, and a new record is simply appended.
const lineOf = (record) => `${JSON.stringify(record)}\n`;
const records = [
{ id: 'BK-10046', note: 'gift wrap\nno invoice' },
{ id: 'BK-10047', note: 'पुणे शाखा' },
];
let text = records.map(lineOf).join('');
const count = (s) => s.split('\n').length - 1;
console.log(`${records.length} records, ${count(text)} lines:`);
console.log(text.slice(0, -1));
text += lineOf({ id: 'BK-10048', note: null }); // appending rewrites nothing
console.log(`after appending BK-10048: ${count(text)} lines`);
// The mistake: a space argument spreads every record over several lines, and no line parses on its own.
const spread = records.map((r) => `${JSON.stringify(r, null, 2)}\n`).join('');
const lines = spread.split('\n').slice(0, -1);
let parsed = 0;
for (const line of lines) {
try {
JSON.parse(line);
parsed += 1;
} catch {
// not a whole JSON text
}
}
console.log(`with a space argument of 2: ${lines.length} lines, ${parsed} of them parse on their own`); Output
2 records, 2 lines:
{"id":"BK-10046","note":"gift wrap\nno invoice"}
{"id":"BK-10047","note":"पुणे शाखा"}
after appending BK-10048: 3 lines
with a space argument of 2: 8 lines, 0 of them parse on their own
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node write_jsonl.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The line break inside the first note came out as the two characters \n, so the record stayed on one line, and the
Devanagari note went out as UTF-8 text. Appending BK-10048 added a third line and changed nothing above it. With
indentation, the two records took eight lines and none of them parses alone.
Reading follows the same rule: take a line, parse that line alone, and either keep the record or note the line number. Step through the reader on the export above, then run it:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- Line 1. The reader takes line 1, the order BK-10041, and nothing more. The rest of the file is still unread, so memory holds one line at a time, however long the export is.
- Line 1 parses. The line is not blank, and it holds one whole JSON object, so the parser returns it.
- Kept. The record is kept. The counts now read one kept and none reported.
- Line 2. Back to the top for line 2, the order BK-10042. It parses as well, so two records are kept.
- Line 3 is blank. Line 3 is empty. In JSON Lines every line holds a JSON value, so the reader reports line 3 and moves on without calling the parser.
- Line 4 fails. Line 4, the order BK-10043, ends its object with a comma before the closing brace, which JSON refuses. The parser fails for this line alone, and the reader records line 4 with the parser's message.
- Line 5. Line 5, the order BK-10044, parses: three records are kept.
- Line 6 was cut off. Line 6 stops after "amount_paise": 24, with no closing brace and no line ending: the export was cut off. The parser fails, and the missing line ending is the clue worth reporting with it.
- The end of the file. Three records are kept and three lines reported, and the total uses only the kept records: 177,800 paise. Parsed as one JSON text, the same file fails at once, because it holds more than one value.
Python · lines/read_jsonl.py
# Read a JSON Lines export one line at a time: keep every good record and report each bad line by its number.
import json
good = []
bad = []
with open("orders.jsonl", encoding="utf-8") as export:
for number, line in enumerate(export, start=1):
text = line.rstrip("\n")
if not text.strip():
bad.append((number, "blank line: JSON Lines has no empty records"))
continue
try:
good.append(json.loads(text))
except json.JSONDecodeError as e:
problem = f"{e.msg} (column {e.colno})"
if not line.endswith("\n"):
problem += "; the file ends here without a line ending, so it may be cut off"
bad.append((number, problem))
for number, problem in bad:
print(f"line {number}: {problem}")
print(f"kept {len(good)} records, reported {len(bad)} lines")
print("kept:", ", ".join(record["id"] for record in good))
print("paise in the kept records:", sum(record["amount_paise"] for record in good)) Output
line 3: blank line: JSON Lines has no empty records line 4: Illegal trailing comma before end of object (column 61) line 6: Expecting ',' delimiter (column 56); the file ends here without a line ending, so it may be cut off kept 3 records, reported 3 lines kept: BK-10041, BK-10042, BK-10044 paise in the kept records: 177800
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 read_jsonl.py
JavaScript · lines/read_jsonl.mjs
// The same export as orders.jsonl, as one string: a page gets it from fetch() or a file input. Each line is parsed
// on its own, so one bad line costs one record, never the file.
const text = [
'{"id": "BK-10041", "branch": "Pune", "amount_paise": 59900}',
'{"id": "BK-10042", "branch": "Pune", "amount_paise": 37900}',
'',
'{"id": "BK-10043", "branch": "Nagpur", "amount_paise": 12500,}',
'{"id": "BK-10044", "branch": "Nashik", "amount_paise": 80000}',
'{"id": "BK-10045", "branch": "Pune", "amount_paise": 24',
].join('\n');
const lines = text.split('\n');
// A file that ends with a line ending, as JSON Lines recommends, splits into one empty string after the last record.
if (lines.at(-1) === '') lines.pop();
const good = [];
const bad = [];
lines.forEach((line, i) => {
const number = i + 1;
if (line.trim() === '') {
bad.push([number, 'blank line: JSON Lines has no empty records']);
return;
}
try {
good.push(JSON.parse(line));
} catch (e) {
bad.push([number, `${e.name}: ${e.message}`]);
}
});
for (const [number, problem] of bad) console.log(`line ${number}: ${problem}`);
console.log(`kept ${good.length} records, reported ${bad.length} lines`);
console.log('kept:', good.map((record) => record.id).join(', '));
console.log('paise in the kept records:', good.reduce((sum, record) => sum + record.amount_paise, 0)); Output
line 3: blank line: JSON Lines has no empty records line 4: SyntaxError: Expected double-quoted property name in JSON at position 61 (line 1 column 62) line 6: SyntaxError: Expected ',' or '}' after property value in JSON at position 55 (line 1 column 56) kept 3 records, reported 3 lines kept: BK-10041, BK-10042, BK-10044 paise in the kept records: 177800
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node read_jsonl.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Version note
The Run button runs JavaScript in QuickJS, which reports lines 4 and 6 in words of its own. The line numbers, the three kept orders and the total are the same; no specification fixes the wording of a parser’s errors.
In your browser, QuickJS (quickjs-emscripten 0.32.0) prints:
line 3: blank line: JSON Lines has no empty records line 4: SyntaxError: expecting property name line 6: SyntaxError: expecting '}' kept 3 records, reported 3 lines kept: BK-10041, BK-10042, BK-10044 paise in the kept records: 177800
Both readers kept BK-10041, BK-10042 and BK-10044, 177,800 paise between them, and reported lines 3, 4 and 6. Python
names the column of each problem, and its reader adds the clue that line 6 has no line ending, the mark of an export
that stopped mid-record. The JavaScript version drops the empty piece that split("\n") leaves after a final line
ending, which the recommended layout always produces.
Pretty-printing records into a JSON Lines file
Wrong: json.dumps(record, indent=2) or JSON.stringify(record, null, 2) for each record, which spreads it over
several lines that no line reader can parse. Right: write each record with no indentation, as one line, and
pretty-print only when you show a record to a person.
Checkpoint
Check: JSON Lines, one value per line
JSON text sequences recover from a cut-off record
RFC 7464, a Proposed Standard, defines JSON text sequence: The format of RFC 7464, media type application/json-seq: each JSON text is written after the record separator character U+001E and before a line feed, so a reader can start again after a damaged record., media type
application/json-seq. Every JSON text is written after the record separator character, RS (U+001E), and before a
line feed. A reader splits the stream at RS and parses each piece; when a piece fails, the RFC says to go on with the
rest, so a damaged record never takes its neighbours with it.
The line feed has a second job. A number, true, false or null does not show where it ends: a record 12 may be
the start of 1234 that was cut off. RFC 7464 therefore says a reader must drop such a value unless white space
follows it. The program writes two records, then reads a stream in which a writer stopped in the middle of record 2,
the next writer appended record 3, and the stream ended after 12:
Python · seq/json_seq.py
# RFC 7464 JSON text sequences: RS (U+001E) before every JSON text and LF after it. A reader splits at RS, so a
# damaged record costs only itself, and the LF shows whether a number at the end was cut off.
import json
RS, LF = "\x1e", "\n"
def show(text):
"""The stream with its control characters made visible."""
return text.replace(RS, "<RS>").replace(LF, "<LF>")
def write_seq(values):
return "".join(RS + json.dumps(v, separators=(",", ":")) + LF for v in values)
def read_seq(text):
kept, warnings = [], []
# Text before the first RS belongs to no record; RS twice in a row leaves an empty piece, which is no record.
pieces = [p for p in text.split(RS)[1:] if p]
for number, piece in enumerate(pieces, start=1):
try:
value = json.loads(piece)
except json.JSONDecodeError:
warnings.append(f"record {number}: {show(piece)} is not a whole JSON text, skipped")
continue
# Numbers, true, false and null do not show where they end, so whitespace must follow them (RFC 7464, 2.4).
if not isinstance(value, (dict, list, str)) and not piece[-1].isspace():
warnings.append(f"record {number}: {show(piece)} has nothing after it and may be cut off, dropped")
continue
kept.append(value)
return kept, warnings
stream = write_seq([{"id": "BK-10041", "qty": 2}, {"id": "BK-10042", "qty": 1}])
print("written:", show(stream))
print("read back:", json.dumps(read_seq(stream)[0], separators=(",", ":")))
# A writer stopped in the middle of record 2, the next writer appended record 3, and the stream was cut after "12".
damaged = RS + '{"id":"BK-10041","qty":2}' + LF + RS + '{"id":"BK-100' + RS + '{"id":"BK-10043","qty":4}' + LF + RS + "12"
kept, warnings = read_seq(damaged)
for w in warnings:
print(w)
print("kept:", ", ".join(r["id"] for r in kept))
# The same damage in JSON Lines: the cut-off record and the next one share a line, and both are lost.
as_lines = '{"id":"BK-10041","qty":2}\n{"id":"BK-100{"id":"BK-10043","qty":4}\n12'
for number, line in enumerate(as_lines.split("\n"), start=1):
try:
json.loads(line)
print(f"JSON Lines line {number}: parsed")
except json.JSONDecodeError:
print(f"JSON Lines line {number}: not JSON") Output
written: <RS>{"id":"BK-10041","qty":2}<LF><RS>{"id":"BK-10042","qty":1}<LF>
read back: [{"id":"BK-10041","qty":2},{"id":"BK-10042","qty":1}]
record 2: {"id":"BK-100 is not a whole JSON text, skipped
record 4: 12 has nothing after it and may be cut off, dropped
kept: BK-10041, BK-10043
JSON Lines line 1: parsed
JSON Lines line 2: not JSON
JSON Lines line 3: parsed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 json_seq.py
JavaScript · seq/json_seq.mjs
// RFC 7464 JSON text sequences: RS (U+001E) before every JSON text and LF after it. A reader splits at RS, so a
// damaged record costs only itself, and the LF shows whether a number at the end was cut off.
const RS = '\u001e';
const LF = '\n';
/** The stream with its control characters made visible. */
const show = (text) => text.replaceAll(RS, '<RS>').replaceAll(LF, '<LF>');
const writeSeq = (values) => values.map((v) => RS + JSON.stringify(v) + LF).join('');
function readSeq(text) {
const kept = [];
const warnings = [];
// Text before the first RS belongs to no record; RS twice in a row leaves an empty piece, which is no record.
const pieces = text.split(RS).slice(1).filter((p) => p !== '');
pieces.forEach((piece, i) => {
const number = i + 1;
let value;
try {
value = JSON.parse(piece);
} catch {
warnings.push(`record ${number}: ${show(piece)} is not a whole JSON text, skipped`);
return;
}
// Numbers, true, false and null do not show where they end, so whitespace must follow them (RFC 7464, 2.4).
const delimitsItself = typeof value === 'string' || (typeof value === 'object' && value !== null);
if (!delimitsItself && !/\s$/.test(piece)) {
warnings.push(`record ${number}: ${show(piece)} has nothing after it and may be cut off, dropped`);
return;
}
kept.push(value);
});
return { kept, warnings };
}
const stream = writeSeq([{ id: 'BK-10041', qty: 2 }, { id: 'BK-10042', qty: 1 }]);
console.log('written:', show(stream));
console.log('read back:', JSON.stringify(readSeq(stream).kept));
// A writer stopped in the middle of record 2, the next writer appended record 3, and the stream was cut after "12".
const damaged = RS + '{"id":"BK-10041","qty":2}' + LF + RS + '{"id":"BK-100' + RS + '{"id":"BK-10043","qty":4}' + LF + RS + '12';
const { kept, warnings } = readSeq(damaged);
for (const w of warnings) console.log(w);
console.log('kept:', kept.map((r) => r.id).join(', '));
// The same damage in JSON Lines: the cut-off record and the next one share a line, and both are lost.
const asLines = '{"id":"BK-10041","qty":2}\n{"id":"BK-100{"id":"BK-10043","qty":4}\n12';
asLines.split('\n').forEach((line, i) => {
try {
JSON.parse(line);
console.log(`JSON Lines line ${i + 1}: parsed`);
} catch {
console.log(`JSON Lines line ${i + 1}: not JSON`);
}
}); Output
written: <RS>{"id":"BK-10041","qty":2}<LF><RS>{"id":"BK-10042","qty":1}<LF>
read back: [{"id":"BK-10041","qty":2},{"id":"BK-10042","qty":1}]
record 2: {"id":"BK-100 is not a whole JSON text, skipped
record 4: 12 has nothing after it and may be cut off, dropped
kept: BK-10041, BK-10043
JSON Lines line 1: parsed
JSON Lines line 2: not JSON
JSON Lines line 3: parsed
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node json_seq.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The sequence lost only record 2: BK-10043 came through, and the 12 without a line feed was dropped as possibly cut
off. The same damage in JSON Lines did worse. The cut-off record and BK-10043 ended up on one line, which failed, so
both were lost, and the 12 on the last line parsed as a record, wrong data that nothing flagged. The table sums up
where each layout lets a reader start again:
| Format | What marks a record | After a damaged record, the reader resumes at |
|---|---|---|
| JSON Lines and NDJSON | a line ending after it | the next line, unless the damage took the line ending |
| JSON text sequence | RS before it, LF after it | the next RS |
| Values back to back | nothing | nowhere certain: it has lost its place |
Values written back to back
Some loggers and command-line tools write one JSON value after another, with nothing or only white space between them.
json.loads takes exactly one value and refuses the rest as extra data. JSONDecoder.raw_decode parses one value from
the start of a string and returns it with the position where it ended, which is enough to walk the whole text:
# JSON values written back to back, as some loggers and command-line tools print them. json.loads takes exactly one
# value, so it refuses the text; raw_decode parses one value and says where it ended, so a loop takes them all.
import json
text = '{"id": "BK-10041", "qty": 2}{"id": "BK-10042",\n "qty": 1}\n[1, 2] "done" 42'
try:
json.loads(text)
except json.JSONDecodeError as e:
print("json.loads:", e)
decoder = json.JSONDecoder()
print("one call:", decoder.raw_decode(text))
values = []
rest = text
while rest.strip():
rest = rest.lstrip(" \t\r\n") # raw_decode itself skips no leading whitespace
value, end = decoder.raw_decode(rest)
values.append(value)
rest = rest[end:]
print(f"{len(values)} values:", json.dumps(values, separators=(",", ":"))) Output
json.loads: Extra data: line 1 column 29 (char 28)
one call: ({'id': 'BK-10041', 'qty': 2}, 28)
5 values: [{"id":"BK-10041","qty":2},{"id":"BK-10042","qty":1},[1,2],"done",42]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 split_values.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The first call returned the first order and 28, the length of its text, and the loop then took all five values,
including the order that spanned two lines. raw_decode skips no white space before a value, so the loop removes it
first. JavaScript’s JSON.parse also takes exactly one text, as
the syntax lesson of this module recorded, so this kind of input
needs a streaming parser there. When you can, ask the producer for JSON Lines instead.
Checkpoint
Check: Record separators and values back to back
JSONC adds comments, and a safe reader knows where strings are
Visual Studio Code reads its own settings files, such as settings.json, in a mode called JSON with Comments: // to
the end of a line and /* … */ blocks, as in JavaScript. Microsoft’s parser for it, jsonc-parser, sums the format up
as “JSON with JavaScript style comments”. That is JSONC: JSON with JavaScript-style comments, // to the end of a line and /* … */ blocks, used for settings files that people edit. Its draft specification leaves trailing commas optional.. Trailing commas are less settled: the
editor accepts them but shows a warning, and the draft specification on jsonc.org lets parsers support them or not.
The draft recommends the extension .jsonc, and the media type application/jsonc rather than application/json,
so that a strict reader is never handed comments.
Removing comments sounds like one regular expression, but // also appears inside strings, in every https://
address. A reader has to know at each character whether it is inside a string, and the lesson’s reader below does: it
replaces each comment with spaces, keeps line endings so positions stay right, refuses trailing commas unless asked,
and hands the rest to the strict parser.
JavaScript · jsonc/jsonc.mjs
// A small JSONC reader. Comments outside strings become spaces (line endings stay, so positions still point at the
// right line and column), trailing commas are refused unless allowed, and what is left goes to JSON.parse, which
// accepts only strict JSON. Strings are skipped whole, escapes included, so "//" or "/*" inside a string stays text.
const JSON_SPACE = new Set([' ', '\t', '\n', '\r']);
/** "line 6, column 32" for position i of text. */
export function where(text, i) {
const before = text.slice(0, i);
return `line ${before.split('\n').length}, column ${i - before.lastIndexOf('\n')}`;
}
/** Where the string that starts at i (an opening quote) ends: the position after its closing quote. */
function endOfString(text, i) {
let j = i + 1;
while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
return j + 1;
}
/** text with every comment outside strings replaced by spaces, line endings kept. */
export function blankComments(text) {
let out = '';
let i = 0;
while (i < text.length) {
if (text[i] === '"') {
const end = endOfString(text, i);
out += text.slice(i, end);
i = end;
} else if (text.startsWith('//', i)) {
let end = text.indexOf('\n', i);
if (end === -1) end = text.length;
out += ' '.repeat(end - i);
i = end;
} else if (text.startsWith('/*', i)) {
const close = text.indexOf('*/', i + 2);
if (close === -1) throw new SyntaxError(`comment not closed (${where(text, i)})`);
out += text.slice(i, close + 2).replace(/[^\n]/g, ' ');
i = close + 2;
} else {
out += text[i];
i += 1;
}
}
return out;
}
/** Positions of the commas, outside strings, that only white space separates from a closing } or ]. */
export function trailingCommas(text) {
const found = [];
let i = 0;
while (i < text.length) {
if (text[i] === '"') {
i = endOfString(text, i);
continue;
}
if (text[i] === ',') {
let j = i + 1;
while (JSON_SPACE.has(text[j])) j += 1;
if (text[j] === '}' || text[j] === ']') found.push(i);
}
i += 1;
}
return found;
}
/** The value of a JSONC text; trailing commas only with allowTrailingCommas, as jsonc.org leaves them optional. */
export function parseJsonc(text, { allowTrailingCommas = false } = {}) {
let strict = blankComments(text);
const commas = trailingCommas(strict);
if (commas.length && !allowTrailingCommas) throw new SyntaxError(`trailing comma (${where(text, commas[0])})`);
for (const i of commas) strict = `${strict.slice(0, i)} ${strict.slice(i + 1)}`;
return JSON.parse(strict);
} Python · jsonc/jsonc.py
# A small JSONC reader. Comments outside strings become spaces (line endings stay, so positions still point at the
# right line and column), trailing commas are refused unless allowed, and what is left goes to json.loads. Strings are
# skipped whole, escapes included, so "//" or "/*" inside a string stays text.
import json
import re
JSON_SPACE = " \t\n\r"
class JsoncError(ValueError):
"""A JSONC text that this reader refuses."""
def refuse_constant(name):
"""json.loads accepts NaN and Infinity unless told otherwise; strict JSON has neither."""
raise JsoncError(f"{name} is not JSON")
def where(text, i):
"""'line 6, column 32' for position i of text."""
before = text[:i]
return f"line {before.count(chr(10)) + 1}, column {i - before.rfind(chr(10))}"
def end_of_string(text, i):
"""Where the string that starts at i (an opening quote) ends: the position after its closing quote."""
j = i + 1
while j < len(text) and text[j] != '"':
j += 2 if text[j] == "\\" else 1
return j + 1
def blank_comments(text):
"""text with every comment outside strings replaced by spaces, line endings kept."""
out = []
i = 0
while i < len(text):
if text[i] == '"':
end = end_of_string(text, i)
out.append(text[i:end])
i = end
elif text.startswith("//", i):
end = text.find("\n", i)
if end == -1:
end = len(text)
out.append(" " * (end - i))
i = end
elif text.startswith("/*", i):
close = text.find("*/", i + 2)
if close == -1:
raise JsoncError(f"comment not closed ({where(text, i)})")
out.append(re.sub(r"[^\n]", " ", text[i : close + 2]))
i = close + 2
else:
out.append(text[i])
i += 1
return "".join(out)
def trailing_commas(text):
"""Positions of the commas, outside strings, that only white space separates from a closing } or ]."""
found = []
i = 0
while i < len(text):
if text[i] == '"':
i = end_of_string(text, i)
continue
if text[i] == ",":
j = i + 1
while j < len(text) and text[j] in JSON_SPACE:
j += 1
if j < len(text) and text[j] in "}]":
found.append(i)
i += 1
return found
def parse_jsonc(text, allow_trailing_commas=False):
"""The value of a JSONC text; trailing commas only when allowed, as jsonc.org leaves them optional."""
strict = blank_comments(text)
commas = trailing_commas(strict)
if commas and not allow_trailing_commas:
raise JsoncError(f"trailing comma ({where(text, commas[0])})")
for i in commas:
strict = strict[:i] + " " + strict[i + 1 :]
return json.loads(strict, parse_constant=refuse_constant) Follow it through the first lines of a settings file:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The first brace. The reader starts outside any string, at the { that opens the settings. Outside strings it copies every character as it is.
- A line comment. Line 2 starts with //, outside a string, so a line comment begins. The reader writes a space for each of its characters, up to the line ending.
- Back outside. The line ending ends the comment. It is copied, not blanked, so line 3 is still line 3 and later messages point at the right line.
- A key. On line 3 a quote opens the string "exportUrl". Inside a string nothing is a comment; the reader copies up to the closing quote, and skips past each backslash with the character after it.
- The URL. The next string is "https://example.com/orders". Its two slashes are part of the URL, so the reader copies them. A regular expression that deletes from // to the end of the line cuts the string here.
- A block comment. Line 4 opens a block comment with /, outside a string. The reader blanks everything up to /, two lines of it, and keeps the line ending between them.
- Outside again. After */ the reader is outside strings again. Every comment is now spaces, every string is as it was, and the text keeps its lines and columns, ready for the strict parser.
JavaScript · jsonc/strip_comments.mjs
// A settings file written in JSONC, read three ways: a regular expression that deletes comments, and the reader of
// jsonc.mjs with trailing commas refused, then allowed.
import { parseJsonc } from './jsonc.mjs';
const settings = `{
// Where the nightly export goes
"exportUrl": "https://example.com/orders",
/* Branches that send orders;
Nashik joins later. */
"branches": ["Pune", "Nagpur",],
"retry": { "times": 3, "pauseMs": 500 } // milliseconds
}`;
// 1. Delete everything from // to the end of a line, and every /* ... */.
const naive = settings.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
console.log('regular expression, line 3 is now:', JSON.stringify(naive.split('\n')[2]));
try {
JSON.parse(naive);
console.log(' JSON.parse accepted it');
} catch {
console.log(' JSON.parse refused it: the URL lost its // and the string its closing quote');
}
// 2. The reader that knows where strings are, with trailing commas refused (the default).
try {
parseJsonc(settings);
} catch (e) {
console.log('reader, trailing commas refused:', e.message);
}
// 3. The same reader with trailing commas allowed: strict JSON comes out, ready for any parser.
const value = parseJsonc(settings, { allowTrailingCommas: true });
console.log('reader, trailing commas allowed:', JSON.stringify(value)); Output
regular expression, line 3 is now: " \"exportUrl\": \"https:"
JSON.parse refused it: the URL lost its // and the string its closing quote
reader, trailing commas refused: trailing comma (line 6, column 32)
reader, trailing commas allowed: {"exportUrl":"https://example.com/orders","branches":["Pune","Nagpur"],"retry":{"times":3,"pauseMs":500}}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node strip_comments.mjs
Python · jsonc/strip_comments.py
# A settings file written in JSONC, read three ways: a regular expression that deletes comments, and the reader of
# jsonc.py with trailing commas refused, then allowed.
import json
import re
from jsonc import JsoncError, parse_jsonc
settings = """{
// Where the nightly export goes
"exportUrl": "https://example.com/orders",
/* Branches that send orders;
Nashik joins later. */
"branches": ["Pune", "Nagpur",],
"retry": { "times": 3, "pauseMs": 500 } // milliseconds
}"""
# 1. Delete everything from // to the end of a line, and every /* ... */.
naive = re.sub(r"/\*.*?\*/", "", re.sub(r"//.*$", "", settings, flags=re.M), flags=re.S)
print("regular expression, line 3 is now:", json.dumps(naive.split("\n")[2]))
try:
json.loads(naive)
print(" json.loads accepted it")
except json.JSONDecodeError:
print(" json.loads refused it: the URL lost its // and the string its closing quote")
# 2. The reader that knows where strings are, with trailing commas refused (the default).
try:
parse_jsonc(settings)
except JsoncError as e:
print("reader, trailing commas refused:", e)
# 3. The same reader with trailing commas allowed: strict JSON comes out, ready for any parser.
value = parse_jsonc(settings, allow_trailing_commas=True)
print("reader, trailing commas allowed:", json.dumps(value, separators=(",", ":"))) Output
regular expression, line 3 is now: " \"exportUrl\": \"https:"
json.loads refused it: the URL lost its // and the string its closing quote
reader, trailing commas refused: trailing comma (line 6, column 32)
reader, trailing commas allowed: {"exportUrl":"https://example.com/orders","branches":["Pune","Nagpur"],"retry":{"times":3,"pauseMs":500}}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 strip_comments.py
Runs on this device, in your browser. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The regular expression cut line 3 down to "exportUrl": "https:, a string with no closing quote, so the strict parser
refused the result. The reader kept the URL whole and stopped at the comma before ] on line 6, column 32, the
position in the original file. Allowed to take trailing commas, it returned the settings as strict JSON, which any
program can read.
Hide a comment marker in a string
Press Edit on the JavaScript tab, change "Nagpur" to "Nagpur // central", and run it again. The reader keeps the
whole name, because the slashes are inside a string; the regular expression would cut it off.
Checkpoint
Check: JSONC comments, read safely
JSON5 takes more syntax from ECMAScript, and only JSON5 parsers read it
JSON5: A superset of JSON with syntax from ECMAScript 5.1: comments, trailing commas, single quotes, unquoted names, hexadecimal numbers, Infinity and NaN. Only JSON5 parsers read it., version 1.0.0 of its specification, is a superset of JSON that adds productions from ECMAScript 5.1. Every JSON text is a JSON5 text; the reverse is far from true. The table lists what it adds:
| JSON5 adds | Example |
|---|---|
| Comments, single-line and multi-line | {"qty": 2 /* boxes */} |
| One trailing comma in objects and arrays | {"qty": 2,} |
| Names written as ECMAScript identifiers | {qty: 2} |
| Single-quoted strings, and strings across lines with an escaped line break | {'sku': 'BK-101'} |
| Hexadecimal numbers | {"mask": 0xFF} |
| A leading or trailing decimal point | {"ratio": .5} |
| Infinity, negative infinity and NaN | {"limit": Infinity} |
| An explicit plus sign | {"delta": +1} |
| More white-space characters, and more escapes in strings | {"code": "\x41"} |
The program runs ten such documents through the strict parser and through the JSONC reader of the last section, with trailing commas allowed:
JavaScript · jsonc/variant_matrix.mjs
// Ten small documents, from strict JSON to features only JSON5 has, through JSON.parse and through the JSONC reader of
// jsonc.mjs (trailing commas allowed). "yes" means the reader returned a value.
import { parseJsonc } from './jsonc.mjs';
const inputs = [
'{"qty": 2}',
'{"qty": 2 /* boxes */}',
'{"qty": 2} // per order',
'{"qty": 2,}',
'{qty: 2}',
"{'sku': 'BK-101'}",
'{"mask": 0xFF}',
'{"ratio": .5}',
'{"limit": Infinity}',
'{"delta": +1}',
];
const accepts = (read, text) => {
try {
read(text);
return 'yes';
} catch {
return 'no';
}
};
console.log(`${'input'.padEnd(26)}${'JSON.parse'.padEnd(12)}JSONC reader`);
for (const text of inputs) {
const strict = accepts(JSON.parse, text);
const jsonc = accepts((t) => parseJsonc(t, { allowTrailingCommas: true }), text);
console.log(`${text.padEnd(26)}${strict.padEnd(12)}${jsonc}`);
} Output
input JSON.parse JSONC reader
{"qty": 2} yes yes
{"qty": 2 /* boxes */} no yes
{"qty": 2} // per order no yes
{"qty": 2,} no yes
{qty: 2} no no
{'sku': 'BK-101'} no no
{"mask": 0xFF} no no
{"ratio": .5} no no
{"limit": Infinity} no no
{"delta": +1} no no
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node variant_matrix.mjs
Python · jsonc/variant_matrix.py
# Ten small documents, from strict JSON to features only JSON5 has, through json.loads and through the JSONC reader of
# jsonc.py (trailing commas allowed). "yes" means the reader returned a value.
import json
from jsonc import parse_jsonc
inputs = [
'{"qty": 2}',
'{"qty": 2 /* boxes */}',
'{"qty": 2} // per order',
'{"qty": 2,}',
"{qty: 2}",
"{'sku': 'BK-101'}",
'{"mask": 0xFF}',
'{"ratio": .5}',
'{"limit": Infinity}',
'{"delta": +1}',
]
def accepts(read, text):
try:
read(text)
return "yes"
except ValueError: # json.JSONDecodeError and JsoncError are both ValueErrors
return "no"
print(f"{'input':<26}{'json.loads':<12}JSONC reader")
for text in inputs:
strict = accepts(json.loads, text)
jsonc = accepts(lambda t: parse_jsonc(t, allow_trailing_commas=True), text)
print(f"{text:<26}{strict:<12}{jsonc}") Output
input json.loads JSONC reader
{"qty": 2} yes yes
{"qty": 2 /* boxes */} no yes
{"qty": 2} // per order no yes
{"qty": 2,} no yes
{qty: 2} no no
{'sku': 'BK-101'} no no
{"mask": 0xFF} no no
{"ratio": .5} no no
{"limit": Infinity} yes no
{"delta": +1} no no
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 variant_matrix.py
Runs on this device, in your browser. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
JSON.parse took only the first row. json.loads also took Infinity, an extension its documentation names, which
is why the Python JSONC reader passes parse_constant and refuses it. The JSONC reader took the two comment rows and
the trailing comma, and refused the six rows that only JSON5 allows. A JSON5 parser accepts all ten, by its
specification, and that is the cost: once a file uses unquoted names or hexadecimal numbers, every tool that opens it
needs a JSON5 parser, and a value such as Infinity has no strict JSON form to convert it to.
Checkpoint
Check: What JSON5 adds
Accept comments where people edit, and pass on strict JSON
The variants exist for people. A settings file that a person edits gains from a note beside a value and from a forgiven trailing comma; a message between two programs gains nothing, and every lenient reader on its path is one more parser that can disagree with the others. So the rule has two halves: accept JSONC or JSON5 only at the boundary where people write files, reading them once, at load time, with a parser made for them; and convert at once, so that everything your program sends on, such as API bodies, messages and logs, is strict JSON.
A request labelled application/json promises RFC 8259 JSON, so an API that quietly accepts comments invites clients
that break every other reader of the same data. Answer the three questions about a file or a message of your own:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Syntax | Notes for people | Any JSON parser reads it | Records one at a time | Easy to edit by hand |
|---|---|---|---|---|
| Strict JSON (RFC 8259) What it is: one value that every JSON parser reads | Poor no comments; a "_note" member is data that every reader sees | Good JSON.parse and json.loads read it, as the first row of the matrix shows | Mixed one array must be read whole, and appending means rewriting its closing ] | Mixed quotes on every name, and one stray comma fails the file |
| JSONC What it is: JSON plus // and /* */ comments | Good // and /* */ comments, as in editor settings files | Poor strict parsers said "no" to the comment and trailing-comma rows; it needs a JSONC reader | Poor one document; no rule for a stream of records | Good comments, and trailing commas where the reader allows them |
| JSON5 What it is: JSONC plus quotes, keys and numbers from ECMAScript | Good the same comments as JSONC | Poor needs a JSON5 parser: the JSONC reader refused all six rows that only JSON5 allows | Poor one document; no rule for a stream of records | Good unquoted names, single quotes, hexadecimal and trailing commas |
| JSON Lines What it is: one strict JSON value on each line | Poor no comments; every line must be a JSON value | Mixed each line is strict JSON, but the file as a whole is not one JSON text | Good one line, one record: append, stream, split; a bad line costs only itself | Poor one long line per record, and no line may wrap |
How the answers decide
- “Who writes it?” Programs only: rules out JSONC and JSON5. Programs leave no notes, and a lenient syntax would only force every reader of their output to carry a lenient parser.
- “Who reads it?” Other services, tools or teams: rules out JSONC and JSON5. Other readers expect application/json, which is strict JSON. Keep comments in your own files and send strict JSON.
- “What does it hold?” Many records, often appended: rules out JSONC and JSON5 and adds a caveat to Strict JSON (RFC 8259). Many records fit JSON Lines, one per line. A single array works, but is read whole and rewritten to append; JSONC and JSON5 have no stream form.
- “What does it hold?” One document: rules out JSON Lines. JSON Lines holds a list of records, not one nested document such as a settings file.
- “Who writes it?” People, in an editor: adds a caveat to Strict JSON (RFC 8259). People can edit strict JSON, but cannot leave a note in it, and one trailing comma fails the whole file.
- “Who writes it?” People, in an editor and “Who reads it?” Only your own program, which loads it at start-up: adds a caveat to JSON5. JSON5 fits, but every tool that opens the file then needs a JSON5 parser; JSONC is the smaller step away from JSON.
In short: JSONC for files people edit and only your program reads; strict JSON for anything another program reads; and
JSON Lines for many records written over time. Some formats called JSON variants change no syntax at all: JSON-LD 1.1,
a W3C Recommendation, gives names their meaning through an @context, and “a JSON-LD document is always a valid JSON
document”, so strict parsers read it as it is.
Key idea
A lenient syntax belongs where people type: read it once with a parser made for it, check it, and hand everything after that point strict JSON.
Checkpoint
Check: Where a lenient syntax belongs
Repair near-JSON in the open, and refuse what was cut off
Near-JSON turns up in logs and in the answers of language models: a Python dict printed with print(), an answer
wrapped in a Markdown code fence with a trailing comma, an answer that stopped mid-string. Repair is worth doing only
under three conditions: change only what is certain, list every change with its place, and check the result with a
strict parser. Text that was cut off is a different case. Closing its string and its brackets would invent the end of
data that never arrived, so the honest result is a refusal that says where it stopped.
# Repair near-JSON in the open: every change is listed with where it was made, the result must pass json.loads, and
# text that was cut off is refused, because closing it would be a guess about data that never arrived.
import json
PYTHON_WORDS = {"True": "true", "False": "false", "None": "null"}
def where(text, i):
before = text[:i]
return f"line {before.count(chr(10)) + 1}, column {i - before.rfind(chr(10))}"
def repair(text):
"""(strict JSON text, list of changes); raises ValueError when the text cannot be repaired without guessing."""
starts = [i for i in (text.find("{"), text.find("[")) if i != -1]
if not starts:
raise ValueError("no object or array in the text")
start = min(starts)
changes = [f"{where(text, 0)}: dropped {start} characters before the JSON"] if start else []
out, stack, i = [], [], start
while i < len(text):
c = text[i]
if c in "\"'": # a string: copy it, turning '...' into "..."
j, body = i + 1, []
while j < len(text) and text[j] != c:
if text[j] == "\\" and j + 1 < len(text):
body.append("'" if c == "'" and text[j + 1] == "'" else text[j : j + 2])
j += 2
else:
body.append('\\"' if text[j] == '"' else text[j])
j += 1
if j == len(text):
raise ValueError(f"the text ends inside a string that starts at {where(text, i)}: it was cut off")
if c == "'":
changes.append(f"{where(text, i)}: single quotes became double quotes")
out.append('"' + "".join(body) + '"')
i = j + 1
continue
word = next((w for w in PYTHON_WORDS if text.startswith(w, i)), None)
if word:
changes.append(f"{where(text, i)}: {word} became {PYTHON_WORDS[word]}")
out.append(PYTHON_WORDS[word])
i += len(word)
continue
if c == ",":
j = i + 1
while j < len(text) and text[j] in " \t\r\n":
j += 1
if j < len(text) and text[j] in "}]":
changes.append(f"{where(text, i)}: dropped a trailing comma")
i += 1
continue
if c in "{[":
stack.append("}" if c == "{" else "]")
elif c in "}]":
if not stack or stack.pop() != c:
raise ValueError(f"{where(text, i)}: {c} closes nothing that is open")
if not stack:
out.append(c)
rest = text[i + 1 :]
if rest.strip():
changes.append(f"{where(text, i + 1)}: dropped {len(rest.strip())} characters after the JSON")
break
out.append(c)
i += 1
if stack:
raise ValueError(f"the text ends with {len(stack)} brackets still open: it was cut off")
result = "".join(out)
json.loads(result) # the result is strict JSON, or this raises
return result, changes
samples = {
"a model's answer in a code fence": 'Here is the order:\n```json\n{"id": "BK-10042", "items": [{"sku": "BK-101", "qty": 2},]}\n```',
"a Python dict printed to a log": "{'id': 'BK-10042', 'gift': True, 'note': None, 'title': 'Pune\\'s pick'}",
"an answer that stopped early": '{"id": "BK-10042", "items": [{"sku": "BK-1',
}
for name, text in samples.items():
print(f"{name}:")
try:
fixed, changes = repair(text)
except ValueError as e:
print(f" not repaired: {e}")
continue
for change in changes:
print(f" {change}")
print(f" result: {fixed}") Output
a model's answer in a code fence:
line 1, column 1: dropped 27 characters before the JSON
line 3, column 57: dropped a trailing comma
line 3, column 60: dropped 3 characters after the JSON
result: {"id": "BK-10042", "items": [{"sku": "BK-101", "qty": 2}]}
a Python dict printed to a log:
line 1, column 2: single quotes became double quotes
line 1, column 8: single quotes became double quotes
line 1, column 20: single quotes became double quotes
line 1, column 28: True became true
line 1, column 34: single quotes became double quotes
line 1, column 42: None became null
line 1, column 48: single quotes became double quotes
line 1, column 57: single quotes became double quotes
result: {"id": "BK-10042", "gift": true, "note": null, "title": "Pune's pick"}
an answer that stopped early:
not repaired: the text ends inside a string that starts at line 1, column 38: it was cut off
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 repair.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The code-fenced answer needed three changes: 27 characters of prose and fence before the JSON, the comma before ],
and the closing fence. The log line needed eight, six strings from single to double quotes, with the escaped
apostrophe in Pune's kept, and True and None turned into true and null. The answer that stopped early was
refused, naming the column where its last string began. Every repaired text then passed json.loads.
Never run near-JSON as code
Wrong: passing a printed dict or a model’s answer to eval() or new Function() because it “looks like” a literal,
which runs whatever the text holds. Right: parse it as data, as the repair above does, and keep it out of anything
that executes code.
Checkpoint
Check: Repairing near-JSON in the open
Practise: validate a JSON Lines export
The exercise asks for the validator that an import job runs first: count the good lines of a 200-line export and name every bad one by its number, without stopping at the first. Its tests include CRLF line endings, a final line ending, values that are not objects and a last line that was cut off:
Exercise · Medium · Python, JavaScript
A JSON Lines validator that names every bad line
Write a validator for the text of a JSON Lines file, validate_jsonl(text) in validate.py and validateJsonl(text) in validate.mjs. It returns how many lines hold a JSON value and every line that does not, with its number:
- Python:
{"good": 189, "bad": [(33, "…"), (40, "blank line"), …]}, the bad lines as(number, message)pairs; - JavaScript:
{ good: 189, bad: [[33, '…'], [40, 'blank line'], …] }.
The rules are those of JSON Lines:
- lines end with
\n, and a\rjust before it belongs to the line ending, not to the record; - a line ending after the last record is allowed, and creates no empty record;
- an empty line, or one of spaces only, is bad, with the message
"blank line"; - a line that is not valid JSON is bad, with a message that says why (the parser's own message will do);
- any JSON value on a line counts as good, an object or not; the first bad line never stops the check.
List the bad lines in the order of their numbers. Your function gets a 200-line file in the tests, so read it line by line instead of parsing the whole text at once.
Python · Starter code · validate.py
import json
def validate_jsonl(text):
"""{"good": number of lines that hold a JSON value, "bad": [(line number, message), ...]} for a JSON Lines text."""
good = 0
bad = []
return {"good": good, "bad": bad} The sample tests · test_validate.py
from validate import validate_jsonl
def export_of_200_lines():
"""Lines 40, 80, ... are blank; lines 33, 66, ... have a trailing comma; every other line is an order."""
lines = []
for n in range(1, 201):
if n % 40 == 0:
lines.append("")
elif n % 33 == 0:
lines.append('{"n": %d,}' % n)
else:
lines.append('{"n": %d, "id": "BK-%d"}' % (n, 10000 + n))
return "\n".join(lines) + "\n"
def test_counts_a_200_line_file():
"""counts 189 good lines and names the 11 bad ones, in order"""
result = validate_jsonl(export_of_200_lines())
assert result["good"] == 189
assert [n for n, _ in result["bad"]] == [33, 40, 66, 80, 99, 120, 132, 160, 165, 198, 200]
def test_messages():
"""says "blank line" for blank lines and gives a reason for the others"""
result = validate_jsonl('{"a": 1}\n \n{"a": 2,}\n')
assert result["bad"][0] == (2, "blank line")
number, message = result["bad"][1]
assert number == 3 and isinstance(message, str) and message
def test_line_endings():
"""accepts CRLF line endings and a final line ending, and a file without one"""
assert validate_jsonl('{"a": 1}\r\n{"a": 2}\r\n') == {"good": 2, "bad": []}
assert validate_jsonl('{"a": 1}\n[2]') == {"good": 2, "bad": []}
def test_any_value_and_a_cut_off_last_line():
"""counts any JSON value as good and reports a last line that was cut off"""
result = validate_jsonl('null\n"text"\n42\n{"id": "BK-10045", "amount_paise": 24')
assert result["good"] == 3
assert [n for n, _ in result["bad"]] == [4]
def test_empty_text():
"""finds nothing in an empty file"""
assert validate_jsonl("") == {"good": 0, "bad": []} JavaScript · Starter code · validate.mjs
/** { good: number of lines that hold a JSON value, bad: [[line number, message], ...] } for a JSON Lines text. */
export function validateJsonl(text) {
const good = 0;
const bad = [];
return { good, bad };
} The sample tests · validate.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { validateJsonl } from './validate.mjs';
/** Lines 40, 80, ... are blank; lines 33, 66, ... have a trailing comma; every other line is an order. */
function exportOf200Lines() {
const lines = [];
for (let n = 1; n <= 200; n++) {
if (n % 40 === 0) lines.push('');
else if (n % 33 === 0) lines.push(`{"n": ${n},}`);
else lines.push(`{"n": ${n}, "id": "BK-${10000 + n}"}`);
}
return `${lines.join('\n')}\n`;
}
test('counts 189 good lines and names the 11 bad ones, in order', () => {
const result = validateJsonl(exportOf200Lines());
assert.equal(result.good, 189);
assert.deepEqual(result.bad.map(([n]) => n), [33, 40, 66, 80, 99, 120, 132, 160, 165, 198, 200]);
});
test('says "blank line" for blank lines and gives a reason for the others', () => {
const result = validateJsonl('{"a": 1}\n \n{"a": 2,}\n');
assert.deepEqual(result.bad[0], [2, 'blank line']);
const [number, message] = result.bad[1];
assert.equal(number, 3);
assert.ok(typeof message === 'string' && message.length > 0, 'a message for line 3');
});
test('accepts CRLF line endings and a final line ending, and a file without one', () => {
assert.deepEqual(validateJsonl('{"a": 1}\r\n{"a": 2}\r\n'), { good: 2, bad: [] });
assert.deepEqual(validateJsonl('{"a": 1}\n[2]'), { good: 2, bad: [] });
});
test('counts any JSON value as good and reports a last line that was cut off', () => {
const result = validateJsonl('null\n"text"\n42\n{"id": "BK-10045", "amount_paise": 24');
assert.equal(result.good, 3);
assert.deepEqual(result.bad.map(([n]) => n), [4]);
});
test('finds nothing in an empty file', () => {
assert.deepEqual(validateJsonl(''), { good: 0, bad: [] });
}); A hint
Split the text at \n and drop the last piece only when it is empty, which is what a final line ending leaves. Number the pieces from 1, remove one \r from the end of each, then check for a blank line before you call the parser, and catch the parser's error for each line on its own (json.JSONDecodeError in Python, SyntaxError in JavaScript).
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then take the questions on all six ideas. The session starts with the idea you have answered least well, and its summary links each idea back to the section that teaches it:
Practice that adapts to you
Practise this lesson
15 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): when would you store records as JSON Lines instead of one JSON array?
When records arrive or are processed one at a time: logs, event streams, exports and batch files. JSON Lines puts one
JSON value on each line, so a writer appends a record without rewriting the file, a reader holds one line in memory at
a time, a large file can be split at any line ending for parallel work, and a damaged line costs one record instead of
the whole file. A JSON array suits one bounded document that is read and written whole, such as a short list in an API
response. Write each record with no indentation, since a line break inside a string is written as \n, and report
bad lines by number instead of stopping at the first.
Two harder questions follow, about an import that runs out of memory and then loses a night of data, and about a feature that parses JSON from a language model. Decide what you would check first, then open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A nightly job reads a 2 GB JSON Lines export with one call to json.load and fails. After a fix it reads line by line, but stops at the first bad line and loses the rest of the night's orders. Redesign the import, and say how you would test it.
-
Mid to senior
A feature asks a language model for an order summary as JSON, and about 3 replies in 100 fail json.loads: code fences, trailing commas, Python's True and None, and now and then a reply cut off mid-string. A teammate proposes a regular expression that cleans every reply. What would you build instead?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is a JSON Lines file?
One whole JSON value on each line, in UTF-8, each line ended by \n. Records are written without indentation and read a line at a time.
-
Why should a JSON Lines reader report a bad record and go on?
A damaged line spoils one record, not the file. Keep the good records and name each bad one by its line number so it can be fixed.
-
How does an RFC 7464 JSON text sequence mark its records?
With the record separator RS (U+001E) in front of each record and a line feed after it. After damage, a reader picks up again at the next RS.
-
What does JSONC add, and how is it read safely?
Comments in // and /* */ form. A safe reader blanks them only outside strings and keeps positions; trailing commas are optional in its draft, so accept them only on purpose.
-
Name four things JSON5 accepts that JSON refuses.
Unquoted names, single-quoted strings, hexadecimal numbers and Infinity or NaN, along with comments and a trailing comma. Only a JSON5 parser reads them.
-
Where do JSONC and JSON5 belong in a system?
In files people edit by hand. Parse them once with the matching parser, check them, and hand strict JSON to everything downstream.
-
When may a program repair near-JSON, such as a model's output?
Only where the fix is certain. Record every change with its place, check the result with a strict parser, and refuse text that was cut off rather than inventing its end.
Key takeaways
- JSON Lines puts one whole JSON value on each line, in UTF-8 and ended by
\n: write each record with no indentation, read and parse one line at a time, and report a bad line by its number instead of losing the file. - RFC 7464 sequences mark each record with RS before it and a line feed after it, so a reader resynchronises at the
next RS and drops a number with nothing after it;
raw_decodesplits values written back to back. - JSONC adds
//and/* */comments, and a safe reader blanks them only outside strings, keeping positions; trailing commas are optional in its draft, so allow them only on purpose. - JSON5 adds ECMAScript syntax such as unquoted names, single quotes, hexadecimal numbers and
Infinity, which only JSON5 parsers read, so accept it, like JSONC, only in files people edit, and send strict JSON everywhere else. - Repair near-JSON only where the fix is certain, list every change with its place, check the result with a strict parser, and refuse text that was cut off rather than inventing its end.
Terms in this lesson
- JSON Lines
- A text format with one JSON value on each line, in UTF-8, every line ended by a line feed; also called JSONL, and close to NDJSON. Each line is parsed on its own, so a file is read one record at a time.
- JSON text sequence
- The format of RFC 7464, media type application/json-seq: each JSON text is written after the record separator character U+001E and before a line feed, so a reader can start again after a damaged record.
- JSON5
- A superset of JSON with syntax from ECMAScript 5.1: comments, trailing commas, single quotes, unquoted names, hexadecimal numbers, Infinity and NaN. Only JSON5 parsers read it.
- JSONC
- JSON with JavaScript-style comments, // to the end of a line and /* … */ blocks, used for settings files that people edit. Its draft specification leaves trailing commas optional.
References
- JSON Lines (JSON Lines)
- NDJSON: Newline Delimited JSON, specification 1.0.0 (ndjson)
- RFC 7464: JavaScript Object Notation (JSON) Text Sequences (IETF)
- RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format (IETF)
- JSONC Specification (draft) (JSONC-org)
- JSON editing in Visual Studio Code: JSON with Comments (Microsoft)
- node-jsonc-parser: scanner and fault-tolerant parser for JSON with comments (Microsoft)
- The JSON5 Data Interchange Format, version 1.0.0 (JSON5)
- JSON-LD 1.1 (W3C)
- json: JSON encoder and decoder (Python 3.14), JSONDecoder.raw_decode and parse_constant (Python Software Foundation)
- JSON.parse() (Mozilla)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress