Indicator Flows — Long-Format Data and pivot(on=)¶
Many SDMX providers publish data in long format: instead of one column
per indicator, all indicators share a single dimension (often named
MEASURE, INDICATOR, or SUBJECT) and a single OBS_VALUE column.
Each row carries one indicator value.
This recipe shows how to fetch, filter, and pivot these flows into a
wide analytical table with reg.obs(flow) and pivot(on=).
Why on= is needed¶
SDMX 2.1 has a formal <MeasureDimension> element, but almost no major
provider uses it. In practice, providers put indicator codes in an ordinary
dimension. sdmxlib cannot know which dimension carries indicator codes, so
you name it when you pivot:
When the DSD does declare a MeasureDimension, obs.pivot() uses it
without being told — obs.structure.measure_dimension says whether there
is one:
Discovering the right dimension name¶
If you call pivot() with no axis on a structure that declares none, sdmxlib
raises a ValueError that names every enumerated dimension as a candidate:
try:
obs.pivot()
except ValueError as exc:
print(exc)
# pivot() needs an axis: DSD_KEI declares no measure dimension. Pass on= one
# of its enumerated dimensions: REF_AREA, FREQ, MEASURE, UNIT_MEASURE, ACTIVITY.
You can also inspect the DSD directly before fetching any data:
with sl.RestRegistry(sl.Provider.OECD) as reg:
urn = sl.DataStructure.urn_for(agency="OECD.SDD.STES", id="DSD_KEI")
dsd = reg.get(urn, resolve=True)
for dim in dsd.dimensions:
if dim.is_enumerated:
cl = dim.representation()
print(f"{dim.id}: {cl.id} ({len(cl.codes)} codes)")
REF_AREA: CL_KEI_REF_AREA (54 codes)
FREQ: CL_FREQ (6 codes)
MEASURE: CL_KEI_MEASURE (50 codes)
UNIT_MEASURE: CL_UNIT_MEASURE (12 codes)
ACTIVITY: CL_KEI_ACTIVITY (3 codes)
ADJUSTMENT: CL_KEI_ADJUSTMENT (3 codes)
TRANSFORMATION: CL_KEI_TRANSFORMATION (6 codes)
The dimension with many codes covering economic indicator names is the one
to pass as on=.
Fetching a long-format flow¶
import sdmxlib as sl
reg = sl.RestRegistry(sl.Provider.OECD)
flow = reg.get(sl.Dataflow.urn_for(agency="OECD.SDD.STES", id="DSD_KEI@DF_KEI"))
obs = reg.obs(flow)
kei = obs.filter(
obs.col("REF_AREA").is_in(["USA", "DEU", "FRA", "GBR"]),
obs.col("FREQ") == "Q",
obs.col("MEASURE").is_in(["GDP", "GFCF", "EMP"]),
)
long = kei.frame()
obs(flow) is lazy: nothing is fetched until a terminal such as
frame() runs — so a query needs its registry open, and the sections below
keep using this reg. Close it (reg.close()), or use with, when done. The filters on dimensions are pushed into the SDMX REST key,
so only matching rows come over the wire — the MEASURE filter included,
without you needing to know its position in the key. kei.explain() shows
what is pushed and what runs locally.
Inspecting what you have¶
print(kei.structure.dimensions.ids()) # ["REF_AREA", "FREQ", "MEASURE", "UNIT_MEASURE", "ACTIVITY", "TIME_PERIOD"]
print(kei.structure.measures.ids()) # ["OBS_VALUE"]
# Peek at the raw long format
long.head(5)
# ┌──────────┬──────┬─────────┬─────────────┬───────────┐
# │ REF_AREA ┆ FREQ ┆ MEASURE ┆ TIME_PERIOD ┆ OBS_VALUE │
# │ --- ┆ --- ┆ --- ┆ --- ┆ --- │
# │ enum ┆ enum ┆ enum ┆ str ┆ f64 │
# ╞══════════╪══════╪═════════╪═════════════╪═══════════╡
# │ DEU ┆ Q ┆ EMP ┆ 2015-Q1 ┆ 39.8 │
# │ DEU ┆ Q ┆ GDP ┆ 2015-Q1 ┆ 772.3 │
# ...
Coded columns are typed from the DSD: each is a pl.Enum over its codelist.
Pivoting to wide format¶
pivot(on="MEASURE") reshapes long → wide, one column per indicator, in the
order the MEASURE codelist lists them. Every other dimension forms the
index. Returns a pl.DataFrame.
wide = kei.pivot(on="MEASURE")
print(wide.columns) # ["REF_AREA", "FREQ", "UNIT_MEASURE", "ACTIVITY", "TIME_PERIOD", "GDP", "GFCF", "EMP"]
The dimension columns that are constant for your filter are still present — drop them after pivoting if not needed:
import polars as pl
wide = kei.pivot(on="MEASURE").select("REF_AREA", "TIME_PERIOD", "GDP", "GFCF", "EMP").sort("REF_AREA", "TIME_PERIOD")
A code the codelist lists but the data lacks is still a column, filled with nulls: the structure decides the columns, not whichever rows arrived.
Writing to Parquet¶
The long format streams to a file a batch at a time, typed as frame() is,
without the whole result in memory:
A pivot needs every row of a series before it can emit one, so the wide table is a frame you write:
Filtering further¶
An ObsQuery refines: each filter returns a new query, and the fetch
happens at the terminal.
# By code
usa = kei.filter(kei.col("REF_AREA") == "USA")
# Any comparison on a component
recent_usa = usa.filter(usa.col("TIME_PERIOD") >= "2015-Q2")
# Collect with human-readable labels beside the codes
recent_usa.frame(labels="en")
labels="en" keeps each code column and adds REF_AREA_label_en and the
like beside it.
Validating indicator data¶
Before loading into a pipeline, check that all codes are valid for their
codelists. A typed read stops at the first bad code; validate() reads the
raw rows and names every one, with a count:
report = kei.validate()
if report.is_empty():
print("Clean — ready for ingest")
else:
print(report.sort("count", descending=True))
# ┌──────────┬───────────────┬───────┐
# │ column │ invalid_value │ count │
# └──────────┴───────────────┴───────┘
Full example — G7 quarterly GDP comparison¶
Fetch quarterly GDP for G7 countries, validate, reshape to one column per country, and save.
import sdmxlib as sl
G7 = ["USA", "DEU", "FRA", "GBR", "ITA", "JPN", "CAN"]
with sl.RestRegistry(sl.Provider.OECD) as reg:
flow = reg.get(sl.Dataflow.urn_for(agency="OECD.SDD.STES", id="DSD_KEI@DF_KEI"))
obs = reg.obs(flow)
gdp = obs.filter(
obs.col("REF_AREA").is_in(G7),
obs.col("FREQ") == "Q",
obs.col("MEASURE") == "GDP",
)
# 1. Validate
report = gdp.validate()
assert report.is_empty(), f"Unexpected codes:\n{report}"
# 2. Pivot on the country instead — one GDP column per country
wide = gdp.pivot(on="REF_AREA").select("TIME_PERIOD", *G7).sort("TIME_PERIOD")
print(wide.columns) # ["TIME_PERIOD", "USA", "DEU", "FRA", "GBR", "ITA", "JPN", "CAN"]
# 3. Save
wide.write_parquet("g7_gdp_quarterly.parquet")
The axis is whichever dimension you name: on="REF_AREA" spreads the
countries, and MEASURE, filtered to one indicator, stays in the index.