Data quality¶
This page explains what happens to a row that is wrong — a price that arrives as
"12,50 €", the same order delivered three times, an order line pointing at an order
nobody ingested. Everything else in bloomery is structural: it decides what a table
means. Data quality decides what a row does when the data disagrees with the model,
and it decides it in the spec, where plan() can see it.
The governing rule is that no row is ever silently discarded. Every rule you declare carries an explicit disposition, and every row ends up somewhere you can count.
Guardrails say the model is wrong; quality rules say the data is wrong¶
These two are easy to confuse and expensive to conflate — a spec where nobody knows what fails when is worse than either check alone. The boundary is normative:
| Guardrails | Quality rules | |
|---|---|---|
| When | compile time | run time |
| Input | the spec | the data |
| Failure | a BloomeryError; nothing is emitted |
a disposition per row |
| Example | "summing an order-grain measure on an item-grain mart" | "this row's discount exceeds its gross" |
Anything decidable from the spec alone is a guardrail and refuses at compile time. Quality rules are the other half: they compile into SQL that runs in your warehouse and decides, row by row.
What a declaration looks like¶
Rules attach in two places: to a mapping field, beside the transform chain that produces the value, and to an entity, where a rule can read more than one column.
fields: # on the mapping, beside the transform
unit_price:
from: "$.Price"
transform: [{to_decimal: [12, 4]}]
quality:
- {rule: range, min: 0, on_fail: quarantine}
- {rule: range, max: 1000000, on_fail: flag}
entities: # on the entity, for row-wide rules
order_item:
grain: one row per line on an order
key: [order_id, line_no]
dedupe: {keep: latest_by, field: _ingested_at, tie_break: [_load_id]}
quarantine: {retention: 90d}
quality:
- {rule: expression, name: discount_not_exceeding_gross,
expr: "discount <= unit_price * quantity", on_fail: flag}
- {rule: referential, via: item_of_order, on_missing: unknown_member}
Everything a rule can say is in that shape: which rule, its parameters, and what happens to a row that fails. The add quality rules guide walks the whole surface; the rest of this page is why it is shaped that way.
The three dispositions¶
on_fail is required on every rule. There is no project-wide default, because a
default is the one setting nobody reads before shipping.
| Disposition | What happens to the row |
|---|---|
flag |
The row passes through unchanged, with the rule's name appended to _quality_flags |
quarantine |
The row is diverted to the entity's <entity>__reject table, where it stays replayable |
fail |
The run stops — the rule compiles to a blocking audit over the rows the pipeline evaluated and the rows already in the entity |
Severity settles what happens when several rules fire on one row: fail beats
quarantine beats flag. A fail rule's audit therefore reads the pre-route
population, not just the finished model — otherwise a row that failed a blocking rule
and a quarantine rule would sit in the reject table with the run carrying on, and the
weaker disposition would quietly win. It reads the finished model too, because a replayed
row enters the entity from the reject table long after its bronze source has aged out of
the incremental window, and an audit blind to that population would report nothing while
the row's own _quality_flags named the rule. Whichever way the row went, every rule it
failed is recorded: in failed_rules if it was diverted, in _quality_flags if it was
kept.
Two absences are deliberate.
There is no drop. Silently discarding rows is the fastest way for an analytics
product to lose trust permanently, and it is the disposition everyone reaches for first.
quarantine is drop plus recoverability — which matters because most quarantined rows
come back after a spec fix. Widening an enum is the normal path, not the exception.
Wanting rows genuinely gone means quarantine plus a retention policy: a deletion with a
paper trail.
There is no repair in v1. Rewriting values needs a recipe contract that does not
exist yet, and a repaired row needs a marker distinct from a flagged one so
"has quality flags" keeps meaning "currently suspect" rather than "was once touched".
When one row fails several rules with different dispositions, severity decides:
fail > quarantine > flag. A row diverted to the reject table records all its
failed rule names, its flag-level failures included — the reject row is the full story,
not just the reason it was diverted.
Coercion failure is a rule¶
The most common data defect is not a business-rule violation, it is a value that will
not cast. So a transform chain on a quality-carrying entity does not raise on a bad
value: it produces a coercion-failure marker, and the implicit coercible rule disposes
of it at quarantine by default. One mechanism, one reject table, one vocabulary —
unmapped enum values, failed casts, and rules you wrote all travel the same road.
coercible is opt-in per entity. An entity joins the quality system by declaring
quality:, quarantine:, or any field-level quality:; dedupe: alone does not. An
entity that declares none of them keeps the produce-or-raise lowering it always had.
Postgres cannot host a quality-carrying entity — nor a dedupe-only one
The marker needs a real NULL-on-failure cast. DuckDB and Trino have TRY_CAST;
Postgres does not, and rendering it there as a plain CAST would turn "quarantine
this row" into "abort this run". Compiling a coercible-carrying entity for
Postgres raises UnsupportedByTarget rather than degrading silently.
The refusal reaches one shape past the rules, which is worth reading against the
paragraph directly above. The ingestion-metadata audit every dedupe:/quarantine:
entity gets asserts that _ingested_at casts to timestamp, and that assertion is
itself a TRY_CAST — so an entity that declares only dedupe:, and therefore
carries no quality rules whatsoever, is refused for Postgres on the audit's own
account. Not joining the quality system spares you the rules; it does not buy back
the dialect.
The pipeline order is fixed¶
Declared once, never per field, never configurable:
flowchart LR
E[extract] --> T[transform] --> D[dedupe] --> F[field rules] --> R[row rules] --> RT[route]
RT --> S[("silver.<entity><br/>_quality_flags")]
RT --> J[("silver.<entity>__reject")]
The order that matters most is dedupe before the rules. The alternative — validate first, deduplicate the survivors — means a corrupt latest row is silently replaced by a stale-but-clean older one. That is data loss disguised as data quality: the dashboard shows an old value with no indication that it is old.
One consequence follows directly. The columns dedupe orders by decide which row
survives, so a value that will not cast there leaves the winner undefined. coercible
is therefore forced to fail on any field named by dedupe.field or tie_break, and
declaring something weaker on such a field is the compile error
DedupeDispositionConflict.
That forcing reaches mapped fields only, and the usual dedupe.field is _ingested_at
— ingestion metadata, never mapped, so no rule is generated for it. A generated
blocking audit on the ingestion-metadata columns covers it instead. The audit stops the
run on any of three conditions: a null _load_id, _ingested_at, or _source_row_id;
a _source_row_id that repeats; or an _ingested_at that is present but does not cast
to timestamp. All three are properties of the data, so no compiler can check them —
which is why they are asserted at run time rather than refused at compile time.
Dedupe's order is total by construction: the recency field descending, then each
tie-break column, then the stable _source_row_id — with NULLS LAST pinned on every
sort key. No two rows can compare equal, so the winner is a function of the data rather
than of the engine's mood. That is also why tie_break is mandatory under
keep: latest_by: without it, two rows sharing a timestamp make the winner arbitrary,
and a nondeterministic model breaks the invariant everything else here rests on.
A rule fires only when it is definitively true¶
Every rule defines a violation predicate and fires only when that predicate evaluates
to SQL TRUE. A NULL-involved comparison evaluates to UNKNOWN, and UNKNOWN never
fires. This holds for range, length, pattern, in_enum, in_set, expression,
and referential alike — a NULL foreign key is not an orphan.
Nulls belong to exactly two rules: not_null and coercible. If a null is invalid in
your model, declare one of them; do not expect a range check to catch it. The
alternative — letting every rule fire on nulls — would make a single missing value light
up half the catalogue, and the reject row would tell you nothing about what actually
went wrong.
The same discipline shapes the referential lowering. An orphan is a non-null foreign
key with no matching row, which is why the rule lowers to
CASE WHEN ref.<pk> IS NULL AND fk IS NOT NULL THEN '__unknown__' ELSE fk END rather
than a bare COALESCE. Routing a NULL fk to the reserved member would silently invent a
relationship the source never claimed.
referential carries on_missing — unknown_member, quarantine, or flag — rather
than on_fail, and fail is deliberately unavailable. Orphans are an expected,
recoverable condition; a pipeline that stops on every one of them punishes the normal
case. When you genuinely want a pipeline-stopping orphan gate, write it as a reconcile
check.
unknown_member is the disposition that keeps aggregates correct. Dropping orphan
rows makes revenue quietly lower than the source system's; routing them to a reserved
Unknown member keeps the total right and makes the problem visible in the dashboard,
which is where someone will actually notice it.
pattern speaks a smaller regex than you do¶
A pattern rule runs on the engine, and the engines disagree: DuckDB and Trino run RE2,
Postgres runs POSIX ARE. So pattern accepts a portable subset defined by what it
names — literals, ., character classes, \d/\w/\s and their negations, the anchors
^ and $, the quantifiers * + ? {n} {n,} {n,m}, alternation, and non-capturing
groups (?:…). Everything else is refused at parse, by name, including constructs the
subset simply does not know about. That direction is deliberate: a list of forbidden
constructs accepts every construct nobody thought of, and the ones nobody thought of —
backreferences, atomic groups, possessive quantifiers, \A/\Z — do not degrade
gracefully on RE2. They abort the run.
Two consequences worth knowing before you write one:
- You write the anchors.
[0-9]{5}is refused;^[0-9]{5}$is what you meant. Every SQL regex predicate matches a substring, so unanchored, that rule acceptsabc12345xyz. With alternation, each branch carries its own pair:^a$|^b$. - Capturing groups are refused in favour of
(?:…). A rule is a boolean match and captures nothing, and numbered groups are what backreferences read.
Two divergences are accepted rather than pretended away, both stated here because they
are the ones that could still surprise you: . excludes newline on RE2 and includes it
on Postgres, and \d/\w/\s are ASCII on RE2 but locale-defined on Postgres.
The reject table and the replay lifecycle¶
One <entity>__reject table per entity — never one per mapping, which would multiply
into small files and make replay N-way. It holds the quarantined bronze payload plus
enough identity to be idempotent:
| Column | What it is |
|---|---|
reject_id |
SHA-256 over the canonical (source_relation, _source_row_id) pair — recomputable from the row itself |
source_relation, mapping, mapping_version |
Where the row came from and which mapping judged it |
failed_rules |
Every rule the row failed, flag-level failures included |
key_values, raw |
The entity key (best-effort) and the bronze payload |
_load_id, _ingested_at, _source_row_id |
The ingestion-metadata contract |
first_seen, last_seen, resolved_at |
The lifecycle — both timestamps are the delivery's _ingested_at, never a wall clock |
_load_id is deliberately not part of the identity. A re-delivery of the same source
row across loads must land on the same reject row — that is what first_seen and
last_seen track. A per-load identity would mint a new reject row per retry and destroy
replay idempotence. The reject model is incremental on reject_id and its merge is
selective: a re-delivery advances last_seen, _load_id and failed_rules while
first_seen keeps the value it already had. first_seen is the column that says when
the problem started, and a merge that overwrote it would leave the pair carrying one
fact under two names.
Replay re-runs the current mapping against raw for unresolved rows and merges the
passers into the entity by key, using the pipeline's own dedupe order — both against the
incumbent row and against each other, so two reject rows resolving to one key produce one
entity row, not two. The rows that still fail are re-stamped from that same evaluation:
failed_rules re-derived, so the reject table's account never ages into a statement about
a spec nobody runs. A candidate that now passes every rule and merely lost its key to a
better one is marked (superseded) rather than left with an empty list of reasons.
last_seen is not touched: it means "when the source last delivered this row", and since
retention measures unresolved rows from it, a replay run advancing it would keep them
forever. Replay never deletes: a resolved row keeps its reject entry as audit history with
resolved_at set, and drops out of the conservation accounting. Retention is the only
deleter.
retention: is required the moment any rule can quarantine — not defaulted, because
raw holds source payloads and therefore PII, and this is the sort of thing that is
trivial now and a legal problem in eighteen months. redact: removes JSONPaths from
raw and key_values at write time, and the compiler refuses a redaction that
intersects a path the mapping reads: you cannot both require a field and destroy it,
because replay would have nothing left to re-run against.
bloomery emits the replay merge; it never runs it
Compilation produces a replay/<entity>.sql artifact holding the MERGE statements
as one unit of work. Executing them — and executing retention deletes — is the
caller's job. The package never touches a warehouse.
plan() knows about all of this. Adding, removing, or changing a rule, changing a
disposition in either direction, and changing dedupe all classify as RESTATING, and
a Plan carries a replay_scope alongside its backfill_scope. The distinction is
real: relaxing quarantine to flag frees rows that are sitting in the reject table,
not in bronze's incremental window, so a backfill alone would leave them quarantined
forever.
The conservation law¶
Every bronze row lands in exactly one of three places: the entity, an unresolved reject, or the deduped count. If that holds, rows cannot vanish — and rows vanishing is the failure mode this whole subsystem exists to make impossible.
It is checked twice. As a property test over generated batches, and as a blocking
audit emitted on every production run, one per entity with a reject table. The audit
reads only the bronze source and the model being built, because SQLMesh does not rewrite
model references inside an audit body; that limit means it is skipped for the single
shape it cannot express — referential with on_missing: quarantine, whose routing
predicate reads a sibling entity. The property test still covers that shape.
The audit is also why mart rowcounts legitimately differ from bronze. Quarantined rows never reach a mart; the conservation accounting is what makes that difference explainable rather than alarming.
Quality is an ordinary mart¶
Every rule evaluation contributes a row to gold.mart_data_quality — entity,
mapping, rule, disposition, the four counts (rows_evaluated, rows_failed,
rows_quarantined, rows_deduped), and the run context. It is a MartIR with measures
and a date role like any other, which is the entire point: quarantine rate is a plain
MetricRequest, groupable by entity, filterable, and answerable through the same planner
as revenue.
The counts are stored, the rate is not. quality_quarantine_rate is a ratio metric over
two additive measures, so it stays correct at every grouping — a stored rate column
would not be.
"Additive" is a claim about the rows, not just the columns, and the mart is shaped to
make it true. Only rows_failed is a fact about a rule; the other three describe the
entity's population — how many rows the rules ran over, how many the split diverted, how
many dedupe removed before any rule saw them. So each entity contributes one extra
accounting row, marked (entity) in the rule and disposition dimensions, and
rule rows carry zero in those columns. Summing then works at any grouping. The trade is
explicit: rows_evaluated cannot be sliced by rule, because it was never a per-rule
number — an absent denominator reads as absent, where a repeated one read as a number
and was wrong by the rule count.
A rising quarantine rate is a semantic drift signal that structural detection misses entirely: prices arriving in cents change no schema.
Two honest limits. Reject tables themselves are never exposed through MetricRequest —
raw payloads with different retention are a deliberately narrow operator surface, not a
metric. And run_id is emitted declared but NULL on the pinned SQLMesh, which
exposes no run-identifier macro; the column exists and carries a comment naming what you
have to supply, rather than a macro name that would fail to expand. run_date comes
from @execution_ds.
What flags look like downstream¶
Silver models gain two columns: _quality_flags (never NULL — a clean row carries the
empty collection) and the generated _quality_ok. A mart whose base entity carries
rules flattens the pair into has_quality_flags, an ordinary boolean dimension, so
"revenue excluding flagged rows" is a filter on a MetricRequest rather than a bespoke
query. Only the base contributes it: a mart is a fact table at its base grain, so "was
this row suspect" is a statement about the base row, and a base with no rules gets no
column rather than a constant FALSE one that would read as "nothing is flagged".
The physical shape adapts to the engine: an array where the dialect has one, otherwise a
comma-delimited string joined in lexicographic rule-name order. Rule names are
identifier-constrained at parse time ([a-z0-9_]+), so neither form ever needs escaping
and the two agree observably.
Rules judge and route; they do not repair. The thing a quality: block cannot do is
mend a value — turn "12,50 €" into 12.50 — because that is repair, and repair
is deliberately absent from v1 (above). The one exception is stated rather than hidden:
referential's on_missing: unknown_member rewrites an orphan foreign key to the
reserved '__unknown__' member, which is the entire point of that disposition and is
visible in the emitted
CASE WHEN ref.<pk> IS NULL AND fk IS NOT NULL THEN '__unknown__' ELSE fk END. It
substitutes one reserved value under one stated condition; it does not compute a new
one. That is the same principle that keeps assert: clauses
(guardrails) as alerts rather than routers. An assert:
says "tell me"; a quality: rule says "act on the row". A field may carry both. When you
are ready to declare some, the
add quality rules guide walks the whole surface, and
Evolve a spec safely covers the replay_scope a
disposition change produces.