Lines and Scale

Working with JSON

Chapter 9 Β· JSON Lines and Large Files

A JSON document is one value. To read the first record of a file that is one huge array, an ordinary parser must read, and build in memory, the whole file, because it cannot know the array is valid until it reaches the final ]. That is fine for a 10 KB API response and a real problem for a database export. This chapter measures how bad it gets, then shows the fixes in the order you should reach for them: JSON Lines (one record per line), streaming parsers for arrays you cannot change, gzip, the binary cousins (MessagePack, CBOR, BSON), and, on the API side, pagination. The numbers come from one generated data set and one Windows machine; treat the ratios as the lesson and the absolute figures as examples.

Every example here was run, except where marked
Node 24.18.0 (stream-json 3.7.0, @msgpack/msgpack 3.1.3, cbor-x 1.6.6), Python 3.14.0 (ijson 3.5.1 with the C backend, msgpack 1.2.3, cbor2 6.1.5, pymongo 4.18.3's bson), jq 1.8.2 and gzip 1.13, on Windows 11, all installed in a scratch folder. The data is 1,000,000 generated records (about 108 MB), made with a fixed random seed. Memory is the peak working set of the process, sampled every 5 ms by a small harness, and time is wall-clock for a single run: both are rough. The format descriptions were checked against jsonlines.org, the NDJSON specification and RFC 7464 (read through a fetch tool, so quotations are short). Not run: files of gigabytes (the 2 GB figures below are straight-line extrapolation, marked as such), Linux and macOS, other languages, Brotli and zstd, random-access gzip variants such as bgzip, and Protocol Buffers (Chapter 10).

The Problem, Measured

The test is the simplest realistic job: count the records whose status is "paid" and add up their amount. Each record looks like this (one line of the data set):

{"id":1,"name":"TomΓ‘s83","status":"shipped","amount":277.58,"tags":[],"ts":"2026-12-13T05:45:00Z"}

The same records were written two ways: as one array (data.json, one record per line inside [ ... ]) and as JSON Lines (data.jsonl). Every program printed the same answer, 250,574 paid records with a total of 62,748,185.57, so the programs did equal work. Peak memory for the "load everything" approach, at three sizes:

RecordsFile sizeNode JSON.parsePython json.load
100,00010.8 MB81 MB87 MB
300,00032.6 MB188 MB224 MB
1,000,000108.9 MB461 MB704 MB

Memory grows with the file. At a million records Node used about 4 times the file size, Python about 6.5 times, and jq loading the same array used 1,172 MB, about 11 times (table below). If that ratio held, which I did not test, a 2 GB file would want somewhere around 9 GB in Node and 13 GB in Python. The same job reading one line at a time stayed at 50-57 MB in Node and 17 MB in Python at every size: memory depends on the size of one record, not of the file.

JSON Lines: One Value per Line

JSON Lines (also called NDJSON, newline-delimited JSON) puts one complete JSON value on each line, separated by \n. Because every line is valid JSON by itself, a program can read a line, process it, throw it away, and move on; a writer can append a line without touching the rest of the file; head, tail, wc -l and split work on records; and a damaged line costs you one record, not the file.

FormatRules (from the sources I read)Extension / media type
JSON Lines (jsonlines.org)UTF-8, no BOM; each line a valid JSON value (any value, not only objects); terminator \n, with \r\n accepted because surrounding whitespace is ignored; a final terminator "strongly recommended but not required"; a blank line is not valid.jsonl (also .jsonl.gz); application/jsonl, which the site says is "not yet standardized"
NDJSON (ndjson-spec)writers end each text with \n (optionally \r\n); parsers must accept both; texts must not contain raw newlines; parsers may skip empty lines if that is documented and configurable.ndjson; application/x-ndjson
JSON text sequences (RFC 7464, Feb 2015, Standards Track)each text is prefixed with an ASCII Record Separator (0x1E) and followed by \n; a parser should carry on past a damaged text, and must drop a top-level number, true, false or null that is not followed by whitespace, because it may be truncatedapplication/json-seq

In practice JSON Lines and NDJSON are the same thing apart from how they treat blank lines and the final newline, and the same file satisfies both if it has no blank lines and ends with \n. The RFC 7464 form is different: the record separator makes it safe to resume reading after damage, which a plain newline cannot promise, and jq --seq speaks it. Newlines inside a string value are never a problem: JSON.stringify and json.dumps write them as \n (two characters), so a compactly written record cannot contain a raw line break. Pretty-printed JSON, written with indent, is not JSON Lines.

Reading JSON Lines in Python and Node

# Python: the file object yields one line at a time import json, sys n = s = 0 with open(sys.argv[1], encoding="utf-8") as f: for line in f: if not line.strip(): continue # skip blank lines (a policy choice) r = json.loads(line) if r["status"] == "paid": n += 1; s += r["amount"] print("paid", n, "sum", "%.2f" % s)
// Node: readline over a stream, one line at a time import fs from "node:fs"; import readline from "node:readline"; let n = 0, s = 0; const rl = readline.createInterface({ input: fs.createReadStream(process.argv[2], "utf8"), crlfDelay: Infinity }); for await (const line of rl) { if (!line) continue; const r = JSON.parse(line); if (r.status === "paid") { n++; s += r.amount; } } console.log("paid", n, "sum", s.toFixed(2));

Both were measured with the million-record file: Node 1.3 s and 57 MB, Python 2.6 s and 17 MB. Both programs are short, but both hide decisions that real files test. I ran each case below:

InputWhat happened
no final newlineboth records parse
CRLF line endings and a blank lineboth records parse (the trailing \r is whitespace to the parser); the blank line is something you must decide about
pretty-printed objectevery line fails (3 errors in 3 lines): it is not JSON Lines
BOM at the startline 1 fails: Python says "Unexpected UTF-8 BOM (decode using utf-8-sig)", Node says the token is not valid JSON; the format forbids a BOM, so open with utf-8-sig only if you must accept files you did not write
truncated last linethe earlier lines were good and the last fails: a copy that stopped half way is visible, as long as you do not swallow errors
one bad line in the middlethe other lines still parse. Decide per job: stop, or count and skip the bad line and report the count
U+2028 inside a string2 records in the file, but Python's str.splitlines() gave 3 pieces and two parse errors, and Node's readline gave 3 lines. Splitting on \n only, or iterating a Python file object, gave the correct 2

The last row is the one that surprises people. JSON allows the raw character U+2028 (LINE SEPARATOR) inside a string, and some line-splitting helpers treat it as a line break, cutting a valid record in two. Python's for line in f and a split on \n are safe; splitlines() is not. In Node I measured readline splitting on it, so for data that may contain U+2028 (text typed by users, for example) split on \n yourself. I tested one version of each and one character; other separators in the Unicode line-break set were not tried.

jq and JSON Lines

jq already treats its input as a stream of values (Chapter 4), so a JSON Lines file is natural input. What matters is how you ask it to read. Measured on the million-record file, for the same "paid" job:

Command (job)TimePeak memory
jq '[.[]|select(.status=="paid")]|length' data.json (whole array)6.3 s1,172 MB
jq -s '...' data.jsonl (-s slurps every line into one array)6.5 s1,171 MB
jq -n 'reduce inputs as $r (0; ...)' data.jsonl4.4 s6 MB
jq -c 'select(.status=="paid")|.id' data.jsonl (one result per input line)8.2 s6 MB
jq -c --stream -n 'fromstream(1|truncate_stream(inputs))|select(...)|.id' data.json (array, streamed)45.9 s6 MB

-s throws away the benefit of JSON Lines: it builds the array in memory exactly as if the file had been one. Use -n with inputs (or reduce/foreach over inputs) when you need totals, and plain jq -c when each line maps to an output line. --stream turns a large array into path/leaf events, so memory stays small, but at 46 seconds against 4-8 it is the slow way: my advice is to convert the array to JSON Lines once (next section) and use the fast forms. A few more jq facts I checked:

  • Array to lines, and back: jq -c '.[]' and jq -s -c . (or jq -n -c '[inputs]'). Both need the whole array in memory on one side, so they suit small files.
  • CRLF on Windows: jq -c wrote \r\n after each line when I redirected to a file; jq -b wrote \n. Parsers accept either, but file sizes, checksums and strict tools differ, so use -b when you write JSON Lines on Windows.
  • A truncated last line: jq printed the earlier results, then reported "Unfinished JSON term at EOF at line 3, column 6" and exited with status 5.
  • --seq changes input as well as output: it writes RS before and LF after each value, which is RFC 7464 output. But I fed it a plain JSON Lines file and it printed "ignoring parse error: Unfinished abandoned text" and no values, exit status 0: with --seq, input must be RS-framed too. A silent exit status 0 is the danger.
  • Piping on this machine was much slower than a file argument: jq -n 'reduce inputs ...' data.jsonl took 4.4 s, while cat data.jsonl | jq ... in Git Bash took 13.9 s and gzip -dc data.jsonl.gz | jq ... took 18.4 s (the last two ran in Git Bash). I did not find the cause and did not test PowerShell, cmd or Linux; if it matters to you, time both forms on your machine.
  • input_line_number reports the line a value came from, which is handy for error messages.

Streaming an Array You Cannot Change

Sometimes the file is a single array and somebody else made it. A streaming (event) parser reads tokens and hands you each array element as it completes, without building the whole document.

# Python: ijson (the C backend, yajl2_c, was the one in use) import ijson, sys n = s = 0 with open(sys.argv[1], "rb") as f: # binary mode for r in ijson.items(f, "item", use_float=True): # "item" = each element of a top-level array if r["status"] == "paid": n += 1; s += r["amount"]
// Node: stream-json 3.x (the API changed between major versions: check its README) import fs from "node:fs"; import { parser } from "stream-json"; import { streamArray } from "stream-json/streamers/stream-array.js"; import chain from "stream-chain"; let n = 0, s = 0; const pipeline = chain([fs.createReadStream(process.argv[2]), parser(), streamArray()]); pipeline.on("data", ({ value: r }) => { if (r.status === "paid") { n++; s += r.amount; } }); pipeline.on("end", () => console.log("paid", n, "sum", s.toFixed(2)));

Two things to know. ijson returns numbers with a fractional part as Decimal by default (I printed the type: Decimal), exact but slower to add up and not mixable with floats; use_float=True gives ordinary floats, as I used. And streaming costs CPU: stream-json took 7.8 s against 1.2 s for plain JSON.parse on the same file, in exchange for 108 MB of memory instead of 461 MB. Streaming a file that fits comfortably in memory is not a win. Results on the million-record array:

ApproachTimePeak memory
Node JSON.parse, whole array1.2 s461 MB
Node readline over JSON Lines1.3 s57 MB
Node stream-json over the array7.8 s108 MB
Python json.load, whole array2.7 s704 MB
Python line by line over JSON Lines2.6 s17 MB
Python ijson over the array2.2 s20 MB

Converting once is the practical answer. Streaming the array with ijson and writing one compact line per record took 6.0 s and 20 MB, and the resulting file was byte-for-byte the same (same SHA-256) as the JSON Lines file the data generator wrote directly. When you control the producer, write JSON Lines from the start.

Compression: gzip

JSON is repetitive text (the same keys on every record), and gzip is very good at that. Python's gzip module at three levels, on the 107.9 MB JSON Lines file, one run each:

LevelSize% of originalCompressDecompress
131,027,86828.8%0.27 s0.15 s
6 (default)18,870,74117.5%1.18 s0.11 s
917,537,60816.3%2.26 s0.12 s

Compression is cheap to undo and can be read as a stream: Node fs.createReadStream(...).pipe(zlib.createGunzip()) into readline took 1.9 s and 66 MB, and Python gzip.open(path, "rt", encoding="utf-8") took 2.9 s and 18 MB, both with the same answer as the uncompressed file. Two gzip members written one after the other decompress as a single stream (I checked), which is why .jsonl.gz files can be appended to. The price is that gzip is not seekable: to read the millionth line you decompress the first 999,999. (Formats that add an index, such as bgzip, exist; I did not run them.) For HTTP, the server and browser can compress for you with Content-Encoding (HTTP itself is covered in API Types & Design 2-4, which I did not re-check for this topic); you do not need to gzip a JSON response by hand.

Binary Cousins: MessagePack, CBOR, BSON

MessagePack, CBOR and BSON are binary formats with the same data model as JSON (plus extras such as byte strings and, in BSON, dates). I encoded the same million records with each, timed Python's encoder and decoder, compressed each with gzip level 6, and checked that every decoded result equals the original records:

FormatBytesAfter gzip -6EncodeDecode
JSON array, compact107,918,86118,870,7431.43 s1.78 s
JSON array, indent=2164,171,27420,464,3641.65 s1.71 s
JSON Lines, compact107,918,86018,870,7413.92 s4.52 s
MessagePack84,963,28718,335,3960.61 s1.09 s
CBOR84,963,39118,334,5122.30 s2.11 s
BSON (one document per record, concatenated)119,598,10521,341,9591.40 s1.14 s

Three lessons. Gzip does most of the work. Raw, MessagePack and CBOR are 21% smaller than compact JSON; after gzip, MessagePack is only 2.8% smaller than gzipped JSON, and BSON is larger than JSON both ways. Pretty-printing is almost free once compressed (164 MB raw but 20.5 MB gzipped), so do not minify for size if the transfer is compressed. Speed differs by library: the JSON Lines row includes splitting lines in my Python code, so it is not a fair judgment of the format. The binary formats are not human-readable, cannot be edited in a text editor or inspected with curl alone, and need a library on every side. BSON has no top-level array (a document is an object), which is why it is stored as one document per record. I also decoded the MessagePack and CBOR files, written by Python, in Node (@msgpack/msgpack and cbor-x) and the results equalled the JSON for 1,000 records. Not tested: integers beyond 64 bits, dates and byte strings (where these formats and JSON diverge), and encoders in languages other than Python and Node.

Pagination: The API-Side Answer

A server should not send a million records in one response either. Pagination returns a page at a time, and there are two common designs. Offset: ?limit=100&offset=200. Cursor: ?limit=100&after=<last id seen>, with the server returning a next value. I wrote a small server of 1,000 items, newest first, and walked it with a client, once with nothing changing and once with 5 new items inserted right after the client fetched the first page:

WalkItems receivedDistinctDuplicates
offset, nothing changes1,0001,0000
offset, 5 inserted after page 11,0051,0005
cursor, nothing changes1,0001,0000
cursor, 5 inserted after page 11,0001,0000

With offset paging, the insert pushed the last five items of page 1 onto page 2, so the client saw them twice. (The mirror problem, a delete making a client skip items, follows from the same arithmetic but I did not run it.) The cursor walk saw each original item once; the five new items were not returned because the cursor was already past them, which is usually what you want for an export and not what you want for a live feed. Cursor paging also avoids the server counting past a large offset, but I did not measure database cost: my server holds an array. The server's design (what next is called, whether the cursor is a bare id) is mine; real APIs choose their own and often use an opaque string.

A server can also stream JSON Lines in a single response. My /stream endpoint sent Content-Type: application/x-ndjson and wrote one line every 200 ms; a Node client reading fetch's body stream parsed the first line at 222 ms and the fifth at 1,031 ms, rather than waiting for the end. That is the main reason NDJSON turns up in log and event APIs. The client has to keep any partial line in a buffer until the next newline arrives (the leftover after the last line was 0 bytes), because a network chunk can end in the middle of a record.

Choosing

SituationReach for
Small file or API responseone JSON document and an ordinary parser: it is simplest
Exports, logs, data that grows by appendingJSON Lines, written one record per line, compact, UTF-8, \n, no BOM
Someone else's huge arraystream it once (ijson, stream-json) and write JSON Lines
Totals or filters in the terminaljq -n with inputs, or jq -c; avoid -s and avoid --stream unless you must
Storage or transfer sizegzip first; reach for binary formats only when speed or typed values matter more than readability
An API with many recordspagination (cursor for exports); streaming NDJSON for event feeds
Mistakes while preparing this chapter
1. My first stream-json run "finished" in 0.35 s using 39 MB. That looked like a spectacular result; it was a crash (exit status 1), because I had guessed the module path from an older version. After I fixed the path, a .pipe() call failed because version 3 changed the API. The real run took 7.8 s and 108 MB. How to avoid it: print the exit status and the result next to every timing, and read the library's own README for its current API.

2. My first "streaming" conversion reported 444 MB. The conversion itself was fine; the same script then read both files whole to compare them. After I split the check into a separate script, the conversion measured 20 MB. How to avoid it: measure one thing per program, and do the verification somewhere that is not being measured.

3. The tool I used to write my test files turned my escape sequence for U+2028 into the real character. The scripts then contained an invisible line separator and could not be read in the solution file. I replaced the characters by building the escape from chr(92). How to avoid it: after writing a file that must contain a backslash escape, search it for the raw character.

4. I assumed jq --seq only changed the output. Fed a plain JSON Lines file, it printed a warning and no values, with exit status 0. How to avoid it: test the flag on the exact input you will give it, and check that values came out, not just that the exit status was 0.

5. I expected cat file | jq to cost the same as jq file. On this machine it took three times as long. How to avoid it: when a pipeline is slow, time the pieces separately before blaming the program.

Exercises

Exercise 1

Generate 1,000,000 records (fixed random seed) as both a JSON array and a JSON Lines file. Write programs that count the records with status "paid" and add their amount in Node (whole file, readline, stream-json), Python (json.load, line by line, ijson) and jq (whole array, -s, -n with inputs, --stream). Write a harness that records wall time, peak memory and exit status for each, and run the whole-versus-lines pair at 100,000, 300,000 and 1,000,000 records. Check that every program gives the same answer and say what the harness cannot tell you.

πŸ“„ View solution
Exercise 2

Write JSON Lines readers in Python and Node and test them on: no final newline, CRLF with a blank line, a pretty-printed object, a BOM, a truncated last line, a bad line in the middle, and a record containing U+2028. Report what each reader does and fix any reader that splits a valid record. Then, with jq, show the CRLF behaviour with and without -b, the output of --seq, what --seq does with a plain JSON Lines input, and what a truncated last line does to the output and the exit status. Finally convert the million-record array to JSON Lines with ijson in constant memory and prove the result identical to the directly generated file with a checksum.

πŸ“„ View solution
Exercise 3

Compress the JSON Lines file with gzip at levels 1, 6 and 9 and report size and time; show that two gzip members back to back decompress as one; read the compressed file as a stream in Node, Python and jq. Encode the records as compact JSON, indented JSON, JSON Lines, MessagePack, CBOR and BSON, compress each, check that every one decodes to the original, and decode the MessagePack and CBOR files in Node. Finally write a small HTTP server with an offset endpoint, a cursor endpoint, an insert endpoint and an NDJSON streaming endpoint, and show how many duplicates each paging style gives when items are inserted mid-walk and when each streamed line arrives.

πŸ“„ View solution

Chapter 9 Quick Reference

  • One JSON document = one value: parsers load all of it. Measured on 108 MB (1M records): Node 461 MB, Python 704 MB, jq 1,172 MB. Line by line: Node 57 MB, Python 17 MB, jq -n + inputs 6 MB. Memory grew with file size for whole-file loads and stayed flat for lines
  • JSON Lines / NDJSON: one compact JSON value per line, UTF-8, no BOM, \n (CRLF tolerated), no blank lines in JSON Lines; .jsonl / .ndjson; application/x-ndjson (NDJSON) and application/jsonl ("not yet standardized"); RFC 7464 application/json-seq uses a 0x1E prefix
  • Reading: Python for line in f and Node readline; do not use splitlines() (U+2028 broke a valid record); Node's readline also split on U+2028 in my test; decide what to do with blank lines and bad lines and count them
  • jq: -c per line; -n + inputs/reduce for totals; avoid -s (1,171 MB) and --stream (46 s vs 4-8 s); -b avoids CRLF on Windows; --seq needs RS-framed input; a truncated last line gives exit status 5
  • Giant array: stream with ijson (use_float=True) or stream-json (slower: 7.8 s vs 1.2 s), then write JSON Lines; the converted file matched the generated one byte for byte
  • gzip -6 shrank JSON Lines to 17.5%; levels 1/6/9 gave 28.8% / 17.5% / 16.3%; streams can be read directly; concatenated members are valid; not seekable
  • MessagePack/CBOR: 21% smaller raw, about 3% smaller after gzip; BSON larger than JSON; pretty-printing is nearly free once gzipped (20.5 MB vs 18.9 MB)
  • Pagination: offset gave 5 duplicates when 5 items were inserted mid-walk; cursor gave none; NDJSON streams deliver each line as it is sent, if the client buffers partial lines
Coming next
Chapter 10 is JSON and its neighbours: JSON against YAML, TOML, JSON5 and JSONC, XML, CSV and Protocol Buffers for configuration, exchange and storage; what each adds or removes (comments, types, schemas, size, speed); converting between them with real tools; YAML's "Norway problem" shown by running a parser; and when JSON is the wrong choice. Start with Working with JSON 10.