Data Engineering5 min read

Data Contracts: Stopping Application Changes from Breaking the Warehouse

Most broken dashboards I've seen weren't caused by bad SQL. They were caused by a perfectly reasonable application change that nobody told the data team about. Data contracts are how I stop that happening, and they're less bureaucratic than they sound.

Gopal Yendluri
Series: Building Data Platforms · Part 2 of 4
  1. Building a Data Platform with Redshift, Looker, and dbt
  2. Data Contracts: Stopping Application Changes from Breaking the Warehouse
  3. Snowpipe Streaming: Getting Data into Snowflake in Seconds, Not Minutes
  4. RDS Postgres to Snowflake: Migration Patterns and Choosing the Right Approach
Contents
  1. The Monday Morning Dashboard Problem
  2. Who Owns the Schema?
  3. Contracts at the Source
  4. Contracts in dbt
  5. CI Checks That Catch It Early
  6. A Schema Change Process People Will Follow
  7. What to Do by Stage
  8. The Takeaway

The Monday Morning Dashboard Problem

Every data team knows this one. A product engineer renames a column, splits a status field into two, or changes an event payload from cents to pounds. The change is correct, reviewed and tested. It ships on Thursday. On Monday the revenue dashboard is wrong, the dbt run has failed, or worse, it succeeded and quietly produced nonsense.

Nobody did anything wrong in isolation. The problem is structural: the warehouse depends on the application's internal schema, but nobody ever agreed that it could. The application team thinks the database is theirs to change. The data team thinks the tables are an interface. Both are half right.

A data contract makes that dependency explicit. It says: these fields, with these types and meanings, are a published interface. Changing them is a versioned, announced event, not a side effect of a refactor.

Who Owns the Schema?

The first thing to settle is ownership, and my view is firm: the producing team owns the contract. The team that writes the data is the only one that can keep a promise about its shape. The data team is a consumer with a seat at the table, not a gatekeeper.

That sounds like more work for product engineers, and it is, a little. The trade is that the contract is small. You are not promising the whole database. You are promising a curated set of entities (orders, subscriptions, customers, deliveries) in a stable shape. Everything else stays private and free to change.

When I built our data platform on Redshift, Looker and dbt (I wrote about that in 2019), we loaded raw application tables and fixed things up in the staging layer. That is fine at the start. It stops being fine once several teams ship to the same database every day and the staging layer becomes the place where every upstream surprise lands.

Contracts at the Source

There are two common ways data leaves an application, and each needs a different kind of contract.

Events

If your application publishes domain events (to SQS, SNS, Kafka or EventBridge), the contract is the event schema. Define it in JSON Schema, Avro or Protobuf, keep it in the repository next to the producer, and validate at publish time. A schema registry (Confluent Schema Registry, AWS Glue Schema Registry) can enforce compatibility rules such as "backward compatible only" so a breaking change is rejected before it is deployed.

For Protobuf, buf breaking in CI does the same job without a registry.

Change Data Capture

With CDC (Debezium, AWS DMS, Fivetran and similar tools), the contract is harder, because you are replicating tables that were designed for the application, not for analysis. The pattern I prefer is an outbox or a set of views: the application team maintains a small number of tables or views that are explicitly published for replication, and the replication job only reads those. Internal tables can change freely. Published ones follow the contract rules.

If that is too much to start with, at least keep a list of the replicated columns in the application repository, so a pull request that touches them is visible.

Contracts in dbt

dbt 1.5 added three features that turn dbt into the enforcement point on the warehouse side: model contracts, model versions and access modifiers.

A contract declares the exact columns and types a model must produce. If the SQL returns a different shape, the build fails before the table is replaced.

models:
  - name: fct_orders
    access: public
    config:
      contract:
        enforced: true
    columns:
      - name: order_id
        data_type: varchar
        constraints:
          - type: not_null
      - name: customer_id
        data_type: varchar
      - name: order_total_pence
        data_type: integer
      - name: ordered_at
        data_type: timestamp

Two caveats. First, how constraints are enforced depends on the platform. Redshift, for example, enforces not_null but treats primary and foreign keys as informational, so keep your dbt tests alongside the contract. Second, contracts check shape, not meaning. A column that switches from pence to pounds still passes.

Model versions handle the case where a breaking change is genuinely needed. You publish v2 alongside v1, give v1 a deprecation_date, and downstream consumers (Looker explores, reverse ETL jobs, other dbt projects) migrate on their own timetable.

models:
  - name: fct_orders
    latest_version: 2
    versions:
      - v: 2
      - v: 1
        deprecation_date: 2025-09-30

Access modifiers (private, protected, public) plus groups let you mark which models are an interface and which are implementation detail. That matters more than it sounds. Most accidental coupling I have seen comes from someone building on an intermediate model because it happened to have the column they needed.

CI Checks That Catch It Early

Contracts only help if they fail in the pull request, not in the nightly run. The checks I would put in place, roughly in order of value:

  1. Producer-side schema checks. A test in the application repository that fails when a published table, view or event schema changes without a version bump.
  2. Slim CI in dbt. Build only modified models and their children against production state with dbt build --select state:modified+ --defer --state prod-artifacts/. With enforced contracts, dbt also flags breaking changes to a contracted model when it compares against that state.
  3. Downstream impact in the pull request. A comment listing affected exposures (dashboards, jobs) so the reviewer can see the blast radius.
  4. Freshness and volume tests on sources, which catch the silent failures that shape checks cannot.

A Schema Change Process People Will Follow

Process fails when it is heavier than the change. This is the lightweight version that has worked for me:

Change type Example Process
Additive New nullable column, new event field Ship it. Tell the data channel.
Internal Change to an unpublished table Ship it. No contract applies.
Semantic Unit, timezone or meaning changes Treat as breaking, even if the type is unchanged.
Breaking Rename, drop, type change on a published field New version alongside the old one, deprecation date, consumer sign-off.

The semantic row is the one teams forget. Most of the painful incidents I have seen were not renames. They were a field whose meaning changed while its name and type stayed the same.

What to Do by Stage

Stage What I'd do
Startup (one team, one database) Keep a list of the tables analytics depends on. Add dbt source freshness and a few not_null/unique tests. Don't build a registry.
Scaleup (several product teams) Published views or an outbox for CDC. Enforced dbt contracts on public marts. Slim CI on every dbt pull request. Producer-side schema tests.
Enterprise (many producers and consumers) Schema registry with compatibility rules. Versioned models with deprecation dates. A written standard, possibly the Open Data Contract Standard, and ownership recorded in a catalogue.

The mistake at every stage is buying a data contract platform before the ownership conversation has happened. Tools enforce agreements. They cannot create them.

The Takeaway

Data contracts are mostly an ownership decision with some YAML attached: the producing team owns a small, published interface and changes it deliberately. Enforce that interface at the source with schemas, on the warehouse side with dbt contracts and versions, and in CI so breakages show up in the pull request rather than on Monday morning. Start with the handful of tables your most important dashboards depend on.

Next in Building Data Platforms
Snowpipe Streaming: Getting Data into Snowflake in Seconds, Not Minutes
data-contractsdbtCDCschema-managementdata-qualityanalytics-engineering