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:
| Setting | What happens to a schema change | Use it for |
|---|---|---|
evolve (default) | Applied automatically | Raw landing tables nobody reads directly |
freeze | Rejected | Tables with a contract |
discard_row | Rows that don't fit are dropped | Feeds where losing a bad row is better than stopping |
discard_value | Values that don't fit become NULL | Optional 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
| Approach | What it enforces | Where it runs |
|---|---|---|
| Bruin | Schema at landing (ingestr), value guarantees as blocking checks, required contracts as policies | In the pipeline and in CI |
| dbt model contracts | A model's column names and data types before it builds (table, view and incremental models). Most warehouses enforce only not_null constraints | In the dbt build |
| Databricks Lakeflow pipelines | Expectations on each table that warn, drop the bad rows or fail the update | Inside Databricks pipelines |
| Soda Core | YAML contracts verified with soda contract verify. Source-available under the Elastic License 2.0 | As a separate step in the pipeline or CI |
| datacontract-cli | Contracts in ODCS format: linted, tested against the data with datacontract test, and checked for breaking changes. MIT licensed | As 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 Foundation | Any 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.