Skip to Content
The HIC Learning Exchange begins July 13, 2026. View the agenda
LabItem 11 of 22 · 2 hr

Lab 3: Medallion dbt Modeling — DHIS2 Case

This is the hands-on session for “Lab 3: Setting Up Medallion Infrastructure — Bronze, Silver, Gold” (event day 10), building on dbt Training: From Installation to First Model and Introduction to Data Modelling: DHIS2 aggregate and tracker data, organized into Bronze, Silver, and Gold layers.

What you'll learn
  • Declare raw DHIS2 tables as dbt sources in the Bronze layer
  • Build Bronze models as thin, 1:1 views over raw aggregate and tracker tables
  • Conform Silver models that resolve UID/ID references into human-readable names
  • Build a Gold-layer star schema with dim_ and fact_ models for aggregate and tracker data
  • Configure per-layer materializations and incremental fact tables in dbt_project.yml
  • Write relationships, unique, and not_null tests across the Bronze, Silver, and Gold layers
Before you start
Download the lab files (.zip)

1. Introduction

DHIS2 stores two fundamentally different kinds of data: aggregate data (totals reported per org unit, period, and category — e.g. “120 malaria cases in Kigali District, March 2026”) and tracker data (individual-level records — clients, enrollments, and events, e.g. a patient’s ANC visit history). Both need to end up analysis-ready in the warehouse, but they start from very different raw tables.

We model both through the same three-layer pattern:

Bronze, Silver, and Gold are the medallion names for the landing, staging, and marts layers you saw in the introduction lecture.

Assumption for this demo: raw DHIS2 PostgreSQL tables (or API extracts landed as tables) are replicated into a raw schema in the warehouse before dbt runs. If you are extracting via the DHIS2 Web API into JSON/Parquet instead, the Bronze layer just becomes a parsing step — the Silver and Gold layers below stay identical.

2. DHIS2 Data Model Recap

AggregateTracker
One row = one summary number for an org unit + period + data element + category comboOne row = one thing that happened to one specific person/entity
Core table: datavalueCore tables: trackedentityinstance, programinstance, programstageinstance
Good for: routine reporting (HMIS totals)Good for: case-based / longitudinal analysis (patient journeys)
No individual identityIndividual identity (de-identified via UID)

3. Layer Architecture

LayerWhat it isMaterialization
BronzeExact structural copy of the source tables. No renaming, no joins, minimal typing fixes.Views — cheap, always fresh
SilverHuman-readable, deduplicated, typed, and joined to resolve UID/ID references into names. One row still means the same thing as in Bronze — we are just making it usable.Views or tables
GoldDimensional star schema: separate dim_ and fact_ models, ready for BI tools (, PowerBI).Tables, often incremental for large fact tables

4. Project Structure

        • bronze_dataelement.sql
        • bronze_dataset.sql
        • bronze_indicator.sql
        • bronze_period.sql
        • bronze_organisationunit.sql
        • bronze_categorycombo.sql
        • bronze_categoryoptioncombo.sql
        • bronze_datavalue.sql
        • bronze_trackedentityinstance.sql
        • bronze_trackedentityattributevalue.sql
        • bronze_program.sql
        • bronze_programstage.sql
        • bronze_programinstance.sql
        • bronze_programstageinstance.sql
        • bronze_trackedentitydatavalue.sql
        • bronze_relationship.sql
        • silver_org_units.sql
        • silver_periods.sql
        • silver_data_elements.sql
        • silver_category_option_combos.sql
        • silver_aggregate_datavalues.sql
        • silver_tracked_entities.sql
        • silver_enrollments.sql
        • silver_events.sql
        • silver_event_data_values.sql
        • dim_org_unit.sql
        • dim_period.sql
        • dim_data_element.sql
        • dim_category_option_combo.sql
        • fact_aggregate_data_value.sql
        • dim_tracked_entity.sql
        • dim_program.sql
        • fact_enrollment.sql
        • fact_event.sql
        • fact_event_data_value.sql

5. Bronze — Declaring Sources

Every raw table is declared once in models/bronze/sources.yml, pointing at the schema where DHIS2’s tables land.

models/bronze/sources.yml
version: 2 sources: - name: dhis2_raw schema: raw tables: # aggregate - name: dataelement - name: dataset - name: indicator - name: period - name: periodtype - name: organisationunit - name: _orgunitstructure - name: categorycombo - name: categoryoptioncombo - name: categoryoption - name: datavalue # tracker - name: trackedentityinstance - name: trackedentityattribute - name: trackedentityattributevalue - name: program - name: programstage - name: programinstance - name: programstageinstance - name: trackedentitydatavalue - name: relationship - name: relationshiptype

6. Bronze — Aggregate Raw Tables

TableWhat it holds
dataelementDefinitions of what is being measured (e.g. “ANC 1st visit”)
datasetGroupings of data elements collected together on one form
indicatorCalculated metrics (numerator/denominator formulas)
periodThe time period a value applies to (monthly, quarterly…)
organisationunitThe facility/district/region hierarchy
_orgunitstructureFlattened org unit hierarchy (level 1–5 names)
categorycombo / categoryoptioncomboDisaggregations (e.g. by age/sex)
datavalueThe actual reported number — one row per org unit + period + data element + category option combo

Bronze models are thin — just a straight select from the source, with light type casting:

models/bronze/aggregate/bronze_datavalue.sql
-- models/bronze/aggregate/bronze_datavalue.sql select dataelementid, periodid, sourceid as organisationunitid, categoryoptioncomboid, attributeoptioncomboid, cast(value as numeric) as value, storedby, lastupdated, created from {{ source('dhis2_raw', 'datavalue') }}
models/bronze/aggregate/bronze_dataelement.sql
-- models/bronze/aggregate/bronze_dataelement.sql select dataelementid, uid, name, shortname, domaintype, -- AGGREGATE or TRACKER valuetype, lastupdated from {{ source('dhis2_raw', 'dataelement') }}
models/bronze/aggregate/bronze_organisationunit.sql
-- models/bronze/aggregate/bronze_organisationunit.sql select organisationunitid, uid, name, parentid, path, hierarchylevel from {{ source('dhis2_raw', 'organisationunit') }}

The remaining aggregate Bronze models (bronze_dataset, bronze_indicator, bronze_period, bronze_categorycombo, bronze_categoryoptioncombo) follow the same one-to-one pattern.

7. Bronze — Tracker Raw Tables

TableWhat it holds
trackedentityinstanceOne row per tracked person/entity (client, household…)
trackedentityattributeDefinitions of person-level attributes (e.g. “Date of Birth”)
trackedentityattributevalueThe actual attribute values per entity
programA tracker program (e.g. “ANC Program”)
programstageA stage within a program (e.g. “ANC Visit 1”)
programinstanceAn enrollment — one entity enrolled in one program
programstageinstanceAn event — one occurrence of a program stage
trackedentitydatavalueThe data values captured on a specific event
relationship / relationshiptypeLinks between entities (e.g. mother–child)
models/bronze/tracker/bronze_trackedentityinstance.sql
-- models/bronze/tracker/bronze_trackedentityinstance.sql select trackedentityinstanceid, uid, organisationunitid, trackedentitytypeid, created, lastupdated, deleted from {{ source('dhis2_raw', 'trackedentityinstance') }}
models/bronze/tracker/bronze_programinstance.sql
-- models/bronze/tracker/bronze_programinstance.sql select programinstanceid, uid, trackedentityinstanceid, programid, organisationunitid, enrollmentdate, incidentdate, status, -- ACTIVE, COMPLETED, CANCELLED lastupdated from {{ source('dhis2_raw', 'programinstance') }}
models/bronze/tracker/bronze_programstageinstance.sql
-- models/bronze/tracker/bronze_programstageinstance.sql select programstageinstanceid, uid, programinstanceid, programstageid, organisationunitid, executiondate, duedate, status, -- COMPLETED, SCHEDULE, SKIPPED, OVERDUE lastupdated from {{ source('dhis2_raw', 'programstageinstance') }}
models/bronze/tracker/bronze_trackedentitydatavalue.sql
-- models/bronze/tracker/bronze_trackedentitydatavalue.sql select programstageinstanceid, dataelementid, value, storedby, lastupdated from {{ source('dhis2_raw', 'trackedentitydatavalue') }}

The remaining tracker Bronze models (bronze_trackedentityattribute, bronze_trackedentityattributevalue, bronze_program, bronze_programstage, bronze_relationship) follow the same pattern.

CheckpointBronze layer builds cleanly
You should see:
$ dbt run --select bronze.* ... 16 of 16 OK created sql view model bronze.bronze_trackedentitydatavalue .... [CREATE VIEW in 0.11s] Completed successfully Done. PASS=16 WARN=0 ERROR=0 SKIP=0 TOTAL=16
Not seeing this?
  • ERROR relation "raw.datavalue" does not exist — the source table isn’t replicated yet, or schema: raw in sources.yml doesn’t match where DHIS2’s tables actually landed. Confirm with select * from raw.datavalue limit 1; before re-running.
  • A bronze view builds but returns 0 rows — the upstream replication job hasn’t populated that table yet; this is a source problem, not a dbt problem. Check the raw schema directly.
  • permission denied for schema raw — the warehouse role running dbt lacks USAGE/SELECT on raw; grant it before continuing.

8. Silver — Aggregate Conformed Models

models/silver/aggregate/silver_org_units.sql
-- models/silver/aggregate/silver_org_units.sql select o.organisationunitid, o.uid as org_unit_uid, o.name as org_unit_name, s.namelevel1 as national_name, s.namelevel2 as province_name, s.namelevel3 as district_name, s.namelevel4 as facility_name, o.hierarchylevel from {{ ref('bronze_organisationunit') }} o left join {{ source('dhis2_raw', '_orgunitstructure') }} s on o.organisationunitid = s.organisationunitid
models/silver/aggregate/silver_aggregate_datavalues.sql
-- models/silver/aggregate/silver_aggregate_datavalues.sql select dv.dataelementid, de.name as data_element_name, dv.organisationunitid, dv.periodid, p.startdate as period_start_date, p.enddate as period_end_date, dv.categoryoptioncomboid, dv.value, dv.lastupdated from {{ ref('bronze_datavalue') }} dv left join {{ ref('bronze_dataelement') }} de on dv.dataelementid = de.dataelementid left join {{ ref('bronze_period') }} p on dv.periodid = p.periodid where dv.value is not null

Similarly, silver_periods, silver_data_elements, and silver_category_option_combos resolve names/hierarchies for use in Gold-layer joins.

9. Silver — Tracker Conformed Models

models/silver/tracker/silver_tracked_entities.sql
-- models/silver/tracker/silver_tracked_entities.sql select tei.trackedentityinstanceid, tei.uid as tracked_entity_uid, tei.organisationunitid, ou.org_unit_name, ou.district_name, tei.created as registration_date, tei.deleted from {{ ref('bronze_trackedentityinstance') }} tei left join {{ ref('silver_org_units') }} ou on tei.organisationunitid = ou.organisationunitid where tei.deleted = false
models/silver/tracker/silver_enrollments.sql
-- models/silver/tracker/silver_enrollments.sql select pi.programinstanceid, pi.uid as enrollment_uid, pi.trackedentityinstanceid, pi.programid, prog.name as program_name, pi.organisationunitid, pi.enrollmentdate, pi.incidentdate, pi.status as enrollment_status from {{ ref('bronze_programinstance') }} pi left join {{ ref('bronze_program') }} prog on pi.programid = prog.programid
models/silver/tracker/silver_events.sql
-- models/silver/tracker/silver_events.sql select psi.programstageinstanceid, psi.uid as event_uid, psi.programinstanceid, psi.programstageid, stg.name as program_stage_name, psi.organisationunitid, psi.executiondate, psi.duedate, psi.status as event_status from {{ ref('bronze_programstageinstance') }} psi left join {{ ref('bronze_programstage') }} stg on psi.programstageid = stg.programstageid
models/silver/tracker/silver_event_data_values.sql
-- models/silver/tracker/silver_event_data_values.sql select tdv.programstageinstanceid, tdv.dataelementid, de.name as data_element_name, tdv.value, tdv.lastupdated from {{ ref('bronze_trackedentitydatavalue') }} tdv left join {{ ref('bronze_dataelement') }} de on tdv.dataelementid = de.dataelementid
CheckpointSilver resolves names and hierarchies
You should see:
$ dbt run --select silver.* ... Done. PASS=9 WARN=0 ERROR=0 SKIP=0 TOTAL=9 select org_unit_name, district_name from silver_org_units limit 3; org_unit_name | district_name ---------------------------+------------------ Kigali Health Center | Kigali District Nyagatare District Hosp. | Nyagatare District Huye Health Post | Huye District
Not seeing this?
  • district_name is null for most rows — the left join to _orgunitstructure isn’t matching because hierarchylevel differs between the two tables, or namelevel3/namelevel4 map to the wrong tier in your DHIS2 instance’s hierarchy. Check the level mapping against organisationunit.path.
  • silver_aggregate_datavalues returns 0 rows even though bronze_datavalue has data — the cast(value as numeric) in Bronze silently failed on a non-numeric string upstream, or the where dv.value is not null filter is dropping everything because the join to bronze_period/bronze_dataelement produced unexpected nulls.
  • Still seeing raw IDs instead of names — you’re querying the Bronze table, not the Silver one; Bronze is intentionally still keyed on raw IDs.

10. Gold — Aggregate Star Schema

models/gold/aggregate/dim_org_unit.sql
-- models/gold/aggregate/dim_org_unit.sql select distinct organisationunitid as org_unit_key, org_unit_uid, org_unit_name, national_name, province_name, district_name, facility_name, hierarchylevel from {{ ref('silver_org_units') }}
models/gold/aggregate/dim_period.sql
-- models/gold/aggregate/dim_period.sql select distinct periodid as period_key, period_start_date, period_end_date, extract(year from period_start_date) as year, extract(quarter from period_start_date) as quarter, extract(month from period_start_date) as month from {{ ref('silver_aggregate_datavalues') }}
models/gold/aggregate/fact_aggregate_data_value.sql
-- models/gold/aggregate/fact_aggregate_data_value.sql select dataelementid as data_element_key, organisationunitid as org_unit_key, periodid as period_key, categoryoptioncomboid as category_option_combo_key, value, lastupdated from {{ ref('silver_aggregate_datavalues') }}

Why this shape: one fact table (fact_aggregate_data_value) joined to dimensions gives BI tools a single, fast query surface — “show me ANC visits by district by quarter” becomes a simple join, no matter how the raw DHIS2 tables were structured.

11. Gold — Tracker Star Schema

models/gold/tracker/dim_tracked_entity.sql
-- models/gold/tracker/dim_tracked_entity.sql select distinct trackedentityinstanceid as tracked_entity_key, tracked_entity_uid, org_unit_name, district_name, registration_date from {{ ref('silver_tracked_entities') }}
models/gold/tracker/dim_program.sql
-- models/gold/tracker/dim_program.sql select distinct programid as program_key, program_name from {{ ref('silver_enrollments') }}
models/gold/tracker/fact_enrollment.sql
-- models/gold/tracker/fact_enrollment.sql select programinstanceid as enrollment_key, trackedentityinstanceid as tracked_entity_key, programid as program_key, organisationunitid as org_unit_key, enrollmentdate, incidentdate, enrollment_status from {{ ref('silver_enrollments') }}
models/gold/tracker/fact_event.sql
-- models/gold/tracker/fact_event.sql select programstageinstanceid as event_key, programinstanceid as enrollment_key, programstageid as program_stage_key, organisationunitid as org_unit_key, executiondate, duedate, event_status from {{ ref('silver_events') }}
models/gold/tracker/fact_event_data_value.sql
-- models/gold/tracker/fact_event_data_value.sql select programstageinstanceid as event_key, dataelementid as data_element_key, data_element_name, value from {{ ref('silver_event_data_values') }}
CheckpointGold star schema queries in a single join
You should see:
$ dbt run --select gold.* ... Done. PASS=10 WARN=0 ERROR=0 SKIP=0 TOTAL=10 select o.district_name, p.quarter, sum(f.value) as total from fact_aggregate_data_value f join dim_org_unit o on f.org_unit_key = o.org_unit_key join dim_period p on f.period_key = p.period_key group by 1, 2 order by 1, 2; district_name | quarter | total -------------------+---------+------- Kigali District | 1 | 842 Kigali District | 2 | 915 Nyagatare District| 1 | 367
Not seeing this?
  • The join to dim_org_unit or dim_period returns no matching rows — a key-type mismatch between the fact and the dimension (e.g. org_unit_key stored as bigint in one model and cast to text in another). Check both select distinct dimension models and the fact model use the same source column type.
  • fact_aggregate_data_value has duplicate rows after a second dbt run — the unique_key on the incremental config in dbt_project.yml doesn’t match the fact’s actual grain; re-run with --full-refresh after fixing it.
  • dim_program or dim_tracked_entity is missing rows present in Silver — remember these dimensions are built with select distinct off a Silver model that already filtered something out (e.g. where tei.deleted = false); check the Silver model’s where clause first.

12. Materialization Config

Set default materializations per layer in dbt_project.yml so nobody has to remember to configure each model individually:

dbt_project.yml
# dbt_project.yml models: my_dhis2_project: bronze: +materialized: view silver: +materialized: view gold: +materialized: table aggregate: fact_aggregate_data_value: +materialized: incremental +unique_key: [data_element_key, org_unit_key, period_key, category_option_combo_key] tracker: fact_event_data_value: +materialized: incremental +unique_key: [event_key, data_element_key]

13. Testing Strategy

LayerWhat to test
Bronzenot_null on primary keys — catch broken replication early
Silverrelationships tests — confirm every foreign key resolves (e.g. every datavalue’s org unit exists)
Goldunique + not_null on dimension keys; row-count freshness checks on facts
models/gold/aggregate/schema.yml
# models/gold/aggregate/schema.yml version: 2 models: - name: dim_org_unit columns: - name: org_unit_key tests: [unique, not_null] - name: fact_aggregate_data_value columns: - name: org_unit_key tests: - relationships: to: ref('dim_org_unit') field: org_unit_key
CheckpointSchema tests pass across all three layers
You should see:
$ dbt test ... Done. PASS=6 WARN=0 ERROR=0 SKIP=0 TOTAL=6
Not seeing this?
  • relationships test on org_unit_key FAILS — a fact row references an org unit that was merged or deleted in DHIS2 after the fact was recorded. Either backfill dim_org_unit with historical (soft-deleted) units, or filter orphaned rows out in the Silver model.
  • not_null FAILS on a dimension key — the select distinct in the Gold model is passing through a null from an unresolved Silver join upstream; trace the null back to the Silver left join that produced it.
  • unique FAILS on org_unit_keydim_org_unit is built from select distinct over columns that aren’t actually unique per org unit (e.g. org_unit_name repeats across districts); distinct on the full row isn’t the same as distinct on the key.

14. Running the Pipeline

# run everything, in dependency order dbt run # run just the aggregate branch, bronze through gold dbt run --select path:models/bronze/aggregate+ path:models/silver/aggregate+ path:models/gold/aggregate+ # run just the tracker branch dbt run --select path:models/bronze/tracker+ path:models/silver/tracker+ path:models/gold/tracker+ # run one layer at a time (good for a live walkthrough) dbt run --select bronze.* dbt run --select silver.* dbt run --select gold.* # test everything dbt test

15. Live Demo Flow

  1. Show the raw datavalue and trackedentityinstance tables directly in the warehouse — point out how unreadable the IDs are
  2. Run dbt run --select bronze.* — 1:1 views appear, still using raw IDs
  3. Run dbt run --select silver.* — names and hierarchies resolve, data becomes human-readable
  4. Run dbt run --select gold.* — dims and facts appear
  5. Query fact_aggregate_data_value joined to dim_org_unit + dim_period — “ANC visits by district by quarter” in one simple SQL join
  6. Query fact_event joined to dim_tracked_entity — a patient’s full visit history in one join
  7. Run dbt test — show relationship tests catching a deliberately broken org unit reference
CheckpointThe broken-reference demo is caught by dbt test
You should see:
$ dbt test --select fact_aggregate_data_value 1 of 1 START test relationships_fact_aggregate_data_value_org_unit_key__org_unit_key__ref_dim_org_unit_ 1 of 1 FAIL 1 relationships_fact_aggregate_data_value_org_unit_key__org_unit_key__ref_dim_org_unit_ .. [FAIL 1 in 0.08s] Done. PASS=0 WARN=0 ERROR=0 FAIL=1 SKIP=0 TOTAL=1
Not seeing this?
  • The test PASSes when it’s supposed to FAIL — the deliberately broken org unit ID has to be inserted into the raw source before re-running dbt run, not after; a stale Gold table still reflects the previous, clean data.
  • The test FAILs but the fact count is 0 — you targeted fact_event instead of fact_aggregate_data_value; the broken-reference demo above is written against the aggregate branch.
  • dbt run errors out instead of the test failing — you broke a not null foreign key column (e.g. set it to NULL) rather than pointing it at a non-existent org unit; use an ID that doesn’t exist in organisationunit, not a null.