Validation & Quality
Quality is where an OLC earns "executable" — and, like transformations, it's SQL-powered. Every rule is a SQL predicate: a row rule is a WHERE-clause that a row must satisfy; a dataset rule is an aggregate query that must hold over the whole dataset. The shorthands (not_null:, range:, accepted_values:, …) are convenience wrappers that compile to SQL, so each is shown here SQL variant first.
If you can write a SQL
WHEREclause, you can write a quality rule. No Python, no custom code — just SQL.
Powered by
Quality rules are Pydantic models; each rule's sql predicate is executed by the active engine — PySpark on Spark, duckdb on DuckDB, polars SQL on Polars — so a rule written once runs natively on every backend (the framework rewrites dialect functions like regexp_matches). Failing rows route to the quarantine.
The shape
quality:
enforce_required: true # auto not_null rules for required fields
fail_pipeline_on_dataset_error: false # a failed dataset rule aborts the run
row_rules: [ ... ] # per-row SQL predicates
dataset_rules: [ ... ] # whole-dataset SQL assertions
Quality field |
Purpose |
|---|---|
enforce_required |
Auto-generate not_null rules for required: true fields. |
fail_pipeline_on_dataset_error |
Whether a dataset-rule failure aborts the pipeline. |
row_rules |
Rules evaluated per row; failing rows are quarantined. |
dataset_rules |
Rules evaluated over the whole (good) dataset. |
Row rules
A row rule is a boolean SQL predicate — the row passes when it's true. The canonical form is name + sql; everything else is shorthand for a common predicate.
Custom SQL (the canonical form)
- name: email_domain_valid
sql: "email NOT LIKE '%@temp-mail.%' AND email NOT LIKE '%@disposable.%'"
category: validity
severity: error # error (quarantine) | warning (log, keep) | info
phase: pre # pre = source columns · post = derived columns
not_null
- not_null: email # simple
- not_null: { fields: [email, status, created_at] } # many
- not_null: { field: customer_id, name: customer_id_required, severity: error }
accepted_values
- accepted_values: { field: status, values: [ACTIVE, INACTIVE, PENDING, SUSPENDED], category: consistency }
regex_match
range
referential_integrity
- referential_integrity: { field: country_code, reference: dim_countries, key: code, category: consistency }
lifecycle_window
Numeric guardrails on a rule
Any QualityRule can attach numeric bounds instead of writing the comparison in SQL:
- { name: amount_in_range, sql: "true", must_be_between: [0, 100000] }
- { name: min_qty, sql: "true", must_be_greater_than: 0 }
- { name: max_qty, sql: "true", must_be_less_than: 1000 }
QualityRule field |
Purpose |
|---|---|
name (req) |
Rule identifier (shown in quarantine + logs). |
sql (req) |
Boolean predicate; row passes when true. |
severity |
error (quarantine) · warning (log, keep row) · info (log only). |
category |
Free-form tag (validity, completeness, consistency…). |
description |
Human explanation, surfaced on failure. |
phase |
pre (source columns) or post (derived columns) — see Execution Order. |
must_be_between / must_be_less_than / must_be_greater_than |
Numeric bounds as an alternative to SQL. |
Dataset rules
A dataset rule is an aggregate SQL assertion over the whole good dataset — it returns a scalar (or boolean) that must hold. Where row rules quarantine bad rows, dataset rules judge the whole batch: they're your in-pipeline quality gate for the aggregate invariants that make up a data-quality SLO — volume, completeness, freshness, reconciliation.
Enforcement — what a failing dataset rule does:
severity: error | warning | info—errorfails the rule;warning/infolog only.fail_pipeline_on_dataset_error: true(on thequalityblock) — a failed error-severity dataset rule aborts the whole run, so a bad aggregate never publishes.must_be_between/must_be_greater_than/must_be_less_than— bound the scalar the rule's SQL returns (else the SQL should return a boolean).
Custom SQL (the canonical form)
- name: active_customer_ratio
sql: "SELECT SUM(CASE WHEN status = 'ACTIVE' THEN 1 ELSE 0 END) * 1.0 / COUNT(*) FROM source"
must_be_greater_than: 0.60
category: validity
unique
null_ratio
row_count_between
Quality-SLO scenarios
Dataset rules are how you encode data-quality SLOs as gates — checked every run, enforced before publish. Each scenario shows the SQL, the rule, and what it protects against. Set fail_pipeline_on_dataset_error: true to make these hard gates.
Volume within band — catch truncated / runaway loads
A daily feed normally lands 8k–40k rows. A source glitch that ships 200 (or a fan-out bug that ships 400k) should fail, not silently publish.
Volume drop vs a rolling baseline — silent-stall detection
Absolute bands miss gradual drift. Compare today's volume to a rolling baseline and fail if it collapses — an upstream feed that quietly stopped.
links:
- { name: volume_baseline, table: "reference.gold_volume_baseline", type: table }
quality:
dataset_rules:
- name: volume_not_dropped
sql: "SELECT COUNT(*) * 1.0 / (SELECT avg_daily_rows FROM volume_baseline) FROM source"
must_be_greater_than: 0.70 # today ≥ 70% of the 30-day average
severity: error
category: volume
Completeness threshold — a key column can't go mostly-null
An upstream schema change nulls customer_email. The completeness SLO is "≤ 2% null."
Financial reconciliation — the numbers must tie out
The classic: net revenue must equal gross − fees − refunds. A reconciliation break (5% off, or 100% off) should stop the run before finance sees wrong totals.
- name: revenue_reconciles
sql: "SELECT ABS(SUM(net_amount) - SUM(gross_amount - fee_amount - refund_amount)) FROM source"
must_be_less_than: 0.01 # penny tolerance
severity: error
category: reconciliation
Freshness gate — block a stale batch
The batch ran, but every row is a day old — the upstream stalled. Fail the publish. (Complements the freshness SLO, which watches it over time; this blocks the current run.)
- name: data_is_fresh
sql: "SELECT max(event_time) >= current_timestamp - INTERVAL 24 HOUR FROM source"
severity: error
category: timeliness
Referential completeness — no orphan facts
Every fact row must match a dimension. Zero facts may reference a driver that isn't in dim_driver. This is the dataset-level guarantee behind the row-level referential_integrity rule.
SELECT COUNT(*) FILTER (WHERE d.driver_id IS NULL)
FROM source f LEFT JOIN dim_driver d ON f.driver_id = d.driver_id
links:
- { name: dim_driver, table: "marketplace.gold_rideflow_dim_driver", type: table }
quality:
dataset_rules:
- name: no_orphan_facts
sql: "SELECT COUNT(*) FILTER (WHERE d.driver_id IS NULL) FROM source f LEFT JOIN dim_driver d ON f.driver_id = d.driver_id"
must_be_less_than: 1
severity: error
category: integrity
Distribution / skew guard — catch fan-out & stuck partitions
No single city should own more than 60% of trips. A runaway join or a stuck partition shows up as skew. Warn (don't block) so the team investigates.
SELECT max(city_share) FROM (
SELECT COUNT(*) * 1.0 / SUM(COUNT(*)) OVER () AS city_share FROM source GROUP BY city_code
)
- name: city_distribution_sane
sql: "SELECT max(city_share) FROM (SELECT COUNT(*) * 1.0 / SUM(COUNT(*)) OVER () AS city_share FROM source GROUP BY city_code)"
must_be_less_than: 0.60
severity: warning
category: distribution
Dataset rules vs. service_levels
Both guard aggregate quality — they differ in when:
| Dataset rules | service_levels (SLOs) |
|
|---|---|---|
| When | In-run, on this batch | Across runs, over time |
| Effect | Pass/fail (and can abort) the current run before publish | Tracked by the control plane → incidents / notifications |
| Use for | Stop bad data shipping now | Track whether the product keeps its promise |
row_count and freshness appear in both by design: the dataset rule is the hard gate, the SLO is the watched target. Author the gate to protect this run; author the SLO to hold the product accountable over time.
Rules that reference injected columns
A dataset rule may reference a column that materialization injects later (a surrogate key, SCD2 audit columns). At validation time that column doesn't exist yet and such keys are unique by construction — the framework treats a "column not found" bind as not-evaluated-here (enforced at materialization), not an error.
Field-level rules
Rules can also travel with a field in model.fields[], so validity lives next to the column it constrains — still SQL under the hood:
model:
fields:
- name: status
type: string
accepted_values: [new, paid, shipped, cancelled]
rules:
- { name: status_lower, sql: "status = lower(status)" }
- { name: amount, type: float, min: 0, max: 100000 }
- { name: email, type: string, max_length: 320 }
See Security & PII for the governance fields on model.fields[] (pii, masking, …).
When rules run (pre vs post)
Row rules default to pre (validate source columns before the good/bad split); mark a rule phase: post to validate derived columns produced by post-transforms. The full sequence is on the Execution Order page:
- Pre rules can reference only source columns.
- Post rules can reference source and derived columns.
- Errors are tagged
[pre]/[post]in the error column for traceability.
Severity
| Severity | Behaviour |
|---|---|
error |
Quarantine the row (default) — bad data never reaches downstream. |
warning |
Log the issue, keep the row — data flows, team is alerted. |
info |
Log only, no action — observability. |
Quarantine
Failing rows aren't dropped — they're written to a quarantine table with the failed rule and reason, so nothing is lost and every rejection is auditable (Quarantine model).
quarantine:
enabled: true
target: "s3://quarantine-bucket/customers" # or table / location
format: parquet # parquet | csv | delta | iceberg | ducklake | json
write_mode: append # append | overwrite
include_error_reason: true # attach the failing rule + message per row
fail_on_quarantine: false # true → any quarantined row fails the run
notifications_enabled: true
strict_notifications: false
notifications: [ ... ] # see Notifications
Quarantine field |
Purpose |
|---|---|
enabled |
Turn quarantine on/off. |
target / table / location |
Where quarantined rows go (named table or path). |
format |
Table format for the quarantine sink. |
write_mode |
How rows are written (append/overwrite). |
include_error_reason |
Attach the failing rule name + reason to each row. |
fail_on_quarantine |
Escalate: any quarantined row aborts the run. |
notifications_enabled / strict_notifications |
Emit alerts on quarantine events. |
notifications |
A list of Notification targets — see Notifications. |