JSON Lines and NDJSON: Streaming Data
Bottom line: One JSON value per line buys you appendability, streaming, and damage isolated to a single line; the cost is the absence of overall structure, so validation has to happen line by line.

Bottom line: use a JSON array when you process a whole batch at once, and JSON Lines when you need streaming appends and bad-line isolation; if you still need to validate the overall structure, do not switch to a line-delimited format.
Once the data exceeds memory, or keeps arriving continuously, the "one big array" structure becomes the enemy.
1. Format rules
JSON Lines (.jsonl) and NDJSON (.ndjson) are practically synonyms, and the rules are short in both cases:
- Encoding is UTF-8, without a BOM;
- Each line holds one complete, valid JSON value;
- Lines are separated by
\n; the final line may or may not end with a newline; - No unescaped newline may appear within a line — a newline inside a string must be written as
\n; - Empty lines are usually ignored, but a strict implementation should treat them as invalid.
{"time":"2026-10-30T10:41:45+08:00","level":"info","message":"boot"}
{"time":"2026-10-30T10:41:46+08:00","level":"warn","message":"slow query","costMs":812}
{"time":"2026-10-30T10:41:47+08:00","level":"error","message":"timeout","traceId":"a1b2"}
The subtle difference between the two: the JSON Lines website emphasizes "one value per line, and the value can be any JSON (including scalars)", while the NDJSON specification adds stricter implementation rules such as "no empty lines and no top-level scalars". In engineering, following the stricter set keeps you out of trouble.
2. Comparison with a JSON array
| Dimension | JSON array | JSON Lines |
|---|---|---|
| Append one record | Rewrite the whole file (change the closing ]) |
Just append one line |
| Streaming read | Needs a streaming parser that tracks bracket depth | Split by line |
| One corrupt entry | The whole file becomes unparsable | Only that line is affected |
| Whole-file validation | Validate everything in one pass | Only line-by-line |
| Compression | Similar size after gzip | Same, and blocks can be compressed in parallel |
| Expressing nesting | Natively supported | Each line can nest internally; lines are unrelated to each other |
| Typical consumers | All JSON tools | Log systems, big-data pipelines, LLM training sets |
The conclusion is direct: whether the data is "a batch of records" or "one document" decides which format to use.
3. Reading: line-by-line streaming
In Node, the simplest approach is readline:
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
/**
* Reads a JSON Lines file line by line and invokes a callback for each record.
* @param filePath Path to the file
* @param onRecord Handler for a single record
* @returns Statistics containing the number of successful and failed lines
*/
async function readJsonLines(
filePath: string,
onRecord: (record: unknown, lineNumber: number) => void,
): Promise<{ okCount: number; badCount: number }> {
const lineReader = createInterface({
input: createReadStream(filePath, { encoding: 'utf8' }),
crlfDelay: Infinity,
});
let okCount = 0;
let badCount = 0;
for await (const rawLine of lineReader) {
const line = rawLine.trim();
if (!line) {
continue;
}
try {
onRecord(JSON.parse(line), okCount + badCount + 1);
okCount += 1;
} catch {
badCount += 1;
}
}
return { okCount, badCount };
}
The memory usage of this code depends only on the size of a single line, not on the total file size — that is the greatest value of a line-delimited format.
3.1 When you only need a few fields, don't JSON.parse first
If each record has 50 fields and you only need 3, JSON.parse builds objects for all 50 of them.
A streaming parser (stream-json, clarinet) can extract only the target fields, at the cost of higher implementation complexity.
The rule of thumb: when a single record is under a few hundred KB, calling JSON.parse directly is usually still the better deal.
3.2 Chinese text and BOMs
.jsonl files generated on Windows often carry a BOM, and JSON.parse on the first line is guaranteed to fail. Strip the BOM before reading, or inspect the first three bytes while streaming the first chunk.
4. Writing: appends and atomicity
import { appendFile } from 'node:fs/promises';
/**
* Appends a single JSON Lines record.
* @param filePath Target file
* @param record The record object to write
* @returns A promise that resolves when the write completes
*/
async function appendRecord(filePath: string, record: unknown): Promise<void> {
const line = `${JSON.stringify(record)}\n`;
await appendFile(filePath, line, { encoding: 'utf8' });
}
Two things to note:
JSON.stringifyoutput contains no newlines (unless a string's\nis escaped into two characters), so one line is always exactly one record;- When several processes append concurrently, a single
writesmaller than PIPE_BUF (usually 4096 bytes on Linux) is atomic; anything larger is not guaranteed. For high-concurrency scenarios, switch to "one file per process, merged after flushing to disk", or use storage with transactions.
5. Handling bad lines
This is the decision a line-delimited format forces you to make in advance:
| Strategy | Suitable for | Cost |
|---|---|---|
| Skip and count | Data that tolerates gaps, such as logs and analytics events | Data is lost silently, so alerting is mandatory |
| Fail immediately | Data that must not be lost, such as billing and invoices | One bad line blocks the whole batch |
| Route to a dead-letter file | Cases that need backfilling later | Extra storage and a replay process |
Whichever you choose, record the line number and a fragment of the original content, otherwise you cannot locate the problem afterwards. The line number is not present in the JSON.parse error message, so you have to maintain it yourself.
6. Compression and sharding
- gzip compresses
.jsonlat a consistently high ratio (field names repeat), so.jsonl.gzis recommended; - gzip compresses in blocks and natively supports streaming decompression, so there is no need to decompress the whole file;
- Split large files into slices of roughly
1GB, with sequence numbers in the names, to make parallel processing and resumable transfer easier; - Never preprocess
.jsonlby "merging it into an array" — that throws away all of its advantages.
7. Trade-offs against other big-data formats
| Format | Structure | Schema included | Good for |
|---|---|---|---|
| JSON Lines | Independent per line | No (needs an external contract) | Logs, imports and exports, training data |
| CSV | Tabular | No | Reports, Excel interchange |
| Parquet | Columnar | Yes | Analytical queries, very large offline datasets |
| Avro | Row-based | Yes | Kafka messages, schema evolution |
The advantage of JSON Lines is that any language and any tool can read and write it immediately; the disadvantage is the lack of a schema and of columnar compression. Once the data no longer fits on a single machine and you need frequent column-oriented queries, it is time to consider Parquet.
8. Checklist
- [ ] The file extension is consistent (
.jsonlor.ndjson) and never mixed; - [ ] The writer guarantees "one record per line", with newlines inside strings already escaped;
- [ ] The reader strips the BOM, skips empty lines, and maintains line numbers;
- [ ] The bad-line strategy is explicitly defined and monitored (bad lines / total lines);
- [ ] Large files are sliced by size and paired with gzip and parallel reads;
- [ ] Where a field contract exists, an additional schema is provided and validated line by line.
FAQ
How do I choose between a JSON array and JSON Lines?
Look at whether the data is a batch of records or a single document. Choose JSON Lines when records are appended and read as a stream: appending means writing one line at the end, and reading only requires splitting by line. Keep a JSON array when the whole structure must be validated in one pass, because a JSON array validates everything at once while JSON Lines can only be validated line by line.
Why does one corrupt line not break the whole file?
Each line in JSON Lines is an independent, complete JSON value, and parsing is naturally line-by-line, so corrupt data affects only that line. In a JSON array, one syntax error makes the whole file unparsable, which is exactly why line-delimited data suits logs and analytics events.
Should I convert JSON Lines into a JSON array?
You can: read line by line, call JSON.parse on each record, and collect them into an array. Doing so requires the entire content to fit in memory and gives up streaming reads and one-line appends. Preprocessing .jsonl into an array throws away all the advantages of the format, so it is only reasonable for one-off delivery where the data fits in memory.