The Canonical Parquet Schema: A Data Dictionary for MRV

Every measurement, reporting, and verification pipeline eventually converges on one physical artifact: the table an auditor actually reads. This page is the authoritative column-by-column dictionary for that table — the canonical GeoParquet dataset referenced across the MRV Data Schema Reference discipline within the wider Pipeline Orchestration & Compliance Reference stack. It pins down the exact Arrow dtype, unit, nullability, and semantic contract for each field so that activity data, geometry, emission factors, computed CO2e, uncertainty, and lineage all survive a third-party verification without a single “what does this column mean?” clarifying email.

A schema is not documentation you write after the fact; it is a runtime contract you enforce on every write. The factors that populate emission_factor and factor_version are themselves versioned artifacts governed by versioning emission factor databases for reproducible MRV, and the identifiers that stitch this table back to its inputs are the raw material for MRV data lineage and provenance tracking. Treating the schema as canonical means one physical layout, one set of units, and one validation gate stand between an upstream transform and the registry — no per-project dialects, no silently-coerced columns, no ambiguity about whether co2e_tonnes is metric tonnes or short tons.

Canonical MRV GeoParquet schema grouped by column family, mapped to Hive-style storage partitions On the left, the canonical mrv_emissions table is drawn as seven stacked column families: identity (record_id, project_id), spatial in teal (geometry WKB, crs_epsg, area_ha), activity (period_start/end, activity_value, activity_unit), factor (factor_id, factor_version, emission_factor). The result, uncertainty and lineage families are drawn in amber to mark the audit-critical outputs: result (co2e_tonnes), uncertainty (uncertainty_pct, tier, method_id), and lineage (lineage_hash, ingested_at). An arrow labelled partitioned by links the table to a right-hand storage tree showing Hive-style directories keyed on country_iso, reporting_year and factor_version, terminating in Zstd-compressed part files that each carry file-level GeoParquet metadata. mrv_emissions · canonical GeoParquet Identity record_id · project_id Spatial geometry (WKB) · crs_epsg · area_ha Activity period_start / period_end · activity_value · activity_unit Factor factor_id · factor_version · emission_factor Result co2e_tonnes Uncertainty uncertainty_pct · tier · method_id Lineage lineage_hash · ingested_at partitioned by country_iso=BR/ reporting_year=2025/ factor_version=v3.2/ part-000.zstd.parquet file-level GeoParquet metadata geo: version 1.1.0 · primary_column bbox · crs (PROJJSON) · encoding WKB Hive-style keys are folder-encoded, never stored in the row group — predicate pushdown prunes whole directories before any read.

Why a Single Canonical Schema Exists

The alternative to a canonical schema is a proliferation of near-identical dialects, and every dialect is a place a verification stalls. Three failure patterns justify the discipline.

First, unit ambiguity is silent and expensive. A column named emissions with no declared unit is worthless to an auditor and dangerous to a downstream aggregation. Is it kilograms or tonnes? CO2 or CO2e? Per record or per hectare? When two feeds disagree by a factor of 1000 because one used kilograms, the error does not raise — it produces a plausible-looking total that fails reconciliation weeks later. The canonical schema fixes co2e_tonnes as metric tonnes of CO2 equivalent, full stop, and pairs activity_value with an explicit activity_unit string so the factor’s denominator is never inferred.

Second, nullability is a contract, not an accident. If lineage_hash is nullable, a record can reach the registry with no traceable provenance, and no gate will have stopped it. Declaring co2e_tonnes, crs_epsg, and lineage_hash as non-nullable turns “we forgot to compute it” into a write-time failure rather than an audit-time finding. The dtype system does the enforcement the code review would otherwise have to.

Third, schema drift breaks reproducibility. Parquet stores its schema in the file footer, so a column that quietly changes from int32 to int64, or a factor_version that migrates from string to dictionary encoding, produces files that a strict reader rejects and a lenient reader silently coerces. Both outcomes are unacceptable under ISO 14064-3, which requires that the same inputs regenerate the same outputs. Pinning the Arrow schema — dtypes, field order, and metadata — makes drift a caught exception.

The Core Column Dictionary

The table below is the authoritative definition of the canonical mrv_emissions dataset. Every field lists its Arrow (pyarrow) type, physical unit, whether nulls are permitted, and its semantic contract. Column families follow the SVG grouping: identity, spatial, activity, factor, result, uncertainty, and lineage.

Column Arrow dtype Unit Nullable Description
record_id string UUIDv7 no Stable primary key; time-ordered UUID for deterministic sort and idempotent upserts.
project_id string registry ID no Registry project identifier (e.g. Verra VCS project number) this record rolls up to.
geometry binary WKB no Reporting-unit polygon as Well-Known Binary, per the GeoParquet 1.1 geometry encoding.
crs_epsg int32 EPSG code no EPSG code of geometry; the file-level GeoParquet metadata carries the full PROJJSON.
area_ha float64 hectare no Planimetric area computed in an equal-area CRS so downstream density arithmetic is honest.
period_start timestamp[us, tz=UTC] UTC instant no Inclusive start of the monitoring period the activity data covers.
period_end timestamp[us, tz=UTC] UTC instant no Exclusive end of the monitoring period; must be strictly greater than period_start.
activity_value float64 see activity_unit no Measured activity-data quantity (e.g. hectares deforested, tonnes fuel combusted).
activity_unit string UCUM token no Unit of activity_value as a UCUM token (ha, t, MWh); the factor’s denominator.
factor_id string factor DB key no Foreign key into the versioned emission-factor database.
factor_version string semver no Semantic version of the factor set applied; also a partition key (see below).
emission_factor float64 tCO2e / activity_unit no Emission or removal factor; sign convention is negative for removals.
co2e_tonnes float64 tCO2e no Computed result: activity_value * emission_factor, in metric tonnes CO2 equivalent.
uncertainty_pct float64 percent yes Relative 95% confidence half-width on co2e_tonnes; null only for Tier 1 defaults.
tier int8 ordinal (1–3) no IPCC methodological tier governing the estimate’s rigour.
method_id string methodology key no Methodology identifier (e.g. VM0047) that authorised the calculation.
lineage_hash string hex digest no SHA-256 over input artifact hashes plus code version; the provenance anchor.
ingested_at timestamp[us, tz=UTC] UTC instant no Write-time stamp for temporal audit and idempotent backfill reconciliation.

Two conventions in this table are load-bearing. Timestamps are always microsecond-precision UTC-tagged — a naive local timestamp is a rejection, because reporting-period boundaries decide which credits fall in which vintage. And the emission_factor sign convention (negative for removals, positive for emissions) means co2e_tonnes can be summed directly across a project without branching on sequestration versus emission.

Partitioning, Metadata & Storage Conventions

Physical layout is part of the schema. The dataset is partitioned Hive-style on three keys chosen to match the dominant query patterns — a verifier pulls one project’s one reporting year under one factor version — so predicate pushdown prunes whole directories before a single row group is read. File-level GeoParquet metadata travels in each file’s footer so any Parquet reader can recover the geometry column and CRS without an external sidecar.

Convention Value Rationale
Partition key 1 country_iso (ISO 3166-1 alpha-2) Aligns with jurisdictional reporting boundaries and NDC accounting.
Partition key 2 reporting_year (int16) Vintage isolation; a re-issued year rewrites one partition, not the dataset.
Partition key 3 factor_version (semver) Lets a factor re-baseline land as a new partition, preserving the prior version.
Row-group size 128 MB target Balances predicate pushdown granularity against per-group metadata overhead.
Compression Zstd level 9 Best ratio for the mixed string/float columns; splittable for parallel readers.
Geometry encoding WKB, GeoParquet 1.1 File metadata geo key records version, primary_column, bbox, and PROJJSON CRS.
Statistics min/max per row group Enables bbox and period_start pushdown without opening full column chunks.
Column ordering identity → spatial → activity → factor → result → uncertainty → lineage Stable field order so schema-diffs are readable and dictionary IDs are reproducible.

Partition keys are folder-encoded and must not be duplicated as row columns — storing factor_version twice invites the folder and the column to disagree after a careless rewrite. The reader reconstructs them from the path.

The pyarrow Schema and Validation Gate

The schema below is the executable form of the dictionary. validate() is the gate every write passes through: it checks field presence, exact dtype, unit metadata, CRS, and the non-null contract, logs a structured event, and raises on any mismatch rather than coercing. Units and semantics ride in per-field Arrow metadata so they are serialized into the Parquet footer and recoverable by any consumer.

from __future__ import annotations

import pyarrow as pa
import pyarrow.parquet as pq
import structlog

log = structlog.get_logger()

# Per-field metadata carries the unit contract into the Parquet footer.
def _f(name: str, dtype: pa.DataType, unit: str, nullable: bool) -> pa.Field:
    return pa.field(name, dtype, nullable=nullable, metadata={b"unit": unit.encode()})

CANONICAL_MRV_SCHEMA = pa.schema([
    # identity
    _f("record_id", pa.string(), "uuidv7", False),
    _f("project_id", pa.string(), "registry_id", False),
    # spatial
    _f("geometry", pa.binary(), "wkb", False),
    _f("crs_epsg", pa.int32(), "epsg", False),
    _f("area_ha", pa.float64(), "ha", False),
    # activity
    _f("period_start", pa.timestamp("us", tz="UTC"), "utc", False),
    _f("period_end", pa.timestamp("us", tz="UTC"), "utc", False),
    _f("activity_value", pa.float64(), "activity_unit", False),
    _f("activity_unit", pa.string(), "ucum", False),
    # factor
    _f("factor_id", pa.string(), "factor_key", False),
    _f("factor_version", pa.string(), "semver", False),
    _f("emission_factor", pa.float64(), "tco2e_per_activity_unit", False),
    # result
    _f("co2e_tonnes", pa.float64(), "tco2e", False),
    # uncertainty
    _f("uncertainty_pct", pa.float64(), "percent", True),
    _f("tier", pa.int8(), "ipcc_tier", False),
    _f("method_id", pa.string(), "methodology_key", False),
    # lineage
    _f("lineage_hash", pa.string(), "sha256_hex", False),
    _f("ingested_at", pa.timestamp("us", tz="UTC"), "utc", False),
])

VALID_CRS_EPSG = {4326, 6933}  # WGS84 for storage, EQ-Earth (6933) for area math


class SchemaViolation(ValueError):
    """Raised when a table breaches the canonical MRV contract."""


def validate(table: pa.Table, *, strict_crs: bool = True) -> pa.Table:
    """Enforce the canonical MRV schema; raise on any structural mismatch."""
    issues: list[str] = []

    # 1. Field presence, order, dtype and nullability must match exactly.
    got = {f.name: f for f in table.schema}
    for expected in CANONICAL_MRV_SCHEMA:
        actual = got.get(expected.name)
        if actual is None:
            issues.append(f"missing_column:{expected.name}")
            continue
        if actual.type != expected.type:
            issues.append(f"dtype:{expected.name}:{actual.type}!={expected.type}")
        if actual.nullable and not expected.nullable:
            issues.append(f"nullability:{expected.name}:declared_nullable")

    # 2. Non-nullable columns must contain no nulls in the data itself.
    for expected in CANONICAL_MRV_SCHEMA:
        if not expected.nullable and expected.name in table.column_names:
            if table.column(expected.name).null_count > 0:
                issues.append(f"null_values:{expected.name}")

    # 3. CRS gate: every geometry must carry a whitelisted EPSG code.
    if strict_crs and "crs_epsg" in table.column_names:
        codes = set(table.column("crs_epsg").drop_null().unique().to_pylist())
        if not codes <= VALID_CRS_EPSG:
            issues.append(f"crs:{codes - VALID_CRS_EPSG}_not_in_{VALID_CRS_EPSG}")

    # 4. Unit contract: co2e_tonnes must be tagged tCO2e, never coerced.
    ef = table.schema.field("co2e_tonnes") if "co2e_tonnes" in table.column_names else None
    if ef is not None and (ef.metadata or {}).get(b"unit") != b"tco2e":
        issues.append("unit:co2e_tonnes_not_tagged_tco2e")

    if issues:
        log.error("mrv.schema.rejected", rows=table.num_rows, issues=issues)
        raise SchemaViolation("; ".join(issues))

    log.info("mrv.schema.validated", rows=table.num_rows,
             columns=table.num_columns, crs=sorted(codes) if strict_crs else None)
    return table.cast(CANONICAL_MRV_SCHEMA)


# GeoParquet file-level metadata written into the footer on export.
GEO_METADATA = {
    b"geo": (
        b'{"version":"1.1.0","primary_column":"geometry",'
        b'"columns":{"geometry":{"encoding":"WKB",'
        b'"geometry_types":["Polygon","MultiPolygon"],'
        b'"crs":"EPSG:4326","bbox":[-73.99,-33.75,-34.79,5.27]}}}'
    )
}


def write_partition(table: pa.Table, root: str) -> None:
    """Validate, then write a Zstd GeoParquet dataset partitioned Hive-style."""
    table = validate(table)
    table = table.replace_schema_metadata({**(table.schema.metadata or {}), **GEO_METADATA})
    pq.write_to_dataset(
        table, root_path=root,
        partition_cols=["country_iso", "reporting_year", "factor_version"],
        compression="zstd", compression_level=9,
        row_group_size=1_000_000,
    )
    log.info("mrv.partition.written", root=root, rows=table.num_rows)

The cast at the end of validate() is deliberate: once the structural checks pass, casting to the canonical schema pins dtypes and field order so no downstream writer can reintroduce drift. Note that country_iso and reporting_year are supplied as partition columns to write_to_dataset and encoded into the path — they are not part of the row-level schema above, which keeps the folder and the footer from ever disagreeing.

Mapping Columns to Compliance Evidence

Each column family answers a specific evidentiary need. Verifiers under ISO 14064-3 do not read prose; they trace a reported figure back to the record that produced it, and the canonical schema is designed so that trace is a single query. The mapping below is what makes the dataset a submission artifact.

Columns Standard & requirement Evidence provided
activity_value, activity_unit, period_start/end ISO 14064-3 § verification of activity data Quantified, unit-explicit, time-bounded activity record the auditor recomputes against.
factor_id, factor_version, method_id Verra VM0047 / VM-series methodology conformance Proof the applied factor and method are the registry-approved versioned artifacts.
co2e_tonnes, tier ISO 14064-3 conservativeness & reproducibility Deterministic result plus the IPCC tier declaring methodological rigour.
uncertainty_pct Verra uncertainty deduction; CSRD ESRS E1 estimation transparency Per-record confidence half-width feeding conservative deduction rules.
lineage_hash, ingested_at ISO 14064-3 audit trail; CSRD ESRS E1 traceability Content-addressable link from figure to inputs and code, with a write timestamp.
geometry, crs_epsg, area_ha Verra VM0047 spatial integrity & no double-counting Non-overlapping, area-honest reporting units in a declared CRS.

The uncertainty_pct column deserves emphasis. CSRD ESRS E1 expects disclosures to state estimation uncertainty rather than a single unqualified number, and the Verra VM-series ties a quantified deduction to that same value — the confidence envelopes produced by emission factor uncertainty mapping land in this column and flow straight into the deduction rule with no manual re-keying.

Production Integration

The schema sits at the join between transformation and submission, enforced on write. In an orchestrated pipeline the canonical table is produced and validated in a fixed ingest → diagnose → transform → validate → export → submit sequence:

  1. Ingest. Read upstream activity-data feeds and the resolved factor set, pinning the exact factor_version so the run is reproducible.
  2. Diagnose. Confirm every input geometry carries a machine-readable CRS and that monitoring-period boundaries are UTC-tagged and non-overlapping before any arithmetic.
  3. Transform. Compute co2e_tonnes and attach uncertainty_pct, tier, and method_id, then derive lineage_hash over the input artifact hashes and code version.
  4. Validate. Call validate() — a SchemaViolation fails the task loudly rather than shipping a coerced table; the structured log event is the first artifact an auditor sees.
  5. Export. Write with write_partition() so the Zstd GeoParquet lands under country_iso/reporting_year/factor_version with GeoParquet metadata in every footer.
  6. Submit. Hand the partitioned dataset to registry integration, where record_id and lineage_hash become the queryable spine of the audit trail.

Enforced this way, the canonical schema stops being a document that drifts from reality and becomes the contract the pipeline cannot violate silently. Every figure that reaches a registry ships with its geometry, its factor version, its uncertainty, and a hash that reconstructs how it was produced — which is precisely what turns a Parquet file into admissible carbon accounting evidence.