Data Quality — Dimensions, Testing, Monitoring, and Contracts
The six dimensions of quality, dbt tests at every layer, anomaly detection, data contracts, and building quality into pipelines rather than checking at the end.
Data Quality Is an Engineering Problem, Not a Monitoring Problem
The most common data quality approach is reactive: run queries on the warehouse after data has been loaded, discover problems, investigate, fix, and repeat. This approach produces a data platform where analysts distrust the data, engineers spend most of their time on incidents, and every new source integration introduces a new class of quality problems.
The correct approach is preventive: build quality checks into every pipeline stage, test at every layer boundary, alert on anomalies before analysts hit them, and define quality contracts with source system owners so violations are caught at ingestion rather than at Gold. This module builds that whole stack around FreshCart’s orders pipeline.
Where a problem is caught determines how much it costs
SOURCE SYSTEM (before ingestion):
Cost: reject the record, log to DLQ, notify source team
Recovery time: minutes
BRONZE LAYER (after landing):
Cost: record in DLQ, Bronze intact, Silver/Gold unaffected
Recovery time: hours (after source team fixes and resends)
SILVER LAYER (after transformation):
Cost: dbt run fails, Silver not updated, Gold build blocked
Recovery time: hours to a day
GOLD LAYER (after aggregation):
Cost: Gold table has wrong data, dashboards show wrong metrics
Recovery time: 1-3 days (investigation + fix + rebuild)
ANALYST DASHBOARD (after analyst queries):
Cost: analyst escalates, business decisions already made on wrong data
Recovery time: unknown, trust damage lasting weeks
THE RULE: every layer a quality issue traverses multiplies its cost by 10×.dbt Tests — The Standard Quality Layer for the Transformation Pipeline
dbt tests are the most widely used data quality mechanism for ELT platforms in 2026. They run after every dbt build, catching quality issues before Gold tables are consumed.
The four generic tests, on FreshCart’s orders model
models:
- name: silver_orders
columns:
- name: order_id
tests:
- not_null # catches missing PKs
- unique # catches duplicates at the grain
- name: customer_id
tests:
- not_null
- relationships: # referential integrity to parent table
to: ref('silver_customers')
field: customer_id
severity: warn # warn not error: some orders arrive before customers
- name: status
tests:
- not_null
- accepted_values: # domain validation
values: ['placed', 'confirmed', 'preparing', 'ready',
'picked_up', 'delivering', 'delivered', 'cancelled']
- name: order_amount
tests:
- not_null
- dbt_utils.accepted_range: {min_value: 0, max_value: 500000}Table-level tests, and checking Bronze freshness
models:
- name: silver_customers
tests:
- dbt_utils.equal_rowcount: {compare_model: ref('stg_customers')}
- dbt_utils.recency: {datepart: hour, field: updated_at, interval: 25}
sources:
- name: bronze
database: freshcart_prod
tables:
- name: orders
freshness:
warn_after: {count: 25, period: hour}
error_after: {count: 49, period: hour}
loaded_at_field: _bronze_date$ dbt test --select silver_orders
1 of 4 PASS not_null_silver_orders_order_id
2 of 4 PASS unique_silver_orders_order_id
3 of 4 WARN relationships_silver_orders_customer_id__customer_id__ref_silver_customers_
4 of 4 PASS accepted_values_silver_orders_status
Done. PASS=3 WARN=1 ERROR=0Custom tests for business rules dbt’s generic tests can’t express
-- tests/assert_no_negative_amounts.sql
-- Passes when this query returns ZERO rows.
SELECT order_id, order_amount
FROM {{ ref('silver_orders') }}
WHERE order_amount < 0;columns:
- name: delivered_at
tests:
- dbt_utils.expression_is_true:
expression: "delivered_at >= created_at OR delivered_at IS NULL"
- name: order_amount
tests:
- dbt_utils.expression_is_true:
expression: "order_amount >= discount_amount"{% test assert_column_sum_equals(model, column_name, compare_model, compare_column) %}
WITH model_sum AS (SELECT SUM({{ column_name }}) AS total FROM {{ model }}),
compare_sum AS (SELECT SUM({{ compare_column }}) AS total FROM {{ compare_model }})
SELECT m.total AS model_total, c.total AS compare_total, ABS(m.total - c.total) AS difference
FROM model_sum m, compare_sum c
WHERE ABS(m.total - c.total) > 0.01
{% endtest %}
# Usage:
# - name: order_amount
# tests:
# - assert_column_sum_equals: {compare_model: ref('silver_payments'), compare_column: payment_amount}$ dbt test -s silver_orders --store-failures
FAIL assert_no_negative_amounts (3 rows)
# creates dbt_test__audit.assert_no_negative_amounts — query it directly:
SELECT * FROM dbt_test__audit.assert_no_negative_amounts;Testing strategy by layer
| Layer | What to test | Severity | Blocks downstream? |
|---|---|---|---|
| Source (Bronze) | Schema existence, file freshness, basic row count range | warn for freshness, error for missing schema | Warn only — Bronze always loads raw |
| Staging (stg_) | not_null on PK, accepted_values on categoricals | error on PK, warn on domain checks | Yes — stale staging blocks Silver |
| Silver | Uniqueness on PK, not_null, relationships, value ranges, freshness | error on PK+nulls, warn on relationships | Yes — bad Silver blocks Gold |
| Gold | Row count anomaly, sum reconciliation to Silver, metric ranges | error on reconciliation, warn on anomalies | Yes — bad Gold blocks dashboard load |
Anomaly Detection — Catching What Rule-Based Tests Miss
Rule-based tests catch known violations. Anomaly detection catches unknown violations — a Silver table suddenly receiving 90% fewer rows than yesterday, a metric that was never negative suddenly going negative. No rule was written for these because nobody anticipated them.
Row count anomaly — comparing today to a rolling average
WITH daily_counts AS (
SELECT DATE(ingested_at) load_date, COUNT(*) row_count
FROM silver.orders WHERE ingested_at >= CURRENT_DATE - 30
GROUP BY 1
),
stats AS (
SELECT load_date, row_count,
AVG(row_count) OVER (ORDER BY load_date ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING) rolling_7d_avg
FROM daily_counts
)
SELECT load_date, row_count, ROUND(rolling_7d_avg, 0) expected_avg,
CASE WHEN ABS(row_count - rolling_7d_avg) / NULLIF(rolling_7d_avg, 0) > 0.5 THEN 'CRITICAL'
WHEN ABS(row_count - rolling_7d_avg) / NULLIF(rolling_7d_avg, 0) > 0.3 THEN 'WARNING'
ELSE 'OK' END AS status
FROM stats WHERE load_date = CURRENT_DATE;load_date row_count expected_avg status
2026-03-17 4,820 48,200 CRITICAL
# Monday's orders table has 10% of its expected volume — alert fires
# before any analyst opens a dashboardZ-score anomaly on a numeric metric
import statistics
def detect_metric_anomaly(metric_name: str, today_value: float,
historical_values: list[float], z_threshold: float = 3.0) -> dict:
if len(historical_values) < 7:
return {'status': 'insufficient_history', 'z_score': None}
mean, stdev = statistics.mean(historical_values), statistics.stdev(historical_values)
if stdev == 0:
return {'status': 'no_variance', 'z_score': 0}
z_score = abs(today_value - mean) / stdev
return {'metric': metric_name, 'today_value': today_value, 'mean': round(mean, 2),
'z_score': round(z_score, 2), 'status': 'ANOMALY' if z_score > z_threshold else 'OK'}
result = detect_metric_anomaly('daily_revenue', query_gold_revenue(date='2026-03-17'),
query_gold_revenue(last_n_days=30))
if result['status'] == 'ANOMALY':
send_alert(f"Revenue anomaly: z_score={result['z_score']}, today={result['today_value']}")>>> detect_metric_anomaly('daily_revenue', 812000.0, [420000]*30)
{'metric': 'daily_revenue', 'today_value': 812000.0, 'mean': 420000.0, 'z_score': 4.1, 'status': 'ANOMALY'}dbt source freshness, and automated tracking with Elementary
sources:
- name: bronze
tables:
- name: orders
loaded_at_field: ingested_at
freshness:
warn_after: {count: 2, period: hour}
error_after: {count: 6, period: hour}
# dbt source freshness — run as an Airflow task, fail the DAG if stale
# Elementary (pip install elementary-data) auto-tracks, per model:
# row count, null % per column, distinct value count — all per time period,
# and alerts on deviation with no rules written by hand.Great Expectations and Soda — Pipeline-Native Quality Frameworks
dbt tests run after transformation. Great Expectations and Soda can run anywhere in the pipeline — on a raw vendor file before it’s even ingested, which is exactly where FreshCart validates incoming delivery files before they touch Bronze.
Great Expectations — validating a file before it’s ingested
import great_expectations as gx
from great_expectations.core.batch import RuntimeBatchRequest
from pathlib import Path
import pandas as pd
context = gx.get_context()
def validate_vendor_file(file_path: str, pipeline_run_id: str) -> bool:
"""Validate a vendor CSV against an expectation suite. Quarantines on failure."""
df = pd.read_csv(file_path)
batch_request = RuntimeBatchRequest(
datasource_name='pandas_datasource', data_connector_name='runtime_data_connector',
data_asset_name='vendor_deliveries', runtime_parameters={'batch_data': df},
batch_identifiers={'run_id': pipeline_run_id},
)
result = context.run_checkpoint(
checkpoint_name='vendor_deliveries_checkpoint',
validations=[{'batch_request': batch_request, 'expectation_suite_name': 'vendor_deliveries.critical'}],
)
if not result.success:
quarantine_path = Path('/data/quarantine') / Path(file_path).name
Path(file_path).rename(quarantine_path)
send_alert(f'Vendor file failed validation: {file_path}. Quarantined at: {quarantine_path}.')
return False
return True# suite.add_expectation(gx.expectations.ExpectColumnValuesToNotBeNull(column="delivery_id"))
# suite.add_expectation(gx.expectations.ExpectColumnValuesToBeUnique(column="delivery_id"))
# suite.add_expectation(gx.expectations.ExpectColumnValuesToBeBetween(
# column="delivery_fee", min_value=0, max_value=5000, mostly=0.99))
# suite.add_expectation(gx.expectations.ExpectTableRowCountToBeBetween(min_value=1000, max_value=500000))INFO Validating vendor file: shipfast_weekly_2026-03-17.csv
FAIL ExpectColumnValuesToBeBetween(delivery_fee): 3.2% of values exceed max_value=5000
WARNING Vendor file failed validation — quarantined at /data/quarantine/shipfast_weekly_2026-03-17.csvSoda — SQL-native checks, straight against the warehouse
checks for silver_orders:
- row_count > 10000:
name: Minimum row count — pipeline produced data
- missing_count(order_id) = 0:
name: No missing order IDs
- duplicate_count(order_id) = 0:
name: No duplicate order IDs
- invalid_count(status) = 0:
name: All statuses are valid
valid values: [placed, confirmed, preparing, ready, picked_up, delivering, delivered, cancelled]
- min(order_amount) >= 0:
name: No negative order amounts
- freshness(updated_at) < 2h:
name: Data is less than 2 hours olddef run_soda_checks(**context):
from soda.scan import Scan
scan = Scan()
scan.set_data_source_name('freshcart_snowflake')
scan.add_sodacl_yaml_files(path='checks/silver_orders.yml')
scan.execute()
if scan.has_error_logs():
raise ValueError(f'Soda checks failed: {scan.get_error_count()} errors.')
quality_check_task = PythonOperator(task_id='soda_silver_orders', python_callable=run_soda_checks)
dbt_silver_task >> quality_check_task >> dbt_gold_task # Gold only runs if checks passData Contracts — Quality Agreements With Source Teams
A data contract is a formal, versioned agreement between a data producer (the team that owns a source system) and a data consumer (data engineering) defining what data will be provided, in what format, with what quality guarantees. It moves quality responsibility to the source — enforced at ingestion, not discovered in Gold hours later.
The contract itself
id: orders_api_v2
version: 2.3.1
owner: orders-team@freshcart.com
consumer: data-engineering@freshcart.com
sla:
schedule: "every 15 minutes"
latency_sla: "data available within 5 minutes of order event"
schema:
fields:
- name: order_id
type: integer
required: true
unique: true
- name: order_amount
type: decimal(10, 2)
required: true
constraints: {min: 0, max: 500000}
- name: status
type: string
required: true
allowed_values: [placed, confirmed, preparing, ready, picked_up, delivering, delivered, cancelled]
quality:
completeness: ["order_id is never null", "row_count is within ±20% of 7-day rolling average"]
timeliness: ["data delivered within 5 minutes of event"]
schema_changes:
breaking_change_notice: "30 days minimum before any breaking change"
additive_change_notice: "7 days minimum before adding new fields"Enforcing it against real data
from dataclasses import dataclass
from typing import Any
import yaml
@dataclass
class ContractViolation:
field: str
constraint: str
actual_value: Any
severity: str
def validate_against_contract(df, contract_path: str) -> list[ContractViolation]:
"""Returns list of violations. Empty list = contract satisfied."""
with open(contract_path) as f:
contract = yaml.safe_load(f)
violations = []
for field_spec in contract['schema']['fields']:
name = field_spec['name']
if field_spec.get('required') and name not in df.columns:
violations.append(ContractViolation(name, 'required_field_missing', None, 'error'))
continue
if field_spec.get('required'):
nulls = df[name].isna().sum()
if nulls > 0:
violations.append(ContractViolation(name, 'not_null', nulls, 'error'))
if 'allowed_values' in field_spec:
invalid = df[name].dropna()[~df[name].dropna().isin(field_spec['allowed_values'])]
if len(invalid) > 0:
violations.append(ContractViolation(name, 'allowed_values', invalid.unique().tolist()[:5], 'error'))
return violations>>> validate_against_contract(bronze_orders_df, 'contracts/orders_api_v2.yml')
[ContractViolation(field='status', constraint='allowed_values',
actual_value=['scheduled'], severity='error')]
# exactly the violation from this module's Real World section belowDetecting breaking changes before they ship
Contracts live in Git as versioned files, so a breaking-change detector can run in CI on every PR that touches one — before the source team’s change ever reaches production.
def is_breaking_change(old_schema: dict, new_schema: dict) -> list[str]:
breaking = []
old_fields = {f['name']: f for f in old_schema['schema']['fields']}
new_fields = {f['name']: f for f in new_schema['schema']['fields']}
for name in old_fields:
if name not in new_fields:
breaking.append(f"Field '{name}' removed — consumers may break")
for name, spec in new_fields.items():
if name not in old_fields and spec.get('required'):
breaking.append(f"New required field '{name}' added — existing data invalid")
for name in old_fields:
if name in new_fields and old_fields[name]['type'] != new_fields[name]['type']:
breaking.append(f"Field '{name}' type changed: {old_fields[name]['type']} → {new_fields[name]['type']}")
for name in old_fields:
old_allowed = set(old_fields.get(name, {}).get('allowed_values', []))
new_allowed = set(new_fields.get(name, {}).get('allowed_values', []))
if old_allowed and new_allowed and not new_allowed.issuperset(old_allowed):
breaking.append(f"Field '{name}': allowed values {old_allowed - new_allowed} removed")
return breaking$ python validate_contract_backwards_compatible.py --old v2.2.0.yml --new v2.3.0.yml
Field 'status': breaking change NOT detected (new value 'scheduled' only ADDS an option)
✓ Additive change — requires 7-day notice, not 30. PR may proceed.
# had this check existed, it's exactly what should have caught the enum
# addition described in this module's Real World sectionQuality Monitoring — The Operational Layer
Tests and contracts catch specific known problems. Quality monitoring gives the ongoing operational picture — which tables are healthy, which pipelines meet their SLAs, and whether quality is trending better or worse over time.
One table records every check result, from every tool
CREATE TABLE monitoring.data_quality_results (
check_id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
run_id UUID NOT NULL,
table_name VARCHAR(200) NOT NULL,
check_name VARCHAR(200) NOT NULL,
check_type VARCHAR(50) NOT NULL, -- 'dbt_test', 'soda', 'custom', 'anomaly'
status VARCHAR(10) NOT NULL, -- 'pass', 'fail', 'warn'
failure_count BIGINT,
failure_rate DECIMAL(6,4),
message TEXT,
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_dq_status_date ON monitoring.data_quality_results (status, checked_at)
WHERE status IN ('fail', 'warn');The three queries that turn raw checks into an operational picture
-- Daily pass rate per table
SELECT table_name, DATE(checked_at) check_date,
ROUND(SUM(CASE WHEN status='pass' THEN 1 ELSE 0 END)::NUMERIC / COUNT(*) * 100, 1) pass_rate_pct
FROM monitoring.data_quality_results
WHERE checked_at >= CURRENT_DATE - 30 GROUP BY 1, 2 ORDER BY 2 DESC;
-- Is quality improving or degrading week over week?
WITH weekly AS (
SELECT DATE_TRUNC('week', checked_at) week_start, table_name,
SUM(CASE WHEN status='fail' THEN 1 ELSE 0 END) failures
FROM monitoring.data_quality_results WHERE checked_at >= CURRENT_DATE - 90 GROUP BY 1, 2
)
SELECT week_start, table_name, failures,
failures - LAG(failures) OVER (PARTITION BY table_name ORDER BY week_start) week_over_week_change
FROM weekly ORDER BY week_start DESC;
-- Tables failing right now, worst first
SELECT table_name, check_name, failure_rate, message
FROM monitoring.data_quality_results
WHERE DATE(checked_at) = CURRENT_DATE AND status = 'fail' AND severity = 'error'
ORDER BY failure_rate DESC;table_name check_date pass_rate_pct
silver_orders 2026-03-17 97.1
silver_customers 2026-03-17 100.0
silver_payments 2026-03-17 84.3 ← worth a lookPutting It Together — The Quality-First Pipeline Architecture
A quality-first pipeline integrates tests at every stage, with a quality gate between each layer that blocks downstream work on failure — the goal is to make a quality failure visible before analysts are affected, not after.
Bronze and Silver gates
with DAG('freshcart_morning_pipeline', ...) as dag:
extract_orders = PythonOperator(task_id='extract_orders', python_callable=run_extraction)
bronze_quality = BashOperator(
task_id='bronze_quality_check',
bash_command='dbt source freshness --select source:bronze.orders',
)
dbt_silver = BashOperator(task_id='dbt_silver',
bash_command='dbt run --select staging.* silver.* --vars \'{"run_date": "{{ ds }}"}\'')
silver_tests = BashOperator(task_id='silver_quality_tests',
bash_command='dbt test --select silver.* --store-failures')A Soda gate, then Gold
def soda_silver_check(**context):
from soda.scan import Scan
scan = Scan()
scan.set_data_source_name('freshcart_snowflake')
scan.add_sodacl_yaml_files(path='checks/silver_orders.yml')
scan.execute()
write_soda_results_to_monitoring(scan, context['run_id'])
if scan.has_error_logs():
raise ValueError('Soda anomaly check failed for Silver orders')
silver_anomaly = PythonOperator(task_id='silver_anomaly_check', python_callable=soda_silver_check)
dbt_gold = BashOperator(task_id='dbt_gold', bash_command='dbt run --select gold.*')
gold_tests = BashOperator(task_id='gold_quality_tests', bash_command='dbt test --select gold.*')Reporting, and the full dependency graph
def post_pipeline_quality_report(**context):
result = query_quality_results(date=context['ds'])
send_slack_message(channel='#data-quality',
text=f'Pipeline quality: {result.pass_rate}% checks passed. {result.total_failures} failures.')
quality_report = PythonOperator(task_id='quality_report', python_callable=post_pipeline_quality_report,
trigger_rule='all_done') # runs whether upstream passed or failed
(extract_orders >> bronze_quality >> dbt_silver >> silver_tests
>> silver_anomaly >> dbt_gold >> gold_tests >> quality_report)Graph view — freshcart_morning_pipeline
extract_orders → bronze_quality → dbt_silver → silver_tests → silver_anomaly
→ dbt_gold → gold_tests → quality_report
Slack: "Pipeline quality: 97.1% checks passed. 3 failures."Five Misconceptions About Data Quality
A Source System Silently Changes an Enum — Catching It at the Contract Boundary
The orders application team added a new status value — “scheduled” — for a new pre-order feature, deployed Friday evening without notifying data engineering. By Monday, 12,847 orders with status='scheduled' were rejected from Silver by the accepted_values test and sitting in the DLQ. The finance dashboard showed no pre-order revenue. An analyst noticed Tuesday.
-- STEP 1: check Silver dbt test failures since Friday
SELECT run_id, check_name, failure_count, message FROM monitoring.data_quality_results
WHERE table_name = 'silver_orders' AND status = 'fail' AND checked_at >= '2026-03-14';
-- 47 runs, all: accepted_values_silver_orders_status "Values not in set: ['scheduled']"
-- ~600,000 rows total rejected across the 47 runs
-- STEP 2: confirm the root cause
SELECT DISTINCT status FROM bronze.orders WHERE _bronze_date >= '2026-03-14';
-- placed, confirmed, delivering, delivered, cancelled, scheduled ← new
-- STEP 3: quantify impact
SELECT SUM(order_amount) FROM bronze.orders WHERE status = 'scheduled';
-- $4.82 million unloaded to Silver/Gold# a) Update VALID_STATUSES in pipeline/validate.py to include 'scheduled'
# b) Update dbt schema.yml accepted_values to include 'scheduled'
# c) Bump the data contract version: contracts/orders_api_v2.yml
$ python dlq_reprocess.py --pipeline orders_incremental --start-date 2026-03-14 --force-reloadDLQ reprocessing complete: attempted=598234 reprocessed=598234 failed=0
SELECT COUNT(*) FROM silver.orders WHERE status = 'scheduled';
-- 598,234 ← all reprocessed correctlyTotal impact: data missing from Silver/Gold for 2 days 14 hours, a $4.82 million revenue gap in dashboards for 67 hours. The incident was caught by dbt’s accepted_values test exactly as designed — the failure was in process, not tooling: no data contract enforcement meant the orders team had no way to know their enum change would break the downstream pipeline. Going forward, the contract now requires 30-day notice for enum changes, a CI check blocks unreviewed allowed_values additions, and Elementary was added for automated anomaly detection — the Z-score check would have caught this Friday evening, not Tuesday.
5 Interview Questions — With Complete Answers
Mistakes Beginners Make Constantly
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓Data quality is an engineering problem, not a monitoring problem. Every layer a quality issue traverses multiplies its cost by 10×. A validation check at Bronze ingestion prevents hours of investigation that the same problem causes at Gold. Build quality into every pipeline stage — not just at the end.
- ✓The six dimensions: Completeness (all records present, required fields populated), Accuracy (values match real-world state), Consistency (same representation across systems), Timeliness (data available when expected), Uniqueness (no duplicate primary keys), Validity (values conform to format, range, and domain rules).
- ✓dbt has four generic tests: not_null, unique, accepted_values, and relationships. These cover uniqueness, validity, and consistency. Add dbt_utils for range checks (accepted_range) and freshness (recency). Custom generic tests handle business rules. Singular tests catch model-specific conditions. Store failures with --store-failures for investigation.
- ✓Testing strategy by layer: Bronze/source → freshness and schema existence (warn). Staging → PK not_null and accepted_values (error). Silver → full suite including uniqueness, relationships, ranges, freshness (error on PK, warn on relationships). Gold → aggregate reconciliation, row count anomaly (error on reconciliation).
- ✓Anomaly detection catches what rule-based tests miss: unusual patterns that no rule was written for. Row count anomaly (compare to rolling 7-day average), Z-score on metric distributions (flag values > 3 standard deviations from mean), and tools like Elementary for automated per-column anomaly tracking. Combine with rule-based tests — they are complementary.
- ✓Great Expectations validates data at any pipeline stage — before ingestion, after landing, before transformation. Define expectation suites in Python. Run at file landing to quarantine bad files before they enter Bronze. The critical rule: test your expectation suites against edge cases (empty files, all-null files) before trusting them in production.
- ✓Soda provides YAML-based quality checks running SQL against warehouse tables. Simpler than Great Expectations for SQL-native checks. Integrates directly with Airflow as a quality gate task. Use as the quality gate between Silver and Gold — if Soda checks fail, the Gold dbt run does not start.
- ✓Data contracts are formal agreements between source teams and data engineering, specifying schema, quality guarantees, SLA, and change management rules. Enforce at ingestion: reject data that violates the contract. Enforce at deployment: source team CI checks that block breaking changes without prior approval. Contracts move quality responsibility to the source.
- ✓A breaking change in a data contract: removing a field, adding a required field, changing a field type, narrowing allowed_values. An additive change: adding a new optional field, adding a new allowed value with notice. Detect breaking changes programmatically in CI before source deployment reaches production.
- ✓The quality monitoring schema (monitoring.data_quality_results) records every check result: table, check name, status, failure count, failure rate, timestamp. Use it for: daily quality scorecards, trend analysis (quality improving or degrading?), SLA reporting, and post-incident investigation to determine when quality first degraded.
What comes next
Module 37 covers data observability — pipeline metrics, structured logging, anomaly detection, and the alerting design that ensures you know about data problems before your stakeholders do.
Module 37 → Data Observability — Metrics, Logging and Anomaly DetectionDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.