HardMLOps

ETL Schema Validation

MLOps

Hard

Problem

Validate warehouse records against an ordered schema. Each schema entry contains column, type ("int", "float", or "str"), and nullable, with optional inclusive min and max bounds. The float schema type accepts Python integers and floats, but Boolean values do not count as integers.

For each column in schema order, check missing values, nullability, type, then range. After a missing value, valid nullable null, or failed type check, skip later checks for that column. Use exact errors "{column}: missing", "{column}: null", "{column}: expected {expected_type}, got {actual_type}", and "{column}: out of range". Return a list of result dictionaries in input order. Each dictionary contains record_index, is_valid, and errors.

Theory

In data pipelines, raw data arrives from many sources: user events, API responses, third-party feeds, IoT sensors, and manual uploads. This data is often messy, inconsistent, or outright wrong. If you load it directly into your data warehouse without validation, the problems propagate to every downstream consumer: dashboards show wrong numbers, ML models train on corrupted features, and business decisions are made on faulty information.

The insidious aspect of data quality issues is that they often go unnoticed for a long time. A missing field defaults to null, an incorrect type gets coerced, an out-of-range value looks plausible at first glance. By the time someone notices the problem, months of data may be corrupted, and tracking down the root cause becomes a nightmare.

Schema validation is the first line of defense. Before loading any record into the warehouse, you validate it against a schema that defines what columns should exist, what types they should have, whether nulls are allowed, and what value ranges are acceptable. Invalid records are flagged and routed to a dead-letter queue for investigation, while valid records proceed to the warehouse.


What is a Schema?

A schema defines the expected structure and constraints of your data. For tabular data, a schema specifies for each column:

Column Name: The identifier for this field

Data Type: What kind of value is expected (integer, float, string, etc.)

Nullable: Whether the field can be absent or contain null values

Range Constraints: For numeric fields, minimum and maximum acceptable values

A well-designed schema acts as a contract between data producers and consumers. Producers agree to send data conforming to the schema; consumers can rely on the data meeting those guarantees.


Types of Validation Errors

Schema validation catches several categories of problems:

Missing Columns

A column defined in the schema does not exist in the record at all. This might happen when:

Error format: "{column}: missing"

Null Values in Non-Nullable Columns

The column exists but contains a null value when the schema says nulls are not allowed. This might happen when:

Error format: "{column}: null"

Type Mismatches

The value exists but has the wrong type. For example, a string "abc" in a column expecting an integer, or a boolean where a float is expected. This might happen when:

Error format: "{column}: expected {type}, got {actual_type}"

Out-of-Range Values

The value has the correct type but falls outside acceptable bounds. For example, a negative age or a percentage above 100. This might happen when:

Error format: "{column}: out of range"


Validation Order and Short-Circuiting

Validation checks are performed in a specific order for each column:

Step 1: Check if column exists If the column is missing from the record, report "missing" error and skip all further checks for this column.

Step 2: Check for null (if column is not nullable) If the value is null and the column does not allow nulls, report "null" error and skip further checks.

Step 3: Check type If the value type does not match the expected type, report "type" error and skip the range check.

Step 4: Check range (if bounds are specified) If the value falls outside [min, max], report "out of range" error.

This short-circuiting approach ensures:


Type Checking Details

Type validation has some nuances:

Integer Type ("int"): Accepts Python int values. Does NOT accept floats (even 1.0) or booleans (even though bool is a subclass of int in Python). Use type(value) == int rather than isinstance(value, int) to exclude booleans.

Float Type ("float"): Accepts both Python float and int values. This is because integers can be safely treated as floats mathematically. Use type(value) in (int, float).

String Type ("str"): Accepts Python str values only.

The distinction between type() and isinstance() matters because in Python, bool is a subclass of int. Using isinstance(True, int) returns True, which would incorrectly accept booleans in integer columns.


Handling Nullable Columns

When a column is marked as nullable:

This allows optional fields where missing data is acceptable and does not indicate an error.


Range Checking

Range checks apply only to numeric columns with defined bounds:

\text{valid} = (\text{min} \leq \text{value} \leq \text{max})

Both bounds are inclusive. A value exactly equal to min or max passes the check.

Range checks are only performed if:

  1. The column exists
  2. The value is not null
  3. The value has the correct type
  4. The schema defines min and/or max bounds for this column

If only min is defined, check value >= min. If only max is defined, check value <= max.


Processing Multiple Columns

For each record, columns are validated in schema order (the order columns appear in the schema definition). This deterministic ordering ensures consistent error reporting.

A single record can have errors in multiple columns. All columns are checked (subject to short-circuiting within each column), and all errors are collected.

The record is valid only if all columns pass all their applicable checks.


A Detailed Worked Example

Schema:

Record 1: {"user_id": 123, "age": 25, "score": 85.5, "name": "Alice"}

Validation:

Result: (0, True, [])

Record 2: {"user_id": "abc", "age": -5, "score": None, "name": "Bob"}

Validation:

Result: (1, False, ["user_id: expected int, got str", "age: out of range"])

Record 3: {"user_id": 456, "score": 75.0, "name": "Carol"}

Validation:

Result: (2, False, ["age: missing"])

Record 4: {"user_id": 789, "age": None, "score": 50, "name": ""}

Validation:

Result: (3, False, ["age: null"])


The Output Format

The validation function returns a list of tuples, one per record:

(\text{record\_index}, \text{is\_valid}, \text{errors})

This format allows downstream code to easily:


Why Schema Validation Matters

Early Detection: Catching problems at ingestion prevents them from spreading through the entire data ecosystem.

Clear Contracts: Schemas make data expectations explicit, reducing confusion between teams.

Debugging Aid: Specific error messages pinpoint exactly what is wrong, speeding up root cause analysis.

Data Quality Metrics: Tracking validation pass rates over time reveals trends in upstream data quality.

Compliance: Many regulations require demonstrating data integrity, which schema validation supports.


Where Schema Validation Shows Up

ETL Pipelines: Every major ETL framework (Apache Spark, Apache Beam, dbt, Airflow) supports schema validation as a core feature.

API Gateways: Incoming API requests are validated against OpenAPI/Swagger schemas before processing.

Data Warehouses: Modern warehouses (Snowflake, BigQuery, Redshift) can enforce schema constraints at load time.

Event Streaming: Kafka Schema Registry ensures that event producers and consumers agree on data formats.

Machine Learning Pipelines: Feature engineering code validates inputs before computing features to prevent garbage-in-garbage-out.

Examples

Example 1

Input
records = [{"name": "Alice", "age": 30, "score": 95.5}, {"name": "Bob", "age": 25, "score": 88}], schema = [{"column": "name", "type": "str", "nullable": false}, {"column": "age", "type": "int", "nullable": false, "min": 0, "max": 150}, {"column": "score", "type": "float", "nullable": false, "min": 0, "max": 100}]
Output
[{"record_index": 0, "is_valid": true, "errors": []}, {"record_index": 1, "is_valid": true, "errors": []}]
Explanation
Both records contain every field with accepted types and ranges.

Example 2

Input
records = [{"name": "Alice", "age": "thirty", "score": 95.5}], schema = [{"column": "name", "type": "str", "nullable": false}, {"column": "age", "type": "int", "nullable": false, "min": 0, "max": 150}, {"column": "score", "type": "float", "nullable": false, "min": 0, "max": 100}]
Output
[{"record_index": 0, "is_valid": false, "errors": ["age: expected int, got str"]}]

Hints

  1. Map each schema type name to a predicate using type(value).
  2. Use continue after missing, nullable-null, and wrong-type cases to avoid extra errors.

Requirements

Constraints

Starter Code

def validate_records(records: list, schema: list) -> list:
    """
    Returns a list of result dictionaries.
    """
    # Write code here
    pass

Test Cases

CaseMatches
all validpublic
type errorpublic