Skip to content
100% in your browser. Nothing you paste is uploaded — all processing runs locally. Read more →

NDJSON in 2026: when JSON Lines beats a JSON array

9 min read #json #ndjson #json-lines #streaming #llm

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:

If you write the strictest version, you can advertise either name and not get into trouble.

Where it shows up in 2026

SystemUse
OpenAI / Anthropic / Gemini streaming APIsServer-Sent Events with data: lines, each containing one JSON event
ClickHouseFORMAT JSONEachRow for both export and ingest
DuckDBNative read_json_auto reads NDJSON directly
PostgreSQL 16+COPY ... TO STDOUT (FORMAT json) outputs NDJSON
MongoDB mongoexportDefault output format
Vector / Fluent Bit / LokiThe lingua franca of cloud log shipping
Datadog Logs APIBulk ingest accepts NDJSON
Stripe / Plaid bulk exportsMulti-MB customer/transaction dumps
Hugging Face datasetsOne 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:

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