Reader-side default rewrites history
Measure your machineNeeds Python
Problem
When a field is added with a default, the default can live in the reader (old records without the field are read as if they had it) or in the writer (the value is stored with each new record). A reader-side default is applied every time the data is read, so changing it changes the meaning of every old record, without touching the data. A writer-side default records what was true when the record was written, and changing it affects only new records.
Create a table of 1,000 events with no delivery_priority field. Apply the reader schema {"delivery_priority": "STANDARD"} and count by value. Change the reader default to "EXPRESS" without touching the data and count again. Then do it with a writer-side default (store the value in the data). Write down which of the three states (absent, null, defaulted) each version can distinguish.
The question. Does changing the default change the counts of existing data, and which states can each approach tell apart?
Predict
Expect the first count to move 100% from STANDARD to EXPRESS with no data change. The writer-side version does not move.
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
reader-side-default-rewrites-history.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """A reader-side default rewrites history. 1,000 stored events with no delivery_priority field, counted by value under a reader schema whose default is STANDARD, then EXPRESS, without touching the data. Then the same with the default written into the data at write time. Also checks which of the three states (absent, null, defaulted) each approach can still tell apart. """ import argparse, json, random from collections import Counter p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--events", type=int, default=1000, help="Events.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() stored_old = [{"id": i} for i in range(args.events)] # written before the field existed def read(stored, default): return Counter(e.get("delivery_priority", default) for e in stored) # the reader fills in the default def write(default): return [{"id": i, "delivery_priority": default} for i in range(args.events)] # the writer stores it rows = [] for d in ("STANDARD", "EXPRESS"): c = read(stored_old, d); rows.append({"approach": "reader-side default", "default_now": d, "STANDARD": c.get("STANDARD", 0), "EXPRESS": c.get("EXPRESS", 0)}) stored_new = write("STANDARD") # the default as it was at write time for d in ("STANDARD", "EXPRESS"): c = read(stored_new, d); rows.append({"approach": "writer-side default", "default_now": d, "STANDARD": c.get("STANDARD", 0), "EXPRESS": c.get("EXPRESS", 0)}) mixed = [{"id": 1}, {"id": 2, "delivery_priority": None}, {"id": 3, "delivery_priority": "STANDARD"}] states = [{"approach": "reader-side default", "absent_vs_defaulted": "no", "null_vs_defaulted": "yes", "seen": ", ".join(str(e.get("delivery_priority", "STANDARD")) for e in mixed)}, {"approach": "writer-side default", "absent_vs_defaulted": "only if absent rows predate the field", "null_vs_defaulted": "yes", "seen": ", ".join(str(e.get("delivery_priority", "<absent>")) for e in mixed)}] for r in rows: print(f"{r['approach']:<20} default {r['default_now']:<9} STANDARD {r['STANDARD']:>5} EXPRESS {r['EXPRESS']:>5}") for s in states: print(f"{s['approach']:<20} absent / null / defaulted reads as: {s['seen']}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"counts": rows, "states": states}}, 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 reader-side-default-rewrites-history.py uv run reader-side-default-rewrites-history.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --events1000 | Events. |
Our run
1,000 existing events
| Default | Default value now | STANDARD | EXPRESS |
|---|---|---|---|
| reader-side default | STANDARD | 1,000 | 0 |
| reader-side default | EXPRESS | 0 | 1,000 |
| writer-side default | STANDARD | 1,000 | 0 |
| writer-side default | EXPRESS | 1,000 | 0 |
Absent, null and defaulted records, as read
| Default | Read as |
|---|---|
| reader-side default | STANDARD, None, STANDARD |
| writer-side default | <absent>, None, STANDARD |
reader-side default default STANDARD STANDARD 1000 EXPRESS 0
reader-side default default EXPRESS STANDARD 0 EXPRESS 1000
writer-side default default STANDARD STANDARD 1000 EXPRESS 0
writer-side default default EXPRESS STANDARD 1000 EXPRESS 0
reader-side default absent / null / defaulted reads as: STANDARD, None, STANDARD
writer-side default absent / null / defaulted reads as: <absent>, None, STANDARDWhat we found
With the reader-side default, changing it moved all 1,000 events from STANDARD to EXPRESS without any data changing. With the writer-side default, the counts stayed at 1,000 STANDARD. Read through the reader-side default, an absent, a null and a defaulted record came out as STANDARD, None, STANDARD: absent and defaulted are indistinguishable. With writer-side defaults: <absent>, None, STANDARD.
Read it
A reader-side default is a statement about the past made in the present. That is acceptable for a value that genuinely applies to all history and dangerous for a business assumption that can change. If the default might ever change, write it into the data, so that history records what was assumed when it was written.