Codelists
oda_reader.codelists fetches the OECD DAC reference tables the data is coded against. A fetch
returns a CodelistSnapshot carrying a pandas DataFrame and the raw bytes it was parsed from.
Which function you call depends on the codelist. Provider and recipient codes come from
fetch_codelists. Purpose codes, channels of delivery, markers and twenty others come from
fetch_code_categories. Provider agencies come from fetch_provider_agencies.
Codelist ID Reference maps all 26 ids to their contract.
Getting a snapshot
oda_reader.codelists is not re-exported from oda_reader, so import the submodule:
codelist_id code label status type dotstat_code iso3 crs tossd activation_date
5 1 Austria active DAC member AUT AUT 1 1 <NA>
5 2 Belgium active DAC member BEL BEL 1 1 <NA>
5 3 Denmark active DAC member DNK DNK 1 1 <NA>
5 4 France active DAC member FRA FRA 1 1 <NA>
5 5 Germany active DAC member DEU DEU 1 1 <NA>
A bare call fetches both area codelists, 577 rows as of July 2026.
Category codelists take an explicit list, since fetching all 23 pulls about 900 KB:
from oda_reader.codelists import fetch_code_categories
purpose_codes = fetch_code_categories(codelist_ids=("10",)).frame
Pass a sequence even for one codelist. codelist_ids="10" raises CodelistValidationError.
Provider agencies take no argument, since codelist 16 is the only one:
Frame keys
Each contract keys its frame differently.
| Contract | Function | One row per |
|---|---|---|
| Area | fetch_codelists |
(codelist_id, code, activation_date) |
| Category | fetch_code_categories |
(codelist_id, code, activation_date, crs, tossd) |
| Agency | fetch_provider_agencies |
(codelist_id, donor_code, code, activation_date) |
snapshot.contract says which pair built a snapshot: "area", "category" or "agency".
The same code appears in several codelists with different meanings, so include codelist_id in every
groupby and merge key.
Area: one row per validity period
OECD publishes providers and recipients as validity periods, so a code whose status changed over time
carries one row per period. Codelist 13 returns 364 rows across 207 distinct codes. Code 130
(Algeria) carries three:
codelist_id code label status iso3 activation_date
13 130 Algeria withdrawn DZA 1996-01-01
13 130 Algeria withdrawn DZA 2011-01-01
13 130 Algeria active DZA 2022-01-01
All three rows stay in the frame.
Each row's status describes its own period. A code's current state is its row with the greatest
activation_date:
frame = fetch_codelists().frame
# ✅ One row per code, carrying its current state
latest = (
frame.sort_values("activation_date")
.groupby(["codelist_id", "code"], dropna=False)
.tail(1)
)
# ❌ Drops every code whose latest period is withdrawn
active = frame[frame["status"] == "active"]
latest returns 420 rows, one per code. active returns 390, silently dropping the 30 codes that
are currently withdrawn.
pd.NA is part of the key. Codelist 5 carries no activation_date at all, so its 213 rows key
on a null third component. Pass dropna=False to any groupby on the key, since pandas drops
null-keyed rows by default.
Category: one row per standard and period
Four codelists define the same code differently under OECD's two reporting standards, and a single
request returns both definitions. The crs and tossd columns say which standard a row belongs to.
Channel of delivery ("3"), code 11000:
Purpose code, Channel of delivery, Type of finance and Co-operation modality all work this way. Filter to the standard you report against:
channels = fetch_code_categories(codelist_ids=("3",)).frame
crs_rows = channels[channels["crs"] == "1"]
There is no standard= parameter.
Category codes also carry validity periods, and those account for more repeated codes than the standard split does. Channel of delivery returns 905 rows over 472 codes: 29 codes are split across the two standards, while 280 carry more than one activation date. Filtering to one standard leaves 164 codes still holding several rows, so take the latest period as well when you need one row per code.
Agency: one row per donor
An agency code is only meaningful inside its donor. Code 1 is the Federal Ministry of Finance under
Austria and the Ministry of Foreign Affairs under Denmark:
codelist_id donor_code code label acronym
16 1 1 Federal Ministry of Finance BMF
16 3 1 Ministry of Foreign Affairs MFA
16 4 1 Government GOV
Codelist 16 returns 1,374 rows over 110 distinct agency codes across 198 donors, and code 1 alone
appears under 194 of them. Join on donor_code and code together.
Reconciling against history
fetch_codelists gives you today's answer. reconcile merges it into the table you already have and
tells you what moved, so a code OECD stops listing survives your next overwrite instead of
disappearing from the table.
import pandas as pd
from oda_reader.codelists import fetch_codelists, reconcile
previous = pd.read_parquet("dac_codelists.parquet")
rec = reconcile(previous=previous, current=fetch_codelists())
if not rec.is_unchanged:
print(f"{len(rec.added)} added, {len(rec.retired)} retired, {len(rec.changed)} changed")
rec.table.to_parquet("dac_codelists.parquet")
Pass previous=None on the first run, before you have a stored table. Where previous comes from is
your choice. reconcile coerces contract columns to their contract dtypes on entry, so a table that
came back from parquet, a CSV, or a warehouse cursor as int64/object reconciles without manual
casting.
rec.table is a superset of what you passed in. A code that stopped being listed comes back with
presence="retired" and its last known values intact, plus lineage columns (first_seen,
last_seen, source_status, presence). Any extra columns your own table carried survive
untouched. The only way to lose a code is to omit previous.
rec.added, rec.retired and rec.changed are small frames meant to be read or alerted on.
reconcile doesn't decide whether a change matters, so thresholds and blocking checks belong in your
orchestrator. Three behaviours matter when you alert on them:
- Retirement is scoped to the codelists in
current. Reconciling a codelist-5-only snapshot against a full table leaves codelist 13 rows atpresence="current". - A code that reappears is a change, not an addition. It returns to
presence="current", keeps its originalfirst_seen, and shows up inrec.changedwithcolumn="presence". is_unchangedignoreslast_seen. Advancing it on an otherwise identical row is not a change.
reconcile takes area snapshots only. For history over the other contracts, keep dated raw
snapshots and diff the frames yourself.
When a fetch fails
Everything raises from CodelistError, and is_retryable tells you which branch you're on:
from oda_reader import CodelistError
from oda_reader.codelists import fetch_codelists
try:
snapshot = fetch_codelists()
except CodelistError as exc:
if exc.is_retryable:
... # transient
else:
raise # the source moved or changed shape, a human needs to look
Every snapshot carries the exact bytes it was parsed from, so save them on every successful run:
from pathlib import Path
for codelist_id, payload in snapshot.raw.items():
Path(f"codelist_{codelist_id}.json").write_bytes(payload)
parse_codelists rebuilds a snapshot from those bytes with no network access, which keeps a pipeline
running while the OECD page is broken:
import datetime
from pathlib import Path
from oda_reader.codelists import parse_codelists
raw = {
"5": Path("codelist_5.json").read_bytes(),
"13": Path("codelist_13.json").read_bytes(),
}
snapshot = parse_codelists(
raw=raw,
fetched_at=datetime.datetime(2026, 7, 20, tzinfo=datetime.UTC),
)
fetched_at must be timezone-aware, and raw must not be empty. parse_code_categories and
parse_provider_agencies do the same for their own payloads.
See the exception reference for which failure raises what.
How often to fetch
These codelists change a handful of times a year. Fetch once a day at most, persist the result, and
read from that on subsequent runs. A fetch_codelists call inside a per-partition loop is a bug.
Requests to the OECD host go through the package-wide rate limiter. See Caching & Performance to configure it.
Next
- Codelists Reference - Signatures, frame columns, and the exception contract
- Codelist ID Reference - All 26 OECD ids, their names, and which contract covers them