Data contracts are one of the most impactful patterns in modern data engineering. This guide explains what they are, why they matter, and how to use them in ObservaKit.
A data contract is a versioned, machine-enforceable agreement between:
- The producer — the team or service that writes data to a table (e.g. backend engineering, a vendor, an ETL pipeline)
- The consumer — the team or system that reads that data (e.g. analytics, ML, reporting)
The contract defines:
- Which columns must exist and their types
- Which columns must never be null
- Allowed values for categorical columns
- Business rules (expressed as SQL assertions)
- Volume expectations (minimum row counts)
- Freshness SLAs
When the producer changes the data in a way that violates the contract, ObservaKit fires an alert before consumers are impacted.
Without contracts, breaking changes happen like this:
- Backend engineer renames
amounttototal_amountin a PR — looks fine to them. - dbt model
select amount from ordersbreaks silently overnight. - Revenue dashboard shows $0 at 9am.
- Data team spends 2 hours finding the root cause.
With contracts:
- Same PR is merged.
- ObservaKit validates the
orders_v1contract at the next pipeline run. - Alert fires within 60 minutes:
Contract violation: column 'amount' is missing from public.orders. - Backend team is notified before the dashboard is opened.
Each contract is a YAML file in config/contracts/. The full schema:
contract:
id: orders_v1 # Unique identifier — used in API calls
version: "1.2.0" # Semantic version — increment on breaking changes
table: public.orders # schema.table format
owner: "data-eng@company.com"
description: |
Core orders table produced by the backend API ingestion pipeline.
Consumed by: analytics dashboards, revenue reporting, ML churn model.
# ---- Column rules ----
columns:
- name: id
type: integer
nullable: false
unique: true
- name: status
type: varchar
nullable: false
allowed_values:
- pending
- confirmed
- shipped
- delivered
- cancelled
- name: amount
type: numeric
nullable: false
min: 0 # value must be >= 0
# max: 100000 # optional upper bound
- name: created_at
type: timestamp
nullable: false
# ---- Volume expectation ----
volume:
min_rows: 1000
# ---- Custom SQL business rules ----
rules:
- name: "No future-dated orders"
sql: "SELECT COUNT(*) FROM public.orders WHERE created_at > NOW()"
assert: "result == 0"
severity: critical # critical | warning (for documentation only currently)
- name: "Revenue non-negative"
sql: "SELECT COALESCE(MIN(amount), 0) FROM public.orders WHERE status != 'refunded'"
assert: "result >= 0"
severity: critical
- name: "Delivered orders have a timestamp"
sql: |
SELECT COUNT(*) FROM public.orders
WHERE status = 'delivered' AND updated_at IS NULL
assert: "result == 0"
severity: warningcp config/contracts/example_orders.yml config/contracts/orders_v1.yml
# Edit to match your tablecontracts:
enabled: true
schedule_minutes: 60 # validate every hour
contracts_dir: config/contracts/alerts:
routing:
- match:
alert_type: "contract"
channel: slack
slack_channel: "#data-contracts"# Via CLI (Dry-run validation of contract YAMLs)
observakit validate-config
# Via API (Trigger real-time validation against the warehouse)
curl -X POST http://localhost:8000/contracts/validate \
-H "X-API-Key: $OBSERVAKIT_API_KEY"
# Validate a specific contract by ID
curl -X POST "http://localhost:8000/contracts/validate?contract_id=orders_v1" \
-H "X-API-Key: $OBSERVAKIT_API_KEY"curl http://localhost:8000/contracts/results \
-H "X-API-Key: $OBSERVAKIT_API_KEY"| Method | Path | Description |
|---|---|---|
POST |
/contracts/validate |
Validate all contracts (or one by ID) |
GET |
/contracts/results |
Query historical validation results |
GET |
/contracts/results?contract_id=orders_v1 |
Filter by contract ID |
GET |
/contracts/results?table_name=public.orders |
Filter by table |
| Field | Type | Description |
|---|---|---|
name |
string | Column name (required) |
type |
string | Expected data type (prefix match — integer matches integer4) |
nullable |
bool | If false, checks for NULLs in the column |
unique |
bool | If true, checks for duplicate values |
allowed_values |
list | Checks that no values outside this list exist |
min |
number | Minimum allowed value |
max |
number | Maximum allowed value |
| Field | Type | Description |
|---|---|---|
min_rows |
int | Minimum row count. Fails if table has fewer rows. |
| Field | Type | Description |
|---|---|---|
name |
string | Human-readable rule name |
sql |
string | SQL query that returns a single scalar value |
assert |
string | Python expression evaluated with result = the query return value |
severity |
string | critical or warning (for documentation; alerts fire regardless) |
Version your contracts. Use semantic versioning. Increment the minor version for backward-compatible additions (new optional columns). Increment the major version for breaking changes (removed columns, narrowed allowed values).
Keep contracts close to the code. Store contract files in the same repository as the service that produces the data, or in a dedicated data-contracts repo. Review them in PRs just like code.
Start small. Don't try to define every constraint on day one. Start with: not-null PKs, not-null FKs, allowed values for status columns, and one or two business rules. Add more over time.
Don't duplicate Soda/GX checks. Data contracts are for producer-consumer agreements. Soda/GX quality checks are for detailed row-level validation. Use contracts for the interface-level guarantees and Soda/GX for the implementation details.
Alert the producer, not just the consumer. Route contract violation alerts to the team that owns the producing pipeline (#backend-alerts), not just the data team. The goal is to fix the source, not just observe the symptom.
Many small teams maintain "data dictionaries" as spreadsheets or Confluence pages. These go stale immediately. Here's how to migrate to machine-enforced contracts:
- For each critical table, copy
example_orders.ymland fill in what you know. - Start with
nullable: falsefor PKs and FKs only. - Add
allowed_valuesfor your most important categorical columns. - Add one or two SQL rules for your most critical business invariants.
- Run validation in
warnmode first — just observe violations without alerting. - Once false positives are tuned out, enable alerting.
This incremental approach gets you from zero to enforced contracts in a week without disruption.