The test that tells you whether you have one
Here is a five-minute diagnostic for any organization that believes it already has data contracts. Go to the service that produces your most-depended-on table or topic. Open a branch. Delete a field that a downstream team reads. Push it and watch continuous integration. If the build goes green, you do not have a contract. You have a description of what the data used to look like, stored somewhere a person would have to think to go read.

That is the whole idea, and almost everything else in this article is downstream of it. A data contract is not a document, a catalog entry, or a conversation in a planning meeting. It is an artifact that sits in the producing team's repository and has the power to stop their merge. The moment it can do that, the arguments about ownership and process resolve themselves, because a build failure is not something anyone has to be persuaded to care about. Until it can do that, the contract is advisory, and advisory checks lose every time a release is late.
We have built this in both directions. We have added contracts to platforms where twelve services published into a warehouse with no schema authority anywhere, and we have been the downstream team that discovered a currency unit had changed by finding a revenue number that was a hundred times too large. The failure patterns repeat, and so do the fixes.
You are probably here because
- A number downstream was wrong for days, and every job in the path ran green the entire time.
- A producing team shipped a rename, a denormalization or a new
deleted_atcolumn as routine work, and your side found out from a dashboard. - You already have contracts in YAML and owners in a catalog, and no build has ever failed because of either one.
- v1 through v4 are all still being written, because nobody can prove who is still reading v1.
The enforcement ladder section below is where this gets answered, and all four usually come from one root cause: the check does not fire anywhere that can stop the change while the change is still cheap.
What a contract has to say, and the three quarters everyone skips
A usable contract has four parts. Most implementations write the first one, generate it from something, and stop, which is why they change nothing.
Schema. Field names, types, nullability, enum domains, the primary or partition key, and the encoding. This is the part every tool does for free, and it is the part that causes the fewest expensive incidents, because a type violation is loud. A string arriving where an integer was expected fails immediately and gets fixed the same morning.
Semantics. What one row means. The grain, stated explicitly: one row per order, or one row per order line, or one row per order-status transition. What every timestamp means, because a table with created_at, updated_at and ingested_at has three timestamps and usually two of them are documented nowhere. Units and currency. Whether null means unknown or means zero. Whether the enum is closed or whether the producer will add values. Whether deletes are hard or soft, and if soft, which flag. Whether a backfill rewrites history in place or appends corrections.
Guarantees. Freshness, completeness, uniqueness on the declared key, ordering within a partition, the late-arrival window, retention, and how a failure is signalled to consumers. These are the promises the producing team is agreeing to be woken up for, so they should be written by the producing team and no one else.
Change policy. What the producer may change unilaterally, what requires notice, how long the deprecation window is, how consumers are notified, and what versioning scheme applies. This is the part that decides whether the contract survives its second year.
Contract Review Weights — What We Score When We Read One
Our review weights, summing to 100. Schema scores lowest because tooling already covers it.
Where the check fires decides whether the contract is real
There are five places a violation can be caught, and they are not equivalent. Each rung down the ladder costs more, involves more people, and arrives after more damage.
Rung one: the producer's type system. If the published payload is generated from a schema definition rather than hand-assembled from a dictionary, a whole class of violation becomes unrepresentable. Generated Protobuf or Avro classes will not compile if you assign a string to an integer field. This is free enforcement and it happens before anyone runs anything.
Rung two: the producing team's CI. A job that diffs the proposed schema against the registered contract and classifies the change. This is the rung that matters most, because it is the only one that fires while the change is still cheap and while the person who made it is still holding the context. buf breaking does this for Protobuf against a git reference, with rule categories for wire compatibility, JSON wire compatibility, file structure and package structure. Registry clients do the equivalent for Avro and JSON Schema by test-registering the new version against the configured compatibility mode.
Rung three: publish-time validation at the boundary. The serializer or a validating gateway rejects a message that does not match the registered subject. This is real enforcement but it fires in production, which means a rejected write is an outage for the producer rather than a red build.
Rung four: quarantine on the landing zone. Bad records go to a dead-letter topic or a quarantine table instead of into the main dataset. Good hygiene, and it keeps a bad hour from becoming a bad quarter of corrupted history. It does not stop the break, it contains it.
Rung five: assertions after load. dbt tests, Great Expectations, Soda. These are the checks most organizations build first because the data team can build them without asking anyone's permission. They are valuable and they are last, because by the time they fire the data has landed, dashboards have refreshed, and somebody has already made a decision on it.
Enforcement Ladder — Our Earliness Ranking
Relative earliness on our ranking, not measured incidence. Build downward from rung two, not upward from rung five.
Change data capture makes your ORM migrations into everyone's contract
Streaming a service's own tables with Debezium or an equivalent connector is the fastest way to get data moving and the most expensive contract you will ever sign by accident. The published interface becomes the producing team's internal storage layout, so a routine column rename in a migration is a downstream break, and the producing team has no way to know. If change data capture is how the data leaves, put a transformation between the raw change stream and anything a consumer subscribes to, and make the output of that transformation the contracted artifact. The producer keeps the right to refactor their own tables, which is the right they will otherwise take back without telling you.
What actually counts as a breaking change
Teams argue about this in the abstract for weeks. The answer is specific to the encoding, and it is worth writing on a page everyone can reach, because the intuitions are wrong in both directions. Renaming a Protobuf field does not break the wire format at all. Adding an enum value breaks more consumers than most people expect.
| Change to the producer | Avro via a registry | Protobuf on the wire | Strict JSON Schema | Warehouse table |
|---|---|---|---|---|
| Add an optional field with a default | Safe under backward and full modes | Safe with a new field number | Breaks if additionalProperties is false | Additive, safe |
| Add a required field, no default | Breaks backward compatibility | Reader sees the zero value and cannot tell it apart from unset | Breaks every existing producer and fixture | Fails a NOT NULL load |
| Remove a field | Allowed under backward, breaks forward readers | Wire-safe only if the number and name are reserved | Breaks anything with it in required | Breaks every query naming the column |
| Rename a field | Breaking, it is a drop plus an add | Wire-compatible, breaks JSON mapping and generated code | Breaking | Breaking |
| Widen int32 to int64 | Promotion works in one direction only | Varint-compatible, old readers truncate silently | Usually safe | Safe |
| Add a value to an enum | Old readers fail on the unknown symbol | Unknown value surfaces as its number | Breaks if the enum is listed | Breaks a CHECK constraint |
| Reuse a retired field number | Not applicable | Silent corruption, the worst outcome on this table | Not applicable | Not applicable |
Two details on that table earn their place. The first is that Confluent Schema Registry compatibility modes come in transitive and non-transitive variants, and the non-transitive ones are the default. Non-transitive means each new version is checked against the immediately previous version only. You can walk a schema through six individually compatible steps and arrive somewhere that cannot read version one, which matters the moment you have a consumer replaying history or a topic with long retention. If consumers read old data, use the transitive mode and accept the extra friction.
The second is field number reuse in Protobuf. Field names are documentation; field numbers are identity on the wire. Delete field 7 in one release, add a different field as 7 in the next, and every consumer still running the old generated code will decode the new data into the old meaning without an error anywhere. The language provides reserved for exactly this, and a lint rule that requires reserving on every deletion is a two-line addition to CI that removes an entire category of silent corruption.
The drift a schema check cannot see
Every incident that costs a full day is semantic. The types were fine. The pipeline was green. The meaning moved.
Units. An amount field switches from cents to a decimal currency value for one region because a new payment provider was integrated and the mapping was written by someone who assumed the obvious. Type-checks pass. The check that catches this is a distribution assertion: the median of this field has moved by two orders of magnitude for one partition.
Time. A field documented as event time quietly becomes ingest time when the producer moves from a batch export to a stream. Both are timestamps. Every downstream window aggregation shifts, and nothing fails. The check is a comparison of the field against wall clock at ingest: if the gap distribution collapses to near zero, event time is no longer event time.
Grain. A table that was one row per order becomes one row per order line because the producer denormalized. Row counts triple, and every sum built on it triples with them. The check is a uniqueness assertion on the declared key, which is why the contract has to declare a key.
Soft deletes. A deleted_at column appears and the producing team treats the feature as shipped. Consumers who do not know to filter on it now count deleted records. Nothing breaks, the numbers just get slowly wrong. Adding a nullable column is the single most common non-breaking change that is semantically breaking.
Backfills. A correction run rewrites eighteen months of history in place. Any downstream model that snapshots or incrementally accumulates is now inconsistent with the source and will stay that way until someone rebuilds it. The contract needs to say whether history is immutable, and if it is not, how a rewrite is announced.
These are the reasons the semantic section carries more weight than the schema section in our review. Machine-checkable does not mean important, and the checks for these are not exotic. They are value and distribution assertions with a threshold, run on every load, alerting on the producer's rotation rather than the data team's.
Guarantees people can actually meet
Freshness written as "updated daily" is not a guarantee, it is a habit. A guarantee has a measurement, a threshold, a window and a consequence. We write three and resist adding a fourth.
Freshness as the maximum event-time lag at the ninety-ninth percentile over a rolling window, not as a schedule. Schedule-based freshness reports success when a job runs on time and produces nothing, which is the exact failure mode you were trying to detect.
Completeness as a row-count band relative to a seasonal baseline, not a fixed floor. A fixed floor passes on the quiet week and fails every holiday. The band should come from the previous four same-weekday windows, and it should be wide enough that a normal business fluctuation does not page anyone.
Correctness as uniqueness on the declared key plus a null-rate ceiling on the fields that are documented as always present in practice. Not every field. The three or four that break a downstream join if they go missing.
Then give the guarantee an error budget. A contract that permits zero violations is one bad week away from being switched off, and switched-off checks never come back on. Something like ninety-nine percent of hourly windows meeting freshness over a rolling thirty days gives the producing team room to have a bad afternoon without the contract losing its authority. When the budget is exhausted, the consequence is a review, not a page at 3am.
Versioning, and how to know it is safe to delete v1
Every serious contract system needs a way to run two versions at once, because breaking changes are sometimes correct and forcing eleven consumers to move on the same day is not a plan. The mechanics are the same everywhere: register v2 alongside v1, dual-write for a defined window, migrate consumers one at a time, then retire v1.
The part that goes wrong is retirement. Organizations end up with v1 through v4 all live, all being written, none deletable, because nobody can prove who is still reading v1. That is not a versioning problem, it is an observability problem, and it has direct answers.
On a message bus, consumer group offsets tell you exactly who is still committing progress against the old subject. On a warehouse, query history does the same job at column granularity. BigQuery exposes it through INFORMATION_SCHEMA.JOBS and Snowflake through the account usage access history views, which report the columns a query actually touched. Run that over ninety days, join it to the contract, and the list of consumers stops being a wiki table somebody last edited two reorganizations ago and becomes a query result.
The one gap to be honest about: query history and consumer groups cover systems that identify themselves. A notebook run by an analyst on a personal service account, a spreadsheet connector, or a vendor integration using a shared credential will show up as an unhelpful blob. Fix that by requiring a per-consumer service account before you start the deprecation, not during it. dbt's versioned models handle the warehouse side of this well, with a deprecation date on the model and a build-time warning for anything still referencing the old version.
Send it over and we will tell you what we would change.
Email the contract or schema file for the one interface that keeps breaking, plus whatever your CI does with it today, to contact@precisionfederal.com. You get back a short written note naming the three things we would change and why. One business day. No charge, no meeting, no deck.
contact@precisionfederal.comWho signs, and the mechanism that makes it mean something
Most contract programs fail here, and the failure is always the same shape: the contract is written by the consuming team, stored in the consuming team's repository, and enforced by the consuming team's pipeline. The producing team never agreed to anything. When they break it, they are surprised, and they are entitled to be.
Three rules make ownership real, and they are all boring.
The contract file lives in the producing repository. Same pull request as the code that changes the payload. Not in a central contracts repo, not in the catalog, not in a wiki. If changing the data and changing the contract are two pull requests in two repositories, they will drift, and the drift will be discovered downstream.
Consumers are named in the file, with a team and an on-call contact. Not "analytics". A team identifier that a routing rule can resolve. This turns a breaking change from an abstract risk into a list of people who will be affected, visible in the diff.
A code ownership rule makes a breaking change require their review. A CODEOWNERS entry on the contract path with the consuming teams listed means a pull request touching that file cannot merge until they approve. This is the whole enforcement mechanism, and it is one file in the repository. Non-breaking changes should not go through this, or the rule becomes a tax and someone will get it removed. The CI job that classifies a change as breaking is what decides whether the review requirement applies, which is why rung two of the ladder is where the money is.
Five ways a data contract program dies
- It is written by the consumer. The producing team never signed, never sees the file, and is genuinely surprised when their release is called an incident. Every argument that follows is about process, and none of them fix the data.
- Every dataset gets a contract in week one. Four hundred YAML files, generated from the current schemas, accurate on the day they were written and stale within a quarter. A stale contract is worse than no contract because people trust it.
- Only the schema is specified. Types are checked, meaning is not, and the incidents keep arriving with green pipelines behind them.
- Enforcement lives entirely downstream. The assertion suite is excellent, it runs after load, and it tells you the damage has already happened. This is monitoring, and monitoring is a fine thing to have that is not a contract.
- The alerts go to the data team. If the people who can fix a producer defect are not the people being notified, the notification is a queue, and queues get long. Route contract violations to the producing team's rotation from day one.
Rolling this out without a platform program
The version of this that works is small, and it starts from incidents rather than from an inventory. Pick the five to ten interfaces that have actually hurt you. Everything else waits until the pattern is proven.
Which Interfaces Go First — Selection Weights
Score each candidate interface, take the top five to ten, and leave the rest alone until the pattern is proven.
Ninety-Day Rollout
Step six is not optional and it is the step teams skip. Deliberately propose a breaking change on the contracted interface and watch what happens: the CI job classifies it, the review requirement engages, the named consumers get pinged, someone approves or objects. Until that has happened once on purpose, you do not know whether any of it works, and the first time you find out will be during a real change under time pressure. A guard nobody has watched fire is a guard you are guessing about.
Warn mode in step three matters too. Turning a blocking check on before anyone has seen what it flags produces a week of false failures and a permanent political problem. Two weeks of warnings tells you the false-positive rate and gives the producing team time to see the job as accurate before it can stop them.
The tooling, and what each piece will not do
| Layer | What it does | Where it fires | What it will not do |
|---|---|---|---|
| Schema registry Confluent, Apicurio, Karapace | Stores schema versions per subject and enforces a compatibility mode on registration | Registration and publish | Anything about meaning, freshness or ownership |
| Protobuf tooling Buf, protolint | Classifies a schema diff as breaking by category and lints for reserved numbers | Producer CI, on the pull request | Look at a single runtime value |
| Contract specifications Open Data Contract Standard, Data Contract Specification | One file carrying schema, semantics, service levels, owners and consumers | The producing repository | Enforce itself without a CI job wired to it |
| Warehouse model contracts dbt contracts and versioned models | Fails the build when a model's output columns or types drift from the declaration | Warehouse build | Anything upstream of the warehouse |
| Assertion runners dbt tests, Great Expectations, Soda | Value, distribution and relationship checks, which is where semantics get enforced | After data lands | Prevent the landing |
| Lineage and catalog OpenLineage, DataHub, OpenMetadata | Maps who consumes what and carries ownership metadata | Continuously, from job metadata | Block a merge |
| Access history Warehouse query and job views | Proves which columns are still read, by whom, over a window | Query time | See consumers that do not query through the warehouse |
Note what is missing from that table: nothing on it enforces a contract on its own. The registry enforces a compatibility mode you configured. The CI job enforces a rule you wrote. The specification is a file format. Buying a catalog and declaring the contract problem solved is the most common expensive mistake in this area, because a catalog is a place to put ownership information and has no ability to stop anything.
What this costs
The first contract is the expensive one because it forces the arguments. Expect two to four engineering weeks across both teams to write it, wire the CI job, build the assertions and run the deliberate break. Most of that is not code, it is the meetings where somebody finally writes down what a row means and two teams discover they disagreed.
Contracts two through ten drop sharply, in our experience to two or three days each, because the CI job, the specification shape and the routing already exist. Maintenance is real but small: budget a few hours per contract per quarter for threshold tuning, plus the review time on breaking changes, which is the cost you were trying to pay all along.
Set against that, the thing to measure is not the number of contracts. It is time to detection on data defects. If a semantic break used to be found by a person looking at a dashboard four days later and is now caught in a pull request, that is the number that moved, and it is the only one worth reporting.
Own these regardless of what you buy
- The contract file in the producing repository, versioned with the code that emits the data
- A one-sentence statement of grain and the declared key for every contracted dataset
- The meaning of every timestamp, unit and currency, written down where the producer can see it
- A CI job that classifies a schema diff as breaking or non-breaking, with its own tests
- Semantic assertions on units, grain and time, routed to the producing team's rotation
- A named owner and named consumers with resolvable contacts, not team-shaped nouns
- A query that answers who is still reading version one, runnable on demand
- One recorded instance of the whole mechanism firing on a deliberate breaking change
Bottom line
A data contract is an enforcement mechanism wearing a document's clothes. The document part is easy and most teams get there. The enforcement part is a CI job in the producing repository, a code ownership rule that engages only on breaking changes, and a small set of semantic assertions that page the producer rather than the analyst. Start with one interface that has already hurt you, write the semantics before the schema, put the check where the change is still cheap, and prove the whole thing fires before you trust it.
Common objections
Our producing teams will never accept a blocking check on their pipeline
They usually accept it faster than expected, provided two conditions hold. The check must be accurate, which is what the warn-mode period buys you. And it must only block on breaking changes, so their ordinary additive work is untouched. What producing teams reject, correctly, is a blocking check written by another team that fires on changes they consider routine. The negotiation is about the classification rule, not about the principle, and once they own the rule they tend to want the protection.
We already have a catalog with schemas and owners in it
A catalog is a good place to keep ownership and discovery metadata, and it has no ability to stop a merge. The distinction to hold onto is that the catalog describes what exists while the contract constrains what may change. Keep the catalog, and put a file next to the producing code that CI can read.
Doesn't this slow everything down?
It slows down breaking changes on the small number of interfaces you contracted, which is the intent. It should not touch anything else. If your rollout is slowing down additive changes or interfaces that were never in scope, the classification rule is too broad and should be tightened before anyone concludes contracts are a tax.
What about data we receive from outside the company?
You cannot put a CI job in a vendor's repository, so the ladder starts at rung three. Validate at the boundary, quarantine what fails, and treat the quarantine rate as a supplier metric you review. Write the contract anyway, because it gives you the specific sentence to send when a feed changes shape, and negotiate a change-notice window into the agreement itself.
Frequently asked questions
A file in the producing team's repository that specifies the schema, the semantics, the service-level guarantees and the change policy for one dataset or topic, wired to a job that can fail the producing team's build when a change violates it. Without the enforcement wiring it is documentation, which is useful but does not change incident rates.
The producing repository, in the same pull request as the code that changes the payload. Central contract repositories drift from the code, because two repositories mean two pull requests and one of them gets skipped under deadline. Publish a rendered copy to the catalog if discovery matters, but keep the source of truth next to the producer.
It depends on the encoding. Removing or renaming a field, adding a required field with no default, narrowing a type and adding an enum value are usually breaking. Adding an optional field with a default usually is not, unless your JSON Schema sets additionalProperties to false. In Protobuf, reusing a retired field number is the one that corrupts data silently, so reserve on every deletion.
Five to ten, chosen because they have already caused incidents, cross an on-call boundary or feed a revenue number. Generating contracts for every dataset in the catalog produces files that are accurate on the day they are written and stale within a quarter, and a stale contract is more dangerous than none because people rely on it.
Prove who still reads it before setting a date. Consumer group offsets on a message bus and column-level access history in the warehouse both answer this directly over a ninety-day window. Require per-consumer service accounts before the deprecation starts, otherwise shared credentials collapse several real consumers into one unattributable entry and you will never be confident enough to delete.
