~/2026/01/21/python-data-warehouse-catching-schema-drift-before-an-etl-run.md
Python: Data Warehouse – Catching Schema Drift Before an ETL Run
--- author: Tom Lasswell date: read: 3 min in: [scripts, engineering] tags: [python, data-warehouse, etl, sql] ---
$ grep -n '^#' post.md
The ETL failures that cost the most time are never the ones that crash loudly. They are the ones where a source table quietly loses a column, or a varchar(50) becomes varchar(255), and the load "succeeds" while writing garbage, truncated values, or nulls downstream. I run this script as a pre-flight check before every scheduled ETL job: snapshot the expected schema once, commit it, then diff the live schema against it on every run and fail fast if something moved.
This version compares more than column names and types. It also compares length, numeric and timestamp precision, interval fields, nullability, the schema-qualified underlying type for arrays and user-defined types, and the domain a column uses. It sorts findings into breaking changes (a column removed, a type changed, a length or precision reduced, a column becoming nullable) and additive ones (a new column, a length increased). A pipeline that selects an explicit column list, like the incremental Postgres-to-BigQuery load, usually survives additive changes, so --allow-additive lets those pass with a warning.
Requirements
- Python 3.10 or later.
psycopg2-binary2.9.1 or later (pip install psycopg2-binary), the first release with Python 3.10 wheels.- A role that can see the tables.
information_schema.columnsonly shows columns the current user has access to, as owner or through some privilege. A table the ETL role can't read looks exactly like a table that was dropped, which is what you want the check to catch anyway. - A baseline snapshot generated once with
--snapshot, committed to version control next to the pipeline that depends on it.
Usage
Take the baseline the first time and commit the file. Table names can be schema-qualified; unqualified names default to public.
python check_schema_drift.py --snapshot \
--dsn "postgresql://etl_reader:<password>@<host>:5432/app" \
--tables public.orders,public.customers,billing.line_items \
--output schema_baseline.json
The baseline records every attribute that is compared:
{
"public.orders": {
"discount_code": {
"character_maximum_length": 32,
"data_type": "character varying",
"datetime_precision": null,
"domain_name": null,
"domain_schema": null,
"interval_type": null,
"is_nullable": "YES",
"numeric_precision": null,
"numeric_scale": null,
"udt_name": "varchar",
"udt_schema": "pg_catalog"
},
"total_cents": {
"character_maximum_length": null,
"data_type": "integer",
"datetime_precision": null,
"domain_name": null,
"domain_schema": null,
"interval_type": null,
"is_nullable": "NO",
"numeric_precision": 32,
"numeric_scale": 0,
"udt_name": "int4",
"udt_schema": "pg_catalog"
}
}
}
Run the check as the first step of the job and chain the load after it, so a non-zero exit stops the pipeline before any extraction:
python check_schema_drift.py \
--dsn "$SOURCE_DSN" \
--tables public.orders,public.customers,billing.line_items \
--baseline schema_baseline.json \
--allow-additive \
&& python postgres_to_bigquery_incremental.py --pg-dsn "$SOURCE_DSN" --source-table public.orders ...
Sample output when drift is found:
BREAKING billing.line_items: sku: character_maximum_length 64 -> 32
ADDITIVE public.customers: column added: loyalty_tier (character varying)
BREAKING public.orders: column removed: discount_code
BREAKING public.orders: total_cents: type int4 -> numeric
3 breaking, 1 additive change(s). Exit 1.
With only additive changes and --allow-additive, it prints the same ADDITIVE lines and exits 0.
Script
#!/usr/bin/env python3
"""
check_schema_drift.py
Compares live PostgreSQL column definitions against a saved baseline and
reports added, removed, retyped (including a change of domain or of the
schema a user-defined type lives in), resized, precision-changed, or
nullability-changed columns before an ETL job runs.
Usage:
Baseline: python check_schema_drift.py --snapshot --dsn <dsn> --tables s.t1,t2 --output baseline.json
Check: python check_schema_drift.py --dsn <dsn> --tables s.t1,t2 --baseline baseline.json [--allow-additive]
Reads: information_schema.columns for each table through the given DSN.
Writes: a JSON snapshot in --snapshot mode; nothing in check mode.
Exit: 0 no drift (or only additive drift with --allow-additive),
1 drift found, 2 usage or connection error.
"""
import argparse
import json
import sys
import psycopg2
# The first five together identify the column's type; the rest are compared one by one.
TYPE_ATTRIBUTES = ("data_type", "udt_schema", "udt_name", "domain_schema", "domain_name")
DETAIL_ATTRIBUTES = (
"character_maximum_length",
"numeric_precision",
"numeric_scale",
"datetime_precision",
"interval_type",
"is_nullable",
)
ATTRIBUTES = TYPE_ATTRIBUTES + DETAIL_ATTRIBUTES
COLUMN_QUERY = """
SELECT column_name, data_type, udt_schema, udt_name, domain_schema, domain_name,
character_maximum_length, numeric_precision, numeric_scale,
datetime_precision, interval_type, is_nullable
FROM information_schema.columns
WHERE table_schema = %s AND table_name = %s
ORDER BY ordinal_position
"""
def qualify(name: str) -> tuple[str, str]:
"""Split 'schema.table' (default schema public) into its parts."""
schema, _, table = name.rpartition(".")
return (schema or "public", table)
def fetch_schema(dsn: str, tables: list[str]) -> dict[str, dict[str, dict]]:
"""Return {'schema.table': {column: {attribute: value}}}. Missing tables map to {}."""
result: dict[str, dict[str, dict]] = {}
conn = psycopg2.connect(dsn)
try:
with conn, conn.cursor() as cursor:
for name in tables:
schema, table = qualify(name)
cursor.execute(COLUMN_QUERY, (schema, table))
columns = {}
for row in cursor.fetchall():
column_name, *values = row
columns[column_name] = dict(zip(ATTRIBUTES, values))
result[f"{schema}.{table}"] = columns
finally:
conn.close()
return result
def type_label(column: dict) -> str:
"""Domain name if the column uses one, else the underlying type; schema shown unless pg_catalog."""
schema = column.get("domain_schema") or column.get("udt_schema")
name = column.get("domain_name") or column.get("udt_name")
return name if schema in (None, "pg_catalog") else f"{schema}.{name}"
def classify(attribute: str, old, new) -> str:
"""Return 'additive' for widening changes, 'breaking' for everything else."""
widening = ("character_maximum_length", "numeric_precision", "datetime_precision")
if attribute in widening and old is not None and new is not None and new > old:
return "additive"
if attribute in widening and old is not None and new is None:
return "additive" # length limit removed, e.g. varchar(50) -> varchar
if attribute == "is_nullable" and old == "YES" and new == "NO":
return "additive" # stricter source never produces a value the load can't take
return "breaking"
def diff_schema(baseline: dict, current: dict) -> list[tuple[str, str, str]]:
"""Return [(severity, table, message)] for every difference."""
findings = []
for table in sorted(set(baseline) | set(current)):
old_cols = baseline.get(table, {})
new_cols = current.get(table, {})
if old_cols and not new_cols:
findings.append(("breaking", table, "table missing or not visible to this role"))
continue
if new_cols and not old_cols:
findings.append(("additive", table, "table not in baseline"))
continue
for column in sorted(set(old_cols) - set(new_cols)):
findings.append(("breaking", table, f"column removed: {column}"))
for column in sorted(set(new_cols) - set(old_cols)):
findings.append(("additive", table, f"column added: {column} ({new_cols[column]['data_type']})"))
for column in sorted(set(old_cols) & set(new_cols)):
old_type = tuple(old_cols[column].get(a) for a in TYPE_ATTRIBUTES)
new_type = tuple(new_cols[column].get(a) for a in TYPE_ATTRIBUTES)
if old_type != new_type:
# One finding per retyped column; precision/length differences follow from it.
old_label, new_label = type_label(old_cols[column]), type_label(new_cols[column])
findings.append(("breaking", table, f"{column}: type {old_label} -> {new_label}"))
continue
for attribute in DETAIL_ATTRIBUTES:
old = old_cols[column].get(attribute)
new = new_cols[column].get(attribute)
if old != new:
severity = classify(attribute, old, new)
findings.append((severity, table, f"{column}: {attribute} {old} -> {new}"))
return findings
def main() -> int:
parser = argparse.ArgumentParser(description="Detect PostgreSQL schema drift before an ETL run.")
parser.add_argument("--dsn", required=True, help="libpq connection string or URI")
parser.add_argument("--tables", required=True, help="Comma-separated tables, optionally schema-qualified")
parser.add_argument("--snapshot", action="store_true", help="Write a baseline instead of checking")
parser.add_argument("--output", default="schema_baseline.json", help="Baseline path to write (--snapshot)")
parser.add_argument("--baseline", help="Baseline path to compare against")
parser.add_argument("--allow-additive", action="store_true", help="Exit 0 when all drift is additive")
args = parser.parse_args()
tables = [t.strip() for t in args.tables.split(",") if t.strip()]
try:
current = fetch_schema(args.dsn, tables)
except psycopg2.Error as error:
print(f"error: could not read schema: {error}", file=sys.stderr)
return 2
if args.snapshot:
empty = [t for t, cols in current.items() if not cols]
if empty:
print(f"error: no visible columns for {', '.join(empty)}", file=sys.stderr)
return 2
with open(args.output, "w", encoding="utf-8") as handle:
json.dump(current, handle, indent=2, sort_keys=True)
handle.write("\n")
print(f"Baseline for {len(current)} table(s) written to {args.output}")
return 0
if not args.baseline:
print("error: --baseline is required unless --snapshot is given", file=sys.stderr)
return 2
with open(args.baseline, encoding="utf-8") as handle:
baseline = json.load(handle)
# Only compare the tables requested on this run.
baseline = {t: cols for t, cols in baseline.items() if t in current}
findings = diff_schema(baseline, current)
if not findings:
print("No schema drift detected.")
return 0
for severity, table, message in findings:
print(f"{severity.upper()} {table}: {message}")
breaking = sum(1 for f in findings if f[0] == "breaking")
additive = len(findings) - breaking
status = 0 if breaking == 0 and args.allow_additive else 1
print(f"{breaking} breaking, {additive} additive change(s). Exit {status}.")
return status
if __name__ == "__main__":
sys.exit(main())
Notes
- Why
table_schemamatters. Filteringinformation_schema.columnsonly ontable_nameis a common mistake. With anorderstable in bothpublicand anarchiveschema, it merges the two column lists, which can hide drift or invent it. Always filter on both. - Why
udt_nameas well asdata_type. For arrays,data_typeis justARRAY, and for enums and other user-defined types it isUSER-DEFINED. The real type is inudt_name, andudt_schemasays which schema it lives in, so two enums calledstatusin different schemas don't compare equal. Without them, changing a column fromtext[]tointeger[], or from one enum to another, doesn't show up. For a domain,data_typeandudt_namereport the underlying type, so the script also comparesdomain_schemaanddomain_name: moving a column between two domains over the same base type is a type change too. - Precision beyond length.
datetime_precisioncatchestimestamp(6)becomingtimestamp(0)(sameudt_name, fewer fractional digits), andinterval_typecatches anintervalcolumn restricted to, say,DAY TO SECOND. - Length is where silent truncation lives.
character_maximum_lengthis null for unboundedvarcharandtext, and set forvarchar(n). A source column widened from 64 to 255 is harmless to read, but a destination still declared at 64 will reject the longer values. That's why the script flags it: the next step is to check the destination, not to suppress the warning. - Nullability direction.
is_nullablegoing fromNOtoYESis breaking because the source can now send nulls into a destination column declaredNOT NULL, or into metrics that assumed a value. The reverse is harmless for the load. Keep in mind that Postgres reportsYESwhenever a column is possibly nullable. - What it doesn't check. Defaults, constraints, indexes, and whether a column's meaning changed. A status column that gains a new value passes this check; data tests (dbt's
accepted_values, or a plainSELECT DISTINCTassertion) cover that. - Treat the baseline like a migration. Update it deliberately, in the same commit as the pipeline change that expects the new shape, not as a reflex when the check goes red. Re-running
--snapshotto make a failing check pass throws away exactly the information the check exists to surface.