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.
- 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
- Completed dbt Training: From Installation to First Model and Introduction to Data Modelling
- dbt installed and connected to a warehouse (see the dbt training guide’s prerequisites)
- Raw DHIS2 PostgreSQL tables (or Web API extracts landed as tables) replicated into a
rawschema in that warehouse before running dbt
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
| Aggregate | Tracker |
|---|---|
| One row = one summary number for an org unit + period + data element + category combo | One row = one thing that happened to one specific person/entity |
Core table: datavalue | Core tables: trackedentityinstance, programinstance, programstageinstance |
| Good for: routine reporting (HMIS totals) | Good for: case-based / longitudinal analysis (patient journeys) |
| No individual identity | Individual identity (de-identified via UID) |
3. Layer Architecture
| Layer | What it is | Materialization |
|---|---|---|
| Bronze | Exact structural copy of the source tables. No renaming, no joins, minimal typing fixes. | Views — cheap, always fresh |
| Silver | Human-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 |
| Gold | Dimensional 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.
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: relationshiptype6. Bronze — Aggregate Raw Tables
| Table | What it holds |
|---|---|
dataelement | Definitions of what is being measured (e.g. “ANC 1st visit”) |
dataset | Groupings of data elements collected together on one form |
indicator | Calculated metrics (numerator/denominator formulas) |
period | The time period a value applies to (monthly, quarterly…) |
organisationunit | The facility/district/region hierarchy |
_orgunitstructure | Flattened org unit hierarchy (level 1–5 names) |
categorycombo / categoryoptioncombo | Disaggregations (e.g. by age/sex) |
datavalue | The 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
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
select
dataelementid,
uid,
name,
shortname,
domaintype, -- AGGREGATE or TRACKER
valuetype,
lastupdated
from {{ source('dhis2_raw', 'dataelement') }}-- 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
| Table | What it holds |
|---|---|
trackedentityinstance | One row per tracked person/entity (client, household…) |
trackedentityattribute | Definitions of person-level attributes (e.g. “Date of Birth”) |
trackedentityattributevalue | The actual attribute values per entity |
program | A tracker program (e.g. “ANC Program”) |
programstage | A stage within a program (e.g. “ANC Visit 1”) |
programinstance | An enrollment — one entity enrolled in one program |
programstageinstance | An event — one occurrence of a program stage |
trackedentitydatavalue | The data values captured on a specific event |
relationship / relationshiptype | Links between entities (e.g. mother–child) |
-- 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
select
programinstanceid,
uid,
trackedentityinstanceid,
programid,
organisationunitid,
enrollmentdate,
incidentdate,
status, -- ACTIVE, COMPLETED, CANCELLED
lastupdated
from {{ source('dhis2_raw', 'programinstance') }}-- 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
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.
$ 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=16Not seeing this?
ERROR relation "raw.datavalue" does not exist— the source table isn’t replicated yet, orschema: rawinsources.ymldoesn’t match where DHIS2’s tables actually landed. Confirm withselect * 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 lacksUSAGE/SELECTonraw; grant it before continuing.
8. Silver — Aggregate Conformed Models
-- 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
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 nullSimilarly, 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
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
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
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
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$ 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 DistrictNot seeing this?
district_nameisnullfor most rows — theleft jointo_orgunitstructureisn’t matching becausehierarchyleveldiffers between the two tables, ornamelevel3/namelevel4map to the wrong tier in your DHIS2 instance’s hierarchy. Check the level mapping againstorganisationunit.path.silver_aggregate_datavaluesreturns 0 rows even thoughbronze_datavaluehas data — thecast(value as numeric)in Bronze silently failed on a non-numeric string upstream, or thewhere dv.value is not nullfilter is dropping everything because the join tobronze_period/bronze_dataelementproduced 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
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
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
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
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
select distinct
programid as program_key,
program_name
from {{ ref('silver_enrollments') }}-- 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
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
select
programstageinstanceid as event_key,
dataelementid as data_element_key,
data_element_name,
value
from {{ ref('silver_event_data_values') }}$ 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 | 367Not seeing this?
- The join to
dim_org_unitordim_periodreturns no matching rows — a key-type mismatch between the fact and the dimension (e.g.org_unit_keystored asbigintin one model and cast totextin another). Check bothselect distinctdimension models and the fact model use the same source column type. fact_aggregate_data_valuehas duplicate rows after a seconddbt run— theunique_keyon the incremental config indbt_project.ymldoesn’t match the fact’s actual grain; re-run with--full-refreshafter fixing it.dim_programordim_tracked_entityis missing rows present in Silver — remember these dimensions are built withselect distinctoff a Silver model that already filtered something out (e.g.where tei.deleted = false); check the Silver model’swhereclause 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
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
| Layer | What to test |
|---|---|
| Bronze | not_null on primary keys — catch broken replication early |
| Silver | relationships tests — confirm every foreign key resolves (e.g. every datavalue’s org unit exists) |
| Gold | unique + not_null on dimension keys; row-count freshness checks on facts |
# 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$ dbt test
...
Done. PASS=6 WARN=0 ERROR=0 SKIP=0 TOTAL=6Not seeing this?
relationshipstest onorg_unit_keyFAILS — a fact row references an org unit that was merged or deleted in DHIS2 after the fact was recorded. Either backfilldim_org_unitwith historical (soft-deleted) units, or filter orphaned rows out in the Silver model.not_nullFAILS on a dimension key — theselect distinctin the Gold model is passing through anullfrom an unresolved Silver join upstream; trace thenullback to the Silverleft jointhat produced it.uniqueFAILS onorg_unit_key—dim_org_unitis built fromselect distinctover columns that aren’t actually unique per org unit (e.g.org_unit_namerepeats 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 test15. Live Demo Flow
- Show the raw
datavalueandtrackedentityinstancetables directly in the warehouse — point out how unreadable the IDs are - Run
dbt run --select bronze.*— 1:1 views appear, still using raw IDs - Run
dbt run --select silver.*— names and hierarchies resolve, data becomes human-readable - Run
dbt run --select gold.*— dims and facts appear - Query
fact_aggregate_data_valuejoined todim_org_unit+dim_period— “ANC visits by district by quarter” in one simple SQL join - Query
fact_eventjoined todim_tracked_entity— a patient’s full visit history in one join - Run
dbt test— show relationship tests catching a deliberately broken org unit reference
$ 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=1Not seeing this?
- The test PASSes when it’s supposed to FAIL — the deliberately broken org unit ID has to be inserted into the
rawsource before re-runningdbt 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_eventinstead offact_aggregate_data_value; the broken-reference demo above is written against the aggregate branch. dbt runerrors out instead of the test failing — you broke anot nullforeign key column (e.g. set it toNULL) rather than pointing it at a non-existent org unit; use an ID that doesn’t exist inorganisationunit, not a null.