Skip to main content
Reference · 7 min read

NDJSON: One JSON Document Per Line

Measured: NDJSON is the same size as a minified array, and survives truncation that destroys the array completely — 502 of 503 records recovered.

NDJSON — newline-delimited JSON, also called JSON Lines or JSONL — is exactly what the name says. One complete JSON document per line, no enclosing array, no commas between records:

{"id":1,"name":"John","active":true}
{"id":2,"name":"Sarah","active":false}
{"id":3,"name":"Ahmed","active":true}

There is no specification committee and barely anything to learn. The rules are: each line is a valid JSON value, lines are separated by \n, and no line contains a raw newline — which is automatic, because JSON escapes newlines inside strings as \n anyway.

It costs nothing

The usual first objection is that dropping the array wastes space. It does not. Measured on 1,000 four-field records:

FormatBytes
Pretty-printed array100,172
Minified array70,171
NDJSON70,169

Two bytes smaller than the minified array, because the newlines replace the commas one for one and the outer [ and ] are gone. Whatever you pick, you are choosing on behaviour, not on size.

The difference that matters: partial files

A JSON array is a single value. It is valid only when complete. If a download is interrupted, a disk fills, a log rotates or a process is killed mid-write, the file has no closing bracket and the whole thing is unparseable — not degraded, unparseable.

I truncated both formats at exactly 50% and tried to read them:

FormatResult
Minified arrayJSON.parse throws. 0 records recovered.
NDJSON502 of 503 lines parsed. Only the final, half-written line was lost.

It streams, which an array does not

A 2 GB array has to be fully parsed before you can read the first record, so peak memory is the size of the file plus the parsed structure. NDJSON is processed a line at a time with memory proportional to the largest single record:

import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const lines = createInterface({
  input: createReadStream('events.ndjson'),
  crlfDelay: Infinity,
});

for await (const line of lines) {
  if (!line.trim()) continue;        // tolerate blank lines
  const record = JSON.parse(line);
  // ... one record in memory at a time
}

The same property makes it appendable. Adding a record is opening the file in append mode and writing one line — no read, no rewrite of a closing bracket, and concurrent appends of lines under the pipe buffer size do not interleave. Appending to a JSON array means rewriting the end of the file every time.

Working with it on the command line

# Array -> NDJSON
jq -c '.[]' records.json > records.ndjson

# NDJSON -> array, when something downstream insists
jq -s '.' records.ndjson > records.json

# Filter without loading the file
jq -c 'select(.active == true)' events.ndjson

# Count, peek, sample — ordinary line tools just work
wc -l events.ndjson
head -3 events.ndjson
shuf -n 100 events.ndjson

That last group is underrated. Because each record is a line, the entire Unix toolkit applies: grep, head, tail, split, sort, wc, shuf. None of that works on a pretty-printed array, where a single record spans many lines and grep returns fragments.

Where you already use it

  • Log pipelines — structured logging writes one JSON object per line, which is why jq is on every ops machine.
  • BigQuery, Redshift, Snowflake bulk loads take newline-delimited JSON, not arrays.
  • Streaming APIs — the OpenAI and Anthropic streaming responses, Docker build output, kubectl --watch.
  • Machine learning datasets — JSONL is the near-universal format for training and eval data.
  • Elasticsearch bulk indexing.

When the array is still right

NDJSON is not a universal replacement. Use a plain JSON document when:

  • The response needs metadata around the records — pagination cursors, totals, a status envelope. That is an object, and it has to be one document.
  • The consumer is a browser calling response.json(). Fetching NDJSON means reading the stream and splitting it yourself.
  • The data is genuinely a single structure rather than a sequence of records — a config file is not a stream.
  • It is small. For fifty records, nothing here matters.

To inspect NDJSON in the viewer or convert it with the CSV exporter, wrap it into an array first with jq -s .. Both expect one document, and one document is what that gives them.