Technical
9 min read

How to Enforce Data Contracts in a Pipeline

A data contract is only real if breaking it stops something. How to enforce one at the three points where a pipeline can stop bad data: when it lands, when a table builds, and when code merges. With examples in Bruin and ingestr, and where dbt model contracts, Soda, the Open Data Contract Standard and datacontract-cli fit.

How to Enforce Data Contracts in a Pipeline

TL;DR: A data contract only works if breaking it stops something. Enforce it at the three points where a pipeline can stop bad data. When data lands: freeze or validate the schema so an unexpected column or type can't slip in (in ingestr, schema_contract: freeze and enforce_schema: true). When a table builds: run blocking checks on the promised columns, so a violation stops every table downstream. When code merges: check in CI that every table declares its contract and still meets it (bruin validate with a policy). Bruin keeps all three in the same files as the pipeline. dbt model contracts, Soda and datacontract-cli cover parts of it, and the Open Data Contract Standard gives you a shared format if contracts cross tools.

Most data contracts fail the same way. Someone writes a careful document about the orders table, consumers read it once, and six months later a source system adds a column, changes a type or starts sending nulls. Nothing stops it, because the document was never connected to anything that runs.

This guide is about the connection. If you want the definition first, read what a data contract is. We build Bruin, so we use it for the examples and say where other tools are the better fit.

Write the contract next to the table

A contract has to live where the pipeline can read it. In Bruin that is the header of the asset that builds the table, here analytics/orders.sql:

/* @bruin
name: analytics.orders
type: bq.sql
owner: [email protected]
description: One row per order. Read by finance, marketing and the AI data analyst.

columns:
  - name: order_id
    type: integer
    primary_key: true
    checks:
      - name: unique
      - name: not_null
  - name: status
    type: string
    checks:
      - name: not_null
      - name: accepted_values
        value: [paid, refunded, pending]
  - name: amount
    type: float
    description: Order total in EUR, after discounts, before tax.
    checks:
      - name: non_negative
  - name: customer_id
    type: integer
    checks:
      - name: not_null
@bruin */

SELECT order_id, status, amount, customer_id
FROM raw.orders

That covers most of a contract: the owner, the columns and their types, the meaning, the key and the guarantees. The rest (who to tell before a change, how much notice) belongs in the pull-request process, which is the third gate.

Gate 1: When data lands

The cheapest place to stop a contract break is before the data reaches the warehouse. If the source sends a column you didn't expect, or a value of the wrong type, decide up front what should happen.

Bruin loads data with ingestr, and an ingestr asset takes two settings for this:

name: raw.orders
type: ingestr
parameters:
  source_connection: postgres_prod
  source_table: public.orders
  destination: bigquery
  enforce_schema: true
  schema_contract: freeze

columns:
  - name: order_id
    type: integer
    primary_key: true
  - name: status
    type: string
  - name: amount
    type: float

enforce_schema: true creates or updates the destination table with the column types you declared, instead of whatever the loader would guess. schema_contract decides what happens when the source changes:

SettingWhat happens to a schema changeUse it for
evolve (default)Applied automaticallyRaw landing tables nobody reads directly
freezeRejectedTables with a contract
discard_rowRows that don't fit are droppedFeeds where losing a bad row is better than stopping
discard_valueValues that don't fit become NULLOptional fields where the rest of the row still matters

freeze is the strict choice: a load with an unexpected change fails, and someone decides whether the contract should change, instead of the warehouse deciding for them.

Gate 2: When a table builds

Types and columns are the shape of the contract. The guarantees about values (unique keys, allowed statuses, no negative amounts) are checked after each table is built.

Every check in the asset header above runs on every bruin run. Checks are blocking by default: when one fails, the asset is marked failed and the assets downstream of it do not run. That is what makes the contract enforceable. A broken guarantee stops at the producer's table, before any consumer reads it.

For rules that span columns or tables, write a custom check as SQL. It passes when the query returns zero:

custom_checks:
  - name: every order has a known customer
    query: |
      SELECT count(*)
      FROM analytics.orders o
      LEFT JOIN analytics.customers c USING (customer_id)
      WHERE c.customer_id IS NULL

Built-in checks cover not_null, unique, accepted_values, positive, non_negative, negative, min, max, pattern and relationships. Mark a slow or advisory check blocking: false if it should report without stopping the pipeline. In Bruin Cloud, a failed check can alert a Slack channel or an email address, so the owner hears about it before a consumer does.

Gate 3: When code merges

The last gate protects the contract from the producer's own changes. Two things should run on every pull request:

A policy that requires a contract. A policy.yml at the project root can refuse any asset that doesn't declare one:

rulesets:
  - name: contracts
    selector:
      - path: .*/analytics/.*
    rules:
      - asset-has-owner
      - asset-has-description
      - asset-has-columns
      - asset-has-primary-key
      - asset-has-checks

bruin validate applies the policy, and bruin run applies it before every run, so an asset without a contract fails before it builds.

Tests for the change. Run bruin validate and bruin unit-test in CI, and build the changed tables with their checks in a staging environment. Our guide to testing data pipelines in CI/CD has the full workflow. Because the contract is part of the asset file, changing a column type or dropping a check shows up in the diff, and the review is where consumers get their say.

How this maps to other approaches

ApproachWhat it enforcesWhere it runs
BruinSchema at landing (ingestr), value guarantees as blocking checks, required contracts as policiesIn the pipeline and in CI
dbt model contractsA model's column names and data types before it builds (table, view and incremental models). Most warehouses enforce only not_null constraintsIn the dbt build
Databricks Lakeflow pipelinesExpectations on each table that warn, drop the bad rows or fail the updateInside Databricks pipelines
Soda CoreYAML contracts verified with soda contract verify. Source-available under the Elastic License 2.0As a separate step in the pipeline or CI
datacontract-cliContracts in ODCS format: linted, tested against the data with datacontract test, and checked for breaking changes. MIT licensedAs a separate step in CI
Open Data Contract Standard (ODCS)Nothing on its own. A shared YAML format for contracts, maintained by Bitol under the LF AI & Data FoundationAny tool that reads it
Confluent Schema Registry (streaming)Rejects schema changes that break the subject's compatibility mode (backward by default, forward or full)In the streaming platform

Best approach by situation:

  • One team owns the pipeline and the contract: keep the contract in the pipeline code (Bruin, or dbt model contracts plus tests).
  • Contracts are agreed between separate teams or companies: write them in ODCS and verify them with datacontract-cli or Soda.
  • Event streams: enforce at the schema registry, then again where the stream lands in the warehouse.

Where to start

Pick the one table that the most people read. Write its contract into the asset file: owner, columns, types, key and the three checks that matter most. Set its ingestion to schema_contract: freeze. Add a policy that requires owners, columns and checks for that folder, and run bruin validate in CI. That is a working contract, enforced at all three gates, in an afternoon.

The Bruin CLI is open source. See the docs for quality checks, ingestr assets and policies.

Sign up to our newsletter

Practical updates on open-source data pipelines, AI analysts, governance, and what we are shipping at Bruin.

The signup form is hosted by Brevo. Allow marketing cookies to load it.