json
NDJSON Is Not a JSON Array With Extra Newlines
Newline-delimited JSON has three rules and three classic ways to break them: a UTF-8 BOM glued to the first token, CRLF line endings from Windows exports, and the blank-line question the two specs answer differently. The mechanics of each failure, and why per-line error isolation is the format's real selling point.
Your export job writes one JSON object per line, the consumer on the other end chokes on line 1 with Unexpected token, and you open the file to find… perfectly valid JSON on every line. So you conclude NDJSON is flaky. It isn’t — NDJSON is a different format from JSON with different parsing rules, and the bytes that break it are exactly the bytes your editor doesn’t show you: a byte order mark, a carriage return, a blank line. A JSON array is one value parsed in one shot; NDJSON is a sequence of independent parses with a byte-level framing contract between them. Treat it as “JSON with the brackets shaved off” and the framing bytes will eventually bite you.
Every failure mode in this post reduces to that framing contract, so let’s start with the contract itself.
The rules: there are only three
Newline-delimited JSON goes by two names — NDJSON (.ndjson) and JSON Lines (.jsonl) — with a spec for each. They agree on the core:
| Rule | What it means |
|---|---|
| One complete JSON value per line | Each line must parse on its own — usually an object, but null, 42, or "hi" are legal lines. No value may span lines. |
| UTF-8, no BOM | JSON Lines states a byte order mark “must NOT be included.” |
\n between values | The newline is a delimiter between records, not part of any record — which is why no string inside a value may contain a raw newline (escape it as \n). |
An honest note on the two names, because their homes have diverged: jsonlines.org still serves the JSON Lines spec, while ndjson.org — historically the NDJSON spec’s home — has lapsed and now serves unrelated content; the NDJSON spec text lives on in the ndjson/ndjson-spec GitHub repo. The two documents differ in exactly the corners this post is about: NDJSON says parsers “MAY silently ignore empty lines” and MUST accept \r\n as a line delimiter; JSON Lines says flatly that “a blank line is not” a valid value, and merely observes that \r\n happens to work because JSON parsing ignores surrounding whitespace. Same format, different answers at the edges — and real files live at the edges.
Also worth internalizing: a trailing newline after the last record is optional. JSON Lines recommends one (“makes generating and concatenating JSON Lines files easier” — concatenating two files without it splices the last record of one onto the first record of the other), but a file without it is equally valid. Any consumer that errors on the final newline — or on its absence — is wrong.
Why you can’t just wrap it in brackets
The tempting shortcut when a tool wants a JSON array: slap [ on top, ] on the bottom, commas in between, parse the whole thing. Two mechanisms argue against it.
One bad line poisons the whole parse. JSON.parse on a wrapped 500,000-line file with a mangled record on line 372,190 gives you one error, a character offset into a multi-hundred-megabyte string, and zero records — the 499,999 good lines are collateral damage of the single parse. Parsed line-by-line, the same file yields 499,999 good records plus one precise, line-numbered error. This per-line error isolation is the format’s real selling point — more than any throughput claim. Logs, event streams, and scraped data are exactly the kinds of data where a few malformed records are a fact of life, and NDJSON is the format that lets you quarantine them instead of losing the batch.
The array must fit in memory; the lines don’t. A JSON array is only known valid at the closing ], so a strict parser holds the entire document. NDJSON’s frames are self-delimiting: read a line, parse it, handle it, drop it. That’s what makes it streamable and appendable — a producer can >> one more record onto the file without rewriting anything, which no JSON array allows. (Note the claim here is incremental processing, not “faster parsing” — the same bytes get parsed either way; what changes is how much you must hold and when you can start.)
The flip side: when a downstream tool genuinely requires an array, convert with validation, not with brackets and string-glue. Our NDJSON formatter’s “To JSON array” mode parses every line first and refuses with a line-numbered error if any line is bad — so the array you hand off is guaranteed parseable, or you learn exactly which line isn’t.
Failure mode 1: the invisible first character
A UTF-8 byte order mark is the character U+FEFF encoded as three bytes (EF BB BF) at the start of a file. Several Windows editors and export paths prepend one; it renders as nothing at all. But it is not JSON whitespace, so it attaches to the first token of line 1, and the parse dies before it begins — Node 22 reports:
SyntaxError: Unexpected token '', "{"a":1}" is not valid JSON
An “unexpected token” you can’t see, at position 0, on a line that looks immaculate — this error has burned hours for a lot of people. The tell is that only line 1 fails while every other line parses, since the BOM exists once, at the front of the file.
The JSON Lines spec bans the BOM outright, but banning it doesn’t stop Excel-adjacent tooling from emitting it, so robust readers strip it defensively. Ours does: the parser behind the NDJSON formatter checks whether the first code unit is U+FEFF and drops it before any line ever reaches JSON.parse — a BOM-prefixed file simply validates clean instead of failing on line 1.
Failure mode 2: CRLF, the bug that hides until a blank line
Windows-authored exports end lines with \r\n. Naively split on '\n' and every line keeps a trailing \r. Here’s the part that makes this failure mode sneaky: that alone usually doesn’t break anything, because \r is legal insignificant whitespace in JSON — JSON.parse('{"a":1}\r') succeeds. So the CRLF bug passes the happy-path test and ships.
Then it detonates somewhere else:
- A “blank” line isn’t blank anymore. In a CRLF file, an empty line splits to the one-character string
"\r". The classic guardif (line === '') continuedoesn’t match it,\rfalls through to the parser, andJSON.parse('\r')throwsUnexpected end of JSON input— on a line that looks empty. The same thing happens on the file’s final line when it ends...}\r\n. - Byte-exact consumers disagree with you. Anything that hashes, deduplicates, or string-compares lines sees
{"a":1}\rand{"a":1}as different records. - Strict emitters are violating spec. The NDJSON spec requires parsers to accept
\r\n, but also says the JSON texts themselves “MUST NOT contain newlines or carriage returns” — the\rbelongs to the delimiter, never to the record.
The fix is to treat CRLF at the split, not the parse: split on /\r\n|\n/ so the \r is consumed as part of the delimiter and never reaches JSON.parse at all. That’s exactly what our parser does, which is why a CRLF file, a Unix file, and a file that mixes both validate identically in the NDJSON formatter.
Failure mode 3: blank lines, where the two specs disagree
Split any file with a trailing newline on \n and you get a phantom final element: the empty string. Files hand-edited or machine-concatenated often carry interior blank lines too. What should a parser do?
The two specs give different answers — JSON Lines: a blank line “is not” a valid value; NDJSON: parsers “MAY silently ignore empty lines,” and must document that they do. Which means a file with a stray blank line can be rejected by one strictly-conforming reader and accepted by another, and both are following their spec. In practice the NDJSON stance is the one that survives contact with real pipelines, because trailing newlines are everywhere and tail -f-style producers emit them constantly.
Our implementation takes the NDJSON position, documented: blank and whitespace-only lines — including the empty final “line” a trailing newline produces — are skipped, not errors, and skipped lines simply don’t appear in the per-line results (line numbers still count them, so reported errors match what your editor shows). A file of three records and a trailing newline validates as exactly 3 valid lines, never “3 valid, 1 invalid.”
If you’re emitting NDJSON, though, hold yourself to the stricter standard: no blank lines, one \n after every record. You don’t control which reader is on the other end.
Who actually consumes this format
The reason these framing details matter is that NDJSON is quietly the lingua franca of line-oriented data plumbing:
- jq and Unix pipelines. jq natively processes a stream of JSON values, so NDJSON flows straight through it —
jq -c '.[]'is also the canonical one-liner for exploding a JSON array into NDJSON.grep,wc -l,head,splitall work on NDJSON precisely because one record is one line. - Log pipelines. Structured application logs are NDJSON by construction: each event appended as one line, shippable and tailable without any parser state.
- Bulk ingestion APIs. Elasticsearch’s
_bulkendpoint takes its actions and documents as newline-delimited JSON, and OpenAI’s Batch API takes its requests as.jsonlfiles — in both cases because per-line framing lets the server process and error-report per record, which is error isolation again, this time on someone else’s infrastructure. (Both have their own size and count rules — check their current docs rather than any blog post, this one included.)
The five-minute triage
When an NDJSON file misbehaves, the debugging order that matches the failure modes above:
- Paste it into the NDJSON formatter in Validate mode. You get a valid/invalid count and a line-numbered list of every failing line with the parser’s actual error. BOM, CRLF, and blank-line artifacts are already neutralized by the parser, so any error you see is a real malformed record, not framing noise — and clean input comes back re-serialized one compact value per line.
- One line failing and you can’t see why? Copy that single line into the JSON formatter — a single-value parse with pretty-printing makes truncated strings and mismatched braces visible fast.
- Need a different shape downstream? “To JSON array” and “From JSON array” convert in either direction with full validation, and once you have the array form, JSON to CSV gets it into a spreadsheet.
Related edge-case tours: CSV to Markdown pitfalls for the delimiter-format equivalent of this post, and JSON to Pydantic gotchas for what happens after your NDJSON parses and you want types.
The short version
| The assumption | The mechanism |
|---|---|
| ”NDJSON is a JSON array minus brackets” | It’s independent per-line parses over a byte-level framing contract: one value per line, UTF-8, \n delimiter |
| ”Line 1 is valid JSON but won’t parse” | Invisible UTF-8 BOM attached to the first token — Unexpected token at position 0; strip U+FEFF before parsing |
| ”CRLF works fine, I tested it” | \r is JSON whitespace so values forgive it — until a blank or final line becomes a bare "\r" and throws; split on `/\r\n |
| ”A trailing newline means a broken empty record” | Trailing newline is optional (and recommended by JSON Lines); skip empty lines when reading, never emit them |
| ”Blank lines are legal / illegal” | Depends which spec: JSON Lines forbids them, NDJSON lets parsers skip them — so read leniently, write strictly |
”Just wrap it in [ ] and parse once” | One bad line then kills all 500,000 — per-line parsing quarantines it with a line number, which is the whole point of the format |