guide
guide8 min read

How to Tell Breaking from Non-Breaking Schema Changes Automatically

A five-layer method for classifying schema changes by what they do to consumers, not by what the diff looks like

You tell breaking from non-breaking schema changes automatically by classifying each change against what its consumers read, not by diffing the schema text. A practitioner post from August 2026, Not every schema change is a breaking change, makes the same argument: a YAML diff cannot show which changes matter. A reworded column description and a narrowed column type both show up as one changed line, yet only one of them can break a downstream job.

Key Takeaways

  • •A raw schema diff lists changes but does not rank them; classification has to look at the change type, the consumer, and the usage.
  • •Removing or renaming a column, narrowing a type, and making a nullable column required are breaking by default; adding a nullable column and editing a description are usually not.
  • •Compatibility is always relative to a reader, so the same change can be safe for one consumer and breaking for another.
  • •Lineage and query history turn a theoretical break into a measured one: a dropped column nobody reads is low risk, a widely read one is not.
  • •Automatic classification works best as a gate with three outcomes: pass, warn with a named blast radius, and block until a person approves.

Why a schema diff cannot tell you what matters

A diff is a textual comparison. It reports that line 14 changed, and it treats a typo fix in a description and a switch from integer to string with the same weight. That is fine for code review of a file, but a schema is a contract between a producer and many readers, and a contract is judged by what its readers depend on. The post linked above frames it the same way: the diff shows the edit, while the question that matters is who breaks.

The second problem is that the diff has no memory of use. It cannot know that the column you are removing feeds a finance dashboard, or that nobody has queried it for a quarter. Two identical diffs in two different warehouses can carry opposite risk. Any automatic classifier therefore needs inputs beyond the two schema versions: it needs the consumers, the lineage, and a record of actual reads.

The third problem is semantic. A column keeps its name and type while its meaning shifts, for example a timestamp moving from local time to UTC. No structural diff catches that. We treat this as a limit of automation, and we say so plainly later in the guide: structural classification covers most changes, and meaning changes still need a person or a contract assertion.

Layer 1: classify by change type

Start with a fixed taxonomy. Breaking by default: dropping a column, renaming a column, narrowing a type (for example bigint to int, or string to date), shortening a length limit, and changing a nullable column to required. Non-breaking by default: adding a nullable column, widening a numeric type, adding a default, and changing documentation text. Put every detected change into exactly one bucket before you look at anything else.

Public formats already encode this idea. The Apache Avro specification defines schema resolution rules that say which writer and reader schemas can interoperate, including which type promotions are allowed. Reusing a published rule set beats inventing your own, because producers and consumers can agree on it without a meeting.

Keep the taxonomy small and testable. Each rule should be a function of the old and new column definition, so you can write a unit test per rule and run it on every pull request. A rule that needs judgment, such as a rename detected by similarity, should return 'needs review' instead of guessing, which leads into the later layers.

Layer 2: make compatibility relative to a reader

A change is only breaking for someone. Confluent's documentation on schema evolution and compatibility describes seven compatibility settings, from backward through forward and full to none, plus transitive variants. The useful lesson is that the setting names a direction: do new readers handle old data, or do old readers handle new data.

For analytics tables the direction is usually backward: existing queries, models and dashboards must keep working against the new shape. That makes adding a column safe and removing one unsafe. For event streams that replay old data, you often need the opposite or both. Decide the direction per asset and store it next to the schema, so the classifier does not apply one rule to every table.

Warehouse tooling can make the reader explicit. dbt's model contracts let a model declare its column names and data types, and the build fails when the model no longer matches. That turns 'this might break someone' into a failing check owned by the producing team, which is exactly where you want the signal.

Layer 3: trace the blast radius through lineage

Once a change is labelled breaking in principle, ask who is downstream. Column-level lineage answers that: which models, views, dashboards and notebooks select this column, directly or through a chain. A dropped column with forty dependents is an incident waiting to happen, and one with none is housekeeping. The classifier should output the named list, not a count, so a reviewer can act on it.

Lineage has gaps, so we do not rely on it alone. Ad hoc queries, exports and external tools read columns without leaving a model dependency. That is why the next layer matters, and why we show lineage-derived and query-derived consumers separately in the report instead of merging them into one number.

The Schema Agent does this step when someone asks a question like 'what's the blast radius if I drop this column?'. It reads the proposed change, walks column-level dependencies, and returns the affected assets with the reason each one is affected. The point is the same with or without an agent: the output must be a list of consumers, not a severity label.

Layer 4: check real usage before you block

Usage data separates a live dependency from a dead one. In Snowflake, the ACCESS_HISTORY view records which columns queries accessed, and the account usage view retains that history for 365 days. A year of reads is long enough to catch quarterly and annual reports that a 30-day window would miss.

Use it to downgrade or upgrade the verdict. A breaking change to a column with no reads in the full window can move from 'block' to 'warn', with a note that the evidence is the access record. A non-breaking-looking change, such as a type widening, can move up if a heavily used consumer parses the value as a fixed-width string.

Record who read the column, not only how often. A single scheduled job owned by another team is a different conversation from a hundred analysts. Name the owners in the verdict so the person who approves the change knows whom to notify. Questions we hear teams ask in this layer include 'what tables haven't been queried in 90 days?' and 'is the customer_id join safe after the migration?'.

Layer 5: gate the change with three outcomes

Turn the classification into a decision. Pass: non-breaking, no flagged consumers, merge as normal. Warn: breaking in principle but no live readers, or a low-risk widening with some readers; merge with the consumer list attached and owners notified. Block: breaking with live readers, held until the producer either ships a compatible version or the consumers sign off.

Run the gate in CI so it fires on the pull request that changes the schema, not after the deploy. dbt also supports model versions, which give a breaking change a path: publish the new shape as a new version, keep the old one for a deprecation window, and let consumers move on their own schedule.

Be honest about what still needs a person. Semantic changes, such as a different unit or time zone, pass every structural rule. Pair the gate with data contract assertions on value ranges and formats, and keep a human approval step for any change the classifier marks 'needs review'. Automation should shrink the review queue, not hide items in it.

Frequently Asked Questions

Is adding a column always a non-breaking change?

Usually, but not always. Adding a nullable column is safe for readers that name their columns. It can break readers that use SELECT *, positional loading into a fixed-width table, or strict schema validation on ingest. Treat it as non-breaking by default and let usage data flag the exceptions.

Is a data type change breaking?

It depends on direction. Widening, such as int to bigint, is normally safe. Narrowing, or changing category, such as number to string, usually breaks consumers that cast, join or compare on that column. Join keys deserve extra care because a silent type mismatch can drop rows without raising an error.

Can a description or comment change break anything?

Not structurally, so classify it as non-breaking. The exception is when documentation is itself consumed, for example a catalog or semantic layer that builds metric definitions from column descriptions. If you have such a consumer, include it in the lineage so its reads are visible to the classifier.