Skip to content
About
Main content

Positional versus named CSV

Break it on purposeNeeds Python

Problem

A reader identifies fields either by position ("the third ") or by name ("price_cents"). Position is a silent : 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

After a column is inserted, how many of 1,000 rows does the positional reader get wrong?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    positional-versus-named-csv.py43 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--rows1000 Shipments.
--seed0 Random seed.

Our run

Three reads

Three reads
ReadExceptionRows wrongFirst row read as
old file, by positionnone0volume 38.1, price 28062
new file, by positionnone1,000volume 17.0, price 38
new file with a header, by namenone0volume 38.1, price 28062
Reference runApple M2 · 8 cores · 8 GB
Output · uv run positional-versus-named-csv.py
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 28062
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What 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 ) so that adding a field is an addition, and validate types and ranges, since a price of 38 cents is a valid number.