Advanced: the relational model

Numbers checked against the live data on

This page shows queries.

open_ef.factors_flat, the view every factor page, export and API row is read from, is built from four core tables: publisher, release, emission_factor and emission_factor_value. The other tables give those four a shared vocabulary for boundaries, places, classifications, quality and suppliers, and keep the publisher's original line so every number can be traced back. You need this page when you join those tables yourself, or when you want to see what the view flattened; The model at a glance reads the same data as the factor page and the view serve it.

The four core tables#

table one row is example
publisher An organisation whose data is published ADEME
release One versioned dataset, with its licence and attribution Base Carbone v23.6
emission_factor A stable factor lineage: activity, region, canonical unit, system boundary Butane, Europe, per kWh, combustion
emission_factor_value One published value of that factor in one release, with its reference year, GWP basis and provenance 0.22968 kg CO2e/kWh, reference year 2014

emission_factor is the identity. emission_factor_value holds every number attached to it across releases, years and GWP characterizations. A factor can point at a parent factor through parent_id, which is how a total and its components, its contribution stages or its nested boundaries hang together (see Parents, children, double counting).

A published slug never stops resolving. When a factor is renamed, when two are merged, or when a mis-keyed lineage is repaired, the old slug stays in emission_factor_alias with a reason of renamed, merged or key_fix. Identifiers and stability covers the rules.

The full model in one picture#

The spine across the top is the four core tables. Everything below emission_factor annotates it: those tables say what a factor covers, where it applies, what it is called and whose it is, and none of them changes its identity. Behind the value sit the publisher's own line and the quality scheme its rating is on.

The open_ef model A publisher issues a release, which publishes an emission factor value. The publisher also owns an emission factor, whose values those are and which can point at a parent factor. Behind each value sit the source row and the data quality scheme. Four groups of tables annotate the factor: what it includes, where it applies, what it is called, and its counterparty and addresses. publisher release emission_factor_value emission_factor issues publishes owns its values parent_id Behind the number source_row data_quality_scheme What it includes system_boundary system_boundary_scope boundary_scope_term Where it applies geography geography_member geography_resolution What it is called activity_classification classification_scheme emission_factor_text text_term Counterparty, address supplier supplier_resolution emission_factor_alias

The reference columns behind each line, for when you write the join yourself:

from column to
release publisher_id publisher
emission_factor_value release_id release
emission_factor_value emission_factor_id emission_factor
emission_factor_value source_row_id source_row
emission_factor_value data_quality_scheme_native data_quality_scheme
emission_factor parent_id emission_factor (its parent)
emission_factor system_boundary system_boundary, then system_boundary_scope.boundary_code and boundary_scope_term.code
emission_factor region_code geography, with geography_member and geography_resolution beside it
emission_factor supplier_id supplier, with supplier_resolution beside it
emission_factor_text factor_id, term_id emission_factor, text_term
activity_classification factor_id, scheme emission_factor, classification_scheme
emission_factor_alias emission_factor_id emission_factor (every slug ever published)

Supporting tables#

system_boundary: what a factor includes#

Publishers describe life-cycle coverage in their own words and at their own precision. system_boundary turns those descriptions into one closed list of codes, such as:

  • combustion: direct emissions from burning a fuel, normally Scope 1 for the buyer;
  • upstream_fuel: extraction, refining and delivery before combustion, normally Scope 3 category 3;
  • cradle_to_gate: raw-material extraction through production;
  • cradle_to_consumer: a product through consumer preparation;
  • transport: a contribution stage explaining part of a parent total.

Each code carries three groupings. role says whether a number on it may be reported alone (whole, part, credit, unknown); preset is the reader-facing coverage group the boundary filter offers; family records which taxonomy the code was defined in (lifecycle, energy_stage, asset_stage, contribution, unknown). An AGRIBALYSE transport stage and a transport-service factor both say "transport", but only the first has boundary_role = 'part', so you can exclude stages without reading labels. System boundaries explains all three.

Fit judgement: does this factor suit your line?#

Five tables answer what a factor's name cannot: what its boundary really counts, where its activity sits in a classification, and what the publisher's quality rating is worth. They are annotations: improving one never changes a factor's identity or its slug.

boundary_scope_term and system_boundary_scope. A name like cradle_to_gate does not say whether capital equipment is inside. boundary_scope_term is the closed list of 21 such questions (raw_materials, upstream_fuel, capital_goods, end_of_life and the rest), each tagged with a stage. system_boundary_scope answers them per boundary with a disposition of in, out or varies. For cradle_to_gate, raw_materials, manufacturing and packaging are in, capital_goods varies, and use_phase and end_of_life are out.

Read those answers with three rules:

  • They are defaults. A factor overrides them in its own boundary_profile, and factors_flat.boundary_scope already serves the merged answer.
  • The table is sparse on purpose. An absent term means not established, never excluded.
  • varies means the answer depends on the publisher, so only the factor's boundary_profile settles it.

boundary_scope is a JSON object keyed by term code, so one term is read by name with ->> and a whole answer is matched with containment (@>). The value is in, out, varies, or NULL for a term nobody established. Do not answer the question by joining system_boundary_scope on boundary_code instead: that returns the boundary's defaults and misses the factor's own override.

One ÖKOBAUDAT product: what its boundary code says about recycling credit by default, what its own scope says, and whether it excludes capital goods.

Show the SQL
SELECT f.system_boundary,
       sbs.disposition                         AS boundary_default,
       f.boundary_scope ->> 'recycling_credit' AS effective,
       f.boundary_scope @> '{"capital_goods":"out"}'::jsonb AS excludes_capital_goods
FROM open_ef.factors_flat f
LEFT JOIN open_ef.system_boundary_scope sbs
       ON sbs.boundary_code = f.system_boundary
      AND sbs.term_code = 'recycling_credit'
WHERE f.library = 'okobaudat'
  AND f.slug = 'ef-okobaudat-2201_eps_rigid_foam-de-l-epd_total-1be8a0b7'
  AND f.is_default_indicator
  AND f.is_latest_activity_year
system_boundary boundary_default effective excludes_capital_goods
epd_total varies in true

Here the factor's own boundary_profile settles what the boundary leaves as varies, and a join on the defaults alone would have answered varies and hidden that. How factors are cleaned counts the factors in each library that carry a profile of their own.

activity_classification and classification_scheme. The open_ef axis reaches the view as four columns, a code and a label at each of two levels: category_code with category, and sub_category_code with sub_category. The code is the stable half, frozen when the leaf is minted and listed in the code lists and in categories.xlsx; the label is display text and may be translated, so key a saved report or a stored filter on the code. The axis says what a factor measures, never which life-cycle stage it covers (how factors are cleaned). activity_classification keeps the whole tree behind those columns: one row per factor, scheme and code, with level, parent_code, the native label, and an origin of source (the publisher stated it) or mapped (open_ef assigned it). classification_scheme registers each scheme with its authority, version and is_standard flag (classification-schemes.xlsx lists them all).

Watch out. In SQL, key on category_code = 'food_agriculture'. A code passed to the category column matches nothing and raises no error: an empty result that reads as a coverage gap rather than as a typo. The search page's cat parameter takes the label (Food and agriculture) instead, because it filters that label column.

is_standard is the join rule. A standard code means the same thing in every library, so you can join on it. A publisher's own tree only means something inside that library. The revision is part of the scheme code for the same reason: hs_2017 and hs_2022 are not interchangeable.

Every classification code on one USEEIO factor, with the scheme it belongs to, whether that scheme is standard, and where the code came from.

Show the SQL
SELECT ac.scheme, cs.is_standard, ac.level, ac.code, ac.label, ac.parent_code, ac.origin
FROM open_ef.factors_flat f
JOIN open_ef.activity_classification ac ON ac.factor_id = f.factor_id
JOIN open_ef.classification_scheme cs ON cs.code = ac.scheme
WHERE f.library = 'useeio'
  AND f.slug = 'ef-useeio-cheese_manufacturing-us-usd-cradle_to_gate-9c6d0a5a'
  AND f.is_default_indicator
  AND f.is_latest_activity_year
ORDER BY ac.scheme, ac.level
scheme is_standard level code label parent_code origin
bea_2017 true 3 311513 NULL NULL mapped
naics_2017 true 2 31 Manufacturing NULL source
naics_2017 true 6 311513 Cheese Manufacturing 31 source
open_ef_category true 1 food_agriculture Food and agriculture NULL mapped
open_ef_category true 2 processed_food Processed food food_agriculture mapped

Those same five rows are already on the view, as classifications: a JSON array of objects, one per node, ordered by scheme, then level, then code. Every element has the same five fields, whoever the scheme belongs to, so a publisher's own tree and open_ef's category axis are read the same way.

{"scheme": "naics_2017", "code": "311513", "label": "Cheese Manufacturing", "level": 6, "parent_code": "31"}

jsonb_array_elements reads a factor's code for a given scheme without a join:

The same factor's six-digit NAICS code read straight from the row's classifications column, without a join.

Show the SQL
SELECT n ->> 'scheme'      AS scheme,
       n ->> 'code'        AS code,
       n ->> 'label'       AS label,
       n ->> 'parent_code' AS parent_code
FROM open_ef.factors_flat f
CROSS JOIN LATERAL jsonb_array_elements(f.classifications) AS n
WHERE f.library = 'useeio'
  AND f.slug = 'ef-useeio-cheese_manufacturing-us-usd-cradle_to_gate-9c6d0a5a'
  AND f.is_default_indicator
  AND f.is_latest_activity_year
  AND n ->> 'scheme' = 'naics_2017'
  AND (n ->> 'level')::int = 6
scheme code label parent_code
naics_2017 311513 Cheese Manufacturing 31

Which of the two you use follows the direction of the query. Reading the codes of a factor you already have, go to classifications: the row is in hand and the join is done. Selecting factors by a code, go to activity_classification, where (scheme, code) is indexed and the jsonb is not.

The schemes each of the public libraries carries. waste_stream is an open_ef tree rather than a publisher's, carried by the libraries that fan a waste material out by treatment route, and it is not a published standard either.

library standard schemes non-standard schemes
ademe naf_rev2 (some factors), open_ef_category ademe_category, waste_stream
agribalyse open_ef_category agribalyse_food_group
desnz open_ef_category desnz_activity, waste_stream
epa open_ef_category epa_emission_category, waste_stream
exiobase nace_rev1_1, open_ef_category none
okobaudat open_ef_category okobaudat_category
openceda bea_2017, open_ef_category ceda_sector
useeio naics_2017, bea_2017, open_ef_category none
aib, miterd open_ef_category none

data_quality_scheme. One publisher's rating of 3.2 is not another's. The value's data_quality_rating_native points at its scheme through data_quality_scheme_native, and the scheme gives scale_min, scale_max, lower_is_better, the publisher's caution_threshold and the axes the rating aggregates. The model refuses a rating without a scheme. ADEME's ademe_dqr runs 1 to 5, lower is better, caution from 3, over six axes. To compare quality across libraries use data_quality_normalized instead (see data quality).

Rolling up by a classification code#

"Every factor under NACE section D" is a join on activity_classification, not a prefix match on the leaf code. The ancestor rung is a row of its own, so you match it directly.

How many EXIOBASE top-level factors sit under NACE section D and under its division 15, each rung matched directly.

Show the SQL
SELECT ac.code, ac.level, ac.label, ac.origin, count(DISTINCT f.factor_id) AS factors
FROM open_ef.factors_flat f
JOIN open_ef.activity_classification ac ON ac.factor_id = f.factor_id
WHERE f.library = 'exiobase'
  AND ac.scheme = 'nace_rev1_1'
  AND ac.code IN ('D', '15')
  AND f.is_default_indicator
  AND f.is_latest_activity_year
  AND f.is_top_parent
GROUP BY ac.code, ac.level, ac.label, ac.origin
ORDER BY ac.level
code level label origin factors
D 1 Manufacturing mapped 3324
15 2 Manufacture of food products and beverages mapped 539

A prefix match could not have found the section at all: NACE writes it as a letter, and D is no prefix of 15. Both rungs read origin = 'mapped' because EXIOBASE ships neither of them: its products carry a code of its own (p15.t), and reading those two digits as the NACE Rev. 1.1 division, then the section letter above it, is open_ef's. The labels are Eurostat's own published NACE names, which is why these mapped rungs carry one.

Nothing enforces that chain. It is there because the EXIOBASE map emits both rungs, and a scheme whose ancestry cannot be read off the code is free to ship only the depth it has. The chain is enforced for one family of schemes: where every level is a strict digit prefix of the level below (HS 2/4/6, CN, CPC), the leaf code already states its chapter and its heading, so an adapter holding one emits the whole parented chain and a gating check refuses the load when a rung is missing. Those truncated rungs carry origin = 'mapped' and, unlike the NACE rungs above, no label: the truncation is ours, and only the scheme itself may name a code it did not ship. None of the libraries these examples are drawn from carries such a scheme, so the enforced shape is one you can read about here but not run.

Why. If one library stored the chain and another only the leaf, the same code would answer a roll-up completely in the first and not at all in the second, and neither library's rows would look wrong on their own. That is what makes the derivable case an invariant rather than a convention.

Everywhere else the depth is whatever the source and its map together establish, and three gaps are correct rather than missing:

  • a published crosswalk instead of a prefix rule: bea_2017 rolls its Detail commodities up to Summary through BEA's own table, so no BEA node carries a parent_code and a roll-up on a Summary code returns nothing;
  • a letter section that is not part of the numeric code (isic_rev4, nace_rev1_1): present when the source states it, or when open_ef can derive it from something the source does ship, as above; absent when neither holds;
  • a source that publishes only the leaf depth, such as a bare naics_2017 level 6.

Only a parent_code naming a row that is not there is a defect.

A derived total inherits a scheme only where its children are unanimous. A minted parent is not a new activity, so it may not claim one child's specificity: the fold keeps a scheme only where every child carries the identical nodes. open_ef_category survives that on every derived total, because the parts of a total measure the same thing. A publisher's own tree often does not. DESNZ files a fuel's combustion child under one activity heading and its well-to-tank child under another, so the total inherits neither.

DESNZ factors split by whether open_ef computed them: how many keep the open_ef category and how many keep the publisher's own activity scheme.

Show the SQL
SELECT f.is_derived,
       count(DISTINCT f.factor_id) AS factors,
       count(DISTINCT f.factor_id) FILTER (WHERE ac.scheme = 'open_ef_category') AS keep_open_ef_category,
       count(DISTINCT f.factor_id) FILTER (WHERE ac.scheme = 'desnz_activity') AS keep_desnz_activity
FROM open_ef.factors_flat f
LEFT JOIN open_ef.activity_classification ac ON ac.factor_id = f.factor_id
WHERE f.library = 'desnz'
  AND f.is_default_indicator
  AND f.is_latest_activity_year
GROUP BY f.is_derived
ORDER BY f.is_derived
is_derived factors keep_open_ef_category keep_desnz_activity
false 1627 1627 1627
true 493 493 0

Watch out. Every aggregate needs WHERE is_top_parent (sum a decomposition). Combine it with a filter on a publisher's own scheme and a whole fuel drops out with no error: the total carries no node, and its children are not top parents. Roll up on open_ef_category or on a scheme marked is_standard, or count the derived totals your filter keeps before you trust the total.

One last shape rule. open_ef_category cannot fan a factor out, because a unique index allows it one node per level per factor. Every other scheme may carry several codes at one level, so count DISTINCT factor_id rather than rows, as the queries above do.

geography: one region system at every grain#

geography holds one place or region per row, and region_code points at it: FR, FR-34 (Hérault), EUROPE, GLOBAL, EU27, EXIOBASE_ROW_EUROPE (Europe without the countries EXIOBASE models separately). parent_code builds the hierarchy, so a factor tagged FR-34 can answer a query for France or Europe by walking up FR-34 → FR → EUROPE → GLOBAL. Both tables are one download, geography.xlsx.

geography_member lists explicit members of a group. EU27 contains its member countries, and a rest-of-world complement lists the countries it leaves out with role = 'excluded'.

geography_resolution records why a publisher's raw label became a code: one row per publisher and raw text, with resolved_by (rule, gazetteer, llm or human) and the evidence.

publisher_code raw_text geography_code resolved_by
ademe France continentale FR rule
ademe Espagne ES gazetteer
ademe Monde GLOBAL rule
exiobase WE EXIOBASE_ROW_EUROPE rule
exiobase WA EXIOBASE_ROW_ASIA_PACIFIC rule

text_term and emission_factor_text: labels in several languages#

text_term stores each unique string once per language. emission_factor_text links a factor to its terms and says which field the term fills, in which lang, and whether its origin is source, translated or generated. The AGRIBALYSE artichoke factor carries:

field lang origin text
activity_main fr source Artichaut, cru
activity_main en source Artichoke, globe, raw
activity_main de translated Artischocke, ganz, roh
activity_full fr source Artichaut, cru
activity_full en source Artichoke, globe, raw
activity_full de translated Artischocke, ganz, roh

The publisher's own French and English wording is kept with an origin of source, and the German line is marked translated, which is how a reader tells a publisher's label from one we added. Search can still run on a normalized label in each language.

Counterparty: which supplier a factor describes#

Most factors describe a generic activity and name nobody. Some describe one named counterparty: an electricity retailer's tariff, the owner of an environmental product declaration. supplier_id points at it and is NULL on the generic majority; the view serves supplier_code, supplier_name and has_supplier.

supplier has one row per company, shared across publishers, with a frozen code minted from the resolved name. The supplier is an annotation, never identity: a legal-name change does not re-key a factor. The name fold keeps legal-form suffixes on purpose, because a wrong merge silently gives one company another's emissions, while a missed merge stays fixable. MITECO's ENDESA ENERGÍA S.A.U. and ENDESA ENERGÍA, S.A. are two supplier rows for that reason.

supplier_resolution is the twin of geography_resolution: each raw handle a source shipped, its raw_kind (name, or an id such as an ÖKOBAUDAT contact UUID), and the supplier_id it resolved to. A respelled supplier gets a new row pointing at the same supplier_id, so no factor is re-keyed.

source_row: the untouched archive#

source_row stores each line of a publisher's file exactly as retrieved: the artifact, the row_index, every original field in raw, a content_hash and an ingest_status. Lines that were deliberately not imported are archived too, with a skip_reason: an all-zero structural EXIOBASE cell, an archived ADEME record, a unit that cannot be converted safely. source_row_id on each value links back to it. Rows not imported shows how to list them.

From a number back to its source#

Every value keeps what the publisher sent:

  • the publisher's own fields on the value: activity_raw, region_raw, unit_raw, source_native_id and a provenance record source_record;
  • the archived line through source_row_id: which file, which line, every original field;
  • unit_conversion_factor, so value_co2e_native / unit_conversion_factor gives back the number in the published unit;
  • *_origin columns that say whether the publisher stated a field or open_ef assigned it.

Take the combustion part of ADEME's butane value for 2018:

The combustion part of ADEME's butane for 2018, from the served value back to the publisher's unit, its native id and the line of the file it came from.

Show the SQL
SELECT f.value_co2e_native, v.unit_conversion_factor,
  round((f.value_co2e_native / v.unit_conversion_factor)::numeric, 2) AS published_value,
  v.unit_raw, v.source_native_id, sr.artifact, sr.row_index, sr.ingest_status
FROM open_ef.factors_flat f
JOIN open_ef.emission_factor_value v ON v.id = f.value_id
JOIN open_ef.source_row sr ON sr.id = f.source_row_id
WHERE f.library = 'ademe'
  AND f.slug = 'ef-ademe-butane_maritime_included-europe-kwh-combustion-896083c5'
  AND f.is_default_indicator
  AND (f.applies_from_year IS NULL OR f.applies_from_year <= 2018)
  AND (f.applies_to_year IS NULL OR f.applies_to_year >= 2018)
value_co2e_native unit_conversion_factor published_value unit_raw source_native_id artifact row_index ingest_status
0.22968 0.0036 63.80 kgCO2e/GJ ICV 13543 Base_Carbone_V23.6.csv 2851 ingested

ADEME published 63.8 kg CO2e/GJ. open_ef stores it per kWh: 63.8 × 0.0036 = 0.22968. Add sr.raw to the select list to read the original line. Trace a value to its source walks a full trace.

Raw, source, native, origin: what the suffixes mean#

Column names carry one of four suffixes or prefixes. Each answers a different question, so you can use harmonized data without losing what the publisher sent.

pattern question it answers example
*_raw What did the publisher write before we normalized it? region_raw is France continentale; region_code is FR. Filter and join on region_code, audit with region_raw.
source_* Which fields belong only to this publication, with no counterpart in the model? source_native_id is 13543, ADEME's own record id. It does not replace the slug.
*_native Which value uses the publisher's own GWP set? value_co2e_native is 0.22968 under gwp_method_native. Two native values on different IPCC sets are not comparable: use value_co2e_normalized.
*_origin Did the publisher state this field, or did open_ef fill it? ADEME states a region, so region_origin is source. AGRIBALYSE rows name no region and are assigned FR under a documented rule, so region_origin is assigned.

On the AGRIBALYSE artichoke factor, region_raw is NULL, region_origin is assigned and the view's region_is_publisher_default is true: you know FR is the library's default, not a fact the row stated.

Next#