Skip to content
About
Main content

Per-value checks ride on the parse

Measure your machineNeeds Python

Problem

Data-quality checks differ enormously in cost. A check on one value (is quantity a positive integer below 100?) needs only that value, which the parser is already reading to convert it, so it rides along almost for free. A check across rows or tables (does every customer_id exist?) needs a or a second scan. Putting value checks inside the load, rather than as a separate job afterwards, is what makes them cheap enough to run on every row.

Generate a CSV of 5 million rows with an integer, a decimal and a date column. Time (a) parsing only, and (b) parsing plus type, range and not-null checks done in the same pass. Then time (c) a separate second pass that checks the already-parsed data.

The question. What does adding value checks to the parse cost, and what does a separate checking pass cost?


Predict

A separate checking pass over rows already parsed into memory: what does it cost against the parse?

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.

    per-value-checks-ride-on-the.py70 linesDownload
    View the code
    per-value-checks-ride-on-the.py
    # /// script
    # requires-python = ">=3.10"
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Per-value checks ride on the parse.
    
    A CSV of 5 million rows with an integer, a decimal and a date. Timed:
    (a) parsing only, (b) parsing plus type, range and not-null checks in the
    same pass, (c) parsing, then a separate second pass over the parsed rows.
    """
    import argparse, csv, datetime, json, os, random, tempfile, time
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--rows", type=int, default=5_000_000, help="Rows in the CSV.")
    p.add_argument("--path", default=os.path.join(tempfile.gettempdir(), "checks-lab.csv"), help="Where the CSV is written.")
    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()
    import atexit, shutil
    atexit.register(lambda: shutil.rmtree(args.path, ignore_errors=True) if os.path.isdir(args.path) else os.path.exists(args.path) and os.remove(args.path))   # leave nothing behind
    random.seed(args.seed)
    
    if not os.path.exists(args.path):
        with open(args.path, "w", newline="") as f:
            w = csv.writer(f); w.writerow(["qty", "price", "day"])
            base = datetime.date(2025, 1, 1)
            for i in range(args.rows):
                w.writerow([random.randint(0, 120), f"{random.uniform(0.5, 120000):.2f}", (base + datetime.timedelta(days=i % 365)).isoformat()])
    
    def ok(q, p, d): return d is not None and 1 <= q <= 100 and 0 < p < 100000
    
    def parse_only():
        n = 0
        with open(args.path) as f:
            for r in csv.DictReader(f):
                q = int(r["qty"]); p = float(r["price"]); d = datetime.date.fromisoformat(r["day"]); n += 1
        return n
    
    def parse_and_check():
        n = bad = 0
        with open(args.path) as f:
            for r in csv.DictReader(f):
                q = int(r["qty"]); p = float(r["price"]); d = datetime.date.fromisoformat(r["day"]); n += 1
                if not ok(q, p, d): bad += 1
        return n, bad
    
    def parse_then_second_pass():
        rows = []
        with open(args.path) as f:
            for r in csv.DictReader(f):
                rows.append((int(r["qty"]), float(r["price"]), datetime.date.fromisoformat(r["day"])))
        t = time.perf_counter()
        bad = sum(1 for q, p, d in rows if not ok(q, p, d))
        return time.perf_counter() - t, bad
    
    t = time.perf_counter(); parse_only(); a = time.perf_counter() - t
    t = time.perf_counter(); _, bad = parse_and_check(); b = time.perf_counter() - t
    second, _ = parse_then_second_pass()
    rows = [{"variant": "(a) parse only", "seconds": a, "vs_parse_pct": 100.0},
            {"variant": "(b) parse + checks in the same pass", "seconds": b, "vs_parse_pct": b / a * 100},
            {"variant": "(c) parse, then a separate checking pass", "seconds": a + second, "vs_parse_pct": (a + second) / a * 100}]
    for r in rows: print(f"{r['variant']:<44} {r['seconds']:6.2f} s  ({r['vs_parse_pct']:5.1f}% of parse only)")
    print(f"rows failing the checks: {bad:,}; the separate pass alone took {second:.2f} s over rows already in memory")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"variants": rows, "summary": [{"inline_extra_pct": (b / a - 1) * 100, "second_pass_s": second,
                       "second_pass_pct": second / a * 100, "bad": bad}]}}, 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 per-value-checks-ride-on-the.py
    uv run per-value-checks-ride-on-the.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--rows5,000,000 Rows in the CSV.
--pathos.path.join(tempfile.gettempdir(), 'checks-lab.csv') Where the CSV is written.
--seed0 Random seed.

Our run

5 million rows

5 million rows
VariantTimeOf parse only
(a) parse only4.84 s100%
(b) parse + checks in the same pass5.4 s111%
(c) parse, then a separate checking pass5.36 s111%
Reference runApple M2 · 8 cores · 8 GB
Output · uv run per-value-checks-ride-on-the.py
(a) parse only                                 4.84 s  (100.0% of parse only)
(b) parse + checks in the same pass            5.40 s  (111.4% of parse only)
(c) parse, then a separate checking pass       5.36 s  (110.7% of parse only)
rows failing the checks: 1,557,240; the separate pass alone took 0.52 s over rows already in memory
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

What we found

Parsing took 4.84 s; adding the checks in the same pass added 11.4%, more than "a few percent" because Python's per-value check is not tiny next to its parsing, though still small. The separate pass over rows already parsed into memory took 517 ms, 10.7% of the parse: about the same as checking inline, not the cost of another scan. A second pass costs a scan only when the data has to be read and parsed again, which is what a separate quality job running after the load usually does. The checks flagged 1.56M rows, by design, since the generator draws values outside the ranges.


Read it

Value checks are nearly free when they ride on a pass you already make, and expensive when they force a pass of their own. Put type, range and not-null checks in the load; reserve separate jobs for the checks that need other rows or other tables.