Back to the lesson·Lesson 2 of 8·How the database is shaped
The hierarchy that changes underneath you
The same deck as the downloads, rendered as a page. Start the slideshow to present it full screen — arrow keys or a click advance one slide, Escape leaves.
What this lesson covers
- One tree, and everything hangs off it
- Groups are not levels
- The property nobody warns you about
- What to do about it
- Reassignment, reconstructed
- Aggregating up without double-counting
- Coverage is a district-level indicator
- What comes next
Speaker notes
Org units, levels and groups, and the property nobody warns you about — the tree is current, not historical, so a facility reassigned in March silently rewrites last year's district totals.One tree, and everything hangs off it — Example
Level 1 Country Level 2 Region Level 3 District Level 4 Facility <- our 38 unitsSpeaker notes
Every value in the system is attached to an org unit, and org units form a single tree: country, region, district, facility. The level a unit sits at is a property of the tree, not a column on the unit.One tree, and everything hangs off it — In Python
import pandas as pd vax = pd.read_csv("vaccination-coverage-2024.v1.csv", parse_dates=["period"]) print(f"{vax['facility_id'].nunique()} facilities in the extract")Speaker notes
Aggregation is the tree walked upward. Ask for a district figure and the system sums every facility below it; ask for a national one and it sums every district. This is the same "aggregate to the grain" discipline the joining course taught, with the grain supplied by a hierarchy somebody else maintains.One tree, and everything hangs off it — In R
library(dplyr) n_distinct(vax$facility_id)Speaker notes
Thirty-eight, all at level 4. The extract carries no parent, which is the usual case and the reason most district totals in this sector are computed by joining to a facility list rather than by the system.Groups are not levels — In Python
by_type = ( vax.groupby("facility_type")["facility_id"].nunique().sort_values(ascending=False) ) print(by_type)Speaker notes
The extract has afacility_typecolumn, and it is worth being clear about what that is.Groups are not levels — In R
vax |> summarise(facilities = n_distinct(facility_id), .by = facility_type)Groups are not levels
Facility type Facilities Health post 20 Health centre 16 District hospital 2 Groups are not levels
- A unit belongs to exactly one parent and any number of groups. Health post is a group; it does not tell you where…
- Groups can overlap. A facility can be in "health post", "hard to reach" and "supported by partner X" at once, and…
- Group sets are the safe version. A group set is a set of mutually exclusive groups — facility type is usually one —…
- Ask whether the thing you are grouping by is a group set — If it is not, check that the groups partition before you sum
Speaker notes
That is an org unit group, not a level. The distinction matters in three practical ways: Ask whether the thing you are grouping by is a group set. If it is not, check that the groups partition before you sum.The property nobody warns you about
- DHIS2 stores the current hierarchy, not a historical one — A data value is attached to a facility
- A district total that differs from the same query run six months ago, with no data entry in between.
- A published annual figure that cannot be reproduced.
- Two reports of the same period disagreeing, both correct on the day they were run.
Speaker notes
Here is the one that costs people a quarter. DHIS2 stores the current hierarchy, not a historical one. A data value is attached to a facility. The facility is attached to a parent now. There is no record, in the ordinary data model, of which parent it had in March. So when a facility is reassigned from District A to District B — a boundary change, a reorganisation, a correction — all of its historical data moves with it. District A's total for last January, already reported and already in a donor's spreadsheet, changes the next time anybody asks for it. Three symptoms, all of which look like data quality problems and none of which is:What to do about it
- Pin the hierarchy with the extract — Pull the org unit list at the same time as the data and store it beside the values
Speaker notes
You cannot stop the hierarchy moving. You can stop it moving your numbers silently. Pin the hierarchy with the extract. Pull the org unit list at the same time as the data and store it beside the values.What to do about it — In Python
org_units = pd.read_csv("outputs/extract/2026-07/org-units.csv") # ea/facility id, name, parent_id, level, extracted_on pinned = vax.merge(org_units[["facility_id", "district_id"]], on="facility_id", how="left", validate="many_to_one") assert pinned["district_id"].notna().all(), "facility with no district in the pinned tree"What to do about it — In R
org_units <- readr::read_csv(here::here("outputs", "extract", "2026-07", "org-units.csv")) pinned <- vax |> left_join(select(org_units, facility_id, district_id), by = "facility_id", relationship = "many-to-one") stopifnot(!any(is.na(pinned$district_id)))What to do about it
- Aggregate from the pinned tree, not from the live one — Then a figure published in March is reproducible in September,…
- Record the extraction date on both — The next lesson but one makes that a property of the extract script rather than a…
Speaker notes
The join ismany_to_oneand the assertion is not decorative. A facility present in the data and absent from the pinned tree means the tree was pulled at a different time from the data, which is exactly the situation this is meant to prevent. Aggregate from the pinned tree, not from the live one. Then a figure published in March is reproducible in September, because the tree it used is on disk. Record the extraction date on both. The next lesson but one makes that a property of the extract script rather than a discipline.Reassignment, reconstructed — In Python
reassignments = pd.DataFrame([ {"facility_id": "FAC017", "from_district": "D01", "to_district": "D02", "effective": "2024-04-01", "reason": "boundary revision"}, ]) def district_at(facility, period, log=reassignments): moves = log[(log["facility_id"] == facility) & (pd.to_datetime(log["effective"]) <= period)] return moves["to_district"].iloc[-1] if len(moves) else NoneSpeaker notes
Where the reassignment date is known — and somebody always knows it, even if the system does not — the honest handling is to carry it explicitly.Reassignment, reconstructed — In R
reassignments <- tibble::tribble( ~facility_id, ~from_district, ~to_district, ~effective, ~reason, "FAC017", "D01", "D02", as.Date("2024-04-01"), "boundary revision" )Reassignment, reconstructed
District totals are computed on the org unit hierarchy as at 2026-07-28. One facility was reassigned from D01 to D02 in April 2024; its 2024 data appears under D02 throughout, including for periods before the reassignment.
Reassignment, reconstructed
- That sentence is the deliverable — It costs nothing and it turns an irreproducible number into a reproducible one
Speaker notes
That is a slowly changing dimension, and treating it as one is the correct answer. It is also more work than most teams will do, so the fallback is honest labelling: That sentence is the deliverable. It costs nothing and it turns an irreproducible number into a reproducible one.Aggregating up without double-counting
- A facility appearing twice in the tree — Impossible in DHIS2 — a unit has one parent — but entirely possible in the CSV…
Speaker notes
Two failure modes when you do the aggregation yourself. A facility appearing twice in the tree. Impossible in DHIS2 — a unit has one parent — but entirely possible in the CSV of facilities somebody emailed you, where a facility that moved appears under both districts.Aggregating up without double-counting — In Python
duplicated = org_units["facility_id"].duplicated(keep=False) assert not duplicated.any(), org_units.loc[duplicated, ["facility_id", "district_id"]]Aggregating up without double-counting — In R
stopifnot(!any(duplicated(org_units$facility_id)))Aggregating up without double-counting
- Summing across levels — A district total plus its facilities is the district counted twice
Speaker notes
Summing across levels. A district total plus its facilities is the district counted twice. This happens when an extract mixes levels — ask for level 4 only, and check.Aggregating up without double-counting — In Python
print(org_units["level"].value_counts())Aggregating up without double-counting
- Every extract should be at exactly one level — If it is not, the first thing your script does is filter to one, and say…
Speaker notes
Every extract should be at exactly one level. If it is not, the first thing your script does is filter to one, and say which.Coverage is a district-level indicator — In Python
by_facility = ( vax[vax["report_submitted"] == True] .query("antigen == 'penta3'") .groupby("facility_id") .agg(doses=("doses_administered", "sum"), target=("target_population", "sum")) ) by_facility["coverage"] = by_facility["doses"] / by_facility["target"] print(by_facility["coverage"].describe()[["min", "max"]].round(3))Speaker notes
A point from the survey course arriving here with a mechanism. Facility catchment populations overlap, and people cross boundaries to be vaccinated, so a facility-level coverage figure is a numerator from one population over a denominator from another.Coverage is a district-level indicator — In R
vax |> filter(report_submitted, antigen == "penta3") |> summarise(coverage = sum(doses_administered) / sum(target_population), .by = facility_id) |> summarise(min = min(coverage), max = max(coverage))Speaker notes
The spread across facilities is far wider than any real difference in service delivery, because the boundary crossing is noise at facility level and cancels at district level. Aggregate to the level where the crossing happens inside the unit, and report facility figures as workload rather than coverage.What comes next
- You can now place a value in space.
Speaker notes
You can now place a value in space. The next unit places it in time — period types, which period a late entry belongs to, and the completeness registration that decides whether the value was ever expected at all.