NDJSON in 2026: when JSON Lines beats a JSON array
If you’ve shipped LLM apps in 2025 or 2026, you’ve shipped NDJSON. The
OpenAI, Anthropic, and Google Gemini streaming APIs all return it.
ClickHouse outputs it as JSONEachRow. Cloud logging pipelines from
Datadog to Vector to Loki ingest it natively. PostgreSQL’s COPY ... TO with format JSON produces it.
It’s the boring, format-of-formats answer for any data shape that’s streamed, logged, or piped between systems — and it’s been quietly displacing JSON arrays in places nobody bothered announcing.
This post covers what NDJSON is, when it beats a regular JSON array, and the patterns that hold up in production.
What NDJSON is
{"id":1,"name":"Alice","email":"alice@example.com"}
{"id":2,"name":"Bob","email":"bob@example.com"}
{"id":3,"name":"Carol","email":"carol@example.com"}
Each line is one independent JSON value, terminated by \n. No outer
array, no commas between records, no trailing punctuation.
Three names for the same thing, slight pedantic differences:
- NDJSON — newline-delimited JSON, the spec at ndjson.org. Strict: each line is a complete JSON value.
- JSON Lines — alias. Same format, used in the jsonlines.org docs. Most code calls them interchangeable.
application/x-ndjson— the MIME type to set on HTTP responses.
If you write the strictest version, you can advertise either name and not get into trouble.
Where it shows up in 2026
| System | Use |
|---|---|
| OpenAI / Anthropic / Gemini streaming APIs | Server-Sent Events with data: lines, each containing one JSON event |
| ClickHouse | FORMAT JSONEachRow for both export and ingest |
| DuckDB | Native read_json_auto reads NDJSON directly |
| PostgreSQL 16+ | COPY ... TO STDOUT (FORMAT json) outputs NDJSON |
MongoDB mongoexport | Default output format |
| Vector / Fluent Bit / Loki | The lingua franca of cloud log shipping |
| Datadog Logs API | Bulk ingest accepts NDJSON |
| Stripe / Plaid bulk exports | Multi-MB customer/transaction dumps |
| Hugging Face datasets | One JSON per example, gigabytes per file |
You can paste any of these into our JSON validator one line at a time to confirm each record is well-formed. Validating the whole stream requires line-by-line parsing — see below.
When NDJSON beats a JSON array
Three properties make it the right pick:
1. You can stream it
A [ {...}, {...}, ... ] array forces the parser to wait for the
closing ] before any value is usable. NDJSON yields one record per
line — your code can process the first record while the last is
still on the wire.
// streaming an OpenAI completion (simplified)
const response = await fetch(url, {
method: "POST",
body: JSON.stringify({ stream: true, /* ... */ }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// Each newline-terminated chunk is a complete JSON value
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
if (!line.trim()) continue;
const event = JSON.parse(line);
handle(event); // your code runs immediately
}
}
A JSON.parse over a giant array would block your event loop for
seconds. NDJSON is non-blocking by construction.
2. Append-only files are trivial
Want to log every API request? Open a file in append mode and write one line per request. No reading, no rewriting:
import json
with open("requests.ndjson", "a") as f:
f.write(json.dumps({"id": req_id, "ts": ts, "user": user_id}) + "\n")
To do the same with a JSON array, you’d need to read the file, parse,
push, re-serialize, write — every time. NDJSON is O(1) per append
where arrays are O(n).
3. Crashes don’t corrupt the file
If your process dies mid-write to a JSON array, you have an unclosed bracket — the file is unparseable. With NDJSON, the worst case is a half-line at the tail. A line-by-line reader skips the corrupt last line and keeps going:
with open("requests.ndjson") as f:
for line in f:
try:
yield json.loads(line)
except json.JSONDecodeError:
continue # skip the partial last line
This is why every modern log format uses it.
When a JSON array still wins
NDJSON isn’t always right:
- Small fixed-size data (config files, API responses < 100 records). The streaming benefit is irrelevant; the array is more readable and the file as a whole has a single root value.
- Strict schema validation of the collection (e.g., “this file must contain at least one user with
role: admin”). Easier with a top-level array your validator can iterate. - Direct loading into tools that expect arrays —
pandas.read_jsonworks on both, but many R / SAS / older Excel paths still want a single document. - Public APIs where developers will copy-paste with
curl | jqand expect to see[ ... ].
For everything streaming, append-only, or larger than ~10 MB, prefer NDJSON.
Production patterns
Reading NDJSON in every common language
// Node.js — readline is built in
import { createReadStream } from "fs";
import { createInterface } from "readline";
const rl = createInterface({ input: createReadStream("data.ndjson") });
for await (const line of rl) {
if (!line.trim()) continue;
const obj = JSON.parse(line);
// ...
}
# Python
import json
with open("data.ndjson") as f:
for line in f:
if not line.strip(): continue
obj = json.loads(line)
# ...
// Go — json.Decoder iterates over a stream
import (
"encoding/json"
"os"
)
f, _ := os.Open("data.ndjson")
defer f.Close()
dec := json.NewDecoder(f)
for dec.More() {
var obj map[string]any
if err := dec.Decode(&obj); err != nil { /* skip */ continue }
// ...
}
// Rust — serde_json::Deserializer::from_reader is zero-config
use std::{fs::File, io::BufReader};
use serde_json::Value;
let f = File::open("data.ndjson")?;
let stream = serde_json::Deserializer::from_reader(BufReader::new(f))
.into_iter::<Value>();
for v in stream {
let obj = v?;
// ...
}
# Shell — jq's -c (compact) and per-line
cat data.ndjson | jq -c 'select(.user != null)' > filtered.ndjson
# Read each line as separate JSON value
while IFS= read -r line; do
echo "$line" | jq '.id'
done < data.ndjson
Writing NDJSON safely
The one rule: don’t put newlines inside the JSON values.
This is fine:
{"name":"Alice","note":"first user"}
This breaks NDJSON readers:
{"name":"Alice","note":"line 1
line 2"}
Most JSON serializers escape newlines as \n by default — JSON.stringify,
Python’s json.dumps, Go’s json.Marshal, all of them. Trouble only
shows up if you hand-construct lines with string concatenation. Don’t
do that.
Compression: always gzip
NDJSON is repetitive (the same field names every line) and compresses
brilliantly. A typical log file shrinks 8–15× with gzip. Most modern
NDJSON consumers (Datadog, ClickHouse, S3 Select) accept .ndjson.gz
directly without explicit decompression.
# Stream NDJSON through gzip on the fly
cat huge.ndjson | gzip -c > huge.ndjson.gz
# Read compressed NDJSON in Node
import { createGunzip } from "zlib";
import { createReadStream } from "fs";
const reader = createInterface({
input: createReadStream("data.ndjson.gz").pipe(createGunzip()),
});
NDJSON in HTTP responses
If you’re serving NDJSON, set the right headers:
Content-Type: application/x-ndjson
Transfer-Encoding: chunked
Cache-Control: no-cache
Don’t set Content-Length — by definition you don’t know the total
size upfront when streaming. The chunked encoding is what lets the
client process records as they arrive.
For Server-Sent Events (SSE), the convention is slightly different —
each NDJSON-like record is wrapped in data: lines:
data: {"event":"token","value":"Hello"}
data: {"event":"token","value":" world"}
data: {"event":"done"}
OpenAI and Anthropic both use this. Strictly speaking it’s not
NDJSON; it’s NDJSON-shaped events inside the SSE protocol. Same
parsing primitives apply — split on the right delimiter, parse each
data: payload as JSON.
Common pitfalls
1. “I’ll just split on \n”
Works in 99% of cases, breaks when JSON values contain escaped
newlines. They shouldn’t (see above), but defensive code uses a real
JSON parser per line — JSON.parse will reject a value with a raw
newline anyway, surfacing the problem.
2. Trailing newline at the end of the file
NDJSON files SHOULD end with a \n. Some readers (jq, Python’s
for line in f) handle either; some (older Java BufferedReader
patterns) don’t. When writing, always emit the trailing newline. When
reading, handle the absence gracefully (skip empty last line).
3. Mixing schemas in one stream
NDJSON allows it (each line is a complete JSON value, no constraint on shape). But consumers often assume a single schema per file. If you must mix, document the discriminator field:
{"type":"event","ts":1234567890,"name":"login"}
{"type":"metric","ts":1234567891,"value":42}
4. Records larger than the line buffer
A single record longer than your readline buffer (default 8 KB in many
implementations) will be truncated. Most modern readers handle
arbitrarily long lines, but check your stack — awk famously doesn’t.
If you’re regularly emitting > 1 MB records, NDJSON might be the wrong choice; consider a length-prefixed format (Protocol Buffers, Cap’n Proto) or chunked HTTP with one record per HTTP body.
Try it
Paste any line from an NDJSON file into our JSON validator. Want to convert an array to NDJSON? Paste it into the formatter, set the indent to “minify (no whitespace)”, then split each top-level element onto its own line.
Further reading
- ndjson.org — the spec
- jsonlines.org — the alias
Server-sent eventsspec — for streaming context- ClickHouse
JSONEachRowreference - DuckDB
read_jsondocs - Our reference: What is JSON?, JSON vs YAML
- Related across the network:
- regex.tooljo.com — line-based pre-filtering of NDJSON benefits from regex before full parse.
- epoch.tooljo.com — NDJSON streams often carry millisecond timestamps; the converter auto-detects format.
- hash.tooljo.com — content-address NDJSON chunks via SHA-256 for streaming dedup.