Skip to content

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

  1. Start with row_count -- Every critical table should have row_count > 0 as a minimum quality check.

  2. Enforce primary keys -- Use unique(pk_column) and no_nulls(pk_column) for every table that has a primary key.

  3. Use severity wisely -- Reserve error for must-have guarantees. Use warn for aspirational goals or new checks that are not yet enforced.

  4. Organize by domain -- Group related contracts in the same YAML file: contracts/finance.yml, contracts/customers.yml, etc.

  5. Run in CI/CD -- Add havn contracts to your CI/CD pipeline to catch quality regressions before deployment.