Positional versus named CSV
Break it on purposeNeeds Python
Problem
A reader identifies fields either by position ("the third column") or by name ("price_cents"). Position is a silent contract: insert a column in the middle and every field after it shifts, and a reader that parses numbers will happily parse the wrong ones. Names survive insertion, at the cost of carrying the names with the data.
Write the headerless CSV from Identity by position with columns shipment_id, volume_kg, price_cents (1,000 shipments). Write a reader that uses position. Insert a weight_kg column in the middle and run the reader again. Then add a header and read by name. Count how many rows are wrong.
The question. After the insertion, does the positional reader fail, and how many rows does it read wrong?
Predict
Expect no exception, and price_cents equal to the old volume. Notice that nothing in the output tells you.
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
positional-versus-named-csv.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Positional against named CSV. A headerless CSV (shipment_id, volume_kg, price_cents) read by position, then the same reader after a weight_kg column is inserted in the middle, then the new file with a header, read by name. Counts the rows read wrong. """ import argparse, csv, io, json, random p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rows", type=int, default=1000, help="Shipments.") 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) truth = [{"shipment_id": f"S{i}", "weight_kg": round(random.uniform(0.5, 20), 1), "volume_kg": round(random.uniform(1, 50), 1), "price_cents": random.randrange(500, 50000)} for i in range(args.rows)] old = "".join(f"{r['shipment_id']},{r['volume_kg']},{r['price_cents']}\n" for r in truth) new = "".join(f"{r['shipment_id']},{r['weight_kg']},{r['volume_kg']},{r['price_cents']}\n" for r in truth) # weight_kg inserted at position 2 def read_by_position(text): return [{"shipment_id": r[0], "volume_kg": float(r[1]), "price_cents": float(r[2])} for r in csv.reader(io.StringIO(text))] def read_by_name(text): return [{"shipment_id": r["shipment_id"], "volume_kg": float(r["volume_kg"]), "price_cents": float(r["price_cents"])} for r in csv.DictReader(io.StringIO(text))] def wrong(rows): return sum(1 for r, t in zip(rows, truth) if r["volume_kg"] != t["volume_kg"] or r["price_cents"] != t["price_cents"]) results = [] for label, fn, text in (("old file, by position", read_by_position, old), ("new file, by position", read_by_position, new), ("new file with a header, by name", read_by_name, "shipment_id,weight_kg,volume_kg,price_cents\n" + new)): try: rows = fn(text); results.append({"read": label, "error": "none", "rows": len(rows), "wrong": wrong(rows), "first": f"volume {rows[0]['volume_kg']}, price {rows[0]['price_cents']:.0f}"}) except Exception as e: results.append({"read": label, "error": type(e).__name__, "rows": 0, "wrong": None, "first": ""}) summary = {"truth_first": f"volume {truth[0]['volume_kg']}, price {truth[0]['price_cents']}"} for r in results: print(f"{r['read']:<34} exception: {r['error']:<5} rows {r['rows']:>5} wrong {r['wrong']:>5} first row: {r['first']}") print(f"true first row: {summary['truth_first']}") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"reads": results}}, 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 positional-versus-named-csv.py uv run positional-versus-named-csv.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows1000 | Shipments. |
| --seed0 | Random seed. |
Our run
Three reads
| Read | Exception | Rows wrong | First row read as |
|---|---|---|---|
| old file, by position | none | 0 | volume 38.1, price 28062 |
| new file, by position | none | 1,000 | volume 17.0, price 38 |
| new file with a header, by name | none | 0 | volume 38.1, price 28062 |
old file, by position exception: none rows 1000 wrong 0 first row: volume 38.1, price 28062
new file, by position exception: none rows 1000 wrong 1000 first row: volume 17.0, price 38
new file with a header, by name exception: none rows 1000 wrong 0 first row: volume 38.1, price 28062
true first row: volume 38.1, price 28062What we found
After the insertion, the positional reader raised none exception and read all 1,000 rows wrong: the first shipment came out as volume 17.0, price 38 instead of volume 38.1, price 28062, the weight in the volume column and the volume as the price. Read by name, the new file gave 0 wrong rows.
Read it
A positional format turns every schema change into a data change unless every reader changes in lockstep. Carry names (a header, a self-describing format, a schema registry) so that adding a field is an addition, and validate types and ranges, since a price of 38 cents is a valid number.