Data Formats (JSON, YAML, TOML, XML, CSV) Module 10 – Across formats: time, conversion, streaming, evolution, binary
Streaming large JSON, XML and CSV files
Read files larger than memory: measure whole and streaming readers, parse one huge JSON array, and handle chunk boundaries, split UTF-8 and backpressure.
What you will learn
- Read files larger than memory with streaming readers in Python and JavaScript
- Measure peak memory and choose between whole-document and streaming parsing
- Handle chunk boundaries, split UTF-8 sequences and backpressure correctly
Before you start
What you’ll learn · Before you start
What you will learn
- Read files larger than memory with streaming readers in Python and JavaScript
- Measure peak memory and choose between whole-document and streaming parsing
- Handle chunk boundaries, split UTF-8 sequences and backpressure correctly
Before you start
On this page
Reading 200,000 orders from a 13.0 MB JSON array with Python’s json.load took 76.0 MB of memory. Reading the same
orders as JSON Lines, one line at a time, took 0.2 MB, and the totals came out identical.
The difference grows with the file: a parsed document takes several times its size, so a file of a few gigabytes cannot be read whole on most machines. You will measure whole and streaming readers for JSON, XML and CSV, read one huge JSON array item by item, keep characters and records intact across chunk boundaries, and let a slow writer hold back a fast reader.
A whole-document parser holds every record at once
json.load and ElementTree.parse build the whole document before they return: every object, string and number of
the file exists in memory together, each with the overhead of a Python object. A streaming reader keeps only the
record it is reading. This program writes the same 200,000 orders as a JSON array, as
JSON Lines: A text format with one JSON value on each line, in UTF-8, every line ended by a line feed; also called JSONL, and close to NDJSON. Each line is parsed on its own, so a file is read one record at a time. More in “JSON Lines, JSONC, JSON5 and other variants”, as XML and as CSV, computes the paise per city from each, and measures the
peak memory: The most memory a program held at any one moment of a run. Python's tracemalloc reports it with get_traced_memory(), and it decides whether a job fits a machine. of each way with tracemalloc:
"""The same 200,000 orders as a JSON array, JSON Lines, XML and CSV: per-city totals computed by reading each file whole
and by streaming it, with the peak memory of each way, as tracemalloc counts it."""
import csv
import json
import os
import random
import tracemalloc
import xml.etree.ElementTree as ET
from collections import Counter
CITIES = ["Pune", "Kochi", "Jaipur", "Indore", "Surat", "Mysuru"]
N = 200_000
def orders():
rng = random.Random(42)
for i in range(N):
yield {"id": f"o{i:06d}", "city": rng.choice(CITIES), "paise": rng.randrange(1_000, 500_000), "items": rng.randint(1, 5)}
# Write the four files once; each holds the same orders.
with open("orders.json", "w", encoding="utf-8") as f:
json.dump(list(orders()), f)
with open("orders.jsonl", "w", encoding="utf-8") as f:
for o in orders():
f.write(json.dumps(o) + "\n")
with open("orders.xml", "w", encoding="utf-8") as f:
f.write("<orders>")
for o in orders():
f.write(f'<order id="{o["id"]}"><city>{o["city"]}</city><paise>{o["paise"]}</paise><items>{o["items"]}</items></order>')
f.write("</orders>\n")
with open("orders.csv", "w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, ["id", "city", "paise", "items"])
w.writeheader()
w.writerows(orders())
def json_whole():
totals = Counter()
with open("orders.json", encoding="utf-8") as f:
for o in json.load(f):
totals[o["city"]] += o["paise"]
return totals
def json_lines():
totals = Counter()
with open("orders.jsonl", encoding="utf-8") as f:
for line in f:
o = json.loads(line)
totals[o["city"]] += o["paise"]
return totals
def xml_whole():
totals = Counter()
for order in ET.parse("orders.xml").getroot():
totals[order.findtext("city")] += int(order.findtext("paise"))
return totals
def xml_stream():
totals = Counter()
root = None
for event, el in ET.iterparse("orders.xml", events=("start", "end")):
if root is None:
root = el
elif event == "end" and el.tag == "order":
totals[el.findtext("city")] += int(el.findtext("paise"))
root.clear() # drop the finished orders, or the tree keeps every one of them
return totals
def csv_stream():
totals = Counter()
with open("orders.csv", encoding="utf-8", newline="") as f:
for row in csv.DictReader(f):
totals[row["city"]] += int(row["paise"])
return totals
results = []
print(f"{'reader':<34}{'file MB':>8}{'peak MB':>9}")
for name, path, read in [
("json.load, the whole array", "orders.json", json_whole),
("JSON Lines, one line at a time", "orders.jsonl", json_lines),
("ElementTree.parse, the whole tree", "orders.xml", xml_whole),
("iterparse with root.clear()", "orders.xml", xml_stream),
("csv.DictReader, one row at a time", "orders.csv", csv_stream),
]:
tracemalloc.start()
totals = read()
peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
results.append(totals)
print(f"{name:<34}{os.path.getsize(path) / 1e6:>8.1f}{peak / 1e6:>9.1f}")
print("same totals from all five:", all(r == results[0] for r in results))
print("Pune:", results[0]["Pune"], "paise") Output
reader file MB peak MB json.load, the whole array 13.0 76.0 JSON Lines, one line at a time 12.8 0.2 ElementTree.parse, the whole tree 16.6 137.4 iterparse with root.clear() 16.6 0.4 csv.DictReader, one row at a time 4.8 0.2 same totals from all five: True Pune: 8340826118 paise
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 measure.py
The whole array took 76.0 MB for a 13.0 MB file, almost six times its size, and the XML tree 137.4 MB for 16.6 MB, more than eight times. The streaming readers stayed between 0.2 and 0.4 MB, the size of one record and the reader’s own buffer, and they would stay there for a file a thousand times larger. All five found the same totals.
Each format streams in its own way. JSON Lines puts one value on each line, so a plain loop over the file’s lines
reads one record at a time. A CSV reader already returns one row at a time. XML streams with a
pull parser: A parser that streams a document while the program asks it for the next event, such as the start or end of an element. Python's iterparse and XMLPullParser and Java's StAX work this way. More in “Parsing XML: trees, events and streaming” such as iterparse, as long as the program clears each finished element,
as the XML parsing lesson showed. A JSON array has no line breaks to rely on, which is the next section’s problem.
Which reader fits a file depends on its size, its shape and the job. Answer three questions about a file of yours:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Reader | Memory for 200,000 orders | Sees the whole document | Works with the file as it is |
|---|---|---|---|
| Whole document Holds in memory: every record, as json.load and ElementTree.parse do | Poor 76.0 MB for a 13.0 MB JSON array, 137.4 MB for 16.6 MB of XML | Good sort, join and look back at any record | Good any JSON or XML document |
| JSON Lines line by line Holds in memory: one line | Good 0.2 MB, the size of one line | Poor one record at a time; keep what you need yourself | Mixed only JSON Lines: one value per line |
| Incremental array parser Holds in memory: one item and a chunk | Good 0.3 MB with 64 KB chunks | Poor one item at a time | Mixed one big array of items; other shapes need a path to the array |
| iterparse with clear() Holds in memory: the element being read | Good 0.4 MB, once each finished order is cleared | Poor one element at a time, and only if you clear what you used | Good any XML document |
| CSV reader row by row Holds in memory: one row | Good 0.2 MB, one row at a time | Poor one row at a time | Mixed flat tables only |
How the answers decide
- “Does the parsed file fit in memory with room to spare, at five to ten times its size?” No, or not always: rules out Whole document. A parsed document took six to eight times its file size here; a file that does not fit that way must be streamed.
- “Does the job need the whole document at once, to sort it or follow references inside it?” Yes: adds a caveat to JSON Lines line by line, Incremental array parser, iterparse with clear() and CSV reader row by row. A streaming reader sees one record at a time: keep only what the job needs, or load the records into a database first.
- “What is the file?” JSON Lines: rules out Incremental array parser, iterparse with clear() and CSV reader row by row. Each of these reads another shape of file.
- “What is the file?” One big JSON array: rules out JSON Lines line by line, iterparse with clear() and CSV reader row by row. One big array has no line breaks between records to rely on, so it needs a parser that cuts out each item.
- “What is the file?” XML: rules out JSON Lines line by line, Incremental array parser and CSV reader row by row. XML needs an XML parser: iterparse streams it, ElementTree.parse reads it whole.
- “What is the file?” CSV: rules out JSON Lines line by line, Incremental array parser and iterparse with clear(). CSV needs a CSV reader, which already streams one row at a time.
Measuring with a small sample
Wrong: trying json.load on a 10 MB sample, seeing it work, and shipping it for the 6 GB export. Memory grows with the
file, six to eight times its size here, so the job that worked on the sample is killed on the real file. Right:
measure the peak on the sample, multiply by the real size, and stream when the result does not fit.
Checkpoint
Check: Whole-document and streaming readers
One huge JSON array needs an incremental parser
An export that writes [{…}, {…}, …] with millions of items is one JSON value, and json.load reads it only whole.
Python’s standard library still has what an incremental reader needs: json.JSONDecoder.raw_decode parses one value
starting at a position of a string and returns where it ended. Read the file in chunks, skip the commas between items,
and decode one item at a time. Step through the loop:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first chunk. The reader takes 65,536 characters of the file and checks that the text starts with [. It never holds more than the unread rest of one chunk plus the next one.
- Between items. Before each item it skips white space and, after the first item, one comma. Anything else, such as a second comma, stops the read with the position of the mistake.
- One value. json.JSONDecoder.raw_decode parses one JSON value starting at a position and says where it ended, which is exactly what reading an array item by item needs.
- Cut by the chunk. The last item of a chunk is usually cut in the middle. raw_decode raises JSONDecodeError, or a number ends exactly at the buffer's end, so the reader adds the next chunk and tries the same position again.
- The item. A complete order is handed to the caller, which adds its paise to the city's total and lets it go. Only the totals stay.
- A small buffer. When more is read, the characters already used are dropped. The peak stayed at 0.3 MB for 200,000 orders, against 73.5 MB for json.load of the same file.
The program reads the same orders as one array both ways:
"""One JSON array of 200,000 orders, read item by item from 64 KB chunks with the standard library's raw_decode, against
json.load of the same file: the same totals, and the peak memory of each."""
import json
import random
import tracemalloc
from collections import Counter
CITIES = ["Pune", "Kochi", "Jaipur", "Indore", "Surat", "Mysuru"]
rng = random.Random(42)
with open("orders.json", "w", encoding="utf-8") as f:
json.dump([{"id": f"o{i:06d}", "city": rng.choice(CITIES), "paise": rng.randrange(1_000, 500_000)} for i in range(200_000)], f)
def iter_array(f, chunk_size=65_536):
"""Yield the items of the JSON array in the text file f, reading chunk_size characters at a time."""
decoder = json.JSONDecoder()
buf, pos, eof = "", 0, False
def more():
nonlocal buf, pos, eof
piece = f.read(chunk_size)
eof = piece == ""
buf, pos = buf[pos:] + piece, 0 # keep only what is not yet read
def skip_space():
nonlocal pos
while True:
while pos < len(buf) and buf[pos] in " \t\r\n":
pos += 1
if pos < len(buf) or eof:
return
more()
more()
skip_space()
if buf[pos:pos + 1] != "[":
raise ValueError("not a JSON array")
pos += 1
first = True
while True:
skip_space()
if buf[pos:pos + 1] == "]":
return
if not first:
if buf[pos:pos + 1] != ",":
raise ValueError(f"expected , or ] at character {pos}")
pos += 1
skip_space()
while True:
try:
item, end = decoder.raw_decode(buf, pos)
if end < len(buf) or eof: # a value at the very end of the buffer may go on in the next chunk
break
except json.JSONDecodeError:
if eof:
raise
more()
pos, first = end, False
yield item
def totals_of(items):
totals = Counter()
for o in items:
totals[o["city"]] += o["paise"]
return totals
for name, read in [("json.load", lambda f: totals_of(json.load(f))), ("iter_array, 64 KB chunks", lambda f: totals_of(iter_array(f)))]:
with open("orders.json", encoding="utf-8") as f:
tracemalloc.start()
totals = read(f)
peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
print(f"{name:<26} peak {peak / 1e6:5.1f} MB, Pune {totals['Pune']} paise") Output
json.load peak 73.5 MB, Pune 8297547192 paise iter_array, 64 KB chunks peak 0.3 MB, Pune 8297547192 paise
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stream_array.py
iter_array peaked at 0.3 MB against 73.5 MB for json.load, with the same total for Pune. The one subtle rule is in
the inner loop: a value that ends exactly at the end of the buffer may go on in the next chunk, as the number 12 may
really be 1234, so the reader accepts a value only when something follows it or the file has ended.
In JavaScript, the same idea is a small state machine. This reader counts brackets outside strings to find where each item ends, and parses each item on its own, with bytes delivered in chunks of 1,000:
// One JSON array of 50,000 orders, fed to a small incremental reader in 1,000-byte chunks, as a network stream would
// deliver it. The reader cuts out each item by counting brackets outside strings, then parses the item alone.
const CITIES = ["Pune", "Kochi", "Jaipur", "Indore", "Surat", "Mysuru"];
let seed = 42;
const next = () => (seed = (seed * 1103515245 + 12345) % 2 ** 31); // a fixed sequence, so every run is the same
const orders = Array.from({ length: 50_000 }, (_, i) => ({ id: `o${i}`, city: CITIES[Math.floor((next() / 2 ** 31) * 6)], note: i % 997 === 0 ? 'says "hi" [ok]' : "" }));
const bytes = new TextEncoder().encode(JSON.stringify(orders));
function* chunks(size) {
for (let i = 0; i < bytes.length; i += size) yield bytes.subarray(i, i + size);
}
/** Yields each item of a JSON array whose bytes arrive in chunks. */
function* items(source) {
const decoder = new TextDecoder(); // stream: true below keeps a character split between chunks
let depth = 0, inString = false, escaped = false, item = "", split = 0;
for (const chunk of source) {
const text = decoder.decode(chunk, { stream: true });
for (const c of text) {
if (depth >= 2 || (depth === 1 && c !== "," && c !== "]" && !/\s/.test(c))) item += c;
if (inString) {
if (escaped) escaped = false;
else if (c === "\\") escaped = true;
else if (c === '"') inString = false;
} else if (c === '"') inString = true;
else if (c === "{" || c === "[") depth++;
else if (c === "}" || c === "]") {
depth--;
if (depth === 1 && item) {
yield JSON.parse(item);
item = "";
}
}
}
if (item) split++; // this chunk ended in the middle of an item
}
console.log(`chunks that ended inside an item: ${split}`);
}
const totals = {};
let count = 0;
for (const o of items(chunks(1_000))) {
totals[o.city] = (totals[o.city] ?? 0) + 1;
count++;
}
const whole = {};
for (const o of JSON.parse(new TextDecoder().decode(bytes))) whole[o.city] = (whole[o.city] ?? 0) + 1;
console.log(`${bytes.length} bytes, ${count} items read one at a time`);
console.log("orders per city:", JSON.stringify(totals));
console.log("the same as JSON.parse of the whole text:", JSON.stringify(totals) === JSON.stringify(whole)); Output
chunks that ended inside an item: 1943
2055924 bytes, 50000 items read one at a time
orders per city: {"Indore":7950,"Mysuru":8265,"Surat":8172,"Pune":8575,"Kochi":8461,"Jaipur":8577}
the same as JSON.parse of the whole text: true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node stream_array.mjs
1,943 of the chunks ended in the middle of an item, and every item still came out whole, with the same counts per city
as JSON.parse of the entire text. A " inside a string, and the brackets in says "hi" [ok], are why the reader
tracks strings and escapes: counting brackets alone would end an item too early.
For real files, a library does this with more care: ijson in Python yields the items under a path such as item,
@streamparser/json parses a JSON stream in browsers and Node.js, and stream-json’s streamArray emits one array
element at a time in Node.js pipelines.
Key idea
Streaming means holding one record at a time: lines for JSON Lines and CSV, cleared elements for XML, and for one big JSON array a parser that cuts out each item, so memory stays flat however large the file grows.
Checkpoint
Check: Reading one huge JSON array
Chunks cut through characters and records
A stream hands over bytes in chunks of whatever size the disk or the network delivers, and the cuts fall anywhere.
Many characters take several bytes in UTF-8: the rupee sign ₹ takes three, E2 82 B9. A chunk that ends after the
first two holds half a character, and a chunk that ends inside a line holds half a record. Step through two such
chunks:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The first chunk. The first 26 bytes arrive. They end with E2 82, two of the three bytes that UTF-8 uses for the rupee sign; the third, B9, is in the next chunk.
- A decoder per chunk. A new TextDecoder for each chunk cannot know that more bytes follow, so it writes U+FFFD for the unfinished bytes, and another for the lone B9 in the next chunk. The price reads ��1,250, and no error is raised.
- One decoder, streaming. With one decoder and stream: true, the two bytes wait inside the decoder. Only the complete characters before them go on as text.
- An unfinished line. The line splitter has no line break yet, so it keeps the start of the first record and parses nothing. A splitter that parsed every piece would fail on half a record.
- The second chunk. The other 43 bytes arrive. The decoder joins B9 to the bytes it kept and writes the rupee sign whole, then the rest of the text.
- Two records. Now the splitter has two complete lines. It parses both and keeps the empty piece after the last line break for whatever comes next.
This program decodes the same two chunks four ways:
// Two JSON Lines records whose bytes arrive in two chunks, cut inside the first record, between the bytes of its
// rupee sign (E2 82 B9 in UTF-8). Three ways to decode the chunks, then a reader that also keeps the unfinished line.
const text = '{"city":"Pune","price":"₹1,250"}\n{"city":"Kochi","price":"₹980"}\n';
const bytes = new TextEncoder().encode(text);
const at = new TextEncoder().encode(text.slice(0, text.indexOf("₹"))).length; // where the sign's three bytes start
const parts = [bytes.subarray(0, at + 2), bytes.subarray(at + 2)]; // cut after two of them
console.log(`chunk sizes: ${parts.map((p) => p.length).join(" + ")} bytes; the first ends with byte ${parts[0].at(-1).toString(16)}`);
// 1. A new decoder for every chunk: each half of the sign becomes U+FFFD, without an error.
const separately = parts.map((p) => new TextDecoder().decode(p)).join("");
console.log("decoded one chunk at a time:", JSON.stringify(separately.split("\n")[0]));
// 2. The same, with fatal: true: the damage becomes an error instead.
try {
new TextDecoder("utf-8", { fatal: true }).decode(parts[0]);
} catch (e) {
console.log(`with fatal: true: ${e.name}: ${e.message}`);
}
// 3. One decoder for the whole stream, with stream: true, keeps the unfinished bytes for the next chunk.
const decoder = new TextDecoder();
const streamed = parts.map((p) => decoder.decode(p, { stream: true })).join("") + decoder.decode();
console.log("one decoder, stream: true: ", JSON.stringify(streamed.split("\n")[0]));
// 4. A web stream, as fetch() hands a body over: bytes through TextDecoderStream, then whole lines only.
const body = new ReadableStream({
start(controller) {
for (const p of parts) controller.enqueue(p);
controller.close();
},
});
let pending = "";
const records = [];
for await (const piece of body.pipeThrough(new TextDecoderStream())) {
const lines = (pending + piece).split("\n");
pending = lines.pop(); // the last piece has no newline yet: keep it for the next chunk
for (const line of lines) if (line) records.push(JSON.parse(line));
}
console.log("records read through TextDecoderStream:", JSON.stringify(records)); Output
chunk sizes: 26 + 43 bytes; the first ends with byte 82
decoded one chunk at a time: "{\"city\":\"Pune\",\"price\":\"��1,250\"}"
with fatal: true: TypeError: The encoded data was not valid for encoding utf-8
one decoder, stream: true: "{\"city\":\"Pune\",\"price\":\"₹1,250\"}"
records read through TextDecoderStream: [{"city":"Pune","price":"₹1,250"},{"city":"Kochi","price":"₹980"}]
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node split_utf8.mjs
A new TextDecoder per chunk turned the sign into two replacement characters, ��1,250, and raised nothing, which
is the worst outcome: corrupted data that looks like data. With fatal: true the same mistake became a TypeError.
One decoder used with stream: true kept the two bytes until the third arrived. In a browser, fetch() hands the
response body over as a ReadableStream of bytes, and pipeThrough(new TextDecoderStream()) turns it into text with
the same care; the program builds such a stream by hand, then keeps the unfinished line in pending until its line
break arrives.
Python’s defaults differ, so here are the same chunks in Python:
"""The same two chunks in Python: bytes.decode per chunk, errors="replace", and an incremental decoder."""
import codecs
import json
text = '{"city":"Pune","price":"₹1,250"}\n{"city":"Kochi","price":"₹980"}\n'
data = text.encode("utf-8")
at = len(text[: text.index("₹")].encode("utf-8"))
parts = [data[: at + 2], data[at + 2 :]]
try:
"".join(p.decode("utf-8") for p in parts)
except UnicodeDecodeError as e:
print("decode per chunk:", e)
replaced = "".join(p.decode("utf-8", errors="replace") for p in parts)
print('errors="replace":', json.loads(replaced.splitlines()[0])["price"])
decoder = codecs.getincrementaldecoder("utf-8")()
pending, records = "", []
for p in parts:
lines = (pending + decoder.decode(p)).split("\n")
pending = lines.pop() # an unfinished line waits for the next chunk
records += [json.loads(line) for line in lines if line]
decoder.decode(b"", final=True) # raises if the stream ended inside a character
print("incremental decoder:", [r["price"] for r in records]) Output
decode per chunk: 'utf-8' codec can't decode bytes in position 24-25: unexpected end of data errors="replace": ��1,250 incremental decoder: ['₹1,250', '₹980']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 split_utf8.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
bytes.decode is strict by default, so decoding the first chunk alone raised UnicodeDecodeError, and
errors="replace" turned it into the same silent damage. An
incremental decoder: A decoder that turns a stream of byte chunks into text and keeps the unfinished bytes of a character for the next chunk, such as codecs.getincrementaldecoder in Python or a TextDecoder used with stream: true. from codecs keeps unfinished bytes as stream: true
does, and final=True at the end raises if the stream stopped inside a character. A file opened in text mode, with
open(path, encoding="utf-8"), decodes incrementally already: the trap is raw byte chunks from sockets, HTTP bodies
or read(n) on a binary file.
Decoding each chunk on its own
Wrong: chunk.decode("utf-8", errors="replace") or new TextDecoder().decode(chunk) for every chunk, which damages
every character a chunk boundary cuts. Right: one decoder for the whole stream (codecs.getincrementaldecoder,
TextDecoder with stream: true, TextDecoderStream), and refuse invalid bytes instead of replacing them.
Checkpoint
Check: Chunk boundaries and split characters
Backpressure keeps a fast reader from flooding a slow writer
A pipeline that reads quickly and writes slowly, to a busy disk or a slow network, must not read faster than it can
write, or everything read but not yet written waits in memory. Node.js streams signal this with
backpressure: A signal from the slow end of a pipeline to the fast end to wait, so that data does not pile up in memory. In Node.js, write() answers false and the stream later emits drain.: write() answers false once the stream’s buffer passes its
high-water mark: The amount of buffered data at which a Node.js stream asks the producer to wait: 65,536 bytes by default for byte streams in Node.js 24, or 16 objects in object mode., and the stream emits drain when the buffer has emptied. Step
through the exchange:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Writing while there is room. The producer calls write() for each record. While the buffer holds less than 16,384 bytes, write() answers true and the producer goes on.
- The answer turns false. The record that takes the buffer past 16,384 bytes is accepted, but write() answers false. That is the signal to stop, and nothing more.
- Ignoring it. A producer that ignores the answer keeps writing. Every record waits in memory, and in the run all 620,000 bytes waited at once.
- Waiting for drain. A producer that respects it awaits the drain event. The slow writer empties the buffer one write at a time, and memory stays at the mark.
- drain. When the buffer is empty, the stream emits drain, and the producer writes again. pipeline() does this waiting for you, and its run never held more than 16,399 bytes either.
This program sends 20,000 records into a writer that finishes one write per turn of the event loop, three ways:
// A fast producer of 20,000 JSON Lines records and a slow consumer that finishes one write per turn of the event
// loop, as a busy disk or socket does. How many bytes wait in memory when write()'s answer is ignored, and when it is
// respected by hand or by pipeline().
import { once } from "node:events";
import { Readable, Writable, getDefaultHighWaterMark } from "node:stream";
import { pipeline } from "node:stream/promises";
const LINE = '{"city":"Pune","paise":125000}\n';
const COUNT = 20_000;
const HWM = 16_384;
function slowSink() {
return new Writable({ highWaterMark: HWM, write: (_chunk, _encoding, done) => setImmediate(done) });
}
async function ignoring() {
const sink = slowSink();
let most = 0, refusals = 0;
for (let i = 0; i < COUNT; i++) {
if (!sink.write(LINE)) refusals++; // the answer is false, and the loop writes anyway
most = Math.max(most, sink.writableLength);
}
sink.end();
await once(sink, "finish");
return { most, refusals };
}
async function respecting() {
const sink = slowSink();
let most = 0, refusals = 0;
for (let i = 0; i < COUNT; i++) {
const ok = sink.write(LINE);
most = Math.max(most, sink.writableLength);
if (!ok) {
refusals++;
await once(sink, "drain"); // wait until the buffer has emptied
}
}
sink.end();
await once(sink, "finish");
return { most, refusals };
}
async function piped() {
const sink = slowSink();
let most = 0;
async function* lines() {
for (let i = 0; i < COUNT; i++) {
most = Math.max(most, sink.writableLength);
yield LINE;
}
}
await pipeline(Readable.from(lines()), sink);
return { most, refusals: "handled by pipeline" };
}
console.log(`Node.js ${process.versions.node}: default highWaterMark ${getDefaultHighWaterMark(false)} bytes; this sink's is ${HWM}`);
console.log(`${COUNT} records, ${COUNT * LINE.length} bytes in all`);
for (const [name, run] of [["write() answer ignored", ignoring], ["write() answer respected", respecting], ["pipeline()", piped]]) {
const { most, refusals } = await run();
console.log(`${name.padEnd(26)} most bytes waiting: ${String(most).padStart(6)} write() said false: ${refusals}`);
} Output
Node.js 24.21.0: default highWaterMark 65536 bytes; this sink's is 16384 20000 records, 620000 bytes in all write() answer ignored most bytes waiting: 620000 write() said false: 19472 write() answer respected most bytes waiting: 16399 write() said false: 37 pipeline() most bytes waiting: 16399 write() said false: handled by pipeline
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node backpressure.mjs
Ignoring the answer left all 620,000 bytes waiting at once; write() had said false 19,472 times. Respecting it, by
waiting for drain each of the 37 times, kept at most 16,399 bytes waiting, 529 records of 31 bytes, the last of them
the write that passed the mark, and pipeline() did the same without a line of waiting code. The default mark on
Node.js 24.21.0 is 65,536 bytes, as the first line printed; this sink set 16,384 to keep the numbers small.
Python’s readers pull instead of push: a for loop over a file or over iterparse asks for the next record only when
it has finished the last one, so a synchronous program cannot read ahead of itself. The question comes back with
asynchronous code, where await writer.drain() in asyncio plays the role of the drain event.
Checkpoint
Check: Backpressure
Practise: total a stream that arrives in chunks
The exercise is the reader of this lesson in one function: JSON Lines bytes in chunks cut at every possible position, including inside a city’s name written in Devanagari, totals per city, a clear error with a line number, and invalid bytes refused instead of replaced. The JSON Lines page treats a blank line as not a valid value; this reader skips them:
Exercise · Medium · Python
Total a JSON Lines stream that arrives in chunks
Orders arrive as JSON Lines over the network, one record per line, {"city": "Pune", "paise": 125000}, and the bytes come in chunks of whatever size the connection delivers. Write city_totals(chunks), where chunks is an iterable of bytes, that returns a dict of the total paise per city, while holding only the current line in memory.
- A chunk can end anywhere: in the middle of a record, and in the middle of a character that takes several bytes in UTF-8, such as the letters of
पुणे. Totals must not depend on where the chunks were cut. - Lines end with
\nor\r\n; empty lines are skipped, and the last line may have no line break at all. - A line that is not valid JSON raises
ValueErrorwith a message that starts with its line number,line 3: …. - Bytes that are not valid UTF-8 raise an error too; never replace them silently.
The sample tests cut the same stream at every possible position.
Starter code · totals.py
import json
def city_totals(chunks):
"""Total paise per city from JSON Lines bytes that arrive in chunks. This first try decodes and splits each chunk
on its own, which works only when every chunk ends exactly at the end of a line."""
totals = {}
for chunk in chunks:
for line in chunk.decode("utf-8", errors="replace").split("\n"):
if line.strip():
record = json.loads(line)
totals[record["city"]] = totals.get(record["city"], 0) + record["paise"]
return totals The sample tests · test_totals.py
from totals import city_totals
STREAM = (
'{"city": "Pune", "paise": 125000}\n'
'{"city": "पुणे", "paise": 980}\n'
'\n'
'{"city": "Kochi", "paise": 4500}\r\n'
'{"city": "Pune", "paise": 75000}'
).encode("utf-8")
EXPECTED = {"Pune": 200000, "पुणे": 980, "Kochi": 4500}
def raises(chunks):
try:
city_totals(chunks)
except Exception as e:
return e
return None
def test_one_chunk():
"""the whole stream in one chunk, with a blank line, a CRLF and no final line break"""
assert city_totals([STREAM]) == EXPECTED
def test_every_cut():
"""the same totals wherever the stream is cut in two"""
for cut in range(1, len(STREAM)):
assert city_totals([STREAM[:cut], STREAM[cut:]]) == EXPECTED, f"cut at byte {cut}"
def test_tiny_chunks():
"""one byte at a time, and three bytes at a time"""
assert city_totals([STREAM[i:i + 1] for i in range(len(STREAM))]) == EXPECTED
assert city_totals([STREAM[i:i + 3] for i in range(0, len(STREAM), 3)]) == EXPECTED
def test_empty_stream():
"""no chunks, or only empty ones, give no totals"""
assert city_totals([]) == {}
assert city_totals([b"", b"\n", b""]) == {}
def test_bad_line_names_its_number():
"""a line that is not JSON is refused with its line number"""
error = raises([b'{"city": "Pune", "paise": 1}\n\n{"city": "Kochi", paise: 2}\n'])
assert isinstance(error, ValueError) and str(error).startswith("line 3:"), repr(error)
def test_bad_bytes_are_refused():
"""bytes that are not UTF-8 raise instead of turning into replacement characters"""
assert raises([b'{"city": "Pu\xffne", "paise": 1}\n']) is not None
assert raises([b'{"city": "\xe0\xa4', b'"', b', "paise": 1}\n']) is not None
assert raises([b'{"city": "Pune", "paise": 1}\n', b"\xe0\xa4"]) is not None, "the stream ends inside a character" A hint
Keep one incremental decoder for the whole stream, codecs.getincrementaldecoder("utf-8")(), and a string with the unfinished line. For each chunk, decode it, add it to what was pending, split on "\n", and keep the last piece for the next chunk. When the chunks run out, call the decoder with final=True and handle whatever is still pending.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then check what you would do about memory, one big array, chunk boundaries and backpressure. The session asks first about the idea you know least, and explains each answer:
Practice that adapts to you
Practise this lesson
12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 5 years): what is the difference between parsing a document whole and streaming it?
A whole-document parser, such as json.load or ElementTree.parse, builds every value of the file in memory before
it returns, which costs several times the file’s size: 76 MB for a 13 MB JSON array in CPython. A streaming reader
hands over one record at a time and keeps only that record and a small buffer, so its memory stays flat however large
the file grows. Streaming needs a format or a parser that can find record boundaries: lines in JSON Lines and CSV,
elements with iterparse in XML, an incremental parser for one big JSON array. Choose whole-document parsing when the
file fits easily and the job needs to see everything at once, and streaming when the file is large, grows without
limit or arrives over a network.
Two harder questions follow, about a job killed for memory and about characters damaged in a streamed import. Name the cause you suspect before you read each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A nightly job that totals sales from a 6 GB JSON export is killed for running out of memory. The export is one big array of orders. What do you change, and how do you show that the job now runs within a fixed memory budget?
-
Mid to senior
A streamed import of product prices shows a garbled rupee sign in about one row in four thousand, always a different row, and the source file is fine. What is the bug, and how do you fix it and prove the fix?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How much memory did whole-document parsing take, against streaming?
76.0 MB for a 13.0 MB JSON array and 137.4 MB for 16.6 MB of XML, while streaming readers of the same orders stayed under half a megabyte.
-
How do you stream one huge JSON array?
With an incremental parser: raw_decode over chunks in Python, or a bracket-counting reader in JavaScript that never accepts a value cut off at a chunk's end.
-
Why decode a byte stream with one decoder instead of one per chunk?
A chunk can end in the middle of a UTF-8 character. A new decoder per chunk turned the rupee sign into two replacement characters; one decoder in stream mode carried the bytes over.
-
What must a line reader keep between chunks?
The unfinished last line, until its line break arrives in a later chunk.
-
What is backpressure in a Node.js stream?
The writer asking the reader to wait. Respect write() returning false and the drain event, or let pipeline() do it, and the buffer stays at the high-water mark.
-
When is parsing a whole document still the right choice?
When the file fits easily in memory and the job must see all of it at once. Stream when it is large, grows without limit or arrives over a network.
Key takeaways
- A whole-document parser holds every record: 76.0 MB for a 13.0 MB JSON array and 137.4 MB for 16.6 MB of XML, while streaming readers of the same orders stayed under half a megabyte.
- One huge JSON array streams with an incremental parser,
raw_decodeon chunks in Python or a bracket-counting reader in JavaScript, which must not accept a value cut off at the end of a chunk. - Chunks cut through characters and records: use one decoder for the whole stream, refuse invalid bytes, and keep the unfinished line until its line break arrives.
- Backpressure is the writer saying wait: respect
write()’sfalseand thedrainevent, or letpipeline()do it, and the buffer stays at the high-water mark instead of growing to the whole file.
Terms in this lesson
- backpressure
- A signal from the slow end of a pipeline to the fast end to wait, so that data does not pile up in memory. In Node.js, write() answers false and the stream later emits drain.
- high-water mark
- The amount of buffered data at which a Node.js stream asks the producer to wait: 65,536 bytes by default for byte streams in Node.js 24, or 16 objects in object mode.
- incremental decoder
- A decoder that turns a stream of byte chunks into text and keeps the unfinished bytes of a character for the next chunk, such as codecs.getincrementaldecoder in Python or a TextDecoder used with stream: true.
- peak memory
- The most memory a program held at any one moment of a run. Python's tracemalloc reports it with get_traced_memory(), and it decides whether a job fits a machine.
References
- JSON Lines (JSON Lines)
- json: JSON encoder and decoder (JSONDecoder.raw_decode) (Python Software Foundation)
- tracemalloc: trace memory allocations (Python Software Foundation)
- xml.etree.ElementTree: iterparse (Python Software Foundation)
- csv: CSV file reading and writing (Python Software Foundation)
- codecs: incremental decoders (Python Software Foundation)
- Encoding Standard: TextDecoder and TextDecoderStream (WHATWG)
- Backpressuring in streams (OpenJS Foundation)
- Node.js stream module (OpenJS Foundation)
- asyncio streams: StreamWriter.drain (Python Software Foundation)
- Response: body property (MDN Web Docs) (Mozilla)
- ijson: iterative JSON parser for Python (ijson project)
- @streamparser/json: streaming JSON parser for JavaScript (streamparser-json project)
- stream-json: components for processing JSON streams (stream-json project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress