Quality Checks

Checks are data-quality assertions attached to models. They run against every freshly built table during apply/run/restate, and a failing error-severity check blocks promotion — no views move, the environment stays as it was.

Declaring Checks

On a SQL model:

/* interlace:
  checks:
    - not_null: order_id
    - unique: [order_id]
    - accepted_values: {column: status, values: [open, shipped, closed]}
    - expression: {expression: "amount >= 0", severity: warn}
*/
SELECT ...

On a Python model: @model(checks=[{"not_null": "id"}, ...]).

Two syntaxes per entry:

  • shorthand — one key, the type: - not_null: order_id, - unique: [a, b], - row_count: {min: 1}
  • explicit- {type: not_null, column: order_id, severity: warn} (use column or columns; remaining keys are the check’s parameters)

Built-in Check Types

TypeParametersFails when
not_nullcolumn(s)any listed column is NULL
uniquecolumn(s)duplicate values (composite keys supported)
accepted_valuescolumn, valuesa non-NULL value is outside the list
rangecolumn, min/maxa non-NULL value is out of bounds
patterncolumn, regexa non-NULL value doesn’t match
expressionexpressionany row violates the SQL predicate
relationshipscolumn, to, fielda non-NULL value has no match in model to’s field
row_countmin and/or maxthe row count is out of bounds
freshnesscolumn, max_agemax(column) is older than max_age (2h, 1d, …) — or the table is empty
sqlquerythe query returns rows; {table} is substituted with the model’s table

pattern, range, accepted_values, and relationships deliberately ignore NULLsnot_null is the null check; combine them when NULLs should also fail.

relationships and sql reference other models (to names the parent model; {table} in a sql query is substituted with the model’s physical table). During an apply, those referenced models are scheduled to build first, so the check runs against fresh data.

Severity

Every check takes severity: error | warn | info (default error). Only error blocks — warn and info outcomes are recorded and reported, and the pipeline continues.

Each run records a status: passed (failures = 0), failed (failures > 0), or error (the check query itself threw — bad SQL, a missing column). A result is blocking when status != passed and severity == error, so an error-severity check that errors out blocks just like one that fails.

Python Checks

For assertions SQL can’t express, decorate a function with @check. It receives the built table as a RelationHandle:

from interlace import check

@check(model="event_totals", severity="error")
def totals_are_positive(rel):
    t = rel.table()
    return all(v > 0 for v in t.column("total_amount").to_pylist())

Return True, None, or 0 to pass; False, a failure count, or a pyarrow.Table of failing rows to fail. Put @check functions anywhere under your model paths.

When Checks Run

  1. During builds — after a model materialises (and its column contract validates), its checks run against the fresh snapshot. Any blocking failure aborts the apply before promotion.
  2. On demand — against an environment’s already-promoted tables, no rebuild:
interlace checks run --env prod         # exits 1 on any error-severity failure
interlace checks run -s orders+ --json

Ad-hoc runs use each snapshot’s recorded engine, skip terminal (table/file) models and models declared but not yet promoted to that environment, and report blocking_failures. Or POST /checks/run on the HTTP API.

All outcomes are recorded, whichever path ran them:

interlace checks list --model orders    # newest first

GET /checks and the web UI’s checks view read the same history.

Checks and Rebuilds

Check declarations are metadata: editing a check never rebuilds a model. Add assertions to a large table freely — then verify them immediately with interlace checks run.

Next Steps