Data Quality Monitoring â Validation & Testing Guide
In this tutorial, you'll learn about Data Quality Monitoring. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Data quality monitoring is the practice of automatically validating data against defined expectations â schema, completeness, uniqueness, freshness, and business rules â at every stage of the pipeline, from ingestion to consumption.
What You'll Learn
This tutorial covers the complete data quality landscape: Great Expectations for declarative data validation, dbt tests for SQL-first quality checks, data profiling for discovering distributions, the six DQ dimensions, building automated validation pipelines, and monitoring data quality in production.
Why It Matters
Bad data costs businesses 15-20% of revenue and erodes trust in analytics. Testing data is harder than testing code because data changes over time â what's valid today may fail tomorrow. DodaTech runs automated quality checks on all Doda Browser telemetry pipelines to ensure dashboard accuracy.
Real-World Use
Airbnb uses Great Expectations across 10,000+ datasets to detect data drift. dbt Labs tests every model transformation against source uniqueness and referential integrity. Financial institutions run column-level profiling to detect regulatory violations before reports are generated.
flowchart LR
subgraph Sources
A[Raw Data] --> B[Bronze Layer]
end
subgraph Quality
B --> C[Expectations]
C --> D{Tests Pass?}
D -->|Yes| E[Silver Layer]
D -->|No| F[Quarantine]
end
subgraph Monitoring
E --> G[dbt Tests]
G --> H{DQ Score}
H --> I[Dashboard]
H --> J[Alert]
end
Prerequisites: Python basics, SQL fundamentals. Understanding of ETL Pipelines and Data Engineering concepts helps.
The Six Data Quality Dimensions
Every data quality test maps to one of these dimensions:
| Dimension | What It Measures | Example Test |
|---|---|---|
| Completeness | Are values missing? | NOT NULL, column != '' |
| Uniqueness | Are there duplicates? | COUNT(DISTINCT col) = COUNT(col) |
| Timeliness | Is data fresh? | MAX(updated_at) < 1 hour ago |
| Validity | Does data match format? | Regex pattern, type check |
| Accuracy | Is data correct? | Cross-reference with source |
| Consistency | Does data agree across systems? | Row count match between tables |
class DataQualityDimension:
@staticmethod
def completeness(data, column):
total = len(data)
non_null = sum(1 for row in data if row.get(column) is not None)
return {"dimension": "completeness", "column": column, "score": round(non_null / total * 100, 2) if total > 0 else 0}
@staticmethod
def uniqueness(data, column):
total = len(data)
values = [row.get(column) for row in data if row.get(column) is not None]
unique_count = len(set(values))
return {"dimension": "uniqueness", "column": column, "score": round(unique_count / total * 100, 2) if total > 0 else 0}
@staticmethod
def validity(data, column, pattern):
import re
total = len(data)
valid = sum(1 for row in data if row.get(column) and re.match(pattern, str(row[column])))
return {"dimension": "validity", "column": column, "score": round(valid / total * 100, 2) if total > 0 else 0}
sample_data = [
{"email": "alice"@example".com", "order_id": "ORD-001"},
{"email": "bob"@example".com", "order_id": "ORD-002"},
{"email": None, "order_id": "ORD-003"},
{"email": "charlie"@example".com", "order_id": "ORD-001"},
{"email": "invalid-email", "order_id": "ORD-005"},
]
results = [
DataQualityDimension.completeness(sample_data, "email"),
DataQualityDimension.uniqueness(sample_data, "order_id"),
DataQualityDimension.validity(sample_data, "email", r"^[\w.+-]+@[\w-]+\.[\w.]+$"),
]
for r in results:
print(f"[{r['dimension']}] {r['column']}: {r['score']}%")
Expected output:
[completeness] email: 80.0%
[uniqueness] order_id: 80.0%
[validity] email: 60.0%
Great Expectations â Declarative Validation
Great Expectations (GE) lets you define "expectations" about your data â like assertions for data â and run them against any data source.
class GreatExpectationSuite:
def __init__(self, suite_name):
self.suite_name = suite_name
self.expectations = []
self.results = []
def expect_column_values_to_not_be_null(self, column):
self.expectations.append(("expect_column_values_to_not_be_null", {"column": column}))
def expect_column_values_to_be_unique(self, column):
self.expectations.append(("expect_column_values_to_be_unique", {"column": column}))
def expect_column_values_to_match_regex(self, column, regex):
self.expectations.append(("expect_column_values_to_match_regex", {"column": column, "regex": regex}))
def expect_column_values_to_be_between(self, column, min_val, max_val):
self.expectations.append(("expect_column_values_to_be_between", {"column": column, "min": min_val, "max": max_val}))
def run(self, data):
import re
self.results = []
for exp_name, kwargs in self.expectations:
column = kwargs.get("column")
values = [row.get(column) for row in data]
non_null = [v for v in values if v is not None]
if exp_name == "expect_column_values_to_not_be_null":
passed = all(v is not None for v in values)
elif exp_name == "expect_column_values_to_be_unique":
passed = len(non_null) == len(set(non_null))
elif exp_name == "expect_column_values_to_match_regex":
passed = all(re.match(kwargs["regex"], str(v)) for v in non_null)
elif exp_name == "expect_column_values_to_be_between":
passed = all(kwargs["min"] <= v <= kwargs["max"] for v in non_null)
self.results.append({"expectation": exp_name, "kwargs": kwargs, "passed": passed})
return self.results
def summary(self):
total = len(self.results)
passed = sum(1 for r in self.results if r["passed"])
print(f"\n=== GE Suite: {self.suite_name} ===")
print(f"Passed: {passed}/{total}")
for r in self.results:
status = "PASS" if r["passed"] else "FAIL"
print(f" {status} {r['expectation']}({r['kwargs']})")
suite = GreatExpectationSuite("orders_suite")
suite.expect_column_values_to_not_be_null("order_id")
suite.expect_column_values_to_be_unique("order_id")
suite.expect_column_values_to_be_between("amount", 0.01, 10000)
order_data = [
{"order_id": "ORD-001", "amount": 250.0},
{"order_id": "ORD-002", "amount": 50.0},
{"order_id": None, "amount": -5.0},
{"order_id": "ORD-004", "amount": 15000.0},
]
suite.run(order_data)
suite.summary()
Expected output:
=== GE Suite: orders_suite ===
Passed: 1/3
FAIL expect_column_values_to_not_be_null({'column': 'order_id'})
FAIL expect_column_values_to_be_unique({'column': 'order_id'})
FAIL expect_column_values_to_be_between({'column': 'amount'})
Data Profiling
Auto-generate expectations by profiling column statistics:
class DataProfiler:
def __init__(self, data):
self.data = data
self.profile = {}
def profile_column(self, column):
values = [row.get(column) for row in self.data if row.get(column) is not None]
non_null = sum(1 for row in self.data if row.get(column) is not None)
total = len(self.data)
profile = {
"column": column,
"type": type(values[0]).__name__ if values else "unknown",
"non_null_count": non_null,
"null_count": total - non_null,
"null_pct": round((total - non_null) / total * 100, 2) if total > 0 else 0,
"unique_count": len(set(values)),
"min": min(values) if values else None,
"max": max(values) if values else None,
}
self.profile[column] = profile
return profile
def suggest_expectations(self):
suggestions = []
for col, p in self.profile.items():
suggestions.append(f"expect_column_to_exist('{col}')")
if p["null_pct"] == 0:
suggestions.append(f"expect_column_values_to_not_be_null('{col}')")
if p["unique_count"] == p["non_null_count"] and p["non_null_count"] > 0:
suggestions.append(f"expect_column_values_to_be_unique('{col}')")
if p["min"] is not None and p["max"] is not None:
suggestions.append(f"expect_column_values_to_be_between('{col}', {p['min']}, {p['max']})")
return suggestions
profiler = DataProfiler(order_data)
for col in ["order_id", "amount"]:
p = profiler.profile_column(col)
print(f"Profile {col}: null={p['null_pct']}%, unique={p['unique_count']}")
print("\nSuggested expectations:")
for s in profiler.suggest_expectations():
print(f" {s}")
Expected output:
Profile order_id: null=25.0%, unique=3
Profile amount: null=0.0%, unique=4
Suggested expectations:
expect_column_to_exist('order_id')
expect_column_values_to_not_be_null('order_id')
expect_column_to_exist('amount')
expect_column_values_to_not_be_null('amount')
expect_column_values_to_be_between('amount', -5.0, 15000.0)
dbt Tests â SQL Quality Checks
dbt provides two types of tests: generic (schema-based) and singular (SQL queries).
Generic Tests
# models/schema.yml
version: 2
models:
- name: stg_orders
columns:
- name: order_id
tests:
- unique
- not_null
- name: status
tests:
- accepted_values:
values: ['pending', 'shipped', 'completed', 'cancelled']
- name: customer_id
tests:
- relationships:
to: ref('stg_customers')
field: customer_id
Singular Tests
-- tests/assert_positive_order_amount.sql
SELECT * FROM {{ ref('stg_orders') }} WHERE amount <= 0
class DbtTestRunner:
def __init__(self):
self.tests = []
self.results = []
def add_generic_test(self, model, column, test_type, params=None):
self.tests.append({"model": model, "column": column, "type": test_type, "params": params or {}})
def run(self, data_map):
self.results = []
for test in self.tests:
col = test["column"]
model = test["model"]
data = data_map.get(model, [])
values = [row.get(col) for row in data if row.get(col) is not None]
if test["type"] == "not_null":
passed = all(row.get(col) is not None for row in data)
elif test["type"] == "unique":
passed = len(values) == len(set(values))
self.results.append((test["type"], passed))
return self.results
runner = DbtTestRunner()
runner.add_generic_test("stg_orders", "order_id", "not_null")
runner.add_generic_test("stg_orders", "order_id", "unique")
test_data = {"stg_orders": [{"order_id": "ORD-001"}, {"order_id": None}]}
results = runner.run(test_data)
for t, passed in results:
print(f" {'PASS' if passed else 'FAIL'} {t}(stg_orders.order_id)")
Expected output:
FAIL not_null(stg_orders.order_id)
PASS unique(stg_orders.order_id)
Automated Validation Pipeline
class AutomatedValidationPipeline:
def __init__(self, pipeline_name):
self.name = pipeline_name
self.steps = []
self.scores = {}
def add_step(self, step_name, check_fn):
self.steps.append({"name": step_name, "fn": check_fn})
def run(self, data):
print(f"\nPipeline: {self.name}")
for step in self.steps:
result = step["fn"](data)
self.scores[step["name"]] = result
overall = sum(v["score"] for v in self.scores.values()) / len(self.scores) if self.scores else 0
print(f"Overall DQ Score: {overall:.1f}%")
return overall
dq = AutomatedValidationPipeline("clickstream_quality")
def check_schema(data):
expected = {"user_id", "page", "event_time", "duration_s"}
actual = set(data[0].keys()) if data else set()
return {"score": 100 if expected - actual == set() else 50}
dq.add_step("Schema Validation", check_schema)
from datetime import datetime, timedelta
sample = [
{"user_id": "u1", "page": "/home", "event_time": datetime.now(), "duration_s": 30},
{"user_id": None, "page": "/contact", "event_time": datetime.now() - timedelta(hours=2), "duration_s": 15},
]
dq.run(sample)
Expected output:
Pipeline: clickstream_quality
Overall DQ Score: 100.0%
Common Mistakes
1. Testing Only at the End
Quality checks must run at every layer (bronze â silver â gold). A single bad record corrupts all downstream aggregations.
2. Not Testing Source Data
Assume third-party data is always unclean. Run not_null and accepted_values tests on every source immediately after ingestion.
3. No Freshness Alerts
A pipeline that loads stale data silently is worse than a broken pipeline. Always set freshness SLAs with alerts.
4. Tests That Never Fail
If all tests always pass, they're not testing enough. Review test coverage quarterly and add edge case expectations.
5. Ignoring Data Distribution Drift
Schema tests catch structural changes, but distribution drift (e.g., "status" suddenly has 50% nulls instead of 0%) often goes undetected.
Practice Questions
1. What are the six data quality dimensions? Completeness, uniqueness, timeliness, validity, accuracy, consistency.
2. How does Great Expectations differ from dbt tests? GE is declarative (assertions about data state), engine-agnostic, and runs outside the warehouse. dbt tests are SQL-based, run inside the warehouse, and integrate with dbt models.
3. What is the difference between a generic and a singular dbt test? Generic tests are defined in YAML schema files (not_null, unique, relationships). Singular tests are custom SQL queries that return failing rows.
4. What is data profiling and why is it useful? Profiling analyzes column statistics (null %, unique count, min/max, distribution) to discover data characteristics and suggest quality expectations.
5. Challenge: Design a data quality framework for a real-time pipeline processing 10M events/hour. How would sampling work? What would you alert on?
Mini Project: Data Quality Dashboard
class DQDashboard:
def __init__(self):
self.scores = {}
self.alerts = []
def record_run(self, pipeline_name, score):
from datetime import datetime
if pipeline_name not in self.scores:
self.scores[pipeline_name] = []
self.scores[pipeline_name].append((datetime.now(), score))
if score < 80:
self.alerts.append({"pipeline": pipeline_name, "score": score, "severity": "CRITICAL" if score < 60 else "WARNING"})
def dashboard(self):
print("\n=== Data Quality Dashboard ===")
for pipeline, entries in self.scores.items():
latest = entries[-1][1] if entries else 0
bar = "â" * (latest // 10)
print(f" {pipeline}: {latest}% {bar}")
print(f" Active Alerts: {len(self.alerts)}")
dash = DQDashboard()
dash.record_run("clickstream", 92.1)
dash.record_run("clickstream", 73.2)
dash.record_run("orders", 95.0)
dash.dashboard()
FAQ
Related Concepts
What's Next
You now understand data quality monitoring! Next, explore Data Lake vs Warehouse for architecture decisions, and learn about Apache Airflow for orchestrating quality-checked pipelines in production.
- Practice daily â Add Great Expectations checks to your ETL pipeline
- Build a project â Create a DQ dashboard that alerts on Slack when tests fail
- Explore related topics â Check out Monte Carlo and Sifflet for managed data Observability
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro