Data Validation and Quality in ML Pipelines

Suraj Kumar

Suraj Kumar

Last updated November 5, 2026·16 min read

MLOps course · lesson 9 of 24
  1. 1What Is MLOps? From Notebooks to Reliable Production
  2. 2MLOps Principles and Maturity: From Manual to Automated
  3. 3ML Fundamentals for MLOps: Splits, Leakage, and Metrics
  4. 4A Reproducible ML Project: Layout, Seeds, and Data Code
  5. 5Data Engineering Basics for ML: Ingestion, SQL, and Skew
  6. 6Pipeline Orchestration: Apache Airflow and Kubeflow
  7. 7Batch vs Streaming: Spark, Kafka, and Flink
  8. 8Data Versioning and Lineage: DVC and Beyond
  9. 9Data Validation and Quality in ML Pipelines
  10. 10Experiment Tracking with MLflow
  11. 11Reproducible Training Pipelines
  12. 12Model Evaluation and Validation Gates
  13. 13Model Registry and Versioning
  14. 14CI/CD for Machine Learning
  15. 15Containers and Kubernetes for ML
  16. 16Infrastructure as Code: Terraform and Ansible
  17. 17Cloud ML Platforms: AWS, Azure, GCP, and Self-Built
  18. 18Model Serving Patterns: Batch, Online, and Streaming
  19. 19Inference Engineering Basics: Latency, Batching, and Cost
  20. 20Monitoring and Observability for ML Models
  21. 21Drift Detection and Retraining for ML Models
  22. 22Explainable AI: LIME, SHAP, and Model Interpretability
  23. 23Edge AI Deployment: Jetson, TFLite, and Mobile ML
  24. 24Capstone: An End-to-End MLOps Pipeline
View full syllabus

In traditional software development, input validation is taken for granted. If an API endpoint expects an integer user ID and receives an empty string or negative number, the handler rejects the request with an HTTP 400 bad request error. The bad data is stopped at the perimeter before it can corrupt database tables.

In machine learning, bad data rarely produces an immediate exception. If a billing service migration begins writing negative values into a monthly_spend column, a Gradient Boosting model will not crash. It will parse the numbers, compute feature splits on negative values, and generate nonsensical customer churn predictions. If a database timeout leaves forty percent of your categorical location values null, an imputation transformer might silently replace them with the string "missing", skewing decision boundaries without triggering a single runtime error.

This phenomenon is known as silent data corruption. Models fail silently because numerical algorithms accept whatever numbers they receive. To keep machine learning systems reliable, you must enforce strict data quality gates and formal data contracts across your pipelines.

In this lesson, you will learn how to design data quality gates across ingestion, transformation, pre-training, and inference. You will examine the anatomy of data contracts, study how industry frameworks like Great Expectations operate, and build a fully tested, runnable Python data validator from scratch.

The cost of validating late

The central principle of data quality in MLOps is simple: validate early and fail loudly.

Quality gates positioned across ingestion, transformation, pre-training, and inference in an ML pipeline

Data validation belongs at four distinct gates in the machine learning lifecycle:

Gate 1: Ingestion gate

The ingestion gate evaluates raw data at the boundary where external data sources land in your storage environment. This check occurs before any transformation logic executes.

Key checks at Gate 1:

  • File completeness: Ensuring files are non-empty and uncorrupted.
  • Freshness: Verifying that the data batch arrived within its expected time window (for example, yesterday's booking dump arrived before 02:00 UTC).
  • Schema presence: Checking that expected primary keys, partition timestamps, and critical entity columns exist.

Failing at Gate 1 costs pennies in cloud compute. If the upstream transactional database export stalled and wrote a zero-byte file, your pipeline halts immediately, paging the data engineering team before any downstream training cluster spins up.

Gate 2: Processing and transformation gate

Once raw data is parsed and transformed into feature tables, the transformation gate checks the statistical integrity of individual columns.

Key checks at Gate 2:

  • Null fraction thresholds: Confirming that missing values do not exceed predefined limits (for example, customer_id must have 0% nulls; optional survey answers must not exceed 10% nulls).
  • Numerical range bounds: Enforcing physical and business realities (such as tenure_months >= 0 and monthly_spend >= 0.0).
  • Categorical set membership: Verifying that categorical variables match approved enumerations (such as contract_type in {"month-to-month", "one-year", "two-year"}).
  • Entity uniqueness: Asserting that primary keys remain distinct without unexpected duplicate rows introduced by bad relational joins.

Gate 3: Pre-training gate

Before feeding a curated dataset into a multi-hour model training job, the pre-training gate verifies dataset-level distributions.

Key checks at Gate 3:

  • Target class balance: In StayWell churn prediction, historical churn hovers between 10% and 25%. If a training snapshot exhibits a 1% or 90% churn rate, an upstream join or label filtering logic is broken.
  • Minimum row count: Verifying that seasonal or network filtering did not discard ninety percent of the historical dataset.
  • Feature drift against baseline: Comparing feature distributions against the previous training run to detect sudden distribution shifts.

Failing at Gate 3 prevents the model registry from being polluted with invalid model weights trained on broken labels.

Gate 4: Inference gate

When serving live predictions via REST API or streaming consumer, the inference gate inspects incoming feature payloads.

Key checks at Gate 4:

  • Payload type validation: Ensuring incoming JSON fields match expected types.
  • Outlier clamping: Capping extreme values that could cause numerical overflow in model scoring.
  • Fallback routing: When essential features are missing in a live request, returning a safe population baseline prediction rather than throwing an unhandled HTTP 500 error.

Anatomy of a failing check: quarantine and alerts

When a validation check detects invalid data, how should the pipeline respond? A naive approach is to either ignore the error or drop all invalid rows silently. Both choices are dangerous: ignoring errors corrupts models, while silently dropping invalid rows introduces survivorship bias.

Root-cause tracing of a failing data validation check from error containment to mobile app fix

A good validation architecture separates failures into two severity levels:

Fail versus warn thresholds

  1. FAIL (Critical Severity): Triggered when foundational assumptions are broken. Examples include missing required columns, primary key duplicates, negative monetary values, or target label corruption.
  • Pipeline Action: Abort execution immediately.
  • Data Action: Isolate the invalid batch and write corrupt records to a designated quarantine object store (for example, s3://staywell-mlops-lake/quarantine/).
  • Notification Action: Trigger high-priority alerts to on-call engineers via PagerDuty or Slack.
  1. WARN (Non-Critical Severity): Triggered when metrics drift slightly outside optimal ranges but do not compromise technical execution. Examples include optional survey fields exceeding missingness thresholds by 2%, or target churn rate shifting from 15% to 26% following a promotional campaign.
  • Pipeline Action: Log the warning, continue execution, and record the metric in the run metadata.
  • Notification Action: Dispatch a non-urgent notification to the team dashboard for review during normal business hours.

The quarantine pattern

Never discard failing records into the void. When twenty-four rows in StayWell's nightly booking batch fail because monthly_spend = -120.50, write those twenty-four rows to a quarantine storage bucket alongside metadata recording the timestamp, the rule that failed, and the violation reason.

Quarantining records enables root-cause investigations. In the StayWell case, examining the quarantined rows revealed that a new release of the mobile application (v4.2) had started recording customer refunds as negative spend transactions rather than logging them to a separate refund ledger. Because the validation gate halted the pipeline, zero negative spend values entered the training matrix, protecting the production churn classifier from corrupted feature weights.

Data contracts: decoupling producers and consumers

In modern software organizations, machine learning teams rarely own the databases that produce raw operational data. Software engineers building the checkout flow, billing service, or mobile app modify schemas to support user-facing product features, often unaware that a renamed column breaks an ML pipeline downstream.

A Data Contract is a formal, versioned agreement between data producers (the engineering teams generating data) and data consumers (the ML and analytics teams using data).

A data contract defines:

  • Schema Definitions: Column names, data types, and nullability.
  • Business Semantic Constraints: Value bounds, allowable categories, and expected business logic.
  • Service Level Agreements (SLAs): Update frequency, delivery deadlines, and freshness bounds.
  • Breaking Change Protocol: A required deprecation notice and transition window before schemas change.

Operational data contract template

Here is the production data contract established between StayWell's Core Billing Engineering team and the MLOps team:

yaml
1# StayWell MLOps Data Contract
2# Contract ID: DC-STAYWELL-CHURN-FEATURES-V1
3# Producer: Team Core Billing (billing-eng@staywell.internal)
4# Consumer: Team MLOps (mlops-eng@staywell.internal)
5# Status: ACTIVE (Effective: 2026-11-04)
6
7dataset:
8  name: staywell_customer_features
9  format: parquet
10  delivery_uri: s3://staywell-mlops-lake/staged/features/
11  sla_delivery_time_utc: "02:00"
12
13schema:
14  - name: customer_id
15    type: string
16    required: true
17    nullable: false
18    unique: true
19    description: "Unique alphanumeric customer identifier prefixed with CUST-"
20
21  - name: tenure_months
22    type: integer
23    required: true
24    nullable: false
25    min_value: 0
26    max_value: 120
27    description: "Total consecutive active subscription months"
28
29  - name: monthly_spend
30    type: float
31    required: true
32    nullable: false
33    min_value: 0.0
34    max_value: 5000.0
35    description: "Average recurring monthly billing spend in USD"
36
37  - name: contract_type
38    type: string
39    required: true
40    nullable: false
41    allowed_values: ["month-to-month", "one-year", "two-year"]
42    description: "Active subscription contract term"
43
44  - name: support_tickets
45    type: integer
46    required: false
47    nullable: true
48    max_null_fraction: 0.10
49    min_value: 0
50    max_value: 50
51    description: "Total customer support tickets submitted in last 90 days"
52
53  - name: churn
54    type: integer
55    required: true
56    nullable: false
57    allowed_values: [0, 1]
58    description: "Binary churn indicator: 1 if cancelled within 30 days, else 0"
59
60quality_rules:
61  min_row_count: 50000
62  target_balance:
63    column: churn
64    min_rate: 0.08
65    max_rate: 0.35
66    severity: WARN
67
68change_management:
69  deprecation_notice_days: 30
70  notification_channel: "#alerts-billing-data-contracts"

When schema or semantic changes occur, automated CI pipelines validate proposed migrations against active data contracts, preventing breaking changes from reaching production unnoticed.

Industry validation tools: Great Expectations and Pandera

While custom validation scripts work well for specialized tasks, open-source frameworks standardize how data expectations are declared, executed, and documented.

The official Great Expectations Documentation (verified on 4 November 2026) defines Great Expectations as a shared standard for data quality. In Great Expectations:

  • Expectations: Assertions about data expressed declaratively in Python or JSON (in GX Core 1.x these are classes such as ExpectColumnValuesToNotBeNull and ExpectColumnValuesToBeBetween).
  • Expectation Suites: Bundles of individual expectations grouped into a cohesive contract for a specific dataset.
  • Checkpoints: Execution units that pair an Expectation Suite with a data batch, evaluate the rules, trigger actions (like Slack alerts or S3 quarantines), and generate human-readable HTML documentation known as Data Docs.
python
1# Great Expectations (GX Core 1.x) checks on a small illustrative DataFrame
2import great_expectations as gx
3import great_expectations.expectations as gxe
4import pandas as pd
5
6df = pd.DataFrame({
7    "customer_id": ["C-1", "C-2", "C-3"],
8    "tenure_months": [3, 10, 200],
9    "monthly_spend": [20.0, 35.5, 10.0],
10    "contract_type": ["month-to-month", "one-year", "weird"],
11})
12
13context = gx.get_context()
14data_source = context.data_sources.add_pandas("staywell")
15asset = data_source.add_dataframe_asset(name="features")
16batch_definition = asset.add_batch_definition_whole_dataframe("whole")
17batch = batch_definition.get_batch(batch_parameters={"dataframe": df})
18
19expectations = [
20    gxe.ExpectColumnValuesToNotBeNull(column="customer_id"),
21    gxe.ExpectColumnValuesToBeUnique(column="customer_id"),
22    gxe.ExpectColumnValuesToBeBetween(column="tenure_months", min_value=0, max_value=120),
23    gxe.ExpectColumnValuesToBeBetween(column="monthly_spend", min_value=0.0, max_value=5000.0),
24    gxe.ExpectColumnValuesToBeInSet(
25        column="contract_type", value_set=["month-to-month", "one-year", "two-year"]
26    ),
27]
28for expectation in expectations:
29    result = batch.validate(expectation)
30    print(type(expectation).__name__, result.success)

I ran this on Great Expectations Core 1.24.1 and Python 3.12 on 5 November 2026. It prints True for the not-null, uniqueness and spend-range checks, and False for the tenure check (the value 200 is above the maximum of 120) and the contract-type check (the value "weird" is not in the allowed set). The library also prints progress bars. Older tutorials use context.sources.pandas_default and validator.expect_column_values_to_...; that API no longer exists in GX Core 1.x, so check which version a tutorial targets.

Similarly, the Pandera Documentation (verified on 4 November 2026) provides statistical schema validation for Pandas, Polars, and PySpark DataFrames, integrating validation decorators directly into function signatures.

Hands-on implementation: a tested Python data validator

To understand how validation logic operates without depending on external services, let us implement a lightweight, production-grade data validator using the Python standard library.

This validator enforces:

  1. Required column presence and data types
  2. Null fraction thresholds
  3. Numerical range boundaries
  4. Categorical set enumerations
  5. Entity uniqueness
  6. Target distribution health

The validator implementation (data_validator.py)

Here is the complete implementation of the validation engine:

data_validator.py

python
1"""Production data validator for machine learning pipelines.
2
3Validates tabular datasets against data contracts including schema definitions,
4nullability thresholds, numeric ranges, categorical allowed sets, uniqueness,
5and target label distributions.
6"""
7
8from __future__ import annotations
9
10import dataclasses
11from typing import Any, Dict, List, Optional, Set
12
13
14@dataclasses.dataclass(frozen=True)
15class ColumnRule:
16    """Rules for validating a single column in a dataset."""
17    name: str
18    dtype: type
19    required: bool = True
20    nullable: bool = False
21    max_null_fraction: float = 0.0
22    min_value: Optional[float] = None
23    max_value: Optional[float] = None
24    allowed_values: Optional[Set[Any]] = None
25    unique: bool = False
26
27
28@dataclasses.dataclass(frozen=True)
29class TargetBalanceRule:
30    """Rules for validating target label distribution (e.g. churn rate)."""
31    target_column: str
32    min_rate: float = 0.05
33    max_rate: float = 0.40
34    severity: str = "WARN"  # "WARN" or "FAIL"
35
36
37@dataclasses.dataclass
38class ValidationIssue:
39    """Represents a single validation error or warning."""
40    severity: str  # "FAIL" or "WARN"
41    column: Optional[str]
42    message: str
43
44
45@dataclasses.dataclass
46class ValidationReport:
47    """Summary of data validation execution."""
48    is_valid: bool
49    total_rows: int
50    errors: List[ValidationIssue] = dataclasses.field(default_factory=list)
51    warnings: List[ValidationIssue] = dataclasses.field(default_factory=list)
52    metrics: Dict[str, Any] = dataclasses.field(default_factory=dict)
53
54
55class DataValidator:
56    """Validates row-oriented datasets against a defined data contract."""
57
58    def __init__(
59        self,
60        column_rules: List[ColumnRule],
61        target_rule: Optional[TargetBalanceRule] = None,
62        min_rows: int = 1,
63    ) -> None:
64        self.column_rules = {r.name: r for r in column_rules}
65        self.target_rule = target_rule
66        self.min_rows = min_rows
67
68    def validate(self, dataset: List[Dict[str, Any]]) -> ValidationReport:
69        """Execute all contract checks against the given dataset."""
70        errors: List[ValidationIssue] = []
71        warnings: List[ValidationIssue] = []
72        metrics: Dict[str, Any] = {}
73
74        total_rows = len(dataset)
75        metrics["row_count"] = total_rows
76
77        # 1. Dataset size check
78        if total_rows < self.min_rows:
79            errors.append(
80                ValidationIssue(
81                    severity="FAIL",
82                    column=None,
83                    message=f"Dataset has {total_rows} rows, minimum required is {self.min_rows}.",
84                )
85            )
86            return ValidationReport(
87                is_valid=False,
88                total_rows=total_rows,
89                errors=errors,
90                warnings=warnings,
91                metrics=metrics,
92            )
93
94        # Collect columns present across records
95        present_columns = set()
96        for row in dataset:
97            present_columns.update(row.keys())
98
99        # 2. Required column existence checks
100        for rule_name, rule in self.column_rules.items():
101            if rule.required and rule_name not in present_columns:
102                errors.append(
103                    ValidationIssue(
104                        severity="FAIL",
105                        column=rule_name,
106                        message=f"Required column '{rule_name}' is missing from dataset.",
107                    )
108                )
109
110        # If required columns are missing, return early
111        if errors:
112            return ValidationReport(
113                is_valid=False,
114                total_rows=total_rows,
115                errors=errors,
116                warnings=warnings,
117                metrics=metrics,
118            )
119
120        # Per-column collectors
121        seen_values: Dict[str, Set[Any]] = {name: set() for name, r in self.column_rules.items() if r.unique}
122        duplicate_counts: Dict[str, int] = {name: 0 for name, r in self.column_rules.items() if r.unique}
123        null_counts: Dict[str, int] = {name: 0 for name in self.column_rules}
124        range_violations: Dict[str, int] = {name: 0 for name in self.column_rules}
125        type_violations: Dict[str, int] = {name: 0 for name in self.column_rules}
126        set_violations: Dict[str, int] = {name: 0 for name in self.column_rules}
127
128        # 3. Row-by-row validation
129        for row in dataset:
130            for rule_name, rule in self.column_rules.items():
131                val = row.get(rule_name)
132
133                # Null check
134                if val is None:
135                    null_counts[rule_name] += 1
136                    continue
137
138                # Uniqueness check
139                if rule.unique:
140                    if val in seen_values[rule_name]:
141                        duplicate_counts[rule_name] += 1
142                    else:
143                        seen_values[rule_name].add(val)
144
145                # Type check (allow int for float)
146                if not isinstance(val, rule.dtype):
147                    if rule.dtype is float and isinstance(val, int):
148                        val = float(val)
149                    else:
150                        type_violations[rule_name] += 1
151                        continue
152
153                # Range check
154                if rule.min_value is not None and val < rule.min_value:
155                    range_violations[rule_name] += 1
156                if rule.max_value is not None and val > rule.max_value:
157                    range_violations[rule_name] += 1
158
159                # Categorical set check
160                if rule.allowed_values is not None and val not in rule.allowed_values:
161                    set_violations[rule_name] += 1
162
163        # 4. Aggregate metrics and compile issues
164        for rule_name, rule in self.column_rules.items():
165            null_count = null_counts[rule_name]
166            null_frac = null_count / total_rows
167            metrics[f"{rule_name}.null_fraction"] = null_frac
168
169            if null_frac > rule.max_null_fraction:
170                errors.append(
171                    ValidationIssue(
172                        severity="FAIL",
173                        column=rule_name,
174                        message=(
175                            f"Column '{rule_name}' null fraction {null_frac:.4f} "
176                            f"exceeds maximum allowed {rule.max_null_fraction:.4f}."
177                        ),
178                    )
179                )
180
181            if type_violations[rule_name] > 0:
182                errors.append(
183                    ValidationIssue(
184                        severity="FAIL",
185                        column=rule_name,
186                        message=(
187                            f"Column '{rule_name}' had {type_violations[rule_name]} rows "
188                            f"violating expected type {rule.dtype.__name__}."
189                        ),
190                    )
191                )
192
193            if range_violations[rule_name] > 0:
194                errors.append(
195                    ValidationIssue(
196                        severity="FAIL",
197                        column=rule_name,
198                        message=(
199                            f"Column '{rule_name}' had {range_violations[rule_name]} rows "
200                            f"outside allowed range [{rule.min_value}, {rule.max_value}]."
201                        ),
202                    )
203                )
204
205            if set_violations[rule_name] > 0:
206                errors.append(
207                    ValidationIssue(
208                        severity="FAIL",
209                        column=rule_name,
210                        message=(
211                            f"Column '{rule_name}' had {set_violations[rule_name]} rows "
212                            f"with values not in allowed set {rule.allowed_values}."
213                        ),
214                    )
215                )
216
217            if rule.unique and duplicate_counts[rule_name] > 0:
218                errors.append(
219                    ValidationIssue(
220                        severity="FAIL",
221                        column=rule_name,
222                        message=(
223                            f"Column '{rule_name}' has {duplicate_counts[rule_name]} duplicate values."
224                        ),
225                    )
226                )
227
228        # 5. Target balance check
229        if self.target_rule and self.target_rule.target_column in present_columns:
230            target_col = self.target_rule.target_column
231            target_values = [r.get(target_col) for r in dataset if r.get(target_col) is not None]
232            if target_values:
233                positive_count = sum(1 for v in target_values if v in (1, 1.0, True, "1"))
234                positive_rate = positive_count / len(target_values)
235                metrics["target_positive_rate"] = positive_rate
236
237                if positive_rate < self.target_rule.min_rate or positive_rate > self.target_rule.max_rate:
238                    issue = ValidationIssue(
239                        severity=self.target_rule.severity,
240                        column=target_col,
241                        message=(
242                            f"Target '{target_col}' positive rate {positive_rate:.4f} "
243                            f"is outside acceptable bounds [{self.target_rule.min_rate}, {self.target_rule.max_rate}]."
244                        ),
245                    )
246                    if self.target_rule.severity == "FAIL":
247                        errors.append(issue)
248                    else:
249                        warnings.append(issue)
250
251        is_valid = len(errors) == 0
252        return ValidationReport(
253            is_valid=is_valid,
254            total_rows=total_rows,
255            errors=errors,
256            warnings=warnings,
257            metrics=metrics,
258        )

The unit test suite (data_validator_test.py)

Testing the validator ensures that your quality gates cannot be bypassed by edge cases or broken assumptions. Here is the full test suite covering valid runs, missing columns, range bounds, categorical sets, primary key duplicates, excessive nulls, and target imbalance:

data_validator_test.py

python
1"""Unit tests for production data validator."""
2
3import unittest
4from data_validator import (
5    ColumnRule,
6    DataValidator,
7    TargetBalanceRule,
8)
9
10
11class TestDataValidator(unittest.TestCase):
12
13    def setUp(self):
14        self.staywell_rules = [
15            ColumnRule(name="customer_id", dtype=str, required=True, nullable=False, unique=True),
16            ColumnRule(name="tenure_months", dtype=int, required=True, min_value=0, max_value=120),
17            ColumnRule(name="monthly_spend", dtype=float, required=True, min_value=0.0, max_value=5000.0),
18            ColumnRule(
19                name="contract_type",
20                dtype=str,
21                required=True,
22                allowed_values={"month-to-month", "one-year", "two-year"},
23            ),
24            ColumnRule(
25                name="support_tickets",
26                dtype=int,
27                required=False,
28                nullable=True,
29                max_null_fraction=0.10,
30                min_value=0,
31                max_value=50,
32            ),
33            ColumnRule(name="churn", dtype=int, required=True, allowed_values={0, 1}),
34        ]
35        self.target_rule = TargetBalanceRule(
36            target_column="churn", min_rate=0.08, max_rate=0.35, severity="WARN"
37        )
38        self.validator = DataValidator(
39            column_rules=self.staywell_rules,
40            target_rule=self.target_rule,
41            min_rows=5,
42        )
43
44        # Baseline clean dataset (10 rows, 2 churners = 20% churn rate)
45        self.valid_data = [
46            {
47                "customer_id": f"CUST-{i:04d}",
48                "tenure_months": 12 + i,
49                "monthly_spend": 99.50 + (i * 10.0),
50                "contract_type": "month-to-month" if i % 2 == 0 else "one-year",
51                "support_tickets": i % 3,
52                "churn": 1 if i in (2, 7) else 0,
53            }
54            for i in range(10)
55        ]
56
57    def test_valid_dataset_passes(self):
58        report = self.validator.validate(self.valid_data)
59        self.assertTrue(report.is_valid)
60        self.assertEqual(len(report.errors), 0)
61        self.assertEqual(len(report.warnings), 0)
62        self.assertEqual(report.total_rows, 10)
63        self.assertAlmostEqual(report.metrics["target_positive_rate"], 0.20)
64
65    def test_missing_required_column_fails(self):
66        corrupt_data = [dict(row) for row in self.valid_data]
67        for row in corrupt_data:
68            del row["customer_id"]
69
70        report = self.validator.validate(corrupt_data)
71        self.assertFalse(report.is_valid)
72        self.assertTrue(any(e.column == "customer_id" for e in report.errors))
73
74    def test_numeric_range_violation_fails(self):
75        corrupt_data = [dict(row) for row in self.valid_data]
76        corrupt_data[3]["monthly_spend"] = -45.0  # Negative spend violation
77
78        report = self.validator.validate(corrupt_data)
79        self.assertFalse(report.is_valid)
80        self.assertTrue(any("outside allowed range" in e.message for e in report.errors))
81
82    def test_categorical_set_violation_fails(self):
83        corrupt_data = [dict(row) for row in self.valid_data]
84        corrupt_data[4]["contract_type"] = "lifetime-vip"  # Unknown category
85
86        report = self.validator.validate(corrupt_data)
87        self.assertFalse(report.is_valid)
88        self.assertTrue(any("not in allowed set" in e.message for e in report.errors))
89
90    def test_duplicate_primary_key_fails(self):
91        corrupt_data = [dict(row) for row in self.valid_data]
92        corrupt_data[5]["customer_id"] = corrupt_data[0]["customer_id"]  # Duplicate PK
93
94        report = self.validator.validate(corrupt_data)
95        self.assertFalse(report.is_valid)
96        self.assertTrue(any("duplicate values" in e.message for e in report.errors))
97
98    def test_excessive_nulls_fails(self):
99        corrupt_data = [dict(row) for row in self.valid_data]
100        # Max null fraction is 10%, 3 nulls out of 10 rows is 30%
101        corrupt_data[0]["support_tickets"] = None
102        corrupt_data[1]["support_tickets"] = None
103        corrupt_data[2]["support_tickets"] = None
104
105        report = self.validator.validate(corrupt_data)
106        self.assertFalse(report.is_valid)
107        self.assertTrue(any("null fraction" in e.message for e in report.errors))
108
109    def test_class_imbalance_emits_warning(self):
110        # 10 rows with 0 churners (0% churn, below 8% threshold)
111        skewed_data = [dict(row) for row in self.valid_data]
112        for row in skewed_data:
113            row["churn"] = 0
114
115        report = self.validator.validate(skewed_data)
116        self.assertTrue(report.is_valid)  # Severity is WARN, so is_valid remains True
117        self.assertEqual(len(report.warnings), 1)
118        self.assertTrue(any("outside acceptable bounds" in w.message for w in report.warnings))
119
120
121if __name__ == "__main__":
122    unittest.main()

Save both files in the same folder and run the tests from that folder:

bash
1python3 -m unittest discover -p '*_test.py'

Every test runs in sub-millisecond time. In your CI/CD pipeline, running these validation unit tests ensures that any modification to your data contract or validation engine is verified before deployment.

Mistakes beginners make

1. Validating only after model training fails

Developers often wait until a training job crashes with a NaN loss error or produces zero precision before inspecting their input dataset. By that time, expensive GPU compute hours have been wasted and debugging requires sifting through hundreds of megabytes of intermediate tensors.

Fix: Insert Gate 1 and Gate 2 validation tasks as mandatory prerequisites before model training tasks in your orchestrator DAG. If data checks fail, halt the pipeline immediately before allocating training compute.

2. Silently dropping invalid records without quarantine

When incoming data contains nulls or range violations, teams often run df.dropna() or filter out invalid rows silently. If an upstream bug invalidates thirty percent of your records, your model trains on a skewed subset without alerting anyone.

Fix: Calculate the exact percentage of dropped or invalid rows. If the violation fraction exceeds an allowable threshold (such as 0.5%), abort the pipeline. Always route invalid records to a quarantine storage bucket with full error metadata for root-cause analysis.

3. Hardcoding validation thresholds inside transformation scripts

Embedding validation bounds (such as assert spend >= 0) directly inside feature transformation code scatters expectations across dozens of files. When business logic evolves, updating rules requires modifying and redeploying core pipeline scripts.

Fix: Decouple data contracts into dedicated configuration files (such as YAML or JSON schemas). Your validation engine should ingest the data contract as a configuration parameter, allowing rules to be audited, versioned, and updated independently of transformation code.

4. Treating all validation failures as pipeline-halting errors

Failing an entire nightly retraining run because an optional feedback survey column had 3% nulls instead of 2% creates alert fatigue. Engineers quickly begin disabling validation checks or ignoring PagerDuty notifications when non-critical anomalies block critical business operations.

Fix: Clearly distinguish between FAIL severity (critical structural violations that threaten model integrity) and WARN severity (distributional drift or optional field anomalies). Block pipelines only on FAIL, while logging WARN events to monitoring dashboards for scheduled review.

Recap and try it yourself

In this lesson, you explored how to stop bad data from reaching production machine learning systems:

  • Machine learning models fail silently because numerical algorithms accept corrupt inputs without raising runtime exceptions.
  • Quality gates must be placed across four distinct pipeline stages: ingestion, transformation, pre-training, and inference.
  • Data contracts establish a formal, versioned agreement between data producers and ML consumers, preventing breaking changes.
  • Frameworks like Great Expectations and Pandera standardize declarative expectations and automated data documentation.
  • A tested, lightweight Python validator enforces schema existence, null fractions, numerical ranges, categorical sets, primary key uniqueness, and class balance.

Try it yourself

To practice implementing data quality gates on your machine:

  1. Extract and run the validator: Copy data_validator.py and data_validator_test.py into a local directory and run python3 -m unittest data_validator_test.py. Verify that all seven unit tests pass cleanly.
  2. Add a string pattern rule: Extend ColumnRule and DataValidator to support regex validation (for example, verifying that customer_id strictly matches the format ^CUST-[0-9]{4}$).
  3. Write a mutation test: Intentionally alter the validator logic to comment out the duplicate primary key check. Run the test suite and observe test_duplicate_primary_key_fails catch the defect.
  4. Build a quarantine writer: Write a small wrapper function that takes ValidationReport and the input dataset, filters out rows that caused FAIL errors, and writes them to a local JSON Lines file named quarantine_records.jsonl with an attached error timestamp.

Related lessons in other courses