Skip to content
ExponaBLOG
APACHE SPARK & DATA ENGINEERING · DAY 039 / 100

Data quality checks: a practical Spark & Data Eng guide

Catch bad data before it spreads.

Expona Admin10 min read

Introduction

Bad data silently corrupts pipelines. By the time you discover a schema mismatch or missing value in production, it's already propagated downstream, failed dashboards, and broken decisions. Data quality checks: a practical Spark & Data Eng guide covers the defensive patterns we've used across trading platforms, e-commerce warehouses, and ride-hailing systems to catch problems early, quarantine bad records, and keep pipelines reliable.

Data quality isn't a luxury. It's the foundation of trust in every system that touches data. In this post, we'll walk through schema validation, value-level assertions, tooling choices, and quarantine strategies that actually work in production.

Schema checks — required columns and types

Schema mismatches are the most common upstream surprise. A CSV arrives with columns in the wrong order, a JSON feed adds an unexpected field, or a third-party database changes a column type without notice. By the time your pipeline crashes, you've lost time and trust.

Start with explicit schema definition. Rather than inferring schema from data, declare it upfront in your Spark code. This catches misalignment immediately.

python
from pyspark.sql.types import StructType, StructField, StringType, IntegerType, DoubleType, TimestampType

# Define explicit schema for order data
orders_schema = StructType([
    StructField("order_id", StringType(), nullable=False),
    StructField("customer_id", StringType(), nullable=False),
    StructField("order_date", TimestampType(), nullable=False),
    StructField("amount", DoubleType(), nullable=False),
    StructField("status", StringType(), nullable=False),
])

# Read with schema enforcement
orders_df = spark.read \
    .schema(orders_schema) \
    .option("mode", "failFast") \
    .csv("s3://bucket/orders.csv")

The failFast mode stops immediately on the first schema violation. If you need to be more lenient during development or testing, use permissive mode and validate separately, but always tighten mode in production.

Next, validate that required columns exist and no unexpected columns have appeared:

python
def validate_schema(df, required_columns, expected_columns=None):
    """Check for required and unexpected columns."""
    actual_columns = set(df.columns)
    required_set = set(required_columns)
    
    # Check required columns present
    missing = required_set - actual_columns
    if missing:
        raise ValueError(f"Missing required columns: {missing}")
    
    # Check for unexpected columns (schema drift)
    if expected_columns:
        expected_set = set(expected_columns)
        unexpected = actual_columns - expected_set
        if unexpected:
            raise ValueError(f"Unexpected columns detected (schema drift): {unexpected}")
    
    return True

# Usage
validate_schema(
    orders_df,
    required_columns=["order_id", "customer_id", "order_date", "amount", "status"],
    expected_columns=["order_id", "customer_id", "order_date", "amount", "status", "notes"]
)

This pattern catches both missing required columns and schema drift—when upstream adds fields you didn't expect. In mature systems, schema drift often signals an upstream change that needs communication.

Value checks — nulls, ranges, and uniqueness

Even with correct schemas, individual values can be invalid. Nulls in non-nullable fields, negative amounts, future dates, or duplicate IDs that should be unique. These slip through at scale and corrupt fact tables, revenue calculations, and identity models.

Start by auditing null counts per column:

python
def check_nulls(df, columns, max_null_percent=0.01):
    """
    Check null counts per column.
    Raise if any column exceeds max_null_percent (default 1%).
    """
    total_rows = df.count()
    
    issues = []
    for col in columns:
        null_count = df.filter(df[col].isNull()).count()
        null_pct = null_count / total_rows
        
        if null_pct > max_null_percent:
            issues.append(
                f"{col}: {null_count} nulls ({null_pct*100:.2f}%) exceeds {max_null_percent*100:.1f}%"
            )
    
    if issues:
        raise ValueError("Null checks failed:\n" + "\n".join(issues))
    
    return True

# Usage
check_nulls(
    orders_df,
    columns=["order_id", "customer_id", "amount"],
    max_null_percent=0.005
)

For value ranges, enforce business logic constraints:

python
def check_value_ranges(df, checks):
    """
    checks: dict of column -> (min_val, max_val) or list of conditions
    Example: {"amount": (0, 1_000_000), "discount_pct": (0, 100)}
    """
    issues = []
    
    for col, (min_val, max_val) in checks.items():
        invalid = df.filter((df[col] < min_val) | (df[col] > max_val))
        invalid_count = invalid.count()
        
        if invalid_count > 0:
            issues.append(f"{col}: {invalid_count} records outside [{min_val}, {max_val}]")
    
    if issues:
        raise ValueError("Range checks failed:\n" + "\n".join(issues))
    
    return True

# Usage
check_value_ranges(
    orders_df,
    checks={
        "amount": (0.01, 1_000_000),
        "discount_pct": (0, 100),
    }
)

For uniqueness, identify duplicates that shouldn't exist:

python
def check_uniqueness(df, columns, allow_duplicates=False):
    """Check that specified columns are unique (or count duplicates)."""
    if allow_duplicates:
        # Just report
        grouped = df.groupBy(columns).count()
        dupes = grouped.filter(grouped["count"] > 1)
        dupe_count = dupes.count()
        print(f"Found {dupe_count} duplicate groups in {columns}")
        return dupes
    else:
        # Strict: no duplicates allowed
        grouped = df.groupBy(columns).count()
        dupes = grouped.filter(grouped["count"] > 1)
        dupe_count = dupes.count()
        
        if dupe_count > 0:
            raise ValueError(f"Uniqueness check failed: {dupe_count} duplicate combinations in {columns}")
        
        return True

# Usage: order_id must be globally unique
check_uniqueness(orders_df, columns=["order_id"], allow_duplicates=False)

Tools — Great Expectations, Deequ, or dbt tests

Manual checks work for small teams, but they're labor-intensive and easy to forget. Professional data stacks use dedicated tools.

Great Expectations

Great Expectations gives you a declarative, reusable framework for data validation. Define expectations once, run them everywhere:

python
from great_expectations.dataset.sparkdf_dataset import SparkDFDataset

# Wrap Spark DataFrame
ge_df = SparkDFDataset(orders_df)

# Define expectations
expectations = [
    ge_df.expect_column_to_exist("order_id"),
    ge_df.expect_column_values_to_not_be_null("customer_id"),
    ge_df.expect_column_values_to_be_in_set("status", ["pending", "confirmed", "shipped", "delivered"]),
    ge_df.expect_column_values_to_be_between("amount", min_value=0, max_value=1_000_000),
]

# Run validation
results = ge_df.validate(expectations, result_format="SUMMARY")
print(results)

if not results["success"]:
    raise ValueError(f"Data quality check failed: {results}")

AWS Deequ

For teams deep in the AWS ecosystem, Deequ (from Amazon) is a lightweight PySpark library that scales validation across large datasets:

python
from deequ.checks import Check, CheckLevel
from deequ.verification import VerificationSuite, VerificationResult

# Define checks
checks = [
    Check(CheckLevel.Error, "Order Schema") \
        .hasSize(lambda x: x > 0) \
        .isComplete("order_id") \
        .isUnique("order_id"),
    
    Check(CheckLevel.Warning, "Order Values") \
        .isInRange("amount", 0, 1_000_000) \
        .isContainedIn("status", ["pending", "confirmed", "shipped", "delivered"]),
]

# Run verification
verification_result = VerificationSuite(spark) \
    .onData(orders_df) \
    .addChecks(checks) \
    .run()

# Handle results
if verification_result.status.name != "Success":
    print(verification_result.checkResults)

dbt tests

If you're already on dbt, leverage its testing framework. It's lighter weight and integrates naturally with your transformation DAG:

python
# models/staging/stg_orders.yml
version: 2

models:
  - name: stg_orders
    description: Raw orders staged for transformation
    columns:
      - name: order_id
        description: Unique order identifier
        tests:
          - unique
          - not_null
      - name: customer_id
        tests:
          - not_null
      - name: amount
        tests:
          - not_null
          - dbt_expectations.expect_column_values_to_be_of_type:
              column_type: numeric
          - dbt_expectations.expect_column_values_to_be_between:
              min_value: 0
              max_value: 1000000
      - name: status
        tests:
          - not_null
          - accepted_values:
              values: ['pending', 'confirmed', 'shipped', 'delivered']

Run tests as part of your CI/CD: dbt test will fail the pipeline if any assertion breaks.

Quarantine — route bad records to a separate table

When data fails validation, you have two choices: fail the entire pipeline or quarantine bad records for manual inspection. Production systems need both—fail fast for critical issues, but quarantine for inspection and recovery.

Implement a quarantine pattern:

python
def validate_and_split(df, validation_func):
    """
    Run validation_func on df.
    Return (good_df, bad_df, validation_report)
    """
    bad_records = []
    good_records = []
    
    # Validate row-by-row (for demonstration; optimize with UDFs for scale)
    for row in df.collect():
        try:
            validation_func(row)
            good_records.append(row)
        except Exception as e:
            bad_records.append((row, str(e)))
    
    good_df = spark.createDataFrame(good_records, schema=df.schema) if good_records else None
    
    # Create quarantine table with error messages
    if bad_records:
        quarantine_schema = StructType(
            list(df.schema.fields) + [
                StructField("error_message", StringType()),
                StructField("quarantine_timestamp", TimestampType()),
            ]
        )
        quarantine_rows = [
            (*row, error_msg, datetime.now())
            for row, error_msg in bad_records
        ]
        bad_df = spark.createDataFrame(quarantine_rows, schema=quarantine_schema)
    else:
        bad_df = None
    
    return good_df, bad_df, len(bad_records)

# Usage
def validate_order_row(row):
    """Custom validation for single row."""
    if row.amount < 0 or row.amount > 1_000_000:
        raise ValueError(f"Amount {row.amount} out of range")
    if row.status not in ["pending", "confirmed", "shipped", "delivered"]:
        raise ValueError(f"Invalid status: {row.status}")

good_orders, bad_orders, bad_count = validate_and_split(orders_df, validate_order_row)

# Write good records to production table
good_orders.write.mode("overwrite").parquet("s3://bucket/orders/good/")

# Write bad records to quarantine table for investigation
if bad_orders:
    bad_orders.write.mode("append").parquet("s3://bucket/orders/quarantine/")
    print(f"Quarantined {bad_count} bad records for review")

For higher scale, use a UDF-based approach:

python
from pyspark.sql.functions import udf, col, when
from pyspark.sql.types import BooleanType, StringType

def validate_order_udf(order_id, amount, status):
    """Validate single order, return (is_valid, error_message)."""
    if not order_id:
        return (False, "Missing order_id")
    if amount < 0 or amount > 1_000_000:
        return (False, f"Amount {amount} out of range")
    if status not in ["pending", "confirmed", "shipped", "delivered"]:
        return (False, f"Invalid status: {status}")
    return (True, None)

# Apply validation UDF
from pyspark.sql.functions import col, struct
validation_udf = udf(validate_order_udf, "struct")

validated_df = orders_df.withColumn(
    "validation",
    validation_udf(col("order_id"), col("amount"), col("status"))
)

good_df = validated_df.filter(col("validation.is_valid") == True).drop("validation")
bad_df = validated_df.filter(col("validation.is_valid") == False) \
    .withColumn("error_message", col("validation.error_message")) \
    .drop("validation")

good_df.write.mode("overwrite").parquet("s3://bucket/orders/good/")
bad_df.write.mode("append").parquet("s3://bucket/orders/quarantine/")

Store quarantine records with timestamps and error details. Set up a monitoring alert when quarantine volume spikes—it signals upstream issues.

Common mistakes

  • Skipping schema validation in development. "It works on my machine" leads to surprise failures in production. Always validate schema, even if you're reading from a trusted source.
  • Setting thresholds too tight. If your null check fails at 0.1% nulls but your data naturally has 0.5%, you'll get alert fatigue. Baseline your data first, then set thresholds that catch real anomalies.
  • Failing the entire pipeline on one bad record. For high-volume transactional systems, quarantine bad records and process good ones. Reserve hard failures for schema breaks or systematic failures.
  • Forgetting to validate after transformation. Data quality checks belong at data entry (source validation) and after each major transformation. A bad join or aggregation can corrupt clean input.
  • Not monitoring quarantine tables. A quarantine table that grows silently is worthless. Wire up metrics and alerts so you know when bad data is accumulating.

Key takeaways

  • Define schemas explicitly and validate on every read. Use failFast mode in production to catch schema mismatches immediately.
  • Implement row-level checks for nulls, ranges, and uniqueness. Use UDFs at scale to avoid expensive collect() calls.
  • Choose a validation tool—Great Expectations for flexibility, Deequ for AWS scale, or dbt tests for transformation-native workflows.
  • Quarantine bad records instead of failing the entire pipeline. Route them to a separate table with timestamps and error messages for investigation.
  • Monitor quarantine tables and alert on volume spikes. Silent data quality degradation is worse than pipeline failures.
  • Validate after every transformation, not just at the source. Bad logic upstream corrupts everything downstream.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles