Skip to content

Schema and data drift

validate() says whether data meets its spec. Drift says what moved -- between two versions of a declaration, or between a declaration and the data it is supposed to describe -- and whether each move would break validation.

from polspec.drift import diff, drift

report = diff(OldOrders, NewOrders)   # two declarations
report = drift(Orders, df)            # a declaration and a frame

Both are also methods on a spec: OldOrders.diff(NewOrders) and Orders.drift(df). Either way the result is a DriftReport.

One rule for severity

Every finding is breaking or compatible, and the line between them is mechanical: a finding is breaking when a frame that satisfied the old side could fail the new one. For drift(), that means this frame fails this spec on that column. Nothing else is a judgement call:

Change Severity Because
a bound or domain narrowed breaking values that validated before now fail
a bound or domain widened compatible nothing that passed now fails
a column added breaking a frame without it fails missing_cols="raise"
a column removed breaking a frame carrying it fails extra_cols="raise"
a constraint added (unique, a validator, a check, a key) breaking rows that passed may fail
a constraint removed compatible
nullable turned off breaking nulls that validated before now fail
a null rate that moved within a nullable column compatible the data still validates; the declaration describes it less well

A column added is breaking under this rule, which surprises people in a pull request. It is the honest answer -- validation of the old data would fail -- and the CLI's --fail-on is how a team decides what to gate on.

Two declarations

import datetime as dt

class OrdersV1(FrameSpec):
    order_id = ColSpec(pl.Int64, bounds=(1, None), unique=True)
    status   = ColSpec(pl.Enum(["NEW", "PAID"]))
    total    = ColSpec(pl.Float64, bounds=(0.0, 1_000.0))
    placed   = ColSpec(pl.Date, bounds=(dt.date(2024, 1, 1), dt.date(2025, 1, 1)))

class OrdersV2(FrameSpec):
    order_id = ColSpec(pl.Int64, bounds=(1, None), unique=True)
    status   = ColSpec(pl.Enum(["NEW", "PAID", "SHIPPED"]))   # widened
    total    = ColSpec(pl.Float64, bounds=(0.0, 500.0))         # narrowed
    placed   = ColSpec(pl.Date)                                 # widened
    channel  = ColSpec(pl.Enum(["web", "store"]))               # added

report = OrdersV1.diff(OrdersV2)
print(report)
Drift: 2 breaking, 3 compatible, 'OrdersV1' and 'OrdersV2'
  - [breaking] Column 'channel' added; a frame without it fails missing_cols='raise'
  - [breaking] Column 'total': domain narrowed from bounds [0.0, 1000.0] to bounds [0.0, 500.0]; values that validated before now fail
  - [compatible] Column 'status': dtype changed from Enum(categories=['NEW', 'PAID']) to Enum(categories=['NEW', 'PAID', 'SHIPPED'])
  - [compatible] Column 'status': domain widened from one of ['NEW', 'PAID'] to one of ['NEW', 'PAID', 'SHIPPED']; values that failed before are now accepted
  - [compatible] Column 'placed': domain widened from bounds [2024-01-01, 2025-01-01] to any Date; values that failed before are now accepted

Widened and narrowed are decided by the same Domain comparison a foreign key uses at declaration, run in both directions. A change that is neither -- choices=["A", "B"] to ["B", "C"], or format="email" to "uuid4" -- is domain_changed, and breaking.

A rename is not guessed from similar names. Say it, and it is reported as one column_renamed finding instead of a column removed and another added:

class Renamed(FrameSpec):
    order_ref = ColSpec(pl.Int64, bounds=(1, None), unique=True)
    status    = ColSpec(pl.Enum(["NEW", "PAID"]))
    total     = ColSpec(pl.Float64, bounds=(0.0, 1_000.0))
    placed    = ColSpec(pl.Date, bounds=(dt.date(2024, 1, 1), dt.date(2025, 1, 1)))

assert [f.code for f in OrdersV1.diff(Renamed, renames={"order_id": "order_ref"})] == [
    "column_renamed"
]

A declaration and data

drift() asks the frame the questions the spec makes answerable: is anything outside the declared bounds, and by how much; which values are outside the declared choices, Enum or format; which declared values never appear; has the null rate moved.

good = OrdersV1.generate(2_000, seed=1)
assert OrdersV1.drift(good).unchanged

moved = good.with_columns(
    total=pl.col("total") * 3,
    placed=pl.col("placed") + pl.duration(days=200),
    status=pl.lit("PAID").cast(pl.String),
)
print(OrdersV1.drift(moved))
Drift: 2 breaking, 2 compatible, DataFrame against 'OrdersV1'
  - [breaking] Column 'total': values escape bounds [0.0, 1000.0]: max found 2999.36861541796 by 1999.36861541796 above. Widen the bounds, or fix the source
  - [breaking] Column 'placed': values escape bounds [2024-01-01, 2025-01-01]: max found 2025-07-20 by 200 days above. Widen the bounds, or fix the source
  - [compatible] Column 'status': holds String, declared Enum(categories=['NEW', 'PAID'])
  - [compatible] Column 'status': 1 of 2 declared value(s) never appear: ['NEW']

The generated frame drifts by nothing, and that is pinned by a test: what generate() produces never drifts breakingly from the spec that produced it, the same round trip validation is held to. The other direction is pinned too -- every breaking finding is a column validate() would report -- so breaking never means more than "validation fails here".

A struct column is compared field by field, in both directions: each field its fields describes -- or its dtype alone, where fields says nothing -- goes through every comparison a column does, and a finding names it by path. Describing point.lat with bounds is domain_narrowed on point.lat, breaking for the same reason it is on a column; data whose lat escapes those bounds is bounds_exceeded on point.lat. A field's null rate is measured inside the structs that are present, which is what its null_probability claims. The finding's columns stay ("point",).

What drift() does not measure is what validation already does: uniqueness, composite keys, foreign keys and checks are pass/fail claims about rows, not summaries that move. validate() is still the verdict.

Options

from polspec import DriftOptions

lenient = DriftOptions(null_rate_tolerance=0.2, unseen_values=False)
OrdersV1.drift(good, options=lenient)
OrdersV1.drift(good, null_rate_tolerance=0.2)   # or one keyword at a time, not both
Option Default Meaning
null_rate_tolerance 0.05 how far the observed null rate may sit from null_probability before null_rate_moved is reported; absolute, not relative
unseen_values True report declared values the data never holds (cardinality_moved)
strict_dtypes False the same switch as validation's, decided by the same function: whether a dtype_changed is breaking
max_samples 10 how many offending values a finding's details carry

The report

A DriftReport is data first. report.breaking and report.compatible are the two halves; by_column() and by_code() slice it; to_dict() and to_json() serialise it; bool(report) is report.unchanged, the way bool(ValidationReport) is passed.

to_markdown() renders the shape of a pull-request comment, breaking findings first:

text = OrdersV1.diff(OrdersV2).to_markdown()
assert text.index("## Breaking") < text.index("## Compatible")

The finding codes are listed with the validation codes in Errors and findings.