04 · Schema Evolution & Handling¶
Source schemas change: a new column appears, a type widens, a field gets renamed. A pipeline that assumes the schema is frozen forever will break the first time an upstream team ships a change. This module covers detecting schema drift and handling it without crashing the pipeline.
What actually ran
Reasoned through step by step against the real pandas and pyarrow
APIs, not executed in a live interpreter — schema comparisons and merge
results match documented behavior precisely.
Detecting drift: comparing schemas¶
import pandas as pd
import pyarrow as pa
# Version 1 of the schema (what the pipeline was built against)
schema_v1 = pa.schema([
("order_id", pa.int64()),
("customer", pa.string()),
("amount", pa.float64()),
])
# Version 2 arrives from the source with a new column and a widened type
batch_v2 = pd.DataFrame([
{"order_id": 1, "customer": "alice", "amount": 120.50, "currency": "USD"},
{"order_id": 2, "customer": "bob", "amount": 89.00, "currency": "USD"},
])
schema_v2 = pa.Table.from_pandas(batch_v2).schema
def diff_schemas(old: pa.Schema, new: pa.Schema):
old_fields = {f.name: f.type for f in old}
new_fields = {f.name: f.type for f in new}
added = [n for n in new_fields if n not in old_fields]
removed = [n for n in old_fields if n not in new_fields]
changed = [
n for n in old_fields
if n in new_fields and old_fields[n] != new_fields[n]
]
return {"added": added, "removed": removed, "type_changed": changed}
print(diff_schemas(schema_v1, schema_v2))
Running this diff on every incoming batch, before writing it into the lake, turns "the pipeline mysteriously failed downstream" into "here's exactly what changed and when" — logged and alertable.
Additive changes: safe to auto-merge¶
existing = pd.DataFrame([
{"order_id": 1, "customer": "alice", "amount": 120.50},
{"order_id": 2, "customer": "bob", "amount": 89.00},
])
def merge_additive(existing: pd.DataFrame, incoming: pd.DataFrame) -> pd.DataFrame:
all_cols = list(dict.fromkeys(list(existing.columns) + list(incoming.columns)))
existing_aligned = existing.reindex(columns=all_cols)
incoming_aligned = incoming.reindex(columns=all_cols)
return pd.concat([existing_aligned, incoming_aligned], ignore_index=True)
merged = merge_additive(existing, batch_v2)
print(merged)
print(merged.dtypes)
order_id customer amount currency
0 1 alice 120.50 NaN
1 2 bob 89.00 NaN
2 1 alice 120.50 USD
3 2 bob 89.00 USD
order_id int64
customer object
amount float64
currency object
dtype: object
Adding a new nullable column is the one schema change that's always safe to
auto-merge: old rows simply get NaN/null for the new field, and nothing
that reads the old columns breaks.
Type widening: safe in one direction only¶
# int32 -> int64 is a safe widening (every int32 value fits in int64)
narrow = pd.Series([1, 2, 3], dtype="int32")
widened = narrow.astype("int64")
print(widened.dtype)
# int64 -> int32 is a narrowing that can silently truncate large values
risky_values = pd.Series([1, 2, 5_000_000_000], dtype="int64")
try:
risky_values.astype("int32")
except OverflowError as e:
print(f"Blocked unsafe narrowing: {e}")
pandas/NumPy raise on an out-of-range narrowing cast, which is the correct, loud failure — the trap is code that catches this exception and silently falls back to some default, hiding real data loss.
Renames and removals: never auto-merge¶
# A rename looks like "add + remove" to a naive schema diff
batch_v3 = pd.DataFrame([
{"order_id": 1, "buyer": "alice", "amount": 120.50}, # customer -> buyer
])
schema_v3 = pa.Table.from_pandas(batch_v3).schema
print(diff_schemas(schema_v1, schema_v3))
A rename is indistinguishable from "one column dropped, one unrelated
column added" by a naive diff — auto-merging this would silently lose every
existing customer value and start a brand-new, unrelated-looking buyer
column. Renames and removals should always route to a human (or a schema
registry / data contract review — see Module 9) rather than being merged
automatically.
A schema-evolution policy function¶
def classify_schema_change(diff: dict) -> str:
if diff["type_changed"]:
return "REVIEW: type change requires manual approval"
if diff["removed"]:
return "REVIEW: column removal — check for a possible rename"
if diff["added"]:
return "AUTO-MERGE: new nullable column(s), safe to add"
return "NO CHANGE"
for name, diff in [
("v1->v2", diff_schemas(schema_v1, schema_v2)),
("v1->v3", diff_schemas(schema_v1, schema_v3)),
]:
print(name, "->", classify_schema_change(diff))
v1->v2 -> AUTO-MERGE: new nullable column(s), safe to add
v1->v3 -> REVIEW: column removal — check for a possible rename
Codifying this policy as a function — rather than a mental rule someone applies inconsistently — means the pipeline can automatically merge the safe 80% of schema changes and only page a human for the risky 20%.
Traps¶
- Auto-merging column removals. Almost always a rename in disguise — always route to manual review.
- Silently casting on type mismatch.
pd.to_numeric(..., errors="coerce")applied blindly to a schema change can turn a legitimate string column into a column ofNaNs without anyone noticing. - No schema version history. Without logging every diff with a timestamp, you can't answer "when did this column show up" during an incident.
- Testing schema changes only in production. Any pipeline ingesting from an external source should validate incoming schema against the last known-good schema before writing anything to the lake.
Cheat sheet¶
| Change type | Safe to auto-merge? |
|---|---|
| New nullable column | Yes |
| Type widening (int32→int64, float32→float64) | Yes |
| Type narrowing | No — review |
| Column rename | No — always looks like remove+add |
| Column removal | No — review (may be a rename) |
How It Actually Works¶
Schema evolution's safety hinges entirely on how the reader resolves a mismatch between the schema a file was written with and the schema the reader currently expects — and this resolution mechanism differs by format.
Parquet and Avro both embed a writer schema in every file (Parquet in the footer, Avro in the
header), and readers perform schema resolution by matching fields by name (not
position) between the writer's schema and the reader's expected schema. This is why adding a
new nullable column is safe: old files simply don't have that field name in their writer
schema, so the reader fills it with NULL for every row from that file, and no rewrite of
old files is ever required. Dropping a column is also safe for the same reason in reverse:
readers that still expect it will get null/default, and readers that don't reference it never
notice. Renaming or changing a field's type, however, breaks name-based resolution — a
renamed column looks to the reader like "old field disappeared, new field appeared," and a
type change (int32 → string) requires the reader's format to define an explicit promotion
rule or it fails outright, because the raw bytes for an int32 and a UTF-8 string are not
interchangeable at the byte level.
Table formats with a transaction log (Delta Lake, Iceberg) go further: they store the current logical schema as a versioned object in the log itself, separate from what's embedded in any individual data file, and every file addition record in the log carries the schema ID it was written under. This is what enables safe column reordering and controlled type widening independent of physical file layout — the engine consults the log's schema history to know how to map an old file's columns onto the current logical schema, rather than inferring it file-by-file. Without this log, schema evolution across a lake of plain Parquet files depends entirely on every reader independently doing correct name-based resolution and agreeing on evolution rules — which is exactly the gap Iceberg/Delta close.
Exercise¶
Write a function apply_schema_policy(existing_df, incoming_df) that runs
diff_schemas, calls classify_schema_change, and either returns the
merged DataFrame (for AUTO-MERGE cases) or raises a SchemaReviewRequired
exception carrying the diff (for REVIEW cases). Test it against both
batch_v2 and batch_v3 from this lesson.