Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

orders.jsonl: six lines of an order export JSONL · lines/orders.jsonl
{"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:

Write records as lines, append one, and see what indent does

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

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:

Interactive · stage by stage Reading orders.jsonl one line at a time Step through the six lines of the export. Each line is read, checked and parsed on its own, so one bad line never stops the rest.
Reading orders.jsonl one line at a time, step 9 of 9. Showing Read the next line (line 6: BK-10045), Blank? Report it (line 3 reported), Parse this line alone (line 6: cut off), Keep it, or report it (kept 3 · reported 3).next lineRead the next lineline 6: BK-10045Blank? Report itline 3 reportedParse this line aloneline 6: cut offKeep it, or report itkept 3 · reported 3

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. 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.
  2. Line 1 parses. The line is not blank, and it holds one whole JSON object, so the parser returns it.
  3. Kept. The record is kept. The counts now read one kept and none reported.
  4. Line 2. Back to the top for line 2, the order BK-10042. It parses as well, so two records are kept.
  5. 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.
  6. 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.
  7. Line 5. Line 5, the order BK-10044, parses: three records are kept.
  8. 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.
  9. 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.

Keep the good records, report each bad line by its number

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

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

  1. Question 1 of 2 Which of these lines is a valid record in a JSON Lines file?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: null

    A JSON Lines record is any one JSON value on one line: an object, an array, a string, a number, true, false or null. A blank line is not a value, so a reader reports it, as both readers did for line 3.

  2. Question 2 of 2 A service adds one order a second to an export file. Why does JSON Lines suit it better than one JSON array?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each order is one more line, and nothing already written changes

    An array ends with ], so adding an element means rewriting the end of the file and parsing the whole text to read it. A JSON Lines writer just adds a line, and a reader takes one line at a time.

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:

Write a sequence, then read one with a cut-off record

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

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:

Split values written back to back with raw_decode Python · concat/split_values.py
# 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

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

  1. Question 1 of 2 A JSON text sequence ends with RS followed by 12, and nothing after it. What must the reader do with 12?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Drop it, because a number with nothing after it may have been cut off

    A number does not show where it ends: 12 could be the start of 1234. The line feed after each text is the sign that the record is whole, which is why the program reported record 4 and kept the two orders.

  2. Question 2 of 2 What does this print?

    Read the code, then choose one answer.

    import json
    print(json.JSONDecoder().raw_decode('{"a": 1}{"a": 2}'))
    Show the answer: Question 2 of 2

    Answer: ({'a': 1}, 8)

    raw_decode returns a pair, the value and the index where it ended, so a loop can carry on from that index. The lesson's first call printed the first order and 28, the length of that order's text.

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.

A small JSONC reader on top of 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:

Interactive · step by step Where the JSONC reader is, character by character Step through the first lines of the settings text. The reader is always in one of four places, and only outside strings can a comment begin.
Where the JSONC reader is, character by character, step 7 of 7. Showing Outside strings, Inside a string ("https://example.com/orders"), Line comment ("// Where the nightly …"), Block comment (lines 4 and 5 blanked). In focus: Outside strings." opens and closes//line ending/* … */Outside stringscopies each characterInside a string"https://example.com/orders"Line comment"// Where the nightly …"Block commentlines 4 and 5 blanked

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The first brace. The reader starts outside any string, at the { that opens the settings. Outside strings it copies every character as it is.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.

One settings file read three ways

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

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

  1. Question 1 of 2 The regular expression deleted everything from // to the end of each line of the settings. What did that do to line 3?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It cut the URL after https: and left the string without its closing quote

    A comment can begin only outside strings, so a reader has to track strings, quote by quote, with their escapes. The lesson's reader does, and kept https://example.com/orders whole.

  2. Question 2 of 2 Why does the lesson's JSONC reader turn each comment into spaces instead of deleting it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: So that line and column numbers in any later error still point at the right place in the file

    Blanking keeps every other character where it was, line endings included, so a message from the second pass or from the strict parser names a line and a column a person can find in the editor.

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:

Ten documents through the strict parser and the JSONC reader

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

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

  1. Question 1 of 2 Which of these documents is JSON5 but not JSONC?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: {qty: 2}

    JSONC is JSON plus comments, and trailing commas where allowed. JSON5 goes further: unquoted names, single quotes, hexadecimal numbers, a leading or trailing decimal point, Infinity, NaN and a plus sign.

  2. Question 2 of 2 A JSON5 settings file holds {"limit": Infinity}. Your loader parses it and converts it to strict JSON for other services. What happens to the limit?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing in strict JSON can hold it, so the loader must refuse it or write an agreed value instead

    JSON5's data model is wider than JSON's: it has Infinity and NaN. Converting to strict JSON must refuse such values or map them to something agreed, and the two standard serialisers would silently do something else.

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:

Interactive · answer and compare Which syntax should this file or message accept? Answer three questions about your file or message. Options that cannot work are ruled out, with the reason, and the columns that decide are marked.
Who writes it?
Who reads it?
What does it hold?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Strict JSON, JSONC, JSON5 and JSON Lines against four needs of a file or a message
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

  1. One question A public API takes JSON request bodies. A team asks it to accept comments as well, "like our config files". What should the API do?

    Choose one answer.

    Show the answer: One question

    Answer: Keep accepting only strict JSON, and keep comments in the files people edit

    Lenient syntax is for the place where people type. Messages between programs stay strict, so that every parser on the path reads the same value.

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 three texts, listing every change, and refuse the cut-off one Python · repair/repair.py
# 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

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.

JSON Repair Tool Repair near-JSON of your own in the browser, with every change listed with its line and column.

Checkpoint

Check: Repairing near-JSON in the open

  1. Question 1 of 2 A model's answer ends with {"id": "BK-10042", "items": [{"sku": "BK-1 and nothing more. What should a repair step do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Refuse it and say where it stopped, because closing it would invent data

    A repair is safe only when the right result is certain. The end of a cut-off answer never arrived, so the step should ask for it again instead of making it up.

  2. Question 2 of 2 Which of these does a visible repair do?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • List every change with the line and column where it was made
    • Check the repaired text with a strict parser before using it

    A listed repair can be traced afterwards, by a person reading the list or in a log, and a strict parse at the end proves the result is JSON. Guesses are refused, not hidden.

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 \r just 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).

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.

JSON Lines (JSONL / NDJSON) Converter Check a JSON Lines file of your own line by line, with every error at its line and column.

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.

  1. Question 1 of 15 Which of these is one of the rules of JSON Lines?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: Every line is a valid JSON value

    The three rules are UTF-8 text, a valid JSON value on every line, and \n at the end of each line, with a final line ending strongly recommended.

  2. Question 2 of 15 The reader meets a line that is not valid JSON at line 4 of a six-line export. What does it do?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: It records line 4 with the parser's message and goes on with line 5

    Parsing each line alone means each failure belongs to one line. The reader keeps the good records and names the bad lines, so someone can fix or resend exactly those.

  3. Question 3 of 15 A JSON Lines file written on Windows ends every line with \r\n. What happens when the lesson's readers read it?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: Every line parses, because the \r is white space after the value

    CR LF endings do no harm to JSON Lines: Python's text mode turns them into \n, and both parsers skip a \r after a value. The exercise checks that your validator accepts them.

  4. Question 4 of 15 A 4 GB JSON Lines file is to be processed by four workers at once. How do you give each worker its share?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: Cut near each quarter of the file and move each cut forward to the next \n, so every share holds whole lines

    A line ending can never be inside a JSON Lines record, because a line break in a string is written as \n. That makes every line ending a safe place to cut.

  5. Question 5 of 15 What comes before every JSON text in an RFC 7464 JSON text sequence?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: The record separator character, RS (U+001E)

    RS marks where each record starts, so a reader can always find the next record, and the line feed after it shows that the record ended normally.

  6. Question 6 of 15 In the lesson's damaged stream, why did the sequence keep BK-10043 while the JSON Lines version lost it?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: The sequence reader started again at the RS before BK-10043; in JSON Lines the cut-off record had no line ending, so the two shared a line

    A separator before each record means a damaged record cannot swallow the next one. A line ending after each record works too, unless the damage removed the line ending, which is exactly what happens when a writer stops mid-record.

  7. Question 7 of 15 Why does the lesson's loop strip white space before each call to raw_decode?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: raw_decode skips no white space before a value, so a leading space or line break would make it fail

    json.loads skips white space around its value, raw_decode does not, so a loop over back-to-back values strips it itself, then parses from the start of what remains.

  8. Question 8 of 15 What does JSONC add to JSON?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: Comments, // to the end of a line and /* … */ blocks

    JSONC is JSON with comments. Trailing commas are the one unsettled point: the draft lets parsers support them or not, and the editor accepts them with a warning.

  9. Question 9 of 15 The lesson's JSONC reader refuses trailing commas by default. What did it report for the settings file?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: trailing comma (line 6, column 32)

    The reader blanks the comments first, then looks for commas that only white space separates from } or ]. With the option on, it removes them and returns strict JSON.

  10. Question 10 of 15 Which statement about JSON and JSON5 is true?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: Every JSON text is a JSON5 text, but not every JSON5 text is JSON

    A superset accepts everything the smaller format accepts and more. Strict JSON parsers read only the shared part.

  11. Question 11 of 15 Which row of the matrix did json.loads accept and JSON.parse refuse?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: {"limit": Infinity}

    This is why the Python JSONC reader passes parse_constant: without it, Infinity would slip through a reader that is meant to hand on strict JSON.

  12. Question 12 of 15 Which of these should accept JSONC?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: A settings file that people edit and only your service reads, at start-up

    The Tradeoffs figure rules JSONC out as soon as programs write the data or other services read it. It fits files people edit for one reader.

  13. Question 13 of 15 Your service loads a JSONC settings file at start-up and must report its settings to a monitoring service. What should it send?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: Strict JSON written from the parsed settings

    Read the lenient format once, where it is written, and convert it at once. Everything after that point is strict JSON, which every program can read.

  14. Question 14 of 15 Why should a printed Python dict never be passed to eval() to turn it into data?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: eval() runs the text as code, so the text decides what the program does

    Near-JSON is data from outside the program. Parse it as data, with a repair that lists its changes, and never hand it to anything that executes code.

  15. Question 15 of 15 What did the repair make of the logged value 'Pune\'s pick'?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: "Pune's pick", a double-quoted string with a plain apostrophe

    Changing single quotes to double quotes means two more changes inside the string: a double quote in it must be escaped, and an escaped single quote becomes a plain one.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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_decode splits 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.