Plan: Ingest Udir GSI — grunnskole enrolment, teacher density, special-education share
Ingests GSI (Grunnskolens informasjonssystem) — pupil counts, school counts, teacher density and special-education/Norwegian-reinforcement shares, per kommune, annual — as Atlas's first Udir source. A real, working statistics API exists and was found and verified live; Udir's own public documentation page names the wrong hostname for it.
IMPLEMENTATION RULES: Before implementing this plan, read and follow:
- WORKFLOW.md - The implementation process
- PLANS.md - Plan structure and best practices
Status: Completed
Goal: Add udir-gsi as a served Atlas source, giving Report #10 (School-Capacity Forecast) the
current-enrolment supply side it's missing, and sharpening Atlas's existing education signal beyond
fhi-vgs-gjennomforing's 3-year completion rate.
Last Updated: 2026-10-02
Investigation: INVESTIGATE-new-norwegian-public-sources.md §4 (Tier 1 #4), §C.2 ([Q39]–[Q41])
Prerequisites: None — udir is already a valid publishers.yaml provider (#486), with a logo
and notes already naming GSI specifically. education is a valid topics.yaml id.
Phase 1: Research — verify the mechanism, licence and shape live
Findings
-
1.1 Udir's own public API documentation page names a hostname that does not work.
udir.no/om-udir/data/api-data-fra-elevundersokelsen/documents the base URL ashttps://api.udir-statistikkbanken.no/api/rest/v2/Eksport/{tabellnummer}/data— this host resolves and completes a TLS handshake, but the certificate presented is a generic*.azurewebsites.netwildcard that does not coverapi.udir-statistikkbanken.no, and every path returns Azure's own "Web App - Error 404" landing page, not Udir's API. Confirmed dead, not a transient blip — checked both with and without certificate verification. -
1.2 The real, working host is
api.statistikkbanken.udir.no(no hyphen before "statistikkbanken" — Udir's own docs have the domain segments in the wrong order). Verified live:GET https://api.statistikkbanken.udir.no/api/rest/v2/Eksportreturns a real JSON list of exportable tables (confirmed the investigation's "eight export tables, four primary + four secondary" claim for Elevundersøkelsen exactly:EUG/EUV× Deltakelse/Indikator/Mobbing/Tema). Querying/Eksport/{id}/datawithout a filter returns a real structured error naming the actual Swagger location:{"kode":400,"feiltype":"Feil i forespørselen","melding":"Filter må oppgis!","tilleggsinfo":"Vennligst se API-dokumentasjonen på https://statistikkportalen.udir.no/api/rapportering/swagger"} -
1.3 Full OpenAPI/Swagger doc fetched and read:
https://statistikkportalen.udir.no/api/rapportering/swagger/v1/swagger.json(74 KB). Title: "Grensesnitt for USS (Udirs StatistikkSystem)". ⚠️ Its own description: "Dette APIet tilgjengeliggjør statistikkdata for utdanningsdirektoratet. APIet er ikke ment for ekstern bruk i dag, og vil endres uten varsel." (This API makes statistics data available for Udir. The API is not intended for external use today, and will change without notice.) Real, documented, working — but explicitly not a stability promise. See [Q1]. -
1.4 This single API covers far more than GSI — confirmed live by listing every table under each schema (
GET /rest/v1/Statistikk→BHGbarnehage,GSKgrunnskole,OT,VGOvideregående;GET /rest/v1/Statistikk/{skjema}lists that schema's tables):GSK(grunnskole) includesGSI(this plan),FravaerG([Q39]udir-fravar),NasjonaleProever([Q41]'s Nasjonale prøver),EUG/ElevundersoekelsenG(Elevundersøkelsen),ResultatFagG(grunnskolekarakterer),OvergangerG,SkolebidragG,OekonomiG,VidUtdLaerer.VGO(videregående) includesSluttaV([Q40]udir-sluttet-vgs),GjennomfoeringV(overlapsfhi-vgs-gjennomforing— same overlap question as [Q40] already flagged),EUV,ElevV,LaerereV, and 20+ others.
This is a real finding that changes the investigation's own sequencing assumption. The original Phase 1 PLAN sequence (§D) assumed
udir-fravarandudir-sluttet-vgswere separate acquisition problems from GSI. They are not — same API, same auth (none), same filter mechanism. Once this plan's ingest client exists,PLAN-011throughPLAN-014become parameter changes to the same client, not new research. Recommend re-ranking them as fast follow-ups, not independent investigations. -
1.5 Real data pulled and inspected.
GSK/GSIhas 3 report numbers; report 1 ("Elever og skoler" — pupils and schools) is at version 8, not version 1 (resolved via theRapportsideendpoint,GET /rest/v1/Rapportside/GSK_Elev_Skol— ⚠️ always resolve the current version through this endpoint; do not hardcode a version number, it will go stale). Its own description, fetched via theTekstlookup endpoint: "Tabellen viser hvor mange elever som finnes i grunnskolen per 1. oktober. Det er også tall for antall elever med individuelt tilrettelagt opplæring, antall elever med forsterket opplæring i norsk, og antall skoler." (pupil counts as of Oct 1, individually-tailored-instruction counts, Norwegian-reinforcement counts, school counts) — matches the investigation's "pupil-teacher ratio, special-ed share" framing closely enough to trust, not identical wording. -
1.6 A real SSB-format code is directly in the data — no crosswalk needed, unlike
imdi-bosetting. TheEnhetIDfilter is a real 4-level hierarchy (GET .../filterVerdier?filter=TidID(202510)_EnhetID(*)):nivaa:1national →nivaa:2fylke ("kode":"42"for Agder, SSB's real 2-digit fylke code) →nivaa:3the level this plan ingests ("kode":"4203"for Arendal — SSB's real 4-digit kommune code, verbatim) →nivaa:4individual school ("kode"= organisasjonsnummer, e.g."990334021"for Arendal International School). Nocrosswalk_kommune_name-style name lookup is needed. ⚠️ Correction, Phase 2 — this is not the same claim as "noclassify_region_codemacro needed." It IS needed:2100(Svalbard) sits at this exact tree depth too, because GSI reports a school there. See [Q6]. -
1.7 Suppression exists — the Rapportside metadata for GSI report 1 carries
"erPrikket": true. ⚠️ Mechanism not yet characterized (what marker appears in a suppressed cell) — found as a flag, not yet observed on an actual suppressed value. Confirm during Phase 2. -
1.8 Licence: NLOD, confirmed on Udir's own terms page (
udir.no/om-udir/data): "Dataene er lagt ut for viderebruk under Norsk lisens for offentlig data (NLOD)" — explicit, not inferred, unlike IMDi. -
1.9 No authentication needed for the statistics read endpoints. The Swagger doc also lists
/Altinn/*,/Authentication/*,/Authorization/*paths — these are for the reporting side (schools submitting their own return data via Altinn), not the public statistics read side. Confirmed live: everyGET /rest/v1/Statistikk/...and/rest/v1/Eksport/...call above succeeded with zero credentials.
Validation
Live curl against api.statistikkbanken.udir.no and statistikkportalen.udir.no/api/rapportering
returned real, current (2025-26 school year) GSI data for a named real kommune (Arendal, 4203) with
a verbatim-matching kommune code — not asserted from documentation, pulled and read.
Open Questions
- [Q1] The API disclaims external-use stability. Build against it anyway, or wait for a
blessing? Udir's own Swagger doc says the API "is not intended for external use today, and will
change without notice." It is nonetheless real, documented via Swagger, publicly reachable with no
auth, and demonstrably used by Udir's own public-facing statistics pages (the same mechanism
renders
udir.no/tall-og-forskning/statistikk/statistikk-grunnskole/tall-om-elever-og-skoler/). Recommendation: build against it, the same wayimdi-bosettingscraped IMDi's own public HTML pages rather than waiting for a blessing — but treat a schema change as an expected failure mode, not a surprise, and make the ingest fail loudly (not silently misparse) ifRapportside's structure changes. Does not need Terje's sign-off to start — same class of decision as every prior source's scraping approach this session — but worth naming explicitly since "will change without notice" is a stronger disclaimer than anything seen from NAV, Bufdir or IMDi. - [Q2] Ingest and publish at kommune level, or go further to school level? The investigation's
own framing (§D) calls this "the first sub-kommune resolution; settles
dim_school," and [Q10] in the investigation recommends buildingdim_school(PK: organisasjonsnummer) withcrosswalk_school_to_kommune. Having now seen the real data: theEnhetIDhierarchy makes BOTH levels directly queryable (kommunekodeis alreadykommune_nr; schoolkodeis already organisasjonsnummer, which is exactly Brreg's own numbering —dim_brreg_enhetmay already carry some of these schools as organisations). Recommendation: kommune level only for this plan. Reasons: (a) every existing Atlas source is kommune/fylke/bydel grain — this is the first genuinely new geographic resolution Atlas would carry, anddim_schoolis real new infrastructure (crosswalk, suppression-at-small-N risk is sharper per-school than per-kommune, org-number collision/lifecycle handling); (b) the investigation's own [Q13] flags per-school small-cell suppression as a real privacy concern needing care Atlas hasn't built for yet; (c) kommune is what Report #10 actually asks for (the supply side maps against FHI's kommune-grain demand projection). This deliberately deviates from the investigation's "settles dim_school" framing — named explicitly, not silently dropped, same shape asimdi-bosettingdeviating from [Q42].dim_schoolstays a real, scoped future extension (its own PLAN, once a second school-grain source makes the crosswalk worth building) rather than this plan's job. - [Q3] RESOLVED during Phase 2. Report 1 alone, as recommended — not chased further. Reports 2 and 3 remain uncharacterized; a future Udir plan can pick them up the same way this one was researched.
- [Q4] RESOLVED, measured live 2026-10-02. The suppressed-cell marker is the literal character
*— confirmed on real small kommuner (Træna, Utsira) where the individually-tailored- instruction and Norwegian-reinforcement counts are suppressed while the plain pupil/school counts for the same kommune are not. Same conventionnav-uforetrygduses. - [Q5] RESOLVED — unnecessary once the right query shape was found. Pinning
TrinnID(-10)_KjoennID(-10)_KommunalitetID(-10)(the "alle"/all sentinel for each breakdown dimension) collapses the response's column metadata to exactly 4 entries, and those entries already carry human-readable names ("Antall elever", etc.) directly — no separateTekstlookup round trip needed. The Phase 1 concern assumed the unfiltered, multi-dimensional response shape; the actual ingest never requests that shape. - [Q6] NEW, found during Phase 2 — the depth-3 hierarchy level is not synonymous with "kommune."
Verified live:
2100(Svalbard) and2111sit at the exact same tree depth as genuine kommuner, because GSI reports a school there (Longyearbyen skole) — matching SSB's own21\d{2}Svalbard pattern this project'sclassify_region_codemacro already handles (built for exactly this class of bug, urb-agents #700, previously found via SSB sources). Consequence:raw.udir_gsi's geography column is namedregion_code, notkommune_nr— the Phase 1 claim that "no crosswalk and no derivation macro needed" was half right (no crosswalk) and half wrong (the macro IS needed, just for classification rather than name resolution).indicators__udir_gsi.sqlresolveskommune_nrthroughregion_code_to_kommune_nr/classify_region_code, same as every other kommune-grain source. Measured: of 18,816 raw rows, 18,768 are real kommune rows (all resolve), 48 are Svalbard (correctly NULLkommune_nr,region_kind = 'svalbard').
Phase 2: Ingest module + raw table
Tasks
- 2.1 Created
atlas-data/ingest/src/sources/udir-gsi/:parse.ts— the real response shape turned out simpler than feared once the right query was found: pinningTrinnID(-10)_KjoennID(-10)_KommunalitetID(-10)(the "alle" sentinel for each breakdown dimension) collapses the column metadata to exactly 4 named measures — no nested trinn × kjønn pivot to decode. The real complexity turned out to be elsewhere:inkluderKoder=trueis required to get real codes into row data at all, and a row'sidis an internal path, not a usable code (see Phase 1.6/[Q6]).index.ts— resolves the current report version viaRapportside(no hardcoded rapportNr/rapportVersjon), discovers every valid school year viafilterVerdier(no hardcoded range), fetches.../data?filter=TidID(<year>)_TrinnID(-10)_KjoennID(-10)_KommunalitetID(-10)&radSti=-12.*.*&inkluderKoder=trueper year, upsertsraw.udir_gsi.fetch_retry.ts— copy, adapted header comment.manifest.yml—source_id: udir-gsi,provider: udir,periodicity: P1Y,eu_theme: EDUC,tags.topic: education,license: NLOD(confirmed on Udir's own page, cited directly — unlike IMDi's default).README.mdand__tests__/— golden-file tests against 4 real downloaded responses (Rapportside, 2 years of data, a trimmed filterVerdier years list).
- 2.2 Migration
raw.udir_gsi—region_code(notkommune_nr— see [Q6]),year,measure,value,loaded_at. PK(region_code, year, measure). - 2.3 Dagster registration —
cadence.weekly_polled()/WEEKLY_FRESHNESS, added to the existingannual_sources_refreshjob (no new job needed).
Validation
cd atlas-data/ingest && npm test -- udir-gsi
✅ Done, 2026-10-02. 26 tests pass. A live run against the real API discovered all 12 available
school years (2014-15 through 2025-26) and produced 18,816 real rows end to end (NDJSON + local
Postgres), zero rows silently dropped — including the historical kommune-count decline (1,716 rows
in 2014-15 down to 1,432 in 2024-25/2025-26, matching Norway's real kommune mergers over that
period). npm run typecheck clean.
Phase 3: dbt staging and marts
Tasks
- 3.1 Added
raw.udir_gsitosources.yml. - 3.2
indicators__udir_gsi.sql—kommune_nrviaregion_code_to_kommune_nr/classify_region_code, not a bare passthrough (Phase 1.6's original claim was wrong — see [Q6]).region_kindalso derived, same as every other kommune-grain source. - 3.3 Documented columns in
schema.yml; validated against a local Postgres loaded with the real ingest (18,816 rows), not an empty schema. - 3.4
mart_indicators__udir_gsi.sqlapi passthrough +marts/api/schema.ymlentry. - 3.5
dbt build+dbt test— 16 checks, all green.
Validation
✅ Done, 2026-10-02, against a local Postgres loaded via the real ingest. Checked the
kommune_nr relationship test was not silently passing on an all-null or trivially-small sample
— it was not: select region_kind, count(*), count(kommune_nr) from marts.indicators__udir_gsi group by region_kind shows 18,768 kommune rows all with a non-null kommune_nr, and 48
svalbard rows all correctly NULL — a real, measured split, not a vacuous pass.
Phase 4: Deploy and verify arrival
Tasks
- 4.1 Regenerated every drift-gated artifact up front — see Phase 3's validation note. All 17
dbt checks and the website build were green on the first CI push (PR #504); no follow-up fix
commits needed, unlike
imdi-bosetting's raw-table-count miss. - 4.2 Filed the deploy request to imac, as
urb-agents#1806. Named the exact
relations (
raw.udir_gsi,api_v1.indicators__udir_gsi/mart_indicators__udir_gsi), both image digests labelled by object (ghcr.io/terchris/atlas-data:v20261002-c1e9af2code-location,ghcr.io/terchris/atlas-data/uis:v20261002-c1e9af2artifact), and the derivedLANDS WITH(atlas-data/uis/lands-with.sh 8e672cf..c1e9af2:annual_sources_refreshthentransform_and_publish—udir-gsiwas added to the existingannual_sources_refreshjob, no new job needed). Row-count prediction: 18,816 (measured locally 2026-10-02), with the same "2025-26 is still open, a republish could move it" caveat class as every annual/monthly source this session, plus an explicit check for the Svalbard split (18,768 real kommune rows vs. 48region_kind = 'svalbard'rows withkommune_nr IS NULL) so a non-~48 null count would read as a real signal, not noise. - 4.3 Verified arrival, 2026-10-02 — imac ran
annual_sources_refresh(runfa67a54a, 1065.0s) thentransform_and_publish(run092cebd2, 707.4s), both SUCCESS,raw.udir_gsilanded exactly 18,816 rows matching this plan's own prediction. Not taken on trust — independently re-checked against the live public API myself before closing:All four checks matched exactly — including the Svalbard split, confirmed 48/48, this plan's own "a null count other than ~48 would be worth a second look" criterion never triggered. UnlikeGET /meta_sources?source_id=eq.udir-gsi&select=served_as-> [{"served_as":["indicators__udir_gsi"]}]GET /indicators__udir_gsi?region_code=eq.4203&year=eq.202510&order=measure-> 4 rows: Antall elever=5266, med forsterket opplæring i norsk=176,individuelt tilrettelagt/spesialundervisning=527, Antall skoler=20HEAD with Prefer: count=exact-> content-range 0-18815/18816 (cf-cache-status: MISS — a fresh server read, not stale cache)GET /indicators__udir_gsi?kommune_nr=is.null®ion_kind=eq.svalbard&select=region_code-> 48 rows: region_code 2100 x28, 2111 x20imdi-bosetting's deploy, nothing needed tracing this time — imac's report and this agent's independent check agreed on every figure on the first pass. No regression inimdi-bosetting(7,848),nav-uforetrygd(6,560),bufdir-barnevern(191,673) orfhi-innvandrere(123,680). Full exchange: urb-agents#1806, closedcompleted.
Acceptance Criteria
-
udir-gsiingests cleanly from all 12 available school years with zero rows silently dropped. 18,816 rows. -
raw.udir_gsistores a real SSB-formatregion_codedirectly (verified, not a crosswalk or a guess) — namedregion_code, notkommune_nr, because it is not always a real kommune (Svalbard).kommune_nris resolved in the indicators model viaclassify_region_code. - The suppression marker is identified from real data, not assumed. Literal
*. -
indicators__udir_gsiandmart_indicators__udir_gsibuild and test clean against real loaded data. -
udir-gsiappears inmeta_sources.served_asafter a real deploy, independently verified via livecurl.served_as: ["indicators__udir_gsi"]. - The investigation and
1PRIORITY.mdare updated to mark this candidate shipped.
Implementation Notes
- The corrected API base URL is the single most important fact in this plan. Udir's own public
documentation page names a dead host (
api.udir-statistikkbanken.no— note the hyphen). The real host isapi.statistikkbanken.udir.no. Worth reporting to Udir via their feedback URL (udir.no/om-udir/kontakt-oss/, already inpublishers.yaml) once this ships, since their own documented example doesn't work for anyone who tries it. - Swagger doc:
https://statistikkportalen.udir.no/api/rapportering/swagger/v1/swagger.json— read this before writingparse.ts. It documents thefilter/radStiquery syntax precisely (FylkeID(*)_KarakterTypeID(1_2)_TidID(201506)style — parenthetical, underscore-separated,*for all values). - Schema/table discovery is itself live-queryable —
GET /rest/v1/StatistikkandGET /rest/v1/Statistikk/{skjema}return the current table list. Worth checking at the start of implementation in case the table set has moved since 2026-10-02. - This plan deliberately does not build
dim_school— see [Q2]. If a future source needs per-school resolution, revisit then; don't speculatively build a crosswalk this plan doesn't need.
Outcome
Shipped end to end, 2026-10-02: ingest (12 school years, 18,816 rows, zero dropped) → dbt staging
and api_v1 publication → live cluster deploy → independently verified arrival. Atlas's first Udir
source. Two real findings during implementation that Phase 1's research didn't anticipate: the
response shape is simpler than feared once the right query was found (pinning three breakdown
filters to Udir's own "alle" sentinel collapses a ~420-column cross-tab to exactly 4 named
measures, with no separate text-lookup needed to name them), and the Phase 1 claim that
kommune_nr needed no derivation macro was wrong — Svalbard sits at the same API hierarchy depth
as genuine kommuner, caught and fixed before anything shipped by renaming the raw column to
region_code and resolving kommune_nr through this project's existing classify_region_code
macro. Also corrected Udir's own public documentation, which names a dead API hostname. Plugs the
current-enrolment supply signal Report #10 (School-Capacity Forecast) was missing.
Files to Modify
atlas-data/ingest/src/sources/udir-gsi/manifest.yml(new)atlas-data/ingest/src/sources/udir-gsi/index.ts(new)atlas-data/ingest/src/sources/udir-gsi/parse.ts(new)atlas-data/ingest/src/sources/udir-gsi/fetch_retry.ts(new, copied)atlas-data/ingest/src/sources/udir-gsi/README.md(new)atlas-data/ingest/src/sources/udir-gsi/__tests__/(new)atlas-data/ingest/package.json(ingest:udir-gsiscript)atlas-data/migrations/059_raw_udir_gsi.sql(new)atlas-data/dagster/atlas_data/assets/raw_other.py,schedules.py(asset registration)atlas-data/dbt/models/indicators/sources.yml,indicators__udir_gsi.sql(new),schema.ymlatlas-data/dbt/models/marts/api/mart_indicators__udir_gsi.sql(new),schema.ymlatlas-data/dbt/models/marts/api/mart_atlas_inventory.sql(depends_on + counted list)atlas-data/dbt/scripts/generate_api_v1.py(SCHEMA_COMMENT'sudirlisting)atlas-data/template-info.yaml,website/docs/developers/index.md(regenerated counts)website/docs/ai-developer/plans/backlog/INVESTIGATE-new-norwegian-public-sources.md(mark shipped; re-rank [Q39]/[Q40] as fast follow-ups per Phase 1.4's finding)website/docs/ai-developer/plans/backlog/1PRIORITY.md(mark shipped)