Data Contracts¶
Data contracts are standalone YAML files that define data quality rules. They complement inline @assert directives in SQL models by providing reusable, centralized quality definitions with severity levels and historical tracking.
Contract Format¶
Contracts live in the contracts/ directory as YAML files:
# contracts/orders.yml
contracts:
- name: orders_not_empty
description: "Orders table must have data"
model: gold.orders
severity: error
assertions:
- row_count > 0
- no_nulls(order_id)
- unique(order_id)
- accepted_values(status, ['pending', 'shipped', 'delivered'])
- "total_amount >= 0"
- name: customers_fresh
description: "Customers must be loaded within 24h"
model: silver.customers
severity: warn
assertions:
- row_count > 0
Each YAML file can contain multiple contracts under the contracts: key.
Contract Properties¶
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | yes | file stem | Unique contract identifier |
model |
string | yes | -- | Target model as schema.table |
description |
string | no | "" |
Human-readable description |
severity |
string | no | error |
error or warn |
assertions |
list | yes | -- | List of assertion expressions |
columns |
list | no | -- | Declared column shape, see Column contracts |
strict |
bool | no | false |
An undeclared column is an error |
on_widen |
string | no | warn |
warn, error or ignore for a widening type change |
Column contracts¶
Assertions say what the data must look like. A columns: block says what the
shape must look like, and unlike an assertion it is checked before the build,
against the schema the bind pass infers, on a warehouse where the table may not
exist yet.
# contracts/orders.yml
contracts:
- name: orders_shape
model: gold.orders
strict: true # an undeclared column is an error
on_widen: warn # warn (default) | error | ignore
columns:
- name: order_id
type: BIGINT
nullable: false
description: "Surrogate key, one row per order"
- name: status
type: VARCHAR
- name: total_amount
type: DECIMAL(38,2)
assertions:
- row_count > 0
| Column property | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Must be a plain identifier: letters, digits, underscores, not starting with a digit |
type |
string | yes | Any DuckDB type name, including nested ones like STRUCT(a INTEGER) or VARCHAR[] |
nullable |
bool | no | false asserts the column does not admit nulls. Omitted means the contract says nothing about nulls |
description |
string | no | Documentation for the column |
Type names are checked by DuckDB itself when the contract loads, so a typo is reported where you wrote it rather than becoming a mysterious mismatch later.
A declaration problem -- an unknown type, a name that is not an identifier, a
duplicate column -- is collected rather than raised. The rest of the contract
still loads and still runs, and the problem is reported in havn contracts,
havn validate and GET /api/contracts.
What each difference costs¶
| Difference | Severity | Why |
|---|---|---|
| Declared column is missing | error | Every consumer of that column breaks. |
Undeclared column exists, strict: true |
error | You asked for the shape to be exact. |
Undeclared column exists, strict: false (default) |
info | Declaring three columns of twenty is a normal way to use this. Reported in the contract result, not in havn validate. |
Type widened (INTEGER to BIGINT, DOUBLE to DECIMAL(38,1)) |
warning, or whatever on_widen says |
Nothing downstream breaks, but you should know. |
Type narrowed (BIGINT to SMALLINT) |
error | Values are being lost. |
Type changed category (BIGINT to VARCHAR, VARCHAR to VARCHAR[]) |
error | Every expression over that column changes meaning. |
nullable: false and the column admits nulls |
error | The promise is broken. |
A change of parameters within one type -- DECIMAL(10,2) to DECIMAL(38,2),
VARCHAR(20) to VARCHAR -- is not a difference at all and is never reported.
DECIMAL(38,1) against a declared DOUBLE is treated as a widening, so it
warns rather than failing. This is deliberate and it is the single most common
case: SUM(some_double) resolves to DECIMAL(38,1) in DuckDB, so a contract
written by reading the model's SQL says DOUBLE and the real column is a
DECIMAL. Set on_widen: error on a contract where you want that to fail.
Nullability is often unavailable¶
nullable: false is only checked when DuckDB actually distinguishes nullable
from not-null for that object. A table built with CREATE TABLE AS -- which is
every havn model -- reports every column as nullable through DESCRIBE,
whether or not a null has ever been in it. Taking that at face value would fail
every nullable: false declaration in every project, so an all-nullable answer
is treated as no answer and the check is skipped.
In practice that means nullable: is documentation on a normal model, and an
enforced check only where the object carries a real NOT NULL constraint. For a
data-level guarantee, use no_nulls(column) in assertions: instead, which
queries the rows.
When the check runs¶
| Moment | Source of the real schema |
|---|---|
havn validate (with the bind pass on, the default) |
The bind pass, which resolves the model's SQL without building it |
The pre-build gate in havn serve pipelines |
The same bind pass, before the first transform runs |
| A model the bind pass did not cover | The schema recorded in _havn.model_columns at its last build |
havn contracts, havn check, POST /api/contracts/run |
A live DESCRIBE of the built table |
All four produce the same findings from the same code, so the pre-build and post-build reports cannot disagree with each other.
Schema drift without a contract¶
The same comparison runs without a contract, using the schema recorded at a model's last build as the baseline:
warning gold.orders output schema of gold.orders changes: added region;
removed legacy_id; retyped amount INTEGER -> BIGINT
It is off by default, because the equivalence rules have not been tuned on a real project yet and a warning that fires on every model is a warning nobody reads. Turn it on per project:
# project.yml
validation:
schema_drift: warn # warn | off
or per run:
havn validate --schema-drift
Assertion Types¶
Contracts support the same assertion expressions as inline @assert directives:
row_count > N¶
assertions:
- row_count > 0
- row_count > 1000
- row_count >= 100
unique(column)¶
assertions:
- unique(order_id)
- unique(email)
no_nulls(column)¶
assertions:
- no_nulls(customer_id)
- no_nulls(email)
accepted_values(column, [values])¶
assertions:
- accepted_values(status, ['pending', 'shipped', 'delivered'])
Custom SQL Expressions¶
assertions:
- "AVG(amount) > 0"
- "MAX(created_at) > CURRENT_DATE - INTERVAL '7 days'"
Severity Levels¶
error (default)¶
Contract failures with error severity cause havn check and havn contracts to exit with code 1. Use this for critical data quality rules that must be enforced.
warn¶
Contract failures with warn severity are reported but do not cause command failure. Use this for aspirational quality goals or non-critical checks.
contracts:
- name: revenue_positive
model: gold.daily_revenue
severity: error # Must pass
assertions:
- row_count > 0
- name: high_cardinality
model: gold.daily_revenue
severity: warn # Nice to have
assertions:
- "COUNT(DISTINCT region) > 5"
Running Contracts¶
Run All Contracts¶
havn contracts
Discovers all YAML files in contracts/, evaluates each contract, and reports results:
Running 3 contract(s)...
PASS orders_not_empty (gold.orders) [12ms]
pass row_count > 0
pass unique(order_id)
pass no_nulls(order_id)
FAIL customers_fresh (silver.customers) [8ms]
pass row_count > 0
FAIL "MAX(updated_at) > CURRENT_DATE - INTERVAL '24 hours'" (value: false)
PASS revenue_check (gold.daily_revenue) [5ms]
pass row_count > 0
2 contract(s) passed, 1 failed.
Run Specific Contracts¶
Filter by contract name or model name:
havn contracts orders_not_empty
havn contracts gold.orders
View Contract History¶
havn contracts --history
Shows the pass/fail history of all contract evaluations, ordered by most recent.
Contract History¶
Every contract evaluation is recorded in _havn.contract_results:
| Column | Type | Description |
|---|---|---|
id |
VARCHAR | Unique result ID |
contract_name |
VARCHAR | Contract identifier |
model |
VARCHAR | Target model |
passed |
BOOLEAN | Whether all assertions passed |
severity |
VARCHAR | error or warn |
detail |
JSON | Per-assertion results |
checked_at |
TIMESTAMP | Evaluation timestamp |
History via API¶
curl http://localhost:3000/api/contracts/history
Contracts via API¶
List Contracts¶
curl http://localhost:3000/api/contracts
Returns all discovered contracts with their names, models, assertions, and severity.
Run Contracts¶
curl -X POST http://localhost:3000/api/contracts/run
Returns:
{
"total": 3,
"passed": 2,
"failed": 1,
"results": [
{
"contract_name": "orders_not_empty",
"model": "gold.orders",
"passed": true,
"severity": "error",
"duration_ms": 12,
"error": null,
"assertions": [
{"expression": "row_count > 0", "passed": true, "detail": "1234"}
]
}
]
}
Contracts in havn check¶
The havn check command includes contract evaluation:
havn check
This runs:
1. SQL model validation
2. Inline assertions (@assert)
3. YAML contracts from contracts/
All three must pass for havn check to exit with code 0.
Contracts vs Inline Assertions¶
| Feature | Inline Assertions | YAML Contracts |
|---|---|---|
| Location | Inside SQL files | Separate YAML files |
| Evaluated | During havn transform |
Column declarations during havn validate, before the build; assertions during havn contracts or havn check |
| Severity | Always error | Configurable (error/warn) |
| History | Stored in assertion_results |
Stored in contract_results |
| Use case | Model-specific checks | Cross-model, reusable rules |
Use inline assertions for model-specific quality checks that should run on every build. Use contracts for cross-cutting quality rules, SLA enforcement, and checks that should be run independently of transforms.
Best Practices¶
-
Start with row_count -- Every critical table should have
row_count > 0as a minimum quality check. -
Enforce primary keys -- Use
unique(pk_column)andno_nulls(pk_column)for every table that has a primary key. -
Use severity wisely -- Reserve
errorfor must-have guarantees. Usewarnfor aspirational goals or new checks that are not yet enforced. -
Organize by domain -- Group related contracts in the same YAML file:
contracts/finance.yml,contracts/customers.yml, etc. -
Run in CI/CD -- Add
havn contractsto your CI/CD pipeline to catch quality regressions before deployment.
Related Pages¶
- Quality -- Data quality overview
- Transforms -- Inline assertions in SQL models
- CLI Reference -- Contract-related commands
- API Reference -- Contract API endpoints