cassionData Analysis

Lesson 6 of 8

Unit · Getting the data out

An extract you can point at

One script, one dated folder, a manifest that records what was asked and when, and the raw response kept beside the tidy table — so a figure published in March can still be defended in September.

PythonR75 minUNICEF indicator definitionsCore Humanitarian Standard (CHS)Results-Based Management (RBM)

The question this lesson answers

Six months after you publish a district coverage figure, somebody reruns the query and gets something else. That will happen, and there are four legitimate reasons for it: late data entry, a revised denominator, a reassigned facility, and analytics tables that had not run.

You cannot prevent any of them. You can make it possible to say which one it was, and that is entirely a matter of what you kept.

The shape

extracts/
  2026-07-28/
    manifest.json          what was requested, by whom, when
    raw/
      analytics.json       the response, untouched
      dataValueSets.json
      dataElements.json
      organisationUnits.json
      dataSets.json
      indicators.json
    tidy/
      coverage_by_facility_period.csv

Three rules give that folder its value.

One dated folder per extraction, never overwritten. Disk is cheaper than the meeting.

The raw response is kept untouched. If your parsing turns out to be wrong, the data is still there. A tidy CSV is a derived artefact and a derived artefact cannot be un-derived.

The manifest is the point. Everything else is recoverable from it.

The manifest

import json
import datetime as dt
from pathlib import Path

def write_manifest(folder, request, system_info):
    manifest = {
        "extracted_at": dt.datetime.now(dt.timezone.utc).isoformat(),
        "extracted_by": "me-officer@example.org",
        "instance": request["base_url"],
        "dhis2_version": system_info.get("version"),
        "analytics_last_run": system_info.get("lastAnalyticsTableSuccess"),
        "request": {
            "endpoint": request["endpoint"],
            "dx": request["dx"],
            "ou": request["ou"],
            "pe": request["pe"],
        },
        "rows_returned": request["rows"],
        "script_version": "extract.py 1.3",
    }
    Path(folder, "manifest.json").write_text(json.dumps(manifest, indent=2))
    return manifest
manifest <- list(
  extracted_at        = format(Sys.time(), "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"),
  instance            = base_url,
  analytics_last_run  = system_info$lastAnalyticsTableSuccess,
  request             = list(endpoint = "analytics", dx = dx, ou = ou, pe = pe),
  rows_returned       = nrow(tidy),
  script_version      = "extract.R 1.3"
)

jsonlite::write_json(manifest, file.path(folder, "manifest.json"), auto_unbox = TRUE)

Two fields are the ones that earn their place. analytics_last_run tells you whether late entry could have been included. pe records the exact periods requested, which is what makes a relative window like “last twelve months” reconstructible rather than a description of the day the script ran.

The rest is provenance the Joining and Reshaping course already asked for, arriving here with a system to pull it from.

The script

def extract(base_url, dx, ou, pe, token, out_root="extracts"):
    session = requests.Session()
    session.headers["Authorization"] = f"ApiToken {token}"

    folder = Path(out_root, dt.date.today().isoformat())
    (folder / "raw").mkdir(parents=True, exist_ok=True)
    (folder / "tidy").mkdir(exist_ok=True)

    info = session.get(f"{base_url}/system/info", timeout=60).json()

    payload = analytics(dx, ou, pe, session)
    (folder / "raw" / "analytics.json").write_text(json.dumps(payload))

    for name, path in METADATA_ENDPOINTS.items():
        meta = session.get(f"{base_url}/{path}", timeout=120).json()
        (folder / "raw" / f"{name}.json").write_text(json.dumps(meta))

    tidy = to_frame(payload)
    tidy.to_csv(folder / "tidy" / "coverage_by_facility_period.csv", index=False)

    write_manifest(folder, {"base_url": base_url, "endpoint": "analytics",
                            "dx": dx, "ou": ou, "pe": pe, "rows": len(tidy)}, info)
    return folder
extract <- function(base_url, dx, ou, pe, token) {
  folder <- file.path("extracts", Sys.Date())
  dir.create(file.path(folder, "raw"), recursive = TRUE, showWarnings = FALSE)
  dir.create(file.path(folder, "tidy"), showWarnings = FALSE)
  # ... same shape: system info, analytics, metadata, tidy, manifest
  folder
}

The window is computed, then passed in. The caller decides which twelve months; the script records them. That one choice is what separates an extract you can point at from one you can only rerun.

def last_full_months(n, today=None):
    today = today or dt.date.today()
    first_of_this = today.replace(day=1)
    months = []
    for i in range(n, 0, -1):
        m = first_of_this - dt.timedelta(days=1)
        for _ in range(i - 1):
            m = m.replace(day=1) - dt.timedelta(days=1)
        months.append(m.strftime("%Y%m"))
    return months
last_full_months <- function(n) {
  ends <- seq(Sys.Date(), by = "-1 month", length.out = n + 1)[-1]
  rev(format(ends, "%Y%m"))
}

Note last_full_months, not last_months. The current month is always incomplete, and including it makes every trend appear to fall — the partial period trap from the joining course, arriving with a system that will happily serve it to you.

Validate at the boundary

The extract is a read from someone else’s system, which makes it exactly the seam the cleaning course put a contract on.

def check(tidy, expected_org_units, expected_periods):
    problems = []
    if tidy["ou"].nunique() != expected_org_units:
        problems.append(f"{tidy['ou'].nunique()} org units, expected {expected_org_units}")
    if tidy["pe"].nunique() != expected_periods:
        problems.append(f"{tidy['pe'].nunique()} periods, expected {expected_periods}")
    if tidy["value"].isna().any():
        problems.append("null values in the response")
    return problems
check <- function(tidy, expected_ou, expected_pe) {
  c(if (n_distinct(tidy$ou) != expected_ou) "unexpected org unit count",
    if (n_distinct(tidy$pe) != expected_pe) "unexpected period count")
}

The most valuable of those is the org unit count. A silently smaller extract is the commonest API failure in practice — a permissions change, a reorganisation, a unit closed — and it produces a district total that is quietly lower with nothing in the file to say why.

Diff against the last extract

Two dated folders make a comparison possible, and the comparison is where the four explanations get separated.

def compare(old_folder, new_folder, keys=("ou", "pe", "dx")):
    old = pd.read_csv(Path(old_folder, "tidy", "coverage_by_facility_period.csv"))
    new = pd.read_csv(Path(new_folder, "tidy", "coverage_by_facility_period.csv"))

    merged = old.merge(new, on=list(keys), how="outer",
                       suffixes=("_old", "_new"), indicator=True)
    changed = merged[merged["value_old"] != merged["value_new"]]
    return {
        "in_both": int((merged["_merge"] == "both").sum()),
        "new_only": int((merged["_merge"] == "right_only").sum()),
        "gone": int((merged["_merge"] == "left_only").sum()),
        "values_changed": len(changed),
    }
compare <- function(old, new) {
  full_join(old, new, by = c("ou", "pe", "dx"), suffix = c("_old", "_new")) |>
    summarise(changed = sum(value_old != value_new, na.rm = TRUE),
              gone = sum(is.na(value_new)), added = sum(is.na(value_old)))
}

Four numbers, and each points at a different cause. Values changed with the same keys is late entry or a revision. Rows gone is a reassignment or a permissions change. Rows added is late reporting arriving. And a large change with analytics_last_run moving in between is the analytics tables, not the data.

Run this every month and keep the output. It is the DQA course’s finding series, built from a source you now control.

What this replaces

Before:  a spreadsheet in an email, monthly, method unknown
After:   extracts/2026-07-28/, one command, manifest, raw responses,
         a diff against last month, and a figure you can defend in September

The script is about eighty lines. It is the single highest-return piece of code in this course, and it is the one most teams never write because the manual export works fine on the day.

What comes next

You have a routine figure you can point at. The last unit puts it beside a survey estimate of the same thing — three numbers for acute malnutrition, from 8.7% to 14.9% — and decomposes the gap into the two things that actually cause it.

Teach this lesson

The lesson as a slide deck, with the prose kept in the speaker notes rather than on the slide. Generated from this page, so it cannot fall out of step with it.

Start the slideshowRead the slides

The PDF needs no software and projects from any machine. The PowerPoint file is there to be edited — add your organisation's branding, cut a section for a shorter session, or merge two lessons into a workshop.