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 join 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
Expect (b) to cost a few percent more than (a), and (c) to cost roughly as much as another scan.
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
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)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
| Knob · default | What 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
| Variant | Time | Of parse only |
|---|---|---|
| (a) parse only | 4.84 s | 100% |
| (b) parse + checks in the same pass | 5.4 s | 111% |
| (c) parse, then a separate checking pass | 5.36 s | 111% |
(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 memoryWhat 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.