Splittability: CSV with quoted newlines versus NDJSON
Break it on purposeNeeds Python
Problem
A file is splittable when a reader can start at any byte offset, find the next record boundary, and parse from there; that is how parallel engines divide one large file among workers. NDJSON is splittable because a newline always ends a record: JSON escapes newlines inside strings. CSV is not: a quoted field may contain a newline, and a reader that starts in the middle of the file cannot tell whether the next newline ends a record or sits inside a quote.
Write the same 100,000 records with a note field in both formats. Some notes contain a newline. Then simulate a worker that starts at a random byte offset, skips to the next newline, and begins parsing; 200 splits per format.
The question. How many splits start inside a record and produce broken lines, in CSV and in NDJSON?
Predict
NDJSON gives 0 bad records (except possibly the last, cut at the read limit); CSV sometimes gives broken lines, because a start point can land inside a quoted note.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
splittability-csv-with-quoted-newlines-versus.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Splittability: CSV with quoted newlines against NDJSON. The same 100,000 records, some with a newline inside a note, as CSV and as NDJSON. Parallel readers start at random byte offsets, skip to the next newline and parse: how many records come out broken? """ import argparse, csv, json, os, random, tempfile p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--records", type=int, default=100_000, help="Records.") p.add_argument("--splits", type=int, default=200, help="Random start offsets tried.") p.add_argument("--chunk", type=int, default=200_000, help="Bytes each split reads.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) d = tempfile.mkdtemp() rows = [{"id": i, "note": "ok" if i % 7 else "Fragile\nhandle with care"} for i in range(args.records)] csvp, js = os.path.join(d, "data.csv"), os.path.join(d, "data.ndjson") with open(csvp, "w", newline="") as f: w = csv.DictWriter(f, fieldnames=["id", "note"]); w.writeheader(); w.writerows(rows) with open(js, "w") as f: for r in rows: f.write(json.dumps(r) + "\n") def split_read(path, start): with open(path, "rb") as f: f.seek(start); f.readline() text = f.read(args.chunk).decode(errors="ignore") return text.splitlines()[:-1] # drop the line cut by the read limit def bad_csv(lines): """Parse from the split point with a real CSV reader; a record is broken if its id is not a number or it does not have exactly two fields (the split started inside a quoted note).""" return sum(1 for rec in csv.reader(lines) if len(rec) != 2 or not rec[0].isdigit()) def bad_json(lines): bad = 0 for ln in lines: try: json.loads(ln) except json.JSONDecodeError: bad += 1 return bad res = {"CSV": [0, 0], "NDJSON": [0, 0]} for _ in range(args.splits): for fmt, path, fn in (("CSV", csvp, bad_csv), ("NDJSON", js, bad_json)): s = random.randrange(1000, os.path.getsize(path) - args.chunk) b = fn(split_read(path, s)); res[fmt][0] += b; res[fmt][1] += b > 0 out = [{"format": k, "broken_lines": v[0], "splits_with_broken": v[1], "splits": args.splits} for k, v in res.items()] for r in out: print(f"{r['format']:<7} {r['splits_with_broken']:>3} of {args.splits} splits hit broken records ({r['broken_lines']:,} broken lines)") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"formats": out}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run splittability-csv-with-quoted-newlines-versus.py uv run splittability-csv-with-quoted-newlines-versus.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --records100,000 | Records. |
| --splits200 | Random start offsets tried. |
| --chunk200,000 | Bytes each split reads. |
| --seed0 | Random seed. |
Our run
200 random splits per format
| Format | Splits with broken records | Broken lines |
|---|---|---|
| CSV | 28 | 28 |
| NDJSON | 0 | 0 |
CSV 28 of 200 splits hit broken records (28 broken lines)
NDJSON 0 of 200 splits hit broken records (0 broken lines)What we found
28 of 200 CSV splits started inside a quoted note and produced broken records; NDJSON produced 0.
Read it
A format's splittability is decided by whether its record separator can appear inside a record. CSV can be split safely only with an index of record offsets, or not at all, which is why engines often read a large CSV with one worker, and why line-delimited formats or columnar files with explicit block boundaries scale out.