Investigate: Coded fields across sources — decoding strategy
IMPLEMENTATION RULES: Before implementing this plan, read and follow:
- WORKFLOW.md - The implementation process
- PLANS.md - Plan structure and best practices
Status: Completed
Goal: Decide a per-field treatment for every coded field in Atlas's raw.* tables (sex, age, family_type, household_type, education_level, age_group, period, etc.) so consumers (Next.js, dbt analysts, the /data explorer) see human-readable labels rather than upstream codes — without inventing a heavyweight platform service.
Last Updated: 2026-04-22 Completed: 2026-04-22 — all three follow-up plans (PLAN-001/002/003) implemented. Hybrid approach is now the operating pattern; future sources with coded fields follow the same shape (small inline enum / seed lookup / structured parse).
Origin: Atlas data layer. Atlas's upstream sources publish many dimensions as short codes ("0" for all sexes, "0001" for a family type, "02a" for an education level). The raw layer preserves them verbatim. The marts layer has been renaming some (kommune codes, sex in two sources) but leaving most as-is. That pushes the code-to-meaning problem onto every consumer. Flagged as an open decision in ../../../../docs/stack/data-strategy.md (line 183, "dim_codes or dbt seeds for enum decoding").
Questions to Answer
- Which architectural pattern fits best — inline CASE in dbt, dbt seeds as reference tables, a universal
dim_codeslookup, or dbt macros? Or a hybrid matched per pattern? - For each of the 19 currently-ingested sources, which coded fields need decoding and which architectural pattern fits each?
- Where do canonical labels come from — fetched once from upstream metadata and pinned in CSV (versioned, but drifts), or re-fetched on each
dbt seedrun (always fresh, but seeds stop being declarative)? - Should we ship
label_no(bokmål) only, orlabel_no+label_enfrom day 1 in case Atlas gets an English UI? - How many seed files do we actually commit? (5 candidates listed below; one has only 1 value today.)
- What happens to consumers when an upstream code is retired — do we keep stale rows in our seed, or remove them and risk over-filtering?
Current State
Inventory — every coded field, as it currently exists in Postgres
Data below is probed directly from the raw.* tables (not guessed).
Universal concepts
| Field | Source tables (raw) | Observed codes | Readable? |
|---|---|---|---|
sex (SSB Kjonn) | raw.ssb_07459, raw.ssb_09429 | "0" / "1" / "2" | ❌ numeric |
sex (FHI KJONN, current col name kjonn_code) | raw.fhi_mobbing, raw.fhi_vgs_gjennomforing | "0" / "1" / "2" | ❌ numeric |
age (SSB Alder, single-year) | raw.ssb_07459 | "000" … "104", "105+" | ⚠️ integer-like but string because of "105+" |
age_group (SSB 12944) | raw.ssb_12944 | "999A", "00-17", "18-34", "35-49", "50-66", "067+" | ⚠️ mixed — ranges understandable but "999A" cryptic |
age_group (FHI ALDER, current col name alder_code) | raw.fhi_bor_alene, raw.fhi_trangbodd, raw.fhi_mobbing (indirectly) | "0_120", "16_120", "0_17", "18_29", "30_44", "45_64", "65_74", "75_84", "85_120" … | ⚠️ range-style but needs parsing |
year (SSB Tid) | all SSB non-KOSTRA | "2024" (parses clean to int) | ✅ clean |
period (FHI AAR, current col name aar_code) | all FHI | "2024_2024" (single-year) or "2022_2024" (3-year rolling) | ⚠️ needs parse |
period (SSB 12944) | raw.ssb_12944 | "2022-2024" (hyphen, not underscore) | ⚠️ different range format |
Domain-specific SSB enums
| Field | Source table | Observed codes |
|---|---|---|
family_type (SSB FamilieType) | raw.ssb_06083 | "001" through "009" (9 codes) |
household_type (SSB HusholdType) | raw.ssb_06944 | "0000" through "0004" (5 codes) |
education_level (SSB Nivaa NUS-based) | raw.ssb_09429 | "00", "01", "02a", "03a", "04a", "09a", "11" (7 codes) |
grade (FHI TRINN, current col name trinn_code) | raw.fhi_mobbing | "7", "10" (self-explanatory) |
Domain-specific FHI enums
| Field | Source table | Observed codes |
|---|---|---|
education_level (FHI UTDANN — parents' level, different scheme from SSB!) | raw.fhi_trangbodd, raw.fhi_vgs_gjennomforing | "0", "1", "2", "3", "4" (5 codes) |
housing_status (FHI BODD) | raw.fhi_trangbodd | "trangt" (overcrowded), "uoppgitt" (unknown) |
immigration_category (FHI INNVKAT) | raw.fhi_vgs_gjennomforing | "0" only (all — table doesn't break it down) |
question_id (FHI SPM_ID) | raw.fhi_mobbing | "479" only (opaque internal id) |
measure_type (FHI MEASURE_TYPE) | all FHI | "RATE", "SMR", "MEIS", "TELLER" (semi-cryptic) |
contents_code (varies per table) | every SSB + FHI | Wildly inconsistent — Personer, EUskala60, Folkemengde, SamletInntekt, Folketilvekst, KOSFolkemengdeia0000, RATE, SMR, etc. |
Region codes (separate problem, partially solved)
| Field | Observed | Current treatment |
|---|---|---|
| 4-digit kommune codes | "0301", "5601", etc. | ✅ Already mapped via dim_kommune |
| 2-digit fylke codes | "03", "31", etc. | ✅ Already mapped via dim_fylke |
| XX99 rest-of-fylke aggregates | "0199", "0299" | ✅ Excluded from kommune_nr |
| K_/F_ prefixed codes (06913) | "K_0301", "F_03" | ✅ Stripped in dbt |
| 6-digit bydel codes | "030101" |