Personal Growth

The Schema Evolution Playbook: Managing Data Schema Changes in Production ML Systems

ML pipelines depend on data schemas—the structure, types, and constraints of input data. When a data engineering team adds a column to a source table, changes a field type from string to integer, or renames a feature, the downstream ML pipeline can fail silently (producing wrong predictions) or loudly (crashing the training job). Unlike traditional software where schema changes are managed through database migrations, ML pipelines span multiple systems (data warehouse, feature store, model tr...

The Schema Evolution Playbook: Managing Data Schema Changes in Production ML Systems

The Schema Change Problem in ML Pipelines

ML pipelines depend on data schemas—the structure, types, and constraints of input data. When a data engineering team adds a column to a source table, changes a field type from string to integer, or renames a feature, the downstream ML pipeline can fail silently (producing wrong predictions) or loudly (crashing the training job). Unlike traditional software where schema changes are managed through database migrations, ML pipelines span multiple systems (data warehouse, feature store, model training, inference API) where a single schema change can cascade through the entire stack.

This playbook provides a systematic approach to managing schema evolution in production ML systems, covering detection, validation, backward compatibility, and automated migration.

Phase 1: Schema Registry

Central Schema Definition

# schema_registry/definitions.py — Central schema definitions for ML pipelines
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Any
from enum import Enum
import json
from datetime import datetime

class FieldType(Enum):
    INT32 = "int32"
    INT64 = "int64"
    FLOAT32 = "float32"
    FLOAT64 = "float64"
    STRING = "string"
    BOOLEAN = "boolean"
    TIMESTAMP = "timestamp"
    ARRAY = "array"
    STRUCT = "struct"

class CompatibilityLevel(Enum):
    BACKWARD = "backward"         # New schema can read old data
    FORWARD = "forward"           # Old schema can read new data
    FULL = "full"                 # Both backward and forward compatible
    NONE = "none"                 # No compatibility guarantees

@dataclass
class FieldSchema:
    name: str
    field_type: FieldType
    nullable: bool = True
    default_value: Any = None
    description: str = ""
    metadata: Dict[str, str] = field(default_factory=dict)
    # For ML-specific metadata
    feature_type: Optional[str] = None  # "numeric", "categorical", "text", "embedding"
    is_target: bool = False
    is_key: bool = False

@dataclass
class DatasetSchema:
    name: str
    version: str
    fields: List[FieldSchema]
    compatibility: CompatibilityLevel = CompatibilityLevel.BACKWARD
    created_at: str = ""
    description: str = ""
    owner: str = ""
    
    def __post_init__(self):
        if not self.created_at:
            self.created_at = datetime.utcnow().isoformat()
    
    def get_field(self, name: str) -> Optional[FieldSchema]:
        return next((f for f in self.fields if f.name == name), None)
    
    def to_dict(self) -> dict:
        return {
            "name": self.name,
            "version": self.version,
            "compatibility": self.compatibility.value,
            "fields": [
                {
                    "name": f.name,
                    "type": f.field_type.value,
                    "nullable": f.nullable,
                    "default": f.default_value,
                    "feature_type": f.feature_type,
                    "is_target": f.is_target,
                    "is_key": f.is_key,
                }
                for f in self.fields
            ],
        }


class SchemaRegistry:
    """
    Central registry for all dataset schemas in the ML platform.
    Tracks schema versions, validates changes, and enforces compatibility.
    """
    
    def __init__(self, storage_path: str = "schemas/"):
        self.storage_path = storage_path
        self.schemas: Dict[str, List[DatasetSchema]] = {}  # name -> versions
        self._load_schemas()
    
    def register_schema(self, schema: DatasetSchema) -> bool:
        """
        Register a new schema version. Validates compatibility with previous version.
        Returns True if registered, False if compatibility check fails.
        """
        name = schema.name
        
        if name in self.schemas and self.schemas[name]:
            previous = self.schemas[name][-1]
            
            # Validate compatibility
            compatibility_report = self.check_compatibility(previous, schema)
            
            if not compatibility_report["compatible"]:
                print(f"❌ Schema change is NOT compatible:")
                for issue in compatibility_report["issues"]:
                    print(f"   - {issue}")
                return False
            
            print(f"✅ Schema change is compatible ({schema.compatibility.value})")
            for change in compatibility_report["changes"]:
                print(f"   - {change}")
        
        if name not in self.schemas:
            self.schemas[name] = []
        
        self.schemas[name].append(schema)
        self._save_schema(schema)
        
        return True
    
    def check_compatibility(self, old_schema: DatasetSchema, 
                            new_schema: DatasetSchema) -> dict:
        """Check if new schema is compatible with old schema."""
        issues = []
        changes = []
        
        old_fields = {f.name: f for f in old_schema.fields}
        new_fields = {f.name: f for f in new_schema.fields}
        
        # Check for removed fields
        removed = set(old_fields.keys()) - set(new_fields.keys())
        for field_name in removed:
            old_field = old_fields[field_name]
            if old_field.is_target or old_field.is_key:
                issues.append(f"Cannot remove target/key field: {field_name}")
            elif new_schema.compatibility in [CompatibilityLevel.BACKWARD, CompatibilityLevel.FULL]:
                # Backward compatible: removed fields must have been nullable or have defaults
                if not old_field.nullable and old_field.default_value is None:
                    issues.append(
                        f"Removed non-nullable field without default: {field_name}"
                    )
                else:
                    changes.append(f"Removed field: {field_name} (safe — was nullable)")
        
        # Check for added fields
        added = set(new_fields.keys()) - set(old_fields.keys())
        for field_name in added:
            new_field = new_fields[field_name]
            if new_schema.compatibility in [CompatibilityLevel.FORWARD, CompatibilityLevel.FULL]:
                if not new_field.nullable and new_field.default_value is None:
                    issues.append(
                        f"Added non-nullable field without default: {field_name}"
                    )
                else:
                    changes.append(f"Added field: {field_name} (nullable with default)")
            else:
                changes.append(f"Added field: {field_name}")
        
        # Check for type changes
        common = set(old_fields.keys()) & set(new_fields.keys())
        for field_name in common:
            old_field = old_fields[field_name]
            new_field = new_fields[field_name]
            
            if old_field.field_type != new_field.field_type:
                if self._is_safe_type_promotion(old_field.field_type, new_field.field_type):
                    changes.append(
                        f"Type promotion: {field_name} "
                        f"{old_field.field_type.value} → {new_field.field_type.value}"
                    )
                else:
                    issues.append(
                        f"Unsafe type change: {field_name} "
                        f"{old_field.field_type.value} → {new_field.field_type.value}"
                    )
            
            # Check nullable changes
            if old_field.nullable and not new_field.nullable:
                issues.append(
                    f"Field {field_name} changed from nullable to non-nullable"
                )
        
        return {
            "compatible": len(issues) == 0,
            "issues": issues,
            "changes": changes,
            "fields_added": len(added),
            "fields_removed": len(removed),
            "fields_modified": len([f for f in common 
                                    if old_fields[f].field_type != new_fields[f].field_type]),
        }
    
    def _is_safe_type_promotion(self, old_type: FieldType, new_type: FieldType) -> bool:
        """Check if a type change is a safe widening promotion."""
        safe_promotions = {
            (FieldType.INT32, FieldType.INT64),
            (FieldType.INT32, FieldType.FLOAT64),
            (FieldType.INT64, FieldType.FLOAT64),
            (FieldType.FLOAT32, FieldType.FLOAT64),
        }
        return (old_type, new_type) in safe_promotions
    
    def _load_schemas(self):
        """Load schemas from storage."""
        pass  # Implementation depends on storage backend
    
    def _save_schema(self, schema: DatasetSchema):
        """Save schema to storage."""
        pass

Phase 2: Schema Validation in Pipelines

Runtime Schema Validation

# schema_validation/validator.py — Validate data against schemas at pipeline boundaries
import pandas as pd
import numpy as np
from typing import Dict, List, Optional
from great_expectations.core import ExpectationSuite
import great_expectations as ge

class MLDataValidator:
    """Validate data against registered schemas at pipeline boundaries."""
    
    def __init__(self, schema_registry: SchemaRegistry):
        self.registry = schema_registry
    
    def validate_dataframe(self, df: pd.DataFrame, schema_name: str,
                           schema_version: str = "latest") -> Dict:
        """
        Validate a DataFrame against a registered schema.
        Returns validation report with any issues found.
        """
        schema = self._get_schema(schema_name, schema_version)
        issues = []
        warnings = []
        
        # 1. Check all required fields are present
        for field in schema.fields:
            if field.name not in df.columns:
                if not field.nullable and field.default_value is None:
                    issues.append(f"Missing required field: {field.name}")
                else:
                    warnings.append(f"Missing optional field: {field.name}")
        
        # 2. Check field types
        for field in schema.fields:
            if field.name not in df.columns:
                continue
            
            actual_type = self._pandas_to_field_type(df[field.name].dtype)
            if actual_type != field.field_type:
                if not self._is_safe_type_promotion(field.field_type, actual_type):
                    issues.append(
                        f"Type mismatch for {field.name}: "
                        f"expected {field.field_type.value}, got {actual_type.value}"
                    )
        
        # 3. Check null constraints
        for field in schema.fields:
            if field.name not in df.columns:
                continue
            if not field.nullable:
                null_count = df[field.name].isnull().sum()
                if null_count > 0:
                    issues.append(
                        f"Non-nullable field {field.name} has {null_count} null values "
                        f"({null_count/len(df)*100:.1f}%)"
                    )
        
        # 4. Statistical validation (detect distribution shifts)
        for field in schema.fields:
            if field.name not in df.columns:
                continue
            if field.field_type in [FieldType.FLOAT32, FieldType.FLOAT64, FieldType.INT32, FieldType.INT64]:
                stats = self._compute_field_stats(df[field.name])
                field_stats_key = f"{schema_name}.{field.name}"
                
                # Compare against historical stats
                historical = self._get_historical_stats(field_stats_key)
                if historical:
                    drift_score = self._compute_drift_score(stats, historical)
                    if drift_score > 0.2:  # PSI threshold
                        warnings.append(
                            f"Possible distribution shift in {field.name}: "
                            f"PSI={drift_score:.3f}"
                        )
        
        # 5. Check for unexpected columns
        expected_fields = {f.name for f in schema.fields}
        unexpected = set(df.columns) - expected_fields
        if unexpected:
            warnings.append(f"Unexpected columns: {unexpected}")
        
        return {
            "valid": len(issues) == 0,
            "schema_name": schema_name,
            "schema_version": schema.version,
            "row_count": len(df),
            "issues": issues,
            "warnings": warnings,
        }
    
    def validate_at_boundary(self, df: pd.DataFrame, schema_name: str,
                              boundary: str) -> pd.DataFrame:
        """
        Validate data at a pipeline boundary (ingestion, feature store, model input).
        Raises an exception if validation fails.
        """
        report = self.validate_dataframe(df, schema_name)
        
        if not report["valid"]:
            error_msg = f"Schema validation failed at {boundary}:\n"
            error_msg += "\n".join(f"  ❌ {issue}" for issue in report["issues"])
            raise SchemaValidationError(error_msg)
        
        if report["warnings"]:
            for warning in report["warnings"]:
                print(f"  ⚠️ {warning}")
        
        return df
    
    def _pandas_to_field_type(self, dtype) -> FieldType:
        """Map pandas dtype to FieldType."""
        mapping = {
            "int32": FieldType.INT32,
            "int64": FieldType.INT64,
            "float32": FieldType.FLOAT32,
            "float64": FieldType.FLOAT64,
            "object": FieldType.STRING,
            "bool": FieldType.BOOLEAN,
            "datetime64[ns]": FieldType.TIMESTAMP,
        }
        return mapping.get(str(dtype), FieldType.STRING)
    
    def _compute_field_stats(self, series: pd.Series) -> dict:
        return {
            "mean": float(series.mean()),
            "std": float(series.std()),
            "min": float(series.min()),
            "max": float(series.max()),
            "null_pct": float(series.isnull().mean()),
        }


class SchemaValidationError(Exception):
    pass

Phase 3: Automated Schema Migration

Migration Framework

# schema_evolution/migrator.py — Automated schema migration for ML data
from typing import Callable, Dict, List
import pandas as pd

class SchemaMigrator:
    """
    Migrate data between schema versions.
    Handles field additions, removals, type changes, and renames.
    """
    
    def __init__(self):
        self.migrations: Dict[str, List[MigrationStep]] = {}
    
    def register_migration(self, schema_name: str, from_version: str,
                           to_version: str, steps: List['MigrationStep']):
        """Register a migration path between schema versions."""
        key = f"{schema_name}:{from_version}->{to_version}"
        self.migrations[key] = steps
    
    def migrate(self, df: pd.DataFrame, schema_name: str,
                from_version: str, to_version: str) -> pd.DataFrame:
        """Apply migration steps to transform data from one schema to another."""
        key = f"{schema_name}:{from_version}->{to_version}"
        
        if key not in self.migrations:
            raise ValueError(f"No migration registered for {key}")
        
        result = df.copy()
        
        for step in self.migrations[key]:
            result = step.apply(result)
            print(f"  Applied: {step.description}")
        
        return result


class MigrationStep:
    """Base class for schema migration steps."""
    
    def __init__(self, description: str):
        self.description = description
    
    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
        raise NotImplementedError


class AddFieldStep(MigrationStep):
    """Add a new field with a default value or computed expression."""
    
    def __init__(self, field_name: str, default_value=None,
                 compute_fn: Callable = None, description: str = ""):
        super().__init__(description or f"Add field: {field_name}")
        self.field_name = field_name
        self.default_value = default_value
        self.compute_fn = compute_fn
    
    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
        if self.compute_fn:
            df[self.field_name] = df.apply(self.compute_fn, axis=1)
        else:
            df[self.field_name] = self.default_value
        return df


class RemoveFieldStep(MigrationStep):
    """Remove a field from the schema."""
    
    def __init__(self, field_name: str):
        super().__init__(f"Remove field: {field_name}")
        self.field_name = field_name
    
    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
        if self.field_name in df.columns:
            df = df.drop(columns=[self.field_name])
        return df


class RenameFieldStep(MigrationStep):
    """Rename a field."""
    
    def __init__(self, old_name: str, new_name: str):
        super().__init__(f"Rename field: {old_name} → {new_name}")
        self.old_name = old_name
        self.new_name = new_name
    
    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
        if self.old_name in df.columns:
            df = df.rename(columns={self.old_name: self.new_name})
        return df


class CastFieldStep(MigrationStep):
    """Change a field's data type."""
    
    def __init__(self, field_name: str, new_type: str):
        super().__init__(f"Cast field: {field_name} → {new_type}")
        self.field_name = field_name
        self.new_type = new_type
    
    def apply(self, df: pd.DataFrame) -> pd.DataFrame:
        if self.field_name in df.columns:
            df[self.field_name] = df[self.field_name].astype(self.new_type)
        return df


# Example: Register migrations
migrator = SchemaMigrator()

migrator.register_migration(
    schema_name="customer_features",
    from_version="1.0",
    to_version="2.0",
    steps=[
        RenameFieldStep("cust_id", "customer_id"),
        AddFieldStep("customer_segment", compute_fn=lambda row: segment_customer(row)),
        AddFieldStep("lifetime_value", default_value=0.0),
        RemoveFieldStep("legacy_score"),
        CastFieldStep("age", "int32"),
    ],
)

Phase 4: CI/CD Integration

Schema Change Detection in Pull Requests

# .github/workflows/schema-validation.yml
name: Schema Change Validation

on:
  pull_request:
    paths:
      - 'schemas/**'
      - 'src/features/**'
      - 'pipelines/**'

jobs:
  validate-schema-changes:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for diff
      
      - name: Detect schema changes
        run: |
          git diff origin/main --name-only -- schemas/ > changed_schemas.txt
          echo "Changed schemas:"
          cat changed_schemas.txt
      
      - name: Validate schema compatibility
        run: |
          python scripts/validate_schema_changes.py \
            --changed-schemas changed_schemas.txt \
            --base-branch origin/main
      
      - name: Run data pipeline with new schema
        run: |
          python scripts/test_pipeline_with_schema.py \
            --schema-dir schemas/ \
            --test-data data/test/
      
      - name: Check model compatibility
        run: |
          python scripts/check_model_schema_compatibility.py \
            --new-schema schemas/customer_features_v2.json \
            --current-model mlflow://models/customer-churn/production
      
      - name: Post PR comment with impact analysis
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          path: schema_change_report.md

Conclusion

Schema evolution in ML systems requires a registry-based approach with compatibility enforcement at every pipeline boundary. The schema registry tracks all versions and validates changes against compatibility rules (backward, forward, or full). Runtime validation at ingestion, feature store, and model input boundaries catches schema drift before it causes silent prediction errors. Automated migrations handle field additions, removals, renames, and type changes. CI/CD integration catches incompatible schema changes in pull requests before they reach production. Teams that implement systematic schema management eliminate an entire category of ML pipeline failures—the "it worked yesterday but broke today because someone changed a column name" class of bugs that consume 15-20% of ML engineering time in organizations without schema governance.

Curious how strongly this pattern shows up for you?

Take the related personality test for a reflective percentage-based result.

Take the Determined Personality test

Digital books

Digital Books for Deeper Self-Awareness

My Traits Lab eBooks and workbooks related to personality growth.

Recommended resources

Recommended for Determined Personality

Further reading and tools related to this personality pattern.

Personality (MindTap Course List)
Books

Personality (MindTap Course List)

How would you describe your personality, or can you? Whatever your answer, this text will help you u... How would you describe your personality, or can you? Whatever your answer, this text will help you understand personality -- the qualities and traits that form every individual's distinctive character. You'll learn about theoretical explanations of personality, and about the research that illuminates how those theories are relevant in the world around you.

View Product
The Obsessive-Compulsive Personality Disorder Workbook: CBT Skills to Overcome Rigidity, Allow Imperfection, and Improve Your Relationships
Books

The Obsessive-Compulsive Personality Disorder Workbook: CBT Skills to Overcome Rigidity, Allow Imperfection, and Improve Your Relationships

Get unstuck from procrastination and perfectionism, improve your relationships, and find deeper mean... Get unstuck from procrastination and perfectionism, improve your relationships, and find deeper meaning in your life with this evidence-based workbook. If you have obsessive-compulsive personality disorder (OCPD), you may struggle with debilitating self-judgment, doubt and indecision, perfectionism, and an inability to finish tasks. You may fear situations where you don’t have complete control. And you may feel chronically frustrated and “stuck.” If OCPD has negatively affected your life an

View Product
Personality
Books

Personality

This proven text fuses the best of theory-based and research-based instruction to give readers an il... This proven text fuses the best of theory-based and research-based instruction to give readers an illuminating introduction to personality that is accessible and understandable. The author pairs ""theory, application, and assessment"" chapters with chapters that describe the research programs aligned with every major theoretical approach.

View Product

Disclosure: My Traits Lab may earn from qualifying purchases. Recommendations are educational resources, not medical or clinical advice.

Read more

Related articles