DevDockTools

When to Use NDJSON Instead of a JSON Array

NDJSON beats a JSON array for streaming, logs, and large datasets you process line by line. Here's the exact decision rule and format.

By Daniel Agrici7 min read
NDJSONJSONstreamingdata formatslogging

A JSON array is the default choice for a list of records, and for most API responses it's the right one. But once you're streaming data, appending to a growing file, or processing a dataset too large to hold in memory, the array's structure works against you. NDJSON — newline-delimited JSON — exists specifically for those cases, and knowing when to reach for it saves real engineering pain.

What NDJSON Actually Is

NDJSON is one JSON value per line, with no enclosing array brackets and no commas between records:

{"id": 1, "event": "signup", "ts": "2026-07-29T08:12:00Z"}
{"id": 2, "event": "login", "ts": "2026-07-29T08:14:03Z"}
{"id": 3, "event": "purchase", "ts": "2026-07-29T08:16:41Z", "amount": 42.50}

Compare that to the same data as a JSON array:

[
  {"id": 1, "event": "signup", "ts": "2026-07-29T08:12:00Z"},
  {"id": 2, "event": "login", "ts": "2026-07-29T08:14:03Z"},
  {"id": 3, "event": "purchase", "ts": "2026-07-29T08:16:41Z", "amount": 42.50}
]

The array is a single parseable document — clean, but it requires the entire structure (opening bracket through closing bracket) to be present and syntactically complete before any parser can extract a single record. NDJSON has no such constraint: each line stands alone.

The Core Decision Rule

Use NDJSON when records are processed independently, one at a time, in a stream. Use a JSON array when the whole collection is a single logical document that's read, parsed, and used as one complete structure.

This maps directly onto a few concrete scenarios:

Streaming API Responses

If an API returns results incrementally — search results as they're found, LLM tokens, live event feeds — NDJSON lets the client start processing the first record before the server has finished sending the last one. A JSON array response can't do this: the client-side parser generally can't safely act on partial array content until the closing ] arrives, because the array isn't valid JSON until then.

// Reading an NDJSON stream incrementally in Node
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const rl = createInterface({ input: createReadStream('events.ndjson') });

for await (const line of rl) {
  if (!line.trim()) continue;
  const record = JSON.parse(line);
  processEvent(record); // acts on each record as it arrives
}

Log Files and Append-Only Data

Application logs are written continuously, one entry at a time, often by multiple processes. NDJSON's structure matches this perfectly — each write is JSON.stringify(record) + '\n', appended to the end of the file, no rewrite of prior content ever required. A JSON array log file would need the closing bracket removed, the new record appended, and the bracket re-added on every single write — expensive and fragile, especially under concurrent writers.

Datasets Larger Than Available Memory

Processing a multi-gigabyte dataset as a JSON array typically means loading and parsing the entire array into memory before you can touch a single element (unless you're using a streaming JSON parser, which adds its own complexity). NDJSON lets you read and process line by line with constant memory overhead regardless of total file size — this is why tools like BigQuery, Elasticsearch's bulk API, and various ML training pipelines default to NDJSON for large exports.

Row-Oriented Data Pipelines

Tools like jq, Unix pipes, and command-line data processing generally work better against NDJSON because each line is independently addressable with standard line-oriented tools (grep, wc -l, split) — none of which understand JSON array boundaries but all of which understand a newline.

# Count records matching a condition — works because each line is independent
cat events.ndjson | jq -c 'select(.event == "purchase")' | wc -l

When a JSON Array Is Still the Right Choice

Small, complete API responses. If an endpoint returns a bounded list — a page of 20 search results, a user's list of 5 saved addresses — the array's single-document simplicity is a feature, not a limitation. There's no streaming benefit to capture at that scale, and a plain array is easier for every client library to consume without special handling.

Data that's genuinely one structure, not independent records. If the list has document-level metadata that wraps the array (pagination info, a total count, a schema version), you're modeling one object with an array property, not a stream of independent records — NDJSON doesn't fit that shape at all:

{
  "page": 1,
  "totalCount": 340,
  "results": [ { "id": 1 }, { "id": 2 } ]
}

Anywhere JSON.parse() needs to work as-is. Standard JSON tooling — browser fetch().json(), most REST client libraries, JSON.parse() — expects a single valid JSON document. Sending NDJSON to an endpoint that doesn't know to split on newlines first just produces a parse error; if your consumer isn't NDJSON-aware, don't hand it NDJSON.

Converting Between the Two Formats

Migrating a JSON array export to NDJSON (or the reverse) is a mechanical transform, but it's easy to get wrong on the edges — a stray trailing comma, an unclosed bracket, or a blank final line. In Node, converting an in-memory array to NDJSON is a map and join:

function arrayToNDJSON(records) {
  return records.map(r => JSON.stringify(r)).join('\n') + '\n';
}

The reverse — NDJSON back into an array — means splitting on newlines and filtering out blank lines before parsing, since a trailing newline at end-of-file (which is the POSIX-correct way to end a text file) would otherwise produce an empty string that fails JSON.parse:

function ndjsonToArray(text) {
  return text
    .split('\n')
    .filter(line => line.trim().length > 0)
    .map(line => JSON.parse(line));
}

Neither direction is lossy — the transform is purely structural — but doing it in bulk on a large file is exactly the case where you don't want to hold both representations in memory at once. For that, stream line-by-line and write out immediately rather than buffering the whole array conversion, following the same pattern as the read loop shown earlier.

Common Pitfalls When Working With NDJSON

Trailing newline ambiguity. Some writers end the file with a final \n after the last record, others don't. A parser that naively assumes every line (including a possible empty final one) is a JSON value will throw on the empty string. Always filter or trim before parsing, as shown above.

Embedded newlines inside a value. NDJSON's line-per-record contract only holds if no individual JSON value contains a literal, unescaped newline. This is guaranteed by JSON.stringify — it always escapes newlines within strings as \n — but it can break if you build NDJSON lines by hand-concatenating strings instead of using a proper serializer, or if a downstream process re-pretty-prints a line before writing it back.

Character encoding. NDJSON files should be UTF-8 without a byte-order mark; a BOM on the first line can cause the first record's parse to fail even though every other line is fine, since the BOM bytes become part of what JSON.parse sees as the first key.

Gzip and streaming don't automatically combine. Compressing an NDJSON file with gzip is common for storage and transfer, but a gzip-compressed file generally has to be fully decompressed (or decompressed via a streaming gzip reader) before you can resume reading line by line — plain NDJSON's "start processing before the file finishes arriving" benefit assumes an uncompressed or chunked-streaming transport, not a single monolithic gzip archive.

Working With NDJSON in Practice

For validating or reformatting individual lines during debugging, run a single line through the JSON Validator or JSON Formatter — since each NDJSON line is valid standalone JSON, standard JSON tooling works fine at the per-line level, it just can't be pointed at the whole file at once.

If you're designing a new export format or API and traffic will be consumed incrementally rather than all at once, default to NDJSON. If it's a small, bounded, single-fetch response, a JSON array remains simpler for every consumer and isn't worth complicating.

Frequently Asked Questions

Is NDJSON valid JSON?
No single NDJSON file is valid as one JSON document — parsing the whole file with JSON.parse() fails because it contains multiple top-level values. Each individual line, however, is a valid, independently parseable JSON value on its own.
What's the difference between NDJSON and JSON Lines (JSONL)?
They're functionally the same concept — one JSON value per line, separated by newlines. NDJSON is the more formally specified name with a defined media type, application/x-ndjson, while JSONL is an informal alias used interchangeably in most tooling and documentation.
Can I append to an NDJSON file safely while it's being read?
Yes, as long as writes are append-only and line-atomic — each write completes a full line, including its trailing newline, before the next write starts. This is a major practical advantage over a JSON array, which cannot be appended to without rewriting the closing bracket and re-parsing the whole structure.