ISO 14064 vs GHG Protocol vs CSRD for Spatial MRV
Most organisations reporting land-related emissions end up subject to more than one of these frameworks at once, and the instinct is to pick the strictest and satisfy the others by implication. That works for the headline number and fails everywhere else, because the three differ less in stringency than in what they require to be stated — boundaries, materiality, uncertainty, assurance, and comparability across periods. This guide sets out what each demands of a pipeline, within carbon registry standards and methodologies in the pipeline orchestration and compliance reference stack.
The distinction that organises the comparison is what each framework is for. The GHG Protocol is an accounting standard: it says how to draw a boundary and what to count inside it. ISO 14064 is a specification with guidance for quantification and, in its later parts, for the verification of the resulting assertion. CSRD, through ESRS E1, is a disclosure regulation: it says what must be published, in what structure, with what assurance, alongside financial statements. A pipeline can satisfy the first and still be unable to produce what the third asks for, because the third asks for things the first never mentions.
Root Cause Analysis
Three structural differences drive nearly all the integration work, and each one lands in a different part of the pipeline.
Boundaries are defined by different logic. The GHG Protocol draws its boundary around an organisation by control or equity share, then follows operational control outward through the value chain. CSRD’s boundary follows the financial consolidation scope, so that the sustainability statement covers the same entities as the accounts. These are often the same set and sometimes are not, and when they differ the difference shows up as a set of land parcels included in one report and excluded from the other. A pipeline that hard-codes one boundary cannot produce the other without a reprocessing run.
Materiality means two different things. In the GHG Protocol tradition, materiality is largely about whether an omission would mislead — a quantitative judgement about size. Under CSRD, materiality is double: an impact is material if it matters to the environment or to the enterprise’s financial position, assessed and documented separately. A land parcel with small absolute emissions can be material under the impact limb and immaterial under the financial one, and the assessment itself is a disclosure. Pipelines rarely model this at all, and it is the most common source of scrambling in the first CSRD cycle.
Assurance changes what evidence must exist. A voluntary report checked by a consultant and a limited-assurance opinion attached to a financial filing place different demands on the underlying records. Assurance providers test controls, sample transactions, and trace figures back to source; they need lineage they can follow without the pipeline’s authors present. That requirement is a property of the evidence store rather than of the calculation, and it is where a scientifically excellent pipeline most often falls short.
The connecting theme is that these frameworks agree substantially on the arithmetic and diverge on the record. A pipeline designed around producing numbers will satisfy the arithmetic and struggle with everything else.
Diagnostic Pipeline / Pre-Flight Validation
The most useful pre-flight check is a coverage matrix: for each framework in scope, does the pipeline produce every required field, at the required granularity, with the required supporting record? Running it before a reporting cycle is considerably cheaper than discovering the gaps during one.
from dataclasses import dataclass
from enum import Enum
import structlog
log = structlog.get_logger()
class Framework(str, Enum):
GHG_PROTOCOL = "ghg_protocol"
ISO_14064 = "iso_14064"
CSRD_E1 = "csrd_esrs_e1"
@dataclass(frozen=True)
class Requirement:
"""One thing a framework requires the pipeline to be able to produce."""
req_id: str
framework: Framework
label: str
granularity: str # entity | site | parcel | category
needs_prior_period: bool
needs_assurance_trail: bool
REQUIREMENTS: tuple[Requirement, ...] = (
Requirement("gp-boundary", Framework.GHG_PROTOCOL,
"consolidation approach and entity list", "entity",
False, False),
Requirement("gp-basyear", Framework.GHG_PROTOCOL,
"base year with recalculation policy and trigger log",
"entity", True, False),
Requirement("gp-scope3", Framework.GHG_PROTOCOL,
"scope 3 category coverage with exclusions justified",
"category", False, False),
Requirement("iso-method", Framework.ISO_14064,
"documented quantification methodology per source",
"category", False, True),
Requirement("iso-uncert", Framework.ISO_14064,
"uncertainty assessment per significant source",
"category", False, True),
Requirement("iso-assert", Framework.ISO_14064,
"a GHG assertion signed by a responsible party",
"entity", False, True),
Requirement("e1-dm", Framework.CSRD_E1,
"double materiality assessment, impact and financial",
"site", False, True),
Requirement("e1-comparative", Framework.CSRD_E1,
"prior period comparative on the same basis", "category",
True, True),
Requirement("e1-restatement", Framework.CSRD_E1,
"restatement disclosure where prior figures moved",
"category", True, True),
Requirement("e1-tagging", Framework.CSRD_E1,
"machine-readable tagged values", "category", False, False),
)
@dataclass(frozen=True)
class Capability:
"""What the pipeline can actually produce today."""
req_id: str
produced: bool
granularity: str
has_prior_period: bool
has_lineage: bool
note: str = ""
def assess_coverage(
frameworks: frozenset[Framework], capabilities: dict[str, Capability]
) -> list[tuple[Requirement, str]]:
"""Gaps between what is required and what exists, before a cycle starts.
Granularity is checked as well as presence, because 'we report this'
frequently means 'we report this at entity level' when the framework
asks for it per site — and aggregating up is easy while disaggregating
after the fact is a reprocessing project.
"""
order = ["entity", "site", "parcel", "category"]
gaps: list[tuple[Requirement, str]] = []
for req in REQUIREMENTS:
if req.framework not in frameworks:
continue
cap = capabilities.get(req.req_id)
if cap is None or not cap.produced:
gaps.append((req, "not produced at all"))
continue
if order.index(cap.granularity) < order.index(req.granularity):
gaps.append((
req,
f"produced at {cap.granularity} level, required at "
f"{req.granularity} — disaggregation is a reprocessing job, "
"not a query",
))
if req.needs_prior_period and not cap.has_prior_period:
gaps.append((req, "no comparable prior-period figure on this basis"))
if req.needs_assurance_trail and not cap.has_lineage:
gaps.append((
req,
"no lineage an assurance provider can follow unaided",
))
for req, reason in gaps:
log.warning("coverage.gap", requirement=req.req_id,
framework=req.framework.value, reason=reason)
return gaps
The granularity comparison is the check that earns its place. Organisations routinely believe they report something because a number exists, and discover during assurance that the framework wanted it split by site or by category — a distinction that is trivial going up and expensive going down.
Deterministic Transformation Logic
Producing one set of figures that serves all three frameworks means computing at the finest granularity any of them requires and aggregating upward per framework, rather than computing per framework. The parcel-level figure is the shared substrate; each framework is a different projection of it.
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class ParcelFigure:
"""The atomic unit. Every framework's number is an aggregation of these.
Computing at this level once and projecting upward is what keeps three
reports consistent. Computing per framework guarantees they disagree,
and the disagreement is discovered by whoever reconciles them last.
"""
parcel_id: str
period_start: date
period_end: date
tco2e: float
uncertainty_rel: float
activity_basis: str
emission_factor_id: str
emission_factor_version: str
consolidation_entity: str
financial_consolidation_entity: str | None
ghg_scope: int
ghg_scope3_category: int | None
esrs_material_impact: bool
esrs_material_financial: bool
lineage_run_id: str
@dataclass(frozen=True)
class FrameworkTotal:
framework: Framework
grouping: str
tco2e: float
uncertainty_rel: float
n_parcels: int
excluded_parcels: tuple[str, ...]
exclusion_reason: str
def project(
figures: list[ParcelFigure], framework: Framework
) -> list[FrameworkTotal]:
"""Aggregate parcel figures according to one framework's rules.
The exclusions are returned, not dropped. A framework total that omits
parcels without saying which ones is a completeness claim nobody can
check, and completeness is the first thing an assurance provider tests.
"""
if framework is Framework.CSRD_E1:
in_scope = [
f for f in figures
if f.financial_consolidation_entity is not None
and (f.esrs_material_impact or f.esrs_material_financial)
]
excluded = [
f.parcel_id for f in figures if f not in in_scope
]
reason = ("outside financial consolidation scope, or assessed "
"immaterial under both limbs")
grouping_key = lambda f: f"scope{f.ghg_scope}"
else:
in_scope = [f for f in figures if f.consolidation_entity]
excluded = [f.parcel_id for f in figures if not f.consolidation_entity]
reason = "outside the organisational boundary"
grouping_key = lambda f: (
f"scope3.{f.ghg_scope3_category}" if f.ghg_scope == 3
else f"scope{f.ghg_scope}"
)
groups: dict[str, list[ParcelFigure]] = {}
for f in in_scope:
groups.setdefault(grouping_key(f), []).append(f)
totals: list[FrameworkTotal] = []
for key, members in sorted(groups.items()):
total = sum(m.tco2e for m in members)
# Uncertainties combine in quadrature only for independent sources.
# Parcels sharing an emission factor are not independent, so the
# shared component is carried through unreduced.
shared = {m.emission_factor_id for m in members}
independent_share = 1.0 / max(len(shared), 1)
combined = sum(
(m.tco2e * m.uncertainty_rel) ** 2 for m in members
) ** 0.5
correlated = sum(m.tco2e * m.uncertainty_rel for m in members) * (
1 - independent_share
)
totals.append(
FrameworkTotal(
framework=framework,
grouping=key,
tco2e=round(total, 2),
uncertainty_rel=round(
((combined ** 2 + correlated ** 2) ** 0.5) / total, 4
) if total else 0.0,
n_parcels=len(members),
excluded_parcels=tuple(sorted(excluded)),
exclusion_reason=reason,
)
)
return totals
The uncertainty combination deserves a note. Parcels sharing an emission factor share that factor’s error entirely, so combining every parcel’s uncertainty in quadrature — the default in most implementations — understates the total by a wide margin in exactly the situation carbon reporting is usually in, which is many parcels and few factors.
Compliance Gating & Audit Trail Generation
Four artefacts satisfy the overlapping demands, and producing them once serves all three frameworks.
The parcel-level figure store, immutable per reporting period, with the emission factor version and lineage run id on every row. This is the substrate every projection draws from and the thing an assurance provider samples.
A boundary register recording, per parcel and per period, which consolidation scopes include it and why. Boundaries change as entities are acquired and divested, and a register makes the change visible instead of appearing as an unexplained movement in a total.
The materiality assessment as data rather than as a document. Recording the impact and financial materiality determinations per site, with their basis and date, means the CSRD disclosure is generated rather than assembled, and it means a challenge to one determination is traceable.
A restatement log linking each restated figure to its prior value and the reason. This is required explicitly by CSRD, expected under ISO’s consistency principle, and implied by the GHG Protocol’s recalculation policy — one artefact, three uses.
Production Integration
The practical build order is worth stating because it is not obvious. Build the parcel-level store first, with lineage, before building any report. Reports are cheap to add once the substrate exists and impossible to retrofit consistency onto once three of them have been built separately.
The CSRD-specific mapping — which parcel-level fields land in which ESRS datapoint — is covered in mapping CSRD ESRS E1 disclosures to spatial MRV outputs, and the lineage layer that makes the assurance chain traceable is in tracking data lineage with OpenLineage for ESG audits. For organisations also holding credits, the registry-side requirements sit alongside rather than inside these frameworks; Verra VM0047 vs Gold Standard GIS requirements covers that side.
One integration caution. Do not let a framework’s reporting structure become the pipeline’s internal data model. Reporting structures change — ESRS datapoints have already been revised, and the Protocol’s land sector guidance is comparatively recent — and a pipeline whose tables mirror a disclosure template needs migrating each time. The parcel-level model changes far more slowly, because it describes physical reality rather than a reporting convention.
Frequently Asked Questions
Can one pipeline genuinely serve all three, or is duplication inevitable?
One pipeline serves all three provided it computes at parcel level and projects upward. The duplication people experience comes from building each report as its own pipeline, which is the natural thing to do when the second framework arrives after the first is already in production. The refactor that pays for itself is extracting the parcel-level store from the first report before building the second — after the third, the extraction is a much larger job.
Does CSRD require assurance over spatial data specifically?
It requires assurance over the sustainability statement, and where land-related emissions are material to that statement the spatial data is in scope by inclusion. In practice this means an assurance provider will test how areas were derived, what imagery was used, and whether the classification is reproducible — questions that are ordinary in a carbon crediting context and novel to organisations arriving from financial reporting. The evidence they need is the lineage, not the model’s accuracy.
How should immaterial parcels be handled — excluded or included at zero?
Included, with a materiality flag, and excluded at the projection step rather than at the data step. Excluding at the data step means the parcel does not exist in the store, so a later change in the materiality assessment requires reprocessing, and the count of parcels assessed cannot be stated. Keeping everything and filtering per framework also makes the exclusion list available, which is what supports the completeness assertion.
What happens when the two consolidation boundaries genuinely differ?
They are recorded as separate fields on the parcel, as in the model above, and the difference is reported rather than reconciled away. A joint venture consolidated financially but not under operational control is a real situation with a real answer under each framework, and forcing them to agree misstates one of them. The important thing is that the difference is visible in the data model, so a reader comparing two totals can see why they differ.
Is the GHG Protocol’s Land Sector guidance a separate framework?
It functions as an extension rather than a replacement — it adds the land-specific accounting rules for removals, land use change, and biogenic carbon that the core standard leaves open. For a spatial MRV pipeline it is the more consequential document of the two, since it governs how a sequestration figure enters an inventory and how land use change emissions are attributed over time. Treat it as part of the Protocol requirement set rather than as a fourth framework.
How far back does a comparative prior period have to be reproducible?
Under CSRD the immediately preceding period must be presented on a comparable basis, which means the pipeline must be able to produce a prior figure under current definitions — not merely retrieve what was published. That is a stronger requirement than it appears, because it means the prior period’s inputs must still be reachable and reprocessable. Storing the parcel-level figures rather than only the totals satisfies most of it without a reprocessing run.
Where do voluntary carbon credits sit relative to these frameworks?
Outside the inventory, and the separation is worth enforcing structurally. Credits purchased or retired are disclosed separately under ESRS E1 rather than netted against gross emissions, and the GHG Protocol has always required gross reporting. A pipeline that subtracts retired credits from a parcel-level emission figure has destroyed the gross number, which is the one every framework actually asks for. Keep credits in their own store and join them at reporting time.
Which framework should a pipeline be built against first?
The one with the hardest evidence requirement, which is almost always CSRD where it applies and ISO 14064 where it does not. Building against the accounting standard alone produces correct numbers with an evidence trail that assurance will reject, and adding the trail afterwards means reprocessing every period that has already been reported. Building against the assurance requirement first produces a store that the accounting projections fall out of, at a cost concentrated in the first cycle rather than spread across three.
Related guides
- Carbon Registry Standards and Methodologies — the parent topic and the registry-side standards alongside these frameworks.
- Mapping CSRD ESRS E1 Disclosures to Spatial MRV Outputs — the field-level mapping for the third framework.
- Verra VM0047 vs Gold Standard GIS Requirements — the crediting-side comparison that sits beside this one.
- Tracking Data Lineage with OpenLineage for ESG Audits — the lineage layer the assurance chain depends on.