Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 15 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Five readers of 200,000 orders, with their peak memory Python · measure/measure.py
"""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:

Interactive · answer and compare Which reader fits your large file? Answer three questions about a file you must process. The table rules out the readers that would not work for it.
Does the parsed file fit in memory with room to spare, at five to ten times its size?
Does the job need the whole document at once, to sort it or follow references inside it?
What is the file?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to read a large data file, against what decides between them
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.

Large CSV File Viewer Open a CSV file of several gigabytes in your browser and jump to any row, without loading it whole.

Checkpoint

Check: Whole-document and streaming readers

  1. Question 1 of 2 json.load took 76.0 MB for a 13.0 MB array. About how many megabytes would it take for a 1.3 GB export of the same kind of orders?

    Type a number.

    A hint

    The peak grows in step with the file.

    Show the answer: Question 1 of 2

    Answer: 7600 MB (anything from 7200 to 8000 counts)

    1.3 GB is 100 times 13.0 MB, so the peak is about 100 times 76.0 MB, some 7.6 GB, which is why the job must stream instead. A streaming reader stays near 0.2 MB whatever the size.

  2. Question 2 of 2 Which of these readers already streams, with nothing extra to do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: csv.DictReader over an open file

    CSV and JSON Lines readers stream by nature. XML streams with iterparse plus clear(), and one JSON array needs an incremental parser.

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:

Interactive · stage by stage How iter_array reads one JSON array a chunk at a time Step through one item. Watch the buffer hold one chunk at most, and the loop ask for more only when an item is cut.
How iter_array reads one JSON array a chunk at a time, step 6 of 6. Showing Read a chunk, Skip spaces and a comma, raw_decode one value, Yield the item, Drop what was read. In focus: Drop what was read.cut off: read moreRead a chunk65,536 charactersSkip spaces and a commathen expect , or ]raw_decode one valuefrom the current positionYield the itemone order at a timeDrop what was readthe buffer stays small

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 read item by item with raw_decode Python · array/stream_array.py
"""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:

An incremental array reader for JavaScript, fed in 1,000-byte chunks JavaScript · array/stream_array.mjs
// 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

  1. Question 1 of 2 What does json.JSONDecoder.raw_decode return, and why does iter_array need it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The value parsed from a position, and the position where it ended, so the next item can start there

    With raw_decode, a loop can skip spaces and commas, decode one item, and continue from the returned position.

  2. Question 2 of 2 Why does the JavaScript reader track strings and escapes, and not only count brackets?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Brackets and quotes inside a string, such as says "hi" [ok], must not end an item

    One note in every 997 orders holds brackets and quotes, and every item still came out whole, because the reader only counted brackets outside strings.

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:

Interactive · message by message Two chunks cut inside one character Step through the bytes of two JSON Lines records arriving in two chunks. Watch which reader keeps what for later.
Two chunks cut inside one character, step 6 of 6. Messages so far: The connection to The decoder: 26 bytes, ends E2 82; The decoder to The line splitter: text before the sign; The connection to The decoder: 43 bytes, starts B9; The decoder to The line splitter: rest of the text.The connectiondelivers chunksThe decoderwrites the sign wholeThe line splittertwo records, both right26 bytes, ends E2 82text before the sign43 bytes, starts B9rest of the text

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

A rupee sign split between two chunks, decoded four ways JavaScript · chunks/split_utf8.mjs
// 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 chunks with bytes.decode, errors='replace' and an incremental decoder Python · chunks/split_utf8.py
"""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

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.

Hex Viewer & File Inspector Open a file and look at its bytes, to see where a multi-byte character starts and where a chunk cut it.

Checkpoint

Check: Chunk boundaries and split characters

  1. Question 1 of 2 Each chunk got a new TextDecoder. What became of the rupee sign that the chunk boundary cut?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Two replacement characters, U+FFFD, and no error

    split_utf8.mjs printed a price of two replacement characters and 1,250. Silent damage is worse than an error, which is why the lesson refuses invalid bytes.

  2. Question 2 of 2 After splitting the decoded text on line breaks, why does the reader keep the last piece instead of parsing it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It has no line break yet, so it may be the start of a record whose rest is in the next chunk

    The first chunk ended inside the first record, so the splitter kept that piece and parsed both records only after the second chunk arrived.

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:

Interactive · message by message write() says false, and the producer waits Step through a fast producer and a slow writer. Watch the buffer between them, and who decides when to go on.
write() says false, and the producer waits, step 5 of 5. Messages so far: Producer to The stream's buffer: write(): true; The stream's buffer to Producer: write(): false; The stream's buffer to Slow writer: one write at a time; The stream's buffer to Producer: drain.Producerrecords, 31 bytes eachThe stream's bufferempty againSlow writerone write per turnwrite(): truewrite(): falseone write at a timedrain

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

Ignoring write()'s answer, respecting it, and pipeline() JavaScript · pressure/backpressure.mjs
// 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.

JSON Lines (JSONL / NDJSON) Converter Check a JSON Lines file line by line, with exact line numbers for every error, and convert it to JSON or CSV.

Checkpoint

Check: Backpressure

  1. Question 1 of 2 What does it mean when a Node.js Writable answers false to write()?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its buffer has passed the high-water mark, so the producer should wait for drain

    In the run, write() said false 37 times for the producer that respected it, and each time it waited for drain.

  2. Question 2 of 2 The producer that ignored write()'s answer left 620,000 bytes waiting. What did pipeline() leave at most?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 16,399 bytes, just past the mark of 16,384

    The default mark of Node.js 24.21.0 is 65,536 bytes; the sink of the program used 16,384, and the buffer never held more than that plus the record that crossed it.

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 \n or \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 ValueError with 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.

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.

JSON Viewer & Tree Editor Browse and search a large JSON file as a tree in your browser, and copy the path of any value.

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.

  1. Question 1 of 12 Why does json.load of a 13.0 MB file take so much more than 13.0 MB?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: Every value becomes a Python object with its own overhead, and all of them exist at once

    The ratio was almost six for JSON and more than eight for XML, whose elements carry a tag, attributes and text each.

  2. Question 2 of 12 A nightly job must sort 3 GB of JSON Lines orders by amount. Which plan fits?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: Stream the lines into a database or into sorted runs on disk, then sort there

    The trade-off table puts a caveat on every streaming reader when the job needs the whole document: keep only what the job needs, or load the records into a store first.

  3. Question 3 of 12 This loop totals orders from a large XML file. Its memory grows with the file. Why?

    Read the code, then choose one answer.

    for event, el in ET.iterparse("orders.xml"):
        if el.tag == "order":
            totals[el.findtext("city")] += int(el.findtext("paise"))
    Show the answer: Question 3 of 12

    Answer: Nothing clears the finished orders, so the tree under the root keeps every one of them

    measure.py's iterparse version calls root.clear() after each order, which kept it at 0.4 MB.

  4. Question 4 of 12 Why does iter_array not accept a value that ends exactly at the end of its buffer?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: The value may go on in the next chunk, as 12 may really be 1234

    For objects the closing brace proves the end, but a top-level number or literal does not, so the reader waits until something follows the value or the file has ended.

  5. Question 5 of 12 In ijson, which prefix yields each element of a top-level JSON array?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: item

    ijson.items(f, "item") streams the elements of the top-level array, which is what iter_array does by hand.

  6. Question 6 of 12 You control the export format of a service whose 4 GB file is read by several teams. What do you change?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Write JSON Lines instead of one big array, one order per line

    The cheapest streaming is a format whose records are lines. An incremental parser is the answer when the format is not yours to change.

  7. Question 7 of 12 How many bytes does the rupee sign take in UTF-8?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Three, E2 82 B9

    A chunk can end after one or two of the three bytes, which is why decoding must keep unfinished sequences.

  8. Question 8 of 12 The first chunk ends after two of the rupee sign's three bytes. What does this do?

    Read the code, then choose one answer.

    parts[0].decode("utf-8")
    Show the answer: Question 8 of 12

    Answer: It raises UnicodeDecodeError: unexpected end of data

    split_utf8.py printed "can't decode bytes in position 24-25: unexpected end of data". Python's default refuses; JavaScript's default replaces.

  9. Question 9 of 12 A Python job reads a local file with open(path, encoding="utf-8") and iterates over its lines. Does it need an incremental decoder of its own?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: No, a file opened in text mode decodes incrementally already

    The trap is raw bytes: HTTP bodies, sockets and read(n) on a file opened in binary mode.

  10. Question 10 of 12 What is backpressure in a stream pipeline?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: The slower part signalling the faster one to wait, so data does not pile up in memory

    Without it, the producer's speed decides how much waits in memory: all 620,000 bytes in the run.

  11. Question 11 of 12 What default highWaterMark, in bytes, did Node.js 24.21.0 print for a byte stream?

    Type a number.

    Show the answer: Question 11 of 12

    Answer: 65536 bytes

    getDefaultHighWaterMark(false) printed 65536. Object-mode streams count objects instead, 16 by default.

  12. Question 12 of 12 Why does a synchronous Python loop over iterparse need no backpressure code?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: It pulls the next record only after it has finished the last one, so it can never read ahead of itself

    The question returns with asynchronous code, where await writer.drain() plays the role of the drain event.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. What must a line reader keep between chunks?

    The unfinished last line, until its line break arrives in a later chunk.

  5. 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.

  6. 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_decode on 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()’s false and the drain event, or let pipeline() 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.