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:
- 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.