Skip to content

Metadata Standards, Schema Governance & Data Provenance Contracts: Best Practices for Distributed Analytics Systems

Introduction

Metadata, schema governance, and provenance are the foundation of trustworthy, reproducible analytics. Without them, data becomes a liability: schemas drift, lineage is lost, contracts are violated, and reproducibility fails. This guide provides a complete framework for building metadata-driven, contract-enforced, provenance-tracked data systems.

What This Guide Covers: - Unified metadata model (dataset, column, operational) - Data contracts (definition, types, enforcement) - Schema versioning strategies (SemVer, storage patterns) - Provenance and lineage tracking - Automated validation workflows - Metadata storage architecture - Data quality and SLA governance - Integration with existing systems

Prerequisites: - Understanding of data pipelines, ETL/ELT, and data storage formats - Familiarity with Postgres, Parquet, and data processing frameworks - Experience with version control and CI/CD

Why Metadata, Schema Governance & Provenance Are Critical

The Problem: Data Without Context

Modern analytics ecosystems are distributed: - Data sources: Postgres tables, Parquet files, object stores, APIs - Processing: Prefect workflows, Spark jobs, MLflow pipelines, DuckDB queries - Consumers: ML models, GIS applications, APIs, dashboards, reports

Without metadata and provenance: - Schema drift breaks downstream systems silently - Lineage is lost, making debugging and auditing impossible - Reproducibility fails when you can't identify which data version was used - Trust erodes when data quality is unknown - Governance breaks when changes aren't tracked or validated

The Cost of Poor Metadata

Real-World Failures:

  1. Schema Drift: A Parquet column type changes from int32 to int64, breaking ML inference pipelines that expect int32
  2. Lost Lineage: A data quality issue is discovered, but you can't trace which ETL job or source data caused it
  3. Version Confusion: An ML model is trained on roads_v1, but production uses roads_v2 with different schema
  4. Contract Violations: An API consumer expects risk_level ∈ {low, medium, high}, but receives {1, 2, 3}
  5. Spatial Reference Errors: GeoParquet files use different CRS without metadata, causing misaligned visualizations

The Value of Metadata-Driven Systems

With proper metadata and provenance: - Reproducibility: Know exactly which data version, schema version, and code version produced each result - Auditability: Trace data lineage from source to consumption - Quality Assurance: Automated validation catches schema drift and contract violations - Governance: Schema changes are reviewed, versioned, and tracked - Trust: Data consumers can verify schema, quality, and provenance before use

System Integration

graph TB
    subgraph Sources["Data Sources"]
        PG["PostgreSQL<br/>(PostGIS)"]
        Parquet["Parquet/GeoParquet<br/>(S3/MinIO)"]
        API["APIs"]
    end

    subgraph Metadata["Metadata Layer"]
        Registry["Metadata Registry<br/>(Postgres + JSON-LD)"]
        Contracts["Data Contracts<br/>(YAML/JSON)"]
        SchemaVers["Schema Versions<br/>(SemVer)"]
    end

    subgraph Processing["Processing Layer"]
        ETL["ETL Pipelines<br/>(Prefect, Spark)"]
        ML["ML Pipelines<br/>(MLflow, ONNX)"]
        GIS["GIS Processing<br/>(PostGIS, GeoPandas)"]
    end

    subgraph Provenance["Provenance Tracking"]
        Lineage["Lineage Graph<br/>(Postgres + JSON-LD)"]
        Events["Provenance Events<br/>(ETL logs, MLflow)"]
    end

    subgraph Validation["Validation Layer"]
        SchemaVal["Schema Validation<br/>(Great Expectations, Polars)"]
        ContractVal["Contract Validation<br/>(CI/CD, Prefect)"]
        QualityVal["Quality Checks<br/>(Null ratios, uniqueness)"]
    end

    subgraph Consumers["Data Consumers"]
        Models["ML Models<br/>(ONNX inference)"]
        Dashboards["Dashboards<br/>(Grafana, NiceGUI)"]
        APIs["APIs<br/>(FastAPI)"]
    end

    PG --> Registry
    Parquet --> Registry
    API --> Registry

    Registry --> Contracts
    Registry --> SchemaVers

    Sources --> ETL
    Sources --> ML
    Sources --> GIS

    ETL --> Lineage
    ML --> Lineage
    GIS --> Lineage

    ETL --> Events
    ML --> Events

    Lineage --> Registry
    Events --> Registry

    ETL --> SchemaVal
    ML --> SchemaVal

    SchemaVal --> ContractVal
    ContractVal --> QualityVal

    QualityVal --> Models
    QualityVal --> Dashboards
    QualityVal --> APIs

    Models --> Registry
    Dashboards --> Registry
    APIs --> Registry

Integration Points

GeoParquet: - Metadata embedded in Parquet file footer - CRS, spatial extents, column semantics - Version tracking via directory structure

PostgreSQL: - Schema versioning via schema names (roads_v1, roads_v2) - Metadata tables for dataset and column metadata - CHECK constraints for data quality

MLflow: - Model metadata references training data schema version - Provenance chain from data → model → inference

ETL Pipelines: - Prefect tasks record lineage events - Schema validation before/after transformations - Contract enforcement at pipeline boundaries

JSON-LD Dynamic Forms: - Schema-driven form generation - Validation against metadata contracts - Provenance recording for user inputs

Core Metadata Model

Unified Metadata Standard

A unified metadata model enables consistent governance across all data formats and systems.

Dataset-Level Metadata

Core Fields:

{
  "@context": "https://schema.org/",
  "@type": "Dataset",
  "identifier": "roads-v2",
  "name": "Road Network Dataset",
  "description": "Comprehensive road network data for the United States",
  "version": "2.1.0",
  "schemaVersion": "2.0.0",
  "owner": {
    "name": "Data Engineering Team",
    "email": "data-eng@example.com"
  },
  "tags": ["transportation", "infrastructure", "geospatial"],
  "domains": ["GIS", "ML", "Analytics"],
  "spatialExtent": {
    "type": "Polygon",
    "coordinates": [[[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]]],
    "crs": "EPSG:4326"
  },
  "temporalExtent": {
    "startDate": "2020-01-01T00:00:00Z",
    "endDate": "2024-12-31T23:59:59Z"
  },
  "dataQuality": {
    "completeness": 0.98,
    "accuracy": 0.95,
    "consistency": 0.97,
    "timeliness": 0.99
  },
  "accessLevel": "internal",
  "updateFrequency": "daily",
  "storage": {
    "type": "parquet",
    "location": "s3://data-lake/datasets/roads/v2/",
    "format": "geoparquet",
    "partitioning": ["year", "month"]
  },
  "provenance": {
    "parents": [
      {
        "identifier": "osm-roads-v1",
        "version": "1.5.0",
        "relationship": "derived_from"
      }
    ],
    "transformation": {
      "code": "git://github.com/org/etl-pipelines@abc123",
      "environment": "sha256:def456...",
      "executedBy": "prefect-job-789",
      "timestamp": "2024-01-15T10:30:00Z"
    }
  },
  "lineage": {
    "upstream": ["osm-roads-v1", "census-boundaries-v1"],
    "downstream": ["ml-risk-model-v3", "api-roads-v1"]
  },
  "operational": {
    "lastUpdated": "2024-01-15T10:30:00Z",
    "etlJobId": "prefect-job-789",
    "recordCount": 12500000,
    "fileCount": 365,
    "totalSizeBytes": 10737418240,
    "checksum": "sha256:abc123...",
    "validationStatus": "passed",
    "validationTimestamp": "2024-01-15T10:31:00Z"
  }
}

YAML Format:

# datasets/roads-v2/metadata.yaml
identifier: roads-v2
name: Road Network Dataset
description: Comprehensive road network data for the United States
version: 2.1.0
schemaVersion: 2.0.0

owner:
  name: Data Engineering Team
  email: data-eng@example.com

tags:
  - transportation
  - infrastructure
  - geospatial

domains:
  - GIS
  - ML
  - Analytics

spatialExtent:
  type: Polygon
  coordinates: [[[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]]]
  crs: EPSG:4326

temporalExtent:
  startDate: 2020-01-01T00:00:00Z
  endDate: 2024-12-31T23:59:59Z

dataQuality:
  completeness: 0.98
  accuracy: 0.95
  consistency: 0.97
  timeliness: 0.99

accessLevel: internal
updateFrequency: daily

storage:
  type: parquet
  location: s3://data-lake/datasets/roads/v2/
  format: geoparquet
  partitioning:
    - year
    - month

provenance:
  parents:
    - identifier: osm-roads-v1
      version: 1.5.0
      relationship: derived_from
  transformation:
    code: git://github.com/org/etl-pipelines@abc123
    environment: sha256:def456...
    executedBy: prefect-job-789
    timestamp: 2024-01-15T10:30:00Z

lineage:
  upstream:
    - osm-roads-v1
    - census-boundaries-v1
  downstream:
    - ml-risk-model-v3
    - api-roads-v1

operational:
  lastUpdated: 2024-01-15T10:30:00Z
  etlJobId: prefect-job-789
  recordCount: 12500000
  fileCount: 365
  totalSizeBytes: 10737418240
  checksum: sha256:abc123...
  validationStatus: passed
  validationTimestamp: 2024-01-15T10:31:00Z

Column-Level Metadata

Schema Definition:

{
  "schema": {
    "version": "2.0.0",
    "columns": [
      {
        "name": "road_id",
        "type": {
          "sql": "BIGINT",
          "parquet": "int64",
          "semantic": "identifier"
        },
        "description": "Unique identifier for each road segment",
        "nullable": false,
        "constraints": {
          "primaryKey": true,
          "unique": true
        },
        "validRange": null,
        "categoryMappings": null,
        "foreignKey": null,
        "geospatial": null
      },
      {
        "name": "road_name",
        "type": {
          "sql": "TEXT",
          "parquet": "string",
          "semantic": "name"
        },
        "description": "Official name of the road",
        "nullable": true,
        "constraints": {
          "maxLength": 255
        },
        "validRange": null,
        "categoryMappings": null,
        "foreignKey": null,
        "geospatial": null
      },
      {
        "name": "risk_level",
        "type": {
          "sql": "VARCHAR(20)",
          "parquet": "string",
          "semantic": "category"
        },
        "description": "Risk level classification",
        "nullable": false,
        "constraints": {
          "allowedValues": ["low", "medium", "high"]
        },
        "validRange": null,
        "categoryMappings": {
          "low": 1,
          "medium": 2,
          "high": 3
        },
        "foreignKey": null,
        "geospatial": null
      },
      {
        "name": "geometry",
        "type": {
          "sql": "GEOMETRY(LINESTRING, 4326)",
          "parquet": "binary",
          "semantic": "geometry"
        },
        "description": "Road segment geometry",
        "nullable": false,
        "constraints": {
          "spatialType": "LINESTRING",
          "crs": "EPSG:4326",
          "validGeometry": true
        },
        "validRange": null,
        "categoryMappings": null,
        "foreignKey": null,
        "geospatial": {
          "type": "LINESTRING",
          "crs": "EPSG:4326",
          "dimension": 2,
          "hasZ": false,
          "hasM": false
        }
      },
      {
        "name": "created_at",
        "type": {
          "sql": "TIMESTAMPTZ",
          "parquet": "timestamp[us, tz=UTC]",
          "semantic": "timestamp"
        },
        "description": "Record creation timestamp",
        "nullable": false,
        "constraints": {
          "timezone": "UTC"
        },
        "validRange": {
          "min": "2020-01-01T00:00:00Z",
          "max": "2024-12-31T23:59:59Z"
        },
        "categoryMappings": null,
        "foreignKey": null,
        "geospatial": null
      }
    ]
  }
}

Operational Metadata

Runtime Metrics:

{
  "operational": {
    "lastUpdated": "2024-01-15T10:30:00Z",
    "etlJobId": "prefect-job-789",
    "etlJobRunId": "prefect-run-abc123",
    "recordCount": 12500000,
    "fileCount": 365,
    "totalSizeBytes": 10737418240,
    "checksum": "sha256:abc123...",
    "validationStatus": "passed",
    "validationTimestamp": "2024-01-15T10:31:00Z",
    "qualityMetrics": {
      "nullRatios": {
        "road_name": 0.02,
        "risk_level": 0.0
      },
      "uniqueness": {
        "road_id": 1.0
      },
      "spatialValidity": {
        "geometry": 0.98
      },
      "valueDistribution": {
        "risk_level": {
          "low": 0.6,
          "medium": 0.3,
          "high": 0.1
        }
      }
    },
    "performance": {
      "ingestionDurationSeconds": 1200,
      "validationDurationSeconds": 45,
      "recordsPerSecond": 10416
    }
  }
}

JSON-LD Schema Template

Semantic Web Integration:

{
  "@context": {
    "schema": "https://schema.org/",
    "dc": "http://purl.org/dc/terms/",
    "prov": "http://www.w3.org/ns/prov#",
    "dqv": "http://www.w3.org/ns/dqv#"
  },
  "@type": "schema:Dataset",
  "@id": "https://data.example.com/datasets/roads-v2",
  "schema:identifier": "roads-v2",
  "schema:name": "Road Network Dataset",
  "schema:description": "Comprehensive road network data for the United States",
  "schema:version": "2.1.0",
  "schema:creator": {
    "@type": "schema:Organization",
    "schema:name": "Data Engineering Team"
  },
  "schema:keywords": ["transportation", "infrastructure", "geospatial"],
  "schema:spatialCoverage": {
    "@type": "schema:Place",
    "schema:geo": {
      "@type": "schema:GeoShape",
      "schema:polygon": "[[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]]"
    }
  },
  "schema:temporalCoverage": {
    "@type": "schema:DateTime",
    "schema:startDate": "2020-01-01T00:00:00Z",
    "schema:endDate": "2024-12-31T23:59:59Z"
  },
  "prov:wasDerivedFrom": {
    "@id": "https://data.example.com/datasets/osm-roads-v1",
    "@type": "prov:Entity"
  },
  "prov:wasGeneratedBy": {
    "@type": "prov:Activity",
    "prov:startedAtTime": "2024-01-15T10:30:00Z",
    "prov:used": "https://github.com/org/etl-pipelines@abc123"
  },
  "dqv:hasQualityMeasurement": {
    "@type": "dqv:QualityMeasurement",
    "dqv:completeness": 0.98,
    "dqv:accuracy": 0.95
  }
}

Data Contracts

What Is a Data Contract?

A data contract is a formal, versioned agreement between data producers and consumers that defines: - Schema: Column names, types, constraints - Required fields: Fields that must be present - Valid values: Allowed values for categorical fields - Constraints: Data type constraints, ranges, formats - Allowed breaking changes: What changes are allowed without version bump - Behavioral expectations: Business rules (e.g., "risk_level always ∈ {low, medium, high}") - SLA: Data delivery frequency, latency, availability - Ownership: Who owns the data, who can modify it

Contract Types

API Contract

Example:

# contracts/api-roads-v1.yaml
contract:
  identifier: api-roads-v1
  version: 1.0.0
  type: api
  owner: API Team
  consumers:
    - frontend-dashboard
    - mobile-app

  schema:
    version: 1.0.0
    requiredFields:
      - road_id
      - road_name
      - geometry
    fields:
      - name: road_id
        type: integer
        description: Unique road identifier
        nullable: false
      - name: road_name
        type: string
        description: Road name
        nullable: true
        maxLength: 255
      - name: risk_level
        type: string
        description: Risk level
        nullable: false
        allowedValues:
          - low
          - medium
          - high
      - name: geometry
        type: object
        description: GeoJSON geometry
        nullable: false
        format: geojson
        constraints:
          type: LineString
          crs: EPSG:4326

  behavioralExpectations:
    - field: risk_level
      rule: "Must be one of: low, medium, high"
      validation: enum

  sla:
    updateFrequency: daily
    latency: p95 < 100ms
    availability: 99.9%

  breakingChanges:
    allowed:
      - Adding new optional fields
      - Removing deprecated fields (with 30-day notice)
    notAllowed:
      - Changing field types
      - Removing required fields
      - Changing allowed values for categorical fields

ETL Contract

Example:

# contracts/etl-roads-v2.yaml
contract:
  identifier: etl-roads-v2
  version: 2.0.0
  type: etl
  owner: Data Engineering Team
  consumers:
    - ml-risk-model
    - gis-analysis

  schema:
    version: 2.0.0
    requiredFields:
      - road_id
      - geometry
      - risk_level
    fields:
      - name: road_id
        type:
          sql: BIGINT
          parquet: int64
        description: Unique road identifier
        nullable: false
        constraints:
          primaryKey: true
          unique: true
      - name: geometry
        type:
          sql: GEOMETRY(LINESTRING, 4326)
          parquet: binary
        description: Road geometry
        nullable: false
        constraints:
          spatialType: LINESTRING
          crs: EPSG:4326
          validGeometry: true
      - name: risk_level
        type:
          sql: VARCHAR(20)
          parquet: string
        description: Risk level
        nullable: false
        constraints:
          allowedValues: [low, medium, high]

  qualityRequirements:
    completeness:
      road_id: 1.0
      geometry: 1.0
      risk_level: 1.0
    uniqueness:
      road_id: 1.0
    spatialValidity:
      geometry: 0.98

  sla:
    updateFrequency: daily
    backfillWindow: 7 days
    correctionWorkflow: automated

  breakingChanges:
    allowed:
      - Adding new optional columns
      - Extending allowed values (backward compatible)
    notAllowed:
      - Changing column types
      - Removing columns
      - Changing required columns to optional

Geospatial Dataset Contract

Example:

# contracts/geospatial-roads-v2.yaml
contract:
  identifier: geospatial-roads-v2
  version: 2.0.0
  type: geospatial
  owner: GIS Team
  consumers:
    - map-visualization
    - spatial-analysis

  schema:
    version: 2.0.0
    format: geoparquet
    spatialReference:
      crs: EPSG:4326
      dimension: 2
      hasZ: false
      hasM: false
    geometryColumn: geometry
    geometryType: LINESTRING
    requiredFields:
      - road_id
      - geometry
    fields:
      - name: road_id
        type: int64
        description: Unique road identifier
        nullable: false
      - name: geometry
        type: binary
        description: Road geometry (WKB)
        nullable: false
        constraints:
          spatialType: LINESTRING
          crs: EPSG:4326

  spatialRequirements:
    extent:
      minX: -180
      minY: -90
      maxX: 180
      maxY: 90
    crsConsistency: true
    geometryValidity: 0.98

  sla:
    updateFrequency: daily
    spatialAccuracy: 10 meters
    coverage: complete

Model Input Contract

Example:

# contracts/ml-risk-model-input-v1.yaml
contract:
  identifier: ml-risk-model-input-v1
  version: 1.0.0
  type: model_input
  owner: ML Team
  model: risk-model-v3

  schema:
    version: 1.0.0
    requiredFields:
      - road_id
      - geometry
      - road_type
      - traffic_volume
    fields:
      - name: road_id
        type: int64
        description: Road identifier
        nullable: false
      - name: geometry
        type: binary
        description: Road geometry
        nullable: false
        constraints:
          spatialType: LINESTRING
          crs: EPSG:4326
      - name: road_type
        type: string
        description: Road type
        nullable: false
        constraints:
          allowedValues: [highway, arterial, local]
      - name: traffic_volume
        type: float64
        description: Average daily traffic
        nullable: false
        constraints:
          min: 0
          max: 100000

  preprocessing:
    - step: normalize_geometry
      description: Normalize geometry to EPSG:4326
    - step: encode_road_type
      description: One-hot encode road_type

  validation:
    - check: schema_match
      description: Verify schema matches contract
    - check: value_ranges
      description: Verify values within allowed ranges
    - check: spatial_validity
      description: Verify geometry is valid

Model Output Contract

Example:

# contracts/ml-risk-model-output-v1.yaml
contract:
  identifier: ml-risk-model-output-v1
  version: 1.0.0
  type: model_output
  owner: ML Team
  model: risk-model-v3

  schema:
    version: 1.0.0
    requiredFields:
      - road_id
      - risk_level
      - risk_score
      - confidence
    fields:
      - name: road_id
        type: int64
        description: Road identifier
        nullable: false
      - name: risk_level
        type: string
        description: Predicted risk level
        nullable: false
        constraints:
          allowedValues: [low, medium, high]
      - name: risk_score
        type: float64
        description: Risk score (0-1)
        nullable: false
        constraints:
          min: 0
          max: 1
      - name: confidence
        type: float64
        description: Prediction confidence (0-1)
        nullable: false
        constraints:
          min: 0
          max: 1

  behavioralExpectations:
    - field: risk_level
      rule: "Must match risk_score: low if < 0.33, medium if 0.33-0.66, high if > 0.66"
      validation: business_rule

Contract Enforcement

Automated Schema Validation

Great Expectations Example:

# validation/great_expectations/roads_v2_suite.py
import great_expectations as ge
from great_expectations.dataset import PandasDataset

def create_roads_v2_expectation_suite():
    """Create expectation suite for roads-v2 dataset"""
    suite = ge.ExpectationSuite("roads_v2")

    # Schema expectations
    suite.expect_column_to_exist("road_id")
    suite.expect_column_to_exist("road_name")
    suite.expect_column_to_exist("risk_level")
    suite.expect_column_to_exist("geometry")

    # Type expectations
    suite.expect_column_values_to_be_of_type("road_id", "int64")
    suite.expect_column_values_to_be_of_type("road_name", "object")
    suite.expect_column_values_to_be_of_type("risk_level", "object")

    # Constraint expectations
    suite.expect_column_values_to_be_unique("road_id")
    suite.expect_column_values_to_not_be_null("road_id")
    suite.expect_column_values_to_not_be_null("risk_level")
    suite.expect_column_values_to_be_in_set(
        "risk_level",
        ["low", "medium", "high"]
    )

    # Quality expectations
    suite.expect_column_values_to_not_be_null("road_name", mostly=0.98)
    suite.expect_column_value_lengths_to_be_between("road_name", 1, 255)

    return suite

Polars Validation:

# validation/polars/validate_roads_v2.py
import polars as pl
from typing import Dict, Any

def validate_roads_v2_schema(df: pl.DataFrame, contract: Dict[str, Any]) -> bool:
    """Validate DataFrame against contract schema"""
    schema = contract["schema"]

    # Check required fields
    required_fields = schema["requiredFields"]
    missing_fields = set(required_fields) - set(df.columns)
    if missing_fields:
        raise ValueError(f"Missing required fields: {missing_fields}")

    # Check field types
    for field in schema["fields"]:
        field_name = field["name"]
        if field_name not in df.columns:
            continue

        expected_type = field["type"]["parquet"]
        actual_type = str(df[field_name].dtype)

        # Type mapping
        type_map = {
            "int64": "Int64",
            "string": "Utf8",
            "float64": "Float64",
            "bool": "Boolean",
            "timestamp[us, tz=UTC]": "Datetime"
        }

        if type_map.get(expected_type) != actual_type:
            raise ValueError(
                f"Field {field_name}: expected {expected_type}, got {actual_type}"
            )

        # Check constraints
        if "constraints" in field:
            constraints = field["constraints"]

            # Check allowed values
            if "allowedValues" in constraints:
                allowed = set(constraints["allowedValues"])
                actual = set(df[field_name].unique().to_list())
                invalid = actual - allowed
                if invalid:
                    raise ValueError(
                        f"Field {field_name}: invalid values {invalid}"
                    )

            # Check nullability
            if not field.get("nullable", False):
                null_count = df[field_name].null_count()
                if null_count > 0:
                    raise ValueError(
                        f"Field {field_name}: {null_count} null values found"
                    )

    return True

Postgres CHECK Constraints:

-- Postgres schema with contract enforcement
CREATE TABLE roads_v2 (
    road_id BIGINT PRIMARY KEY,
    road_name TEXT,
    risk_level VARCHAR(20) NOT NULL,
    geometry GEOMETRY(LINESTRING, 4326) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    -- Contract enforcement via CHECK constraints
    CONSTRAINT risk_level_valid CHECK (risk_level IN ('low', 'medium', 'high')),
    CONSTRAINT road_name_length CHECK (LENGTH(road_name) <= 255),
    CONSTRAINT geometry_valid CHECK (ST_IsValid(geometry)),
    CONSTRAINT created_at_range CHECK (
        created_at >= '2020-01-01'::timestamptz AND
        created_at <= '2024-12-31'::timestamptz
    )
);

-- Index for performance
CREATE INDEX idx_roads_v2_risk_level ON roads_v2(risk_level);
CREATE INDEX idx_roads_v2_geometry ON roads_v2 USING GIST(geometry);

Prefect Validation Task:

# prefect/tasks/validate_contract.py
from prefect import task
import polars as pl
from validation.polars.validate_roads_v2 import validate_roads_v2_schema
import yaml

@task
def validate_data_contract(
    data_path: str,
    contract_path: str
) -> bool:
    """Validate data against contract"""
    # Load contract
    with open(contract_path, 'r') as f:
        contract = yaml.safe_load(f)

    # Load data
    df = pl.read_parquet(data_path)

    # Validate
    try:
        validate_roads_v2_schema(df, contract)
        return True
    except ValueError as e:
        raise ValueError(f"Contract validation failed: {e}")

CI/CD Validation:

# .github/workflows/validate-data-contract.yml
name: Validate Data Contract

on:
  pull_request:
    paths:
      - 'data/**'
      - 'contracts/**'

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          pip install polars pyyaml

      - name: Validate data contracts
        run: |
          python scripts/validate_contracts.py \
            --data-dir data/ \
            --contracts-dir contracts/

Schema Versioning Strategy

Semantic Versioning for Schemas

Version Format: MAJOR.MINOR.PATCH

  • MAJOR: Breaking changes (removed fields, type changes, constraint changes)
  • MINOR: Backward-compatible additions (new optional fields, extended allowed values)
  • PATCH: Metadata-only or constraint tweaks (description updates, documentation)

Examples:

  • 1.0.0 → 2.0.0: Removed old_field, changed risk_level from int to string (BREAKING)
  • 1.0.0 → 1.1.0: Added optional new_field (NON-BREAKING)
  • 1.0.0 → 1.0.1: Updated field description (NON-BREAKING)

Versioned Parquet/GeoParquet Directories

Directory Structure:

s3://data-lake/
  datasets/
    roads/
      v1/
        year=2020/
          month=01/
            roads-2020-01.parquet
        year=2020/
          month=02/
            roads-2020-02.parquet
        metadata.yaml
        schema.json
      v2/
        year=2020/
          month=01/
            roads-2020-01.parquet
        year=2020/
          month=02/
            roads-2020-02.parquet
        metadata.yaml
        schema.json
      latest -> v2/

Metadata File:

# datasets/roads/v2/metadata.yaml
identifier: roads-v2
version: 2.1.0
schemaVersion: 2.0.0
storage:
  location: s3://data-lake/datasets/roads/v2/
  format: geoparquet
  partitioning:
    - year
    - month

Versioned Postgres Schemas

Schema Naming Convention:

-- Create versioned schemas
CREATE SCHEMA roads_v1;
CREATE SCHEMA roads_v2;

-- Create tables in versioned schemas
CREATE TABLE roads_v1.roads (
    road_id BIGINT PRIMARY KEY,
    road_name TEXT,
    risk_level INT,  -- Old: integer
    geometry GEOMETRY(LINESTRING, 4326)
);

CREATE TABLE roads_v2.roads (
    road_id BIGINT PRIMARY KEY,
    road_name TEXT,
    risk_level VARCHAR(20),  -- New: string with allowed values
    geometry GEOMETRY(LINESTRING, 4326),
    created_at TIMESTAMPTZ  -- New: optional field
);

-- Create view for latest version
CREATE VIEW roads AS SELECT * FROM roads_v2.roads;

-- Migration script
INSERT INTO roads_v2.roads (road_id, road_name, risk_level, geometry, created_at)
SELECT 
    road_id,
    road_name,
    CASE risk_level
        WHEN 1 THEN 'low'
        WHEN 2 THEN 'medium'
        WHEN 3 THEN 'high'
        ELSE 'unknown'
    END::VARCHAR(20) AS risk_level,
    geometry,
    now() AS created_at
FROM roads_v1.roads;

Model-Bound Schema Versions

MLflow Model Metadata:

# mlflow/model_metadata.py
import mlflow
import mlflow.sklearn
from datetime import datetime

def log_model_with_schema(
    model,
    training_data_schema_version: str,
    contract_version: str
):
    """Log MLflow model with schema version metadata"""
    with mlflow.start_run():
        # Log model
        mlflow.sklearn.log_model(model, "model")

        # Log schema version metadata
        mlflow.log_param("training_data_schema_version", training_data_schema_version)
        mlflow.log_param("contract_version", contract_version)
        mlflow.log_param("schema_version", "2.0.0")

        # Log contract reference
        mlflow.log_artifact("contracts/ml-risk-model-input-v1.yaml")
        mlflow.log_artifact("contracts/ml-risk-model-output-v1.yaml")

        # Log provenance
        mlflow.log_param("training_data", "roads-v2")
        mlflow.log_param("training_data_version", "2.1.0")
        mlflow.log_param("code_version", "git://github.com/org/ml-models@abc123")

ONNX Inference Schema Verification:

# onnx/schema_verification.py
import onnxruntime as ort
import numpy as np
from validation.polars.validate_roads_v2 import validate_roads_v2_schema

def verify_input_schema(
    input_data: np.ndarray,
    model_path: str,
    contract_path: str
) -> bool:
    """Verify input data matches model's expected schema"""
    # Load model
    session = ort.InferenceSession(model_path)

    # Get expected input schema from model metadata
    input_meta = session.get_inputs()[0]
    expected_shape = input_meta.shape
    expected_type = input_meta.type

    # Verify input matches
    if input_data.shape != tuple(expected_shape):
        raise ValueError(
            f"Input shape mismatch: expected {expected_shape}, got {input_data.shape}"
        )

    # Load and validate against contract
    with open(contract_path, 'r') as f:
        contract = yaml.safe_load(f)

    # Validate data against contract
    validate_roads_v2_schema(input_data, contract)

    return True

Provenance & Lineage Tracking

Lineage Events

Event Structure:

{
  "eventId": "lineage-event-abc123",
  "timestamp": "2024-01-15T10:30:00Z",
  "eventType": "transformation",
  "inputs": [
    {
      "datasetId": "osm-roads-v1",
      "version": "1.5.0",
      "location": "s3://data-lake/datasets/osm-roads/v1/",
      "checksum": "sha256:def456..."
    }
  ],
  "outputs": [
    {
      "datasetId": "roads-v2",
      "version": "2.1.0",
      "location": "s3://data-lake/datasets/roads/v2/",
      "checksum": "sha256:abc123..."
    }
  ],
  "transformation": {
    "code": "git://github.com/org/etl-pipelines@abc123",
    "codeHash": "sha256:code123...",
    "environment": "sha256:env456...",
    "executedBy": "prefect-job-789",
    "executionId": "prefect-run-abc123"
  },
  "user": "data-eng@example.com",
  "cluster": "rke2-prod",
  "node": "worker-01"
}

Provenance Storage Methods

Postgres Provenance Tables

Schema:

-- Provenance event table
CREATE TABLE provenance_events (
    event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
    event_type VARCHAR(50) NOT NULL,
    transformation_code TEXT,
    transformation_code_hash TEXT,
    environment_hash TEXT,
    executed_by TEXT,
    execution_id TEXT,
    user_email TEXT,
    cluster_name TEXT,
    node_name TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Input/output relationships
CREATE TABLE provenance_relationships (
    relationship_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id UUID NOT NULL REFERENCES provenance_events(event_id),
    relationship_type VARCHAR(50) NOT NULL,  -- 'input' or 'output'
    dataset_id TEXT NOT NULL,
    dataset_version TEXT NOT NULL,
    location TEXT,
    checksum TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Indexes
CREATE INDEX idx_provenance_events_timestamp ON provenance_events(timestamp);
CREATE INDEX idx_provenance_events_event_type ON provenance_events(event_type);
CREATE INDEX idx_provenance_relationships_dataset ON provenance_relationships(dataset_id, dataset_version);
CREATE INDEX idx_provenance_relationships_event ON provenance_relationships(event_id);

Insert Lineage Event:

# provenance/postgres_lineage.py
import psycopg2
from datetime import datetime
import uuid

def record_lineage_event(
    inputs: list,
    outputs: list,
    transformation: dict,
    user: str,
    cluster: str,
    node: str
):
    """Record lineage event in Postgres"""
    conn = psycopg2.connect("postgresql://user:pass@localhost/metadata")
    cur = conn.cursor()

    event_id = uuid.uuid4()

    # Insert event
    cur.execute("""
        INSERT INTO provenance_events (
            event_id, timestamp, event_type,
            transformation_code, transformation_code_hash,
            environment_hash, executed_by, execution_id,
            user_email, cluster_name, node_name
        ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
    """, (
        event_id,
        datetime.utcnow(),
        "transformation",
        transformation["code"],
        transformation["codeHash"],
        transformation["environment"],
        transformation["executedBy"],
        transformation["executionId"],
        user,
        cluster,
        node
    ))

    # Insert input relationships
    for input_dataset in inputs:
        cur.execute("""
            INSERT INTO provenance_relationships (
                event_id, relationship_type,
                dataset_id, dataset_version,
                location, checksum
            ) VALUES (%s, %s, %s, %s, %s, %s)
        """, (
            event_id,
            "input",
            input_dataset["datasetId"],
            input_dataset["version"],
            input_dataset["location"],
            input_dataset["checksum"]
        ))

    # Insert output relationships
    for output_dataset in outputs:
        cur.execute("""
            INSERT INTO provenance_relationships (
                event_id, relationship_type,
                dataset_id, dataset_version,
                location, checksum
            ) VALUES (%s, %s, %s, %s, %s, %s)
        """, (
            event_id,
            "output",
            output_dataset["datasetId"],
            output_dataset["version"],
            output_dataset["location"],
            output_dataset["checksum"]
        ))

    conn.commit()
    cur.close()
    conn.close()

Parquet Lineage Files

Lineage Metadata in Parquet:

# provenance/parquet_lineage.py
import pyarrow.parquet as pq
import pyarrow as pa
import json

def embed_lineage_in_parquet(
    parquet_path: str,
    lineage_event: dict
):
    """Embed lineage metadata in Parquet file"""
    # Read existing Parquet file
    table = pq.read_table(parquet_path)

    # Add lineage metadata to custom metadata
    custom_metadata = {
        "lineage": json.dumps(lineage_event),
        "provenance": json.dumps({
            "inputs": lineage_event["inputs"],
            "transformation": lineage_event["transformation"]
        })
    }

    # Write with custom metadata
    pq.write_table(
        table,
        parquet_path,
        metadata_collector=lambda metadata: metadata.update(custom_metadata)
    )

JSON-LD Provenance Graphs

Provenance Graph:

{
  "@context": {
    "prov": "http://www.w3.org/ns/prov#",
    "schema": "https://schema.org/"
  },
  "@graph": [
    {
      "@id": "prov:Activity-transformation-abc123",
      "@type": "prov:Activity",
      "prov:startedAtTime": "2024-01-15T10:30:00Z",
      "prov:endedAtTime": "2024-01-15T10:50:00Z",
      "prov:used": {
        "@id": "prov:Entity-osm-roads-v1",
        "@type": "prov:Entity",
        "schema:identifier": "osm-roads-v1",
        "schema:version": "1.5.0"
      },
      "prov:wasAssociatedWith": {
        "@id": "prov:Agent-data-eng",
        "@type": "prov:Person",
        "schema:email": "data-eng@example.com"
      },
      "prov:generated": {
        "@id": "prov:Entity-roads-v2",
        "@type": "prov:Entity",
        "schema:identifier": "roads-v2",
        "schema:version": "2.1.0"
      }
    }
  ]
}

MLflow Metadata

MLflow Provenance:

# mlflow/provenance.py
import mlflow

def log_provenance(
    training_data: str,
    training_data_version: str,
    code_version: str,
    environment_hash: str
):
    """Log provenance to MLflow"""
    with mlflow.start_run():
        # Log data provenance
        mlflow.log_param("training_data", training_data)
        mlflow.log_param("training_data_version", training_data_version)
        mlflow.log_param("code_version", code_version)
        mlflow.log_param("environment_hash", environment_hash)

        # Log parent runs (if applicable)
        # mlflow.set_tag("mlflow.parentRunId", parent_run_id)

Prefect Lineage Integration

Prefect Task with Lineage:

# prefect/tasks/etl_with_lineage.py
from prefect import task, flow
from provenance.postgres_lineage import record_lineage_event

@task
def transform_roads(input_path: str, output_path: str):
    """Transform roads data with lineage tracking"""
    # Load input
    df = pl.read_parquet(input_path)

    # Transform
    df_transformed = df.with_columns([
        pl.col("risk_level").cast(pl.Utf8).alias("risk_level")
    ])

    # Write output
    df_transformed.write_parquet(output_path)

    # Record lineage
    record_lineage_event(
        inputs=[{
            "datasetId": "osm-roads-v1",
            "version": "1.5.0",
            "location": input_path,
            "checksum": compute_checksum(input_path)
        }],
        outputs=[{
            "datasetId": "roads-v2",
            "version": "2.1.0",
            "location": output_path,
            "checksum": compute_checksum(output_path)
        }],
        transformation={
            "code": "git://github.com/org/etl-pipelines@abc123",
            "codeHash": compute_code_hash(),
            "environment": compute_environment_hash(),
            "executedBy": "prefect-job-789",
            "executionId": prefect.context.get("run_id")
        },
        user="data-eng@example.com",
        cluster="rke2-prod",
        node="worker-01"
    )

    return output_path

@flow
def etl_roads_flow():
    """ETL flow with lineage tracking"""
    input_path = "s3://data-lake/datasets/osm-roads/v1/"
    output_path = "s3://data-lake/datasets/roads/v2/"

    transform_roads(input_path, output_path)

Querying Provenance

Trace Dataset Backward:

-- Find all upstream datasets for a given dataset
WITH RECURSIVE upstream_lineage AS (
    -- Base case: start with target dataset
    SELECT 
        pr.dataset_id,
        pr.dataset_version,
        pr.event_id,
        0 AS depth
    FROM provenance_relationships pr
    WHERE pr.dataset_id = 'roads-v2'
      AND pr.relationship_type = 'output'

    UNION

    -- Recursive case: find inputs to events that produced outputs
    SELECT 
        pr_input.dataset_id,
        pr_input.dataset_version,
        pr_input.event_id,
        ul.depth + 1
    FROM upstream_lineage ul
    JOIN provenance_relationships pr_output
        ON ul.event_id = pr_output.event_id
        AND pr_output.relationship_type = 'output'
    JOIN provenance_events pe
        ON pr_output.event_id = pe.event_id
    JOIN provenance_relationships pr_input
        ON pe.event_id = pr_input.event_id
        AND pr_input.relationship_type = 'input'
    WHERE ul.depth < 10  -- Limit recursion depth
)
SELECT DISTINCT
    dataset_id,
    dataset_version,
    depth
FROM upstream_lineage
ORDER BY depth, dataset_id;

Show All Downstream Dependencies:

-- Find all downstream datasets for a given dataset
WITH RECURSIVE downstream_lineage AS (
    -- Base case: start with source dataset
    SELECT 
        pr.dataset_id,
        pr.dataset_version,
        pr.event_id,
        0 AS depth
    FROM provenance_relationships pr
    WHERE pr.dataset_id = 'osm-roads-v1'
      AND pr.relationship_type = 'input'

    UNION

    -- Recursive case: find outputs from events that used inputs
    SELECT 
        pr_output.dataset_id,
        pr_output.dataset_version,
        pr_output.event_id,
        dl.depth + 1
    FROM downstream_lineage dl
    JOIN provenance_relationships pr_input
        ON dl.event_id = pr_input.event_id
        AND pr_input.relationship_type = 'input'
    JOIN provenance_events pe
        ON pr_input.event_id = pe.event_id
    JOIN provenance_relationships pr_output
        ON pe.event_id = pr_output.event_id
        AND pr_output.relationship_type = 'output'
    WHERE dl.depth < 10
)
SELECT DISTINCT
    dataset_id,
    dataset_version,
    depth
FROM downstream_lineage
ORDER BY depth, dataset_id;

Provenance DAG Visualization:

graph TD
    OSM["osm-roads-v1<br/>v1.5.0"] -->|input| T1[Transform<br/>ETL Job 789]
    Census["census-boundaries-v1<br/>v1.0.0"] -->|input| T1
    T1 -->|output| Roads["roads-v2<br/>v2.1.0"]
    Roads -->|input| ML[ML Training<br/>MLflow Run 456]
    ML -->|output| Model["risk-model-v3<br/>v3.0.0"]
    Roads -->|input| API[API Service<br/>FastAPI]
    Model -->|input| Inference[Inference<br/>ONNX Runtime]

Validation Workflows

Great Expectations Schema Enforcement

Expectation Suite:

# validation/great_expectations/roads_v2_suite.py
import great_expectations as ge
from great_expectations.dataset import PandasDataset

def create_roads_v2_expectation_suite():
    """Create comprehensive expectation suite"""
    suite = ge.ExpectationSuite("roads_v2")

    # Schema expectations
    suite.expect_table_column_count_to_equal(5)
    suite.expect_table_columns_to_match_ordered_list([
        "road_id", "road_name", "risk_level", "geometry", "created_at"
    ])

    # Column existence
    for col in ["road_id", "road_name", "risk_level", "geometry", "created_at"]:
        suite.expect_column_to_exist(col)

    # Type expectations
    suite.expect_column_values_to_be_of_type("road_id", "int64")
    suite.expect_column_values_to_be_of_type("road_name", "object")
    suite.expect_column_values_to_be_of_type("risk_level", "object")

    # Constraint expectations
    suite.expect_column_values_to_be_unique("road_id")
    suite.expect_column_values_to_not_be_null("road_id")
    suite.expect_column_values_to_not_be_null("risk_level")
    suite.expect_column_values_to_be_in_set(
        "risk_level",
        ["low", "medium", "high"]
    )

    # Quality expectations
    suite.expect_column_values_to_not_be_null("road_name", mostly=0.98)
    suite.expect_column_value_lengths_to_be_between("road_name", 1, 255)

    # Spatial expectations (custom expectation)
    suite.expect_column_values_to_be_valid_geometry("geometry")

    return suite

Validation Checkpoint:

# validation/great_expectations/checkpoints/roads_v2_checkpoint.py
from great_expectations.checkpoint import Checkpoint

checkpoint = Checkpoint(
    name="roads_v2_checkpoint",
    config_version=1,
    expectation_suite_name="roads_v2",
    validations=[
        {
            "batch_request": {
                "datasource_name": "s3_datasource",
                "data_asset_name": "roads_v2"
            }
        }
    ]
)

Polars/DuckDB Schema Validation

Polars Validation:

# validation/polars/validate_schema.py
import polars as pl
from typing import Dict, Any, List

def validate_schema(
    df: pl.DataFrame,
    contract: Dict[str, Any]
) -> tuple[bool, List[str]]:
    """Validate DataFrame against contract schema"""
    errors = []
    schema = contract["schema"]

    # Check required fields
    required_fields = set(schema["requiredFields"])
    actual_fields = set(df.columns)
    missing_fields = required_fields - actual_fields
    if missing_fields:
        errors.append(f"Missing required fields: {missing_fields}")

    # Check field types and constraints
    for field in schema["fields"]:
        field_name = field["name"]
        if field_name not in df.columns:
            continue

        # Type validation
        expected_type = field["type"]["parquet"]
        actual_type = str(df[field_name].dtype)
        type_map = {
            "int64": "Int64",
            "string": "Utf8",
            "float64": "Float64",
            "bool": "Boolean"
        }
        if type_map.get(expected_type) != actual_type:
            errors.append(
                f"Field {field_name}: type mismatch "
                f"(expected {expected_type}, got {actual_type})"
            )

        # Constraint validation
        if "constraints" in field:
            constraints = field["constraints"]

            # Allowed values
            if "allowedValues" in constraints:
                allowed = set(constraints["allowedValues"])
                actual = set(df[field_name].unique().to_list())
                invalid = actual - allowed
                if invalid:
                    errors.append(
                        f"Field {field_name}: invalid values {invalid}"
                    )

            # Nullability
            if not field.get("nullable", False):
                null_count = df[field_name].null_count()
                if null_count > 0:
                    errors.append(
                        f"Field {field_name}: {null_count} null values "
                        f"(not allowed)"
                    )

    return len(errors) == 0, errors

DuckDB Validation:

-- DuckDB schema validation
CREATE TABLE validation_results (
    field_name TEXT,
    check_type TEXT,
    status TEXT,
    message TEXT,
    checked_at TIMESTAMPTZ DEFAULT now()
);

-- Validate schema
INSERT INTO validation_results (field_name, check_type, status, message)
SELECT
    'road_id' AS field_name,
    'type_check' AS check_type,
    CASE
        WHEN typeof(road_id) = 'BIGINT' THEN 'pass'
        ELSE 'fail'
    END AS status,
    'Type check: ' || typeof(road_id) AS message
FROM roads_v2
LIMIT 1;

-- Validate constraints
INSERT INTO validation_results (field_name, check_type, status, message)
SELECT
    'risk_level' AS field_name,
    'allowed_values' AS check_type,
    CASE
        WHEN risk_level IN ('low', 'medium', 'high') THEN 'pass'
        ELSE 'fail'
    END AS status,
    'Allowed values check' AS message
FROM roads_v2
WHERE risk_level NOT IN ('low', 'medium', 'high')
LIMIT 1;

Postgres CHECK Constraints

Schema with Constraints:

-- Postgres table with comprehensive constraints
CREATE TABLE roads_v2 (
    road_id BIGINT PRIMARY KEY,
    road_name TEXT,
    risk_level VARCHAR(20) NOT NULL,
    geometry GEOMETRY(LINESTRING, 4326) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    -- Type constraints
    CONSTRAINT road_id_type CHECK (road_id IS NOT NULL),

    -- Value constraints
    CONSTRAINT risk_level_valid CHECK (risk_level IN ('low', 'medium', 'high')),
    CONSTRAINT road_name_length CHECK (LENGTH(road_name) <= 255),

    -- Spatial constraints
    CONSTRAINT geometry_valid CHECK (ST_IsValid(geometry)),
    CONSTRAINT geometry_type CHECK (ST_GeometryType(geometry) = 'ST_LineString'),
    CONSTRAINT geometry_crs CHECK (ST_SRID(geometry) = 4326),

    -- Temporal constraints
    CONSTRAINT created_at_range CHECK (
        created_at >= '2020-01-01'::timestamptz AND
        created_at <= '2024-12-31'::timestamptz
    )
);

-- Validation function
CREATE OR REPLACE FUNCTION validate_roads_v2()
RETURNS TABLE (
    field_name TEXT,
    check_type TEXT,
    status TEXT,
    message TEXT
) AS $$
BEGIN
    RETURN QUERY
    SELECT
        'risk_level'::TEXT,
        'allowed_values'::TEXT,
        CASE
            WHEN COUNT(*) FILTER (WHERE risk_level NOT IN ('low', 'medium', 'high')) = 0
            THEN 'pass'::TEXT
            ELSE 'fail'::TEXT
        END,
        'Allowed values check'::TEXT
    FROM roads_v2;
END;
$$ LANGUAGE plpgsql;

JSON Schema Validation

JSON Schema:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["road_id", "risk_level", "geometry"],
  "properties": {
    "road_id": {
      "type": "integer",
      "minimum": 1
    },
    "road_name": {
      "type": "string",
      "maxLength": 255
    },
    "risk_level": {
      "type": "string",
      "enum": ["low", "medium", "high"]
    },
    "geometry": {
      "type": "object",
      "required": ["type", "coordinates"],
      "properties": {
        "type": {
          "type": "string",
          "enum": ["LineString"]
        },
        "coordinates": {
          "type": "array",
          "items": {
            "type": "array",
            "items": {
              "type": "number"
            }
          }
        }
      }
    },
    "created_at": {
      "type": "string",
      "format": "date-time"
    }
  }
}

Python Validation:

# validation/json_schema/validate_json.py
import jsonschema
import json

def validate_json_against_schema(data: dict, schema_path: str) -> bool:
    """Validate JSON data against JSON Schema"""
    with open(schema_path, 'r') as f:
        schema = json.load(f)

    try:
        jsonschema.validate(instance=data, schema=schema)
        return True
    except jsonschema.ValidationError as e:
        raise ValueError(f"JSON validation failed: {e.message}")

Prefect Validation Tasks

Validation Flow:

# prefect/flows/validate_data_flow.py
from prefect import task, flow
from validation.polars.validate_schema import validate_schema
import yaml

@task
def load_contract(contract_path: str) -> dict:
    """Load data contract"""
    with open(contract_path, 'r') as f:
        return yaml.safe_load(f)

@task
def validate_data(data_path: str, contract: dict) -> tuple[bool, list]:
    """Validate data against contract"""
    df = pl.read_parquet(data_path)
    return validate_schema(df, contract)

@task
def quarantine_data(data_path: str, reason: str):
    """Move invalid data to quarantine"""
    # Move to quarantine bucket/table
    pass

@flow
def validate_data_flow(data_path: str, contract_path: str):
    """Data validation flow"""
    contract = load_contract(contract_path)
    is_valid, errors = validate_data(data_path, contract)

    if not is_valid:
        quarantine_data(data_path, "; ".join(errors))
        raise ValueError(f"Validation failed: {errors}")

    return True

CI/CD Validation Pipeline

GitHub Actions:

# .github/workflows/validate-data.yml
name: Validate Data Contracts

on:
  pull_request:
    paths:
      - 'data/**'
      - 'contracts/**'

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          pip install polars pyyaml jsonschema

      - name: Validate data contracts
        run: |
          python scripts/validate_contracts.py \
            --data-dir data/ \
            --contracts-dir contracts/ \
            --fail-on-error

Metadata & Provenance Storage Architecture

Unified Storage System

Architecture:

graph TB
    subgraph Storage["Metadata Storage"]
        PG["PostgreSQL<br/>Metadata Tables"]
        S3["S3/Object Store<br/>Metadata Bundles"]
        JSONLD["JSON-LD<br/>Semantic Layer"]
        ParquetMeta["Parquet<br/>Embedded Metadata"]
        MLflowMeta["MLflow<br/>Model Metadata"]
    end

    subgraph Registry["Metadata Registry"]
        API["Metadata API<br/>(FastAPI)"]
        Query["Query Interface<br/>(SQL + GraphQL)"]
    end

    subgraph Consumers["Consumers"]
        ETL["ETL Pipelines"]
        ML["ML Pipelines"]
        GIS["GIS Applications"]
        Dashboards["Dashboards"]
    end

    PG --> API
    S3 --> API
    JSONLD --> API
    ParquetMeta --> API
    MLflowMeta --> API

    API --> Query
    Query --> ETL
    Query --> ML
    Query --> GIS
    Query --> Dashboards

Postgres Metadata Tables

Schema:

-- Dataset metadata table
CREATE TABLE datasets (
    dataset_id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    version TEXT NOT NULL,
    schema_version TEXT NOT NULL,
    owner_email TEXT NOT NULL,
    tags TEXT[],
    domains TEXT[],
    spatial_extent JSONB,
    temporal_extent JSONB,
    data_quality JSONB,
    access_level TEXT NOT NULL,
    update_frequency TEXT,
    storage_location TEXT NOT NULL,
    storage_type TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Column metadata table
CREATE TABLE columns (
    column_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    dataset_id TEXT NOT NULL REFERENCES datasets(dataset_id),
    column_name TEXT NOT NULL,
    sql_type TEXT,
    parquet_type TEXT,
    semantic_type TEXT,
    description TEXT,
    nullable BOOLEAN NOT NULL DEFAULT true,
    constraints JSONB,
    valid_range JSONB,
    category_mappings JSONB,
    foreign_key JSONB,
    geospatial JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(dataset_id, column_name)
);

-- Operational metadata table
CREATE TABLE operational_metadata (
    operational_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    dataset_id TEXT NOT NULL REFERENCES datasets(dataset_id),
    last_updated TIMESTAMPTZ NOT NULL,
    etl_job_id TEXT,
    etl_job_run_id TEXT,
    record_count BIGINT,
    file_count INTEGER,
    total_size_bytes BIGINT,
    checksum TEXT,
    validation_status TEXT,
    validation_timestamp TIMESTAMPTZ,
    quality_metrics JSONB,
    performance_metrics JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Indexes
CREATE INDEX idx_datasets_version ON datasets(dataset_id, version);
CREATE INDEX idx_datasets_tags ON datasets USING GIN(tags);
CREATE INDEX idx_columns_dataset ON columns(dataset_id);
CREATE INDEX idx_operational_dataset ON operational_metadata(dataset_id, last_updated);

S3/Object Store Metadata Bundles

Directory Structure:

s3://metadata-registry/
  datasets/
    roads-v2/
      metadata.yaml
      schema.json
      contract.yaml
      lineage.jsonld
      quality-report.json
  schemas/
    roads-v2/
      v2.0.0/
        schema.json
        contract.yaml
      v2.1.0/
        schema.json
        contract.yaml
  contracts/
    api-roads-v1.yaml
    etl-roads-v2.yaml
    ml-risk-model-input-v1.yaml

JSON-LD Metadata Layer

Semantic Metadata Store:

{
  "@context": {
    "schema": "https://schema.org/",
    "dc": "http://purl.org/dc/terms/",
    "prov": "http://www.w3.org/ns/prov#"
  },
  "@graph": [
    {
      "@id": "https://data.example.com/datasets/roads-v2",
      "@type": "schema:Dataset",
      "schema:identifier": "roads-v2",
      "schema:version": "2.1.0",
      "schema:name": "Road Network Dataset",
      "prov:wasDerivedFrom": {
        "@id": "https://data.example.com/datasets/osm-roads-v1"
      }
    }
  ]
}

Parquet Embedded Metadata

Metadata in Parquet Footer:

# metadata/parquet_metadata.py
import pyarrow.parquet as pq
import pyarrow as pa
import json

def embed_metadata_in_parquet(
    parquet_path: str,
    metadata: dict
):
    """Embed metadata in Parquet file"""
    table = pq.read_table(parquet_path)

    # Add custom metadata
    custom_metadata = {
        "dataset_id": metadata["identifier"],
        "version": metadata["version"],
        "schema_version": metadata["schemaVersion"],
        "provenance": json.dumps(metadata["provenance"]),
        "quality": json.dumps(metadata["dataQuality"])
    }

    # Write with metadata
    pq.write_table(
        table,
        parquet_path,
        metadata_collector=lambda metadata: metadata.update(custom_metadata)
    )

MLflow Metadata Integration

Model Metadata:

# mlflow/metadata_integration.py
import mlflow

def log_dataset_metadata(
    dataset_id: str,
    dataset_version: str,
    schema_version: str
):
    """Log dataset metadata to MLflow"""
    mlflow.log_param("training_dataset_id", dataset_id)
    mlflow.log_param("training_dataset_version", dataset_version)
    mlflow.log_param("training_schema_version", schema_version)

    # Log contract reference
    mlflow.log_artifact("contracts/ml-risk-model-input-v1.yaml")

Metadata API

FastAPI Metadata Service:

# api/metadata_service.py
from fastapi import FastAPI, HTTPException
from typing import Optional
import psycopg2

app = FastAPI()

@app.get("/datasets/{dataset_id}")
def get_dataset_metadata(
    dataset_id: str,
    version: Optional[str] = None
):
    """Get dataset metadata"""
    conn = psycopg2.connect("postgresql://user:pass@localhost/metadata")
    cur = conn.cursor()

    if version:
        cur.execute("""
            SELECT * FROM datasets
            WHERE dataset_id = %s AND version = %s
        """, (dataset_id, version))
    else:
        cur.execute("""
            SELECT * FROM datasets
            WHERE dataset_id = %s
            ORDER BY version DESC
            LIMIT 1
        """, (dataset_id,))

    row = cur.fetchone()
    if not row:
        raise HTTPException(status_code=404, detail="Dataset not found")

    return {
        "dataset_id": row[0],
        "name": row[1],
        "version": row[3],
        "schema_version": row[4],
        # ... other fields
    }

@app.get("/datasets/{dataset_id}/lineage")
def get_dataset_lineage(dataset_id: str):
    """Get dataset lineage"""
    # Query provenance tables
    pass

Data Quality & SLA Governance

Required Metrics

Quality Metrics:

# governance/quality_requirements.yaml
qualityRequirements:
  completeness:
    road_id: 1.0
    geometry: 1.0
    risk_level: 1.0
    road_name: 0.98

  uniqueness:
    road_id: 1.0

  spatialValidity:
    geometry: 0.98

  valueDistribution:
    risk_level:
      low: [0.5, 0.7]
      medium: [0.2, 0.4]
      high: [0.05, 0.15]

  timeliness:
    maxAgeHours: 24

  accuracy:
    spatialAccuracyMeters: 10

Required Checks

Validation Checklist:

# governance/validation_checklist.py
VALIDATION_CHECKS = [
    {
        "name": "schema_validation",
        "description": "Verify schema matches contract",
        "required": True
    },
    {
        "name": "completeness_check",
        "description": "Verify required fields are not null",
        "required": True
    },
    {
        "name": "uniqueness_check",
        "description": "Verify primary key uniqueness",
        "required": True
    },
    {
        "name": "spatial_validity",
        "description": "Verify geometry validity",
        "required": True
    },
    {
        "name": "value_distribution",
        "description": "Verify value distribution within expected ranges",
        "required": False
    }
]

SLA Definitions

SLA Policy:

# governance/sla_policy.yaml
sla:
  updateFrequency: daily
  updateWindow: "00:00-06:00 UTC"
  backfillWindow: 7 days
  correctionWorkflow: automated
  errorBudget:
    availability: 0.99
    dataQuality: 0.95
    timeliness: 0.98
  consequences:
    - trigger: "availability < 0.99 for 3 consecutive days"
      action: "escalate to data engineering lead"
    - trigger: "dataQuality < 0.95"
      action: "quarantine dataset, notify consumers"

Review Workflow

Schema Change Review Process:

graph LR
    Propose["Propose Schema<br/>Change"] --> Review["Technical Review<br/>(Data Eng)"]
    Review -->|Approved| Contract["Update Contract<br/>(Version Bump)"]
    Review -->|Rejected| Propose
    Contract --> Test["Test in<br/>Staging"]
    Test -->|Pass| Deploy["Deploy to<br/>Production"]
    Test -->|Fail| Contract
    Deploy --> Notify["Notify<br/>Consumers"]

Integration With Existing Repo Content

PostGIS Ingestion

Metadata for PostGIS Tables:

-- PostGIS table with metadata
CREATE TABLE roads_v2 (
    road_id BIGINT PRIMARY KEY,
    road_name TEXT,
    risk_level VARCHAR(20) NOT NULL,
    geometry GEOMETRY(LINESTRING, 4326) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Add metadata comment
COMMENT ON TABLE roads_v2 IS 'Road network dataset v2.1.0';
COMMENT ON COLUMN roads_v2.geometry IS 'Road geometry in EPSG:4326';
COMMENT ON COLUMN roads_v2.risk_level IS 'Risk level: low, medium, or high';

-- Register in metadata registry
INSERT INTO datasets (
    dataset_id, name, version, schema_version,
    storage_location, storage_type
) VALUES (
    'roads-v2',
    'Road Network Dataset',
    '2.1.0',
    '2.0.0',
    'postgresql://localhost/mydb.roads_v2',
    'postgres'
);

Parquet FDWs

Metadata for Parquet FDW:

-- Foreign table with metadata
CREATE FOREIGN TABLE roads_v2_parquet (
    road_id BIGINT,
    road_name TEXT,
    risk_level VARCHAR(20),
    geometry BYTEA,
    created_at TIMESTAMPTZ
) SERVER parquet_s3_fdw
OPTIONS (
    filename 's3://data-lake/datasets/roads/v2/',
    format 'parquet'
);

-- Metadata registration
INSERT INTO datasets (
    dataset_id, name, version, schema_version,
    storage_location, storage_type
) VALUES (
    'roads-v2',
    'Road Network Dataset',
    '2.1.0',
    '2.0.0',
    's3://data-lake/datasets/roads/v2/',
    'parquet'
);

MLflow Model Metadata

Model with Schema Version:

# mlflow/model_with_metadata.py
import mlflow

def train_and_log_model(
    training_data: str,
    training_data_version: str,
    schema_version: str
):
    """Train model and log with metadata"""
    with mlflow.start_run():
        # Train model
        model = train_model(training_data)

        # Log model
        mlflow.sklearn.log_model(model, "model")

        # Log metadata
        mlflow.log_param("training_data", training_data)
        mlflow.log_param("training_data_version", training_data_version)
        mlflow.log_param("schema_version", schema_version)
        mlflow.log_param("contract_version", "ml-risk-model-input-v1")

        # Log contract
        mlflow.log_artifact("contracts/ml-risk-model-input-v1.yaml")

ONNX Inference Panels

Schema Verification in Inference:

# onnx/inference_with_schema.py
import onnxruntime as ort
from validation.polars.validate_schema import validate_schema

def infer_with_schema_verification(
    input_data: np.ndarray,
    model_path: str,
    contract_path: str
):
    """Run inference with schema verification"""
    # Verify input schema
    validate_schema(input_data, contract_path)

    # Load model
    session = ort.InferenceSession(model_path)

    # Run inference
    outputs = session.run(None, {"input": input_data})

    return outputs

Kafka/Timescale Pipelines

Provenance in Kafka:

# kafka/provenance_tracking.py
from kafka import KafkaProducer
import json

def produce_with_provenance(
    topic: str,
    data: dict,
    provenance: dict
):
    """Produce Kafka message with provenance"""
    producer = KafkaProducer(
        value_serializer=lambda v: json.dumps(v).encode('utf-8')
    )

    message = {
        "data": data,
        "provenance": provenance,
        "timestamp": datetime.utcnow().isoformat()
    }

    producer.send(topic, message)
    producer.flush()

JSON-LD Driven NiceGUI Forms

Schema-Driven Form Generation:

# nicegui/schema_driven_form.py
from nicegui import ui
import json

def create_form_from_schema(schema_path: str):
    """Create NiceGUI form from JSON-LD schema"""
    with open(schema_path, 'r') as f:
        schema = json.load(f)

    form_data = {}

    for field in schema["schema"]["columns"]:
        field_name = field["name"]
        field_type = field["type"]["semantic"]

        if field_type == "identifier":
            form_data[field_name] = ui.number(
                label=field["description"],
                value=0
            )
        elif field_type == "name":
            form_data[field_name] = ui.input(
                label=field["description"],
                placeholder="Enter value"
            )
        elif field_type == "category":
            allowed_values = field["constraints"]["allowedValues"]
            form_data[field_name] = ui.select(
                label=field["description"],
                options=allowed_values
            )

    return form_data

Anti-Patterns

No Owner for a Dataset

Symptom: Dataset exists but no one knows who owns it or who to contact for changes.

Why It's Dangerous: Changes can't be approved, issues can't be resolved, quality degrades.

How to Fix: Assign owner in metadata, create ownership review process.

Preventative Best Practice: Require owner assignment when creating dataset metadata.

"Schema by Accident"

Symptom: Schema evolves organically without documentation or versioning.

Why It's Dangerous: Schema drift breaks downstream systems, no way to track changes.

How to Fix: Document current schema, create version, establish change process.

Preventative Best Practice: Require schema documentation and versioning from the start.

No Versioning

Symptom: Datasets overwritten in place, no version history.

Why It's Dangerous: Can't reproduce past results, can't roll back changes.

How to Fix: Implement versioned storage (directories, schemas, tags).

Preventative Best Practice: Always version datasets and schemas.

Breaking Changes Pushed Silently

Symptom: Schema changes break consumers without notification.

Why It's Dangerous: Production systems fail, data quality issues go undetected.

How to Fix: Implement contract validation, versioning, and change notifications.

Preventative Best Practice: Require contract updates and consumer notification for breaking changes.

Silent Field Repurposing

Symptom: Field meaning changes without documentation or version bump.

Why It's Dangerous: Consumers use field incorrectly, results are wrong.

How to Fix: Document field semantics, version schema on semantic changes.

Preventative Best Practice: Treat semantic changes as breaking changes.

No Lineage Tracking

Symptom: Can't trace data from source to consumption.

Why It's Dangerous: Can't debug issues, can't audit data flow, can't ensure reproducibility.

How to Fix: Implement provenance tracking in ETL pipelines.

Preventative Best Practice: Record lineage for all transformations.

Mixing Spatial Reference Systems Without Metadata

Symptom: GeoParquet files use different CRS without recording it.

Why It's Dangerous: Spatial analysis produces wrong results, visualizations misaligned.

How to Fix: Record CRS in metadata, validate CRS consistency.

Preventative Best Practice: Always record CRS in metadata, validate on ingestion.

Storing Metadata Only in Code Comments

Symptom: Metadata exists only in code, not in searchable registry.

Why It's Dangerous: Metadata is hard to find, can't be queried, gets out of sync.

How to Fix: Move metadata to dedicated registry (Postgres, JSON-LD, etc.).

Preventative Best Practice: Store metadata in dedicated registry from the start.

Copy-Paste ETL Pipelines with No Provenance Trail

Symptom: ETL code copied without recording lineage.

Why It's Dangerous: Can't trace data flow, can't debug issues, can't ensure reproducibility.

How to Fix: Refactor to use shared libraries, record provenance in all pipelines.

Preventative Best Practice: Build provenance tracking into ETL framework.

Final Summary

Complete Metadata Checklist

Dataset Creation: - [ ] Dataset identifier assigned - [ ] Name and description documented - [ ] Owner assigned - [ ] Tags and domains defined - [ ] Spatial/temporal extents recorded - [ ] Schema version created - [ ] Contract defined - [ ] Storage location documented - [ ] Access level set - [ ] Update frequency defined

Schema Definition: - [ ] All columns documented - [ ] Types defined (SQL, Parquet, semantic) - [ ] Constraints specified - [ ] Valid ranges defined - [ ] Category mappings documented - [ ] Foreign keys identified - [ ] Geospatial metadata recorded

Provenance: - [ ] Input datasets identified - [ ] Transformation code versioned - [ ] Environment hash recorded - [ ] Execution metadata logged - [ ] Lineage events stored

Quality: - [ ] Quality metrics defined - [ ] Validation checks implemented - [ ] SLA defined - [ ] Monitoring configured

Data Contract Creation Checklist

Contract Definition: - [ ] Contract identifier assigned - [ ] Version set - [ ] Type defined (API, ETL, geospatial, model) - [ ] Owner assigned - [ ] Consumers identified

Schema Specification: - [ ] Required fields listed - [ ] Field types specified - [ ] Constraints defined - [ ] Valid values documented - [ ] Behavioral expectations specified

Enforcement: - [ ] Validation implemented - [ ] CI/CD checks configured - [ ] Quarantine process defined - [ ] Notification process established

Schema Review Workflow

Change Proposal: 1. Create schema change proposal (PR, issue, ADR) 2. Document breaking vs non-breaking changes 3. Identify affected consumers 4. Propose migration strategy

Review Process: 1. Technical review (data engineering team) 2. Consumer impact assessment 3. Contract update review 4. Testing in staging

Approval & Deployment: 1. Approve change 2. Update contract 3. Version schema 4. Deploy to production 5. Notify consumers

Readiness Scorecard

Metadata Maturity: - [ ] All datasets have metadata - [ ] All schemas are versioned - [ ] All contracts are defined - [ ] Provenance is tracked - [ ] Quality metrics are monitored

Governance: - [ ] Change process defined - [ ] Review workflow established - [ ] SLA policies in place - [ ] Monitoring configured

Integration: - [ ] ETL pipelines record provenance - [ ] ML models reference schema versions - [ ] APIs validate contracts - [ ] CI/CD enforces contracts

GitHub/GitLab Workflow Suggestion

Repository Structure:

data-governance/
├── datasets/
│   ├── roads-v2/
│   │   ├── metadata.yaml
│   │   ├── schema.json
│   │   └── lineage.jsonld
│   └── ...
├── contracts/
│   ├── api-roads-v1.yaml
│   ├── etl-roads-v2.yaml
│   └── ...
├── schemas/
│   ├── roads-v2/
│   │   ├── v2.0.0/
│   │   └── v2.1.0/
│   └── ...
└── validation/
    ├── great_expectations/
    ├── polars/
    └── ...

Workflow:

# .github/workflows/data-governance.yml
name: Data Governance

on:
  pull_request:
    paths:
      - 'datasets/**'
      - 'contracts/**'
      - 'schemas/**'

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Validate contracts
        run: python scripts/validate_contracts.py
      - name: Validate schemas
        run: python scripts/validate_schemas.py
      - name: Check metadata completeness
        run: python scripts/check_metadata.py

Future Extensibility

RDF/OWL Integration: - Extend JSON-LD metadata to full RDF - Use OWL for semantic relationships - Implement SHACL for constraint validation

SHACL Validation: - Define SHACL shapes for data contracts - Validate RDF data against shapes - Integrate with existing validation workflows

JSON-LD Forms: - Generate forms from JSON-LD schemas - Validate form inputs against contracts - Record form submissions with provenance

See Also


This guide provides a complete framework for metadata, schema governance, and data provenance. Start with metadata model and contracts, then implement validation and provenance tracking. The goal is trustworthy, reproducible, auditable data systems.