Checks
Declare checks in the recipe and inspect their results after the worker runs them against the finished table.
Read the results of a run with:
mr-data checks RUN_ID --jsonHow a check runs
Section titled “How a check runs”The worker builds the table, writes it, then re-opens the candidate and evaluates every declared check in one bounded pass. Each check produces one result, pass or fail. A failing check never short-circuits the rest.
One result has two spellings, and which one you read depends on where you read it.
mr-data checks prints the worker’s own wire shape, off the run’s terminal event:
{ "check_id": "GRAIN_UNIQUE", "passed": true, "detail": "key (station, observed_at) is distinct across all 6132 rows, as expected"}| Field | Type | Meaning |
|---|---|---|
check_id |
^[A-Z][A-Z0-9_]{0,127}$ |
the recipe’s own check_id, upper-cased |
passed |
boolean | whether the assertion held |
detail |
string or null |
what the worker measured, in words |
scope |
string | which relation this check covered, added only when the run’s receipt records one |
The run record carries check_outcome, the vocabulary every reader of the table sees:
{ "name": "GRAIN_UNIQUE", "status": "passed", "reason": "key (station, observed_at) is distinct across all 6132 rows, as expected"}| Field | Type | Required | Meaning |
|---|---|---|---|
name |
^[A-Z][A-Z0-9_]{0,127}$ |
yes | the recipe’s own check_id, upper-cased |
status |
passed, failed, not_evaluated |
yes | what happened |
reason |
string, ≤1024 chars | no | the detail the worker reported |
The run record has no passed, and the wire has no status. Studio derives status when it writes the record, from the worker’s own status where the worker states one, and otherwise from passed. A detail opening not_evaluated: becomes not_evaluated rather than failed.
The run reports the registered id upper-cased. A recipe writes check_id as a lowercase identifier and the run reports GRAIN_UNIQUE. Upper-casing is injective over the registered alphabet, so two distinct registered ids never collide into one reported identity.
Enforcement
Section titled “Enforcement”Every check states enforcement, and there is no default.
enforcement |
A failure means |
|---|---|
required |
the run ends failed with failure_code: "CHECK_FAILED", failed_stage: "check", and failure_detail of CHECK_ID: <the check's own detail> |
advisory |
the result goes on the receipt and the run continues |
The five kinds
Section titled “The five kinds”A recipe declares at most 128 checks. The schema puts every optional bound on every kind, so which bounds a kind reads is a run-time rule rather than a registration one:
kind |
columns |
Also required | Refused if you also state |
|---|---|---|---|
key_uniqueness |
1 to 16, or none to fall back to table.grain |
none | max_null_ppm, min_value, max_value, min_rows, max_rows, max_gap_seconds |
null_ceiling |
exactly one | max_null_ppm |
every other bound |
value_range |
exactly one | both min_value and max_value |
every other bound |
continuity |
exactly one | max_gap_seconds |
every other bound |
row_expectation |
none. Registration refuses a named column | at least one of min_rows, max_rows |
every other bound |
Registration refuses a bound the check kind does not read instead of silently dropping it. Writing max_rows: 100 beside a null ceiling would otherwise leave a run reporting “1 of 1 declared checks passed” over a bound nothing evaluated. Move the bound to a check of the kind that reads it.
description is prose and belongs to no kind. Every branch drops it alike, and it never appears in a refusal.
key_uniqueness
Section titled “key_uniqueness”Asserts the tuple of the named columns is distinct across every stored row.
columns names 1 to 16 columns. Naming 17 or more returns DECLARED_CHECK_INVALID. Omitting columns, or writing [], falls back to table.grain, the one default the translation synthesizes. The worker refuses a recipe with no grain to fall back to, or one whose grain names more than 16 columns. table.grain allows up to 32, and a uniqueness check may not state a key that wide.
{ "check_id": "grain_unique", "kind": "key_uniqueness", "enforcement": "required", "columns": ["station", "observed_at"], "description": "One row per station and observation time."}Pass detail: key (station, observed_at) is distinct across all 6132 rows, as expected
Fail detail: key (station, observed_at) repeats on 4 of 6132 rows, first at ('NYC', '2026-03-01T00:51:00Z'), where 0 repeats were expected
null_ceiling
Section titled “null_ceiling”Asserts the named column’s share of null rows sits at or below max_null_ppm, in parts per million of the table’s rows. It is a ceiling rather than a zero-null rule: max_null_ppm: 20000 allows two per cent.
Exactly one column. max_null_ppm is an integer between 0 and 1,000,000. A fraction has no spelling here, because the document carries no fractional number.
{ "check_id": "temperature_present", "kind": "null_ceiling", "enforcement": "required", "columns": ["air_temp_c"], "max_null_ppm": 20000}Pass detail: column 'air_temp_c' is 3104 ppm null (19 of 6132 rows), at or below the declared ceiling of 20000 ppm
Fail detail: the same sentence with above in place of at or below.
value_range
Section titled “value_range”Asserts every non-null value of the named column lies inside the inclusive interval [min_value, max_value].
Exactly one column, and both bounds. The worker refuses a one-sided range instead of creating an unspecified open bound.
Bounds are decimal strings matching ^-?(?:0|[1-9][0-9]{0,38})(?:\.[0-9]{1,18})?$. The comparison is exact and never goes through a float, so a row sitting on the declared minimum is inside it.
{ "check_id": "humidity_in_range", "kind": "value_range", "enforcement": "required", "columns": ["relative_humidity_pct"], "min_value": "0", "max_value": "100"}Pass detail: column 'relative_humidity_pct' lies within [0, 100] across 6113 non-null values of 6132 rows
Fail detail: column 'relative_humidity_pct' holds '104.5', above the declared maximum of 100
A column whose values cannot be ordered against the declared bounds fails with whose type is not orderable against the declared range [0, 100].
The registered bound grammar cannot spell a date, so a range over a date or timestamp column has no registered spelling.
continuity
Section titled “continuity”Asserts no two consecutive distinct values of the named column are further apart than max_gap_seconds. Repeated values collapse before the comparison: two rows carrying one timestamp are neither a gap nor a stride.
Exactly one column, and max_gap_seconds.
The name says seconds and the measurement follows the column. The worker infers the ordering domain from the column’s own stored values. Over a temporal column it measures the gap in seconds, the type the member carries. Over a numeric column it measures in the column’s own units, and the member’s name still says seconds. Every detail names the domain it measured in.
{ "check_id": "hourly_continuity", "kind": "continuity", "enforcement": "advisory", "columns": ["observed_at"], "max_gap_seconds": 7200, "description": "Two consecutive observations more than two hours apart is a reporting outage."}Pass detail: column 'observed_at' never gaps by more than 3600 across 6132 distinct values, the widest between '2026-03-01T00:51:00Z' and '2026-03-01T01:51:00Z', at or below the declared ceiling of 7200, measured in seconds between timestamps
Fail detail: column 'observed_at' gaps from '…' to '…' by 18000, above the declared ceiling of 7200, measured in seconds between timestamps
A column with no non-null value, or with one distinct value, passes and says so. No consecutive pair exists for a gap to sit between.
row_expectation
Section titled “row_expectation”Asserts the stored row count sits inside the declared bounds. Either bound may stand alone. Registration refuses a check with neither. The count comes from the Parquet metadata, so nothing scans rows for it.
Registration refuses a named column instead of silently dropping it. min_rows is a non-negative integer and max_rows a positive one. Both stop at 10,000,000 rows, the most one run may write. A floor over that ceiling returns a refusal that names the reason.
{ "check_id": "enough_rows", "kind": "row_expectation", "enforcement": "required", "min_rows": 24, "max_rows": 1000000}Pass detail: row count 6132 satisfies the declared bounds [24, 1000000]
Fail detail: row count 11 is below the declared minimum of 24
When a check is never evaluated
Section titled “When a check is never evaluated”The pass over the stored rows has a byte budget of 64 GiB and a time budget of 900 seconds. Over either, every row-reading check reports not_evaluated with passed: false and the reason in its detail, always behind the literal prefix not_evaluated: . The prefix is how the third state survives a wire that carries only a boolean.
{ "check_id": "HUMIDITY_IN_RANGE", "passed": false, "detail": "not_evaluated: CHECK_SCAN_BUDGET_EXCEEDED: estimated scan of 91234567890 bytes exceeds the 68719476736-byte ceiling"}The run does not fail for it. A deployment budget is not evidence about the data, so an unevaluated check remains unevaluated rather than passing. row_expectation still reports normally because it reads metadata rather than rows.
A key_uniqueness check over the table’s clustered key costs nothing. The key is distinct across the table when every part is internally distinct and no two parts’ recorded ranges overlap. The manifest knows both already. On an unclustered key it is a bounded scan instead.
Codes a run reports
Section titled “Codes a run reports”Every one of these codes settles the run as failed. The run validates the check dialect at the acquire stage, before the first byte arrives. A malformed check costs a queued run rather than a whole build.
| Code | failed_stage |
When |
|---|---|---|
DECLARED_CHECKS_INVALID |
acquire |
the check list itself is wrong: more than 128 checks, or not a list |
DECLARED_CHECK_INVALID |
acquire |
one check is wrong: an unknown kind, a missing bound its kind requires, a bound its kind does not read, the wrong number of columns, an unsatisfiable row bound |
DECLARED_CHECK_DUPLICATE |
acquire |
two checks claim one id |
DECLARED_CHECK_COLUMN_UNKNOWN |
acquire |
a check names a column the specification does not declare |
DECLARED_CHECK_UNREADABLE |
check |
nothing could open or read the finished table, or it does not carry a declared column |
CHECK_FAILED |
check |
a required check failed, and failure_detail is CHECK_ID: <detail> |
Sentences worth recognising:
DECLARED_CHECK_INVALID: check TEMPERATURE_PRESENT reads exactly one column and names 3DECLARED_CHECK_INVALID: check GRAIN_UNIQUE is a key_uniqueness check and states max_rows, which a check of that kind does not read; a bound that is never evaluated would be reported as satisfied, so state it on a check whose kind reads itDECLARED_CHECK_INVALID: check HUMIDITY_IN_RANGE bounds the values of column relh and states no max_value; an unstated bound is not an open oneTwo controls that are not declared checks also report CHECK_FAILED. One is _replay_equivalence, when the re-derived candidate does not match what was written. The others are the required controls whose detail begins RECIPE_DRIFT: or SHAPE_DRIFT:. Run states and errors covers those.
Registration checks the columns, not the shapes
Section titled “Registration checks the columns, not the shapes”The registration walk refuses a check that names a column the table does not declare, and two checks sharing an id. Both answer 422 RECIPE_DOCUMENT_INCOHERENT with the pointer /checks/N/columns/M. It does not apply the per-kind field rules, because the schema accepts every bound on every kind. Those refusals arrive at the acquire stage of the first run. Get the shape right before starting one. Recipe document has the member-by-member grammar.