Metadata Standards, Schema Governance & Data Provenance Contracts: Best Practices for Distributed Analytics Systems¶
Objective: Master production-grade metadata management, schema governance, and data provenance for distributed analytics ecosystems. When you need to ensure data quality, track lineage, enforce contracts, and maintain reproducibility across Postgres, Parquet, MLflow, and ETL pipelines—this guide provides the complete framework.
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:
- Schema Drift: A Parquet column type changes from
int32toint64, breaking ML inference pipelines that expectint32 - Lost Lineage: A data quality issue is discovered, but you can't trace which ETL job or source data caused it
- Version Confusion: An ML model is trained on
roads_v1, but production usesroads_v2with different schema - Contract Violations: An API consumer expects
risk_level ∈ {low, medium, high}, but receives{1, 2, 3} - 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: Removedold_field, changedrisk_levelfrominttostring(BREAKING)1.0.0→1.1.0: Added optionalnew_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¶
- RDF/OWL Metadata Automation - Dynamic knowledge graphs
- Data Engineering Best Practices - ETL/ELT patterns
- GeoParquet Best Practices - Geospatial data formats
- PostgreSQL Best Practices - Database patterns
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.