d2ql — the query + transform language¶
d2ql is a pipeline language for querying and reshaping DHIS2 data. A program reads as a source feeding a chain of stages, optionally ending in a sink:
dataElements
| where domainType = "AGGREGATE" and name ~ "ANC"
| select id, name, categoryCombo.name as combo
| transform { code: id, label: name }
| order name asc
| limit 20
>> "elements.csv"
Expressions inside where, select, order, and transform are written in
d2path, the embedded path/expression language.
This page is the reference. New to d2ql? Start with the tutorial, then come back here to look things up; the cookbook has ready-to-run recipes.
Running a program¶
d2w query eval 'dataElements | where domainType = "AGGREGATE" | select id, name | limit 20'
d2w query run report.d2ql # run a program from a file (or `eval --file`)
d2w query explain 'dataElements | ...' # show what is pushed to DHIS2 vs. run locally
d2w query ast 'dataElements | ...' # print the parsed AST (offline)
d2w query repl # interactive REPL
eval, explain, and ast also accept --file/-f <path> to read a program from a file.
The same engine is available as MCP tools (query_eval, query_explain, query_d2path).
Interactive REPL¶
d2w query repl opens an interactive prompt. With the tui extra installed
(uv add 'dhis2w-cli[tui]') it's a full-screen Textual editor:
Enter runs the program, Shift+Enter / Ctrl+J insert a newline, and pasting a multi-line
pipeline drops it in cleanly. Up/Down recall history at the buffer edges (or Ctrl+P/Ctrl+N), and
move the cursor within a multi-line program otherwise; Ctrl+L clears, Ctrl+Q quits. The editor
has full readline-style keys (Ctrl+A/E/W/K/U, word nav, undo/redo). Ctrl+F cycles the output
format (table / json / ndjson / csv) for wide results, and Ctrl+T toggles tree mode — the
result pane becomes a collapsible JSON tree that each new query repopulates (so you see the tree,
not JSON, first). It takes focus on toggle so you can navigate immediately (up/down move, right/left
or Enter expand/collapse); Tab back to the editor to run another query; Escape or Ctrl+T
returns to the log. The tree is structural, so it renders json and ndjson results alike.
Without the extra, repl falls back to a line-mode prompt that runs on a blank line or a trailing ;.
A library of runnable, commented programs lives in
examples/d2ql/ — run any
with d2w query run examples/d2ql/<name>.d2ql, inspect with d2w query ast "$(cat <file>)", or load
from Python via parse(open(<file>).read()).
Sources¶
- A resource name — any DHIS2 metadata resource:
dataElements,indicators,organisationUnits, … An inline filter is shorthand for a leadingwhere:dataElements[domainType = "AGGREGATE"]. read("path.json")— read rows from a local JSON or NDJSON file (FHIR bundles, fixtures, the output of an earlier query).- A definition — reference a named query as a source (see Definitions).
- A scalar expression —
define Total: 1 + 2(used by definitions, not usually run directly). - A call source for aggregate data:
analytics(dx: "...", pe: "LAST_12_MONTHS", ou: "...")— rows from/api/analytics, one dict per row keyed by dimension (dx,pe,ou,value, ...). An optionalfilter: "..."arg maps to an analytics filter. Beyond dimensions,analytics(...)also takes analytics option args, routed to the matching query params:aggregationType,measureCriteria,outputIdScheme,displayProperty,startDate,endDate,relativePeriodDate,skipMeta,skipData, andincludeNumDen(addsnumerator/denominator/factor). Any arg that isn't a known option orfilteris treated as a dimension.dataValues(dataSet: "...", period: "...", orgUnit: "...")— raw aggregate values from/api/dataValueSets(navigatedataElement,period,orgUnit,value). Accepts the full selection:dataSetordataElementGroup, a singleperiodor astartDate/endDatewindow,orgUnitororgUnitGroupwith optionalchildren: truefor its subtree,includeDeleted: true,lastUpdated(modified-since date/duration), andlimit.
Reading from a file with read(...)¶
read("path") reads rows from a local JSON or NDJSON file and feeds them into the pipeline
like any other source — no profile or network needed. A top-level JSON array becomes one row per
element; a single JSON object becomes a single row; an .ndjson file becomes one row per line. Once
the rows are in, every stage works exactly as it does over a resource:
The natural companion to a >> file sink is a capture-then-reread workflow: run an expensive
query once into a file, then iterate locally over the snapshot without hitting DHIS2 again. Produce
the snapshot:
then re-read it as many times as you like — profiling, filtering, reshaping — all offline:
read(...) is a trusted-surface feature: the CLI allows it, but MCP tools reject read(...) sources
and >> file sinks so a caller cannot reach arbitrary host files through a query.
Stages¶
| Stage | Purpose |
|---|---|
where <predicate> |
Keep rows where the d2path predicate is true. |
select <expr> [as name], … |
Project columns; name with as or let the path name it. |
transform { key: <expr>, … } |
Build a new object per row (native reshaping — see below). |
order <expr> [asc\|desc], … |
Sort by one or more keys. |
limit <n> / skip <n> |
Take / drop rows. |
count |
Replace the stream with its length (a scalar result). |
group by <expr> { name: agg, … } |
Group rows by a key and reduce each group. |
fold { … } |
Collapse the whole stream into one object (FHIR Bundle, GeoJSON FeatureCollection). |
group by¶
group by <group> { total: sum(value), n: count() } groups rows by the group expression and
emits one object per group: the group key (named like a select column) plus each aggregation.
Aggregation expressions are evaluated against the group's rows, so sum(value) gathers value
across the group. Works over any source — metadata, analytics, or data values:
analytics(dx: "fbfJHSPpUQD;cYeuwXTCPkU", pe: "LAST_12_MONTHS", ou: "ImspTQPwCqd")
| where value > 1000
| group by dx { total: sum(value), periods: count() }
| order total desc
fold¶
group by/count reduce per group; fold { … } reduces the whole stream into one object —
an envelope like a FHIR Bundle or a GeoJSON FeatureCollection. The template is built once with
the entire stream in focus: $rows is the rows as a list, and select(...) / aggregate functions
see all rows. Pair it with define functions to keep the per-item shape readable:
define function observation(de): { resourceType: "Observation", status: "final",
code: { coding: [ { system: "dhis2", code: $de.id, display: $de.name } ] } }
dataElements | where domainType = "AGGREGATE"
| transform { resource: observation($this) }
| fold { resourceType: "Bundle", type: "collection", entry: $rows }
fold (like count) yields a single value, so --json / a .json sink emit the object itself,
not a one-element array.
transform¶
transform builds a new value per row — an object literal { … } or any expression that
evaluates to one, e.g. a define function call transform feature($this). Nested objects, arrays,
and computed values are all allowed. It depends on nothing FHIR-specific, but it is exactly what you
use to emit FHIR-shaped output:
dataElements
| where domainType = "AGGREGATE"
| transform {
resourceType: "Observation",
status: "final",
code: { coding: [ { system: "dhis2", code: id, display: name } ] }
}
Sinks¶
End a pipeline with >> to choose where the result goes and in which format. Destination is
stdout or a quoted file path; format is json / ndjson / csv, set explicitly with
as <format> or, for files, inferred from the extension. Format and destination are independent:
| Sink | Destination | Format |
|---|---|---|
(no sink) / >> stdout |
stdout | table (or JSON under --json) — the default renderer |
>> json / >> ndjson / >> csv |
stdout | that format (bare-keyword shorthand) |
>> stdout as ndjson |
stdout | ndjson (explicit; identical to >> ndjson) |
>> "out.csv" / >> "out.json" / >> "out.ndjson" |
file | from the extension |
>> "out.txt" as csv |
file | csv (as overrides the extension) |
dataElements | select id, name >> ndjson # ndjson to stdout (pipe to jq; escapes wide tables)
dataElements | select id, name >> csv # csv to stdout
dataElements | select id, name >> "elements.csv" # csv file (extension)
dataElements | select id, name >> "elements.txt" as csv # csv file, extension overridden
A json/ndjson/csv format applies in the REPL too; or cycle the REPL's default render with
Ctrl+F (or open a tree with Ctrl+T) when tables are too wide. On the CLI, --out FILE is the equivalent of an in-program file
sink.
Definitions¶
A program may begin with defines, making a .d2ql file a reusable library. Reference a scalar
definition or a function parameter with the $ sigil.
define MinLevel: 3
define function isAnc(de): $de.name ~ "ANC"
define Aggregates: dataElements | where domainType = "AGGREGATE"
Aggregates
| where isAnc($this) and level >= $MinLevel
| select id, name
define NAME: <pipeline>— a named query; reference it as a source.define NAME: <expression>— a scalar value; reference it as$NAME.define function NAME(params): <expression>— a reusable function; parameters are read as$paraminside the body.$thisis the current row insidewhere/select/transform.
A library file and --define¶
Collect several definitions in one .d2ql file and it becomes a small library. Save this as
immunisation.d2ql:
// immunisation.d2ql — reusable named queries over the data dictionary
define MinLevel: 2
define function isAnc(de): $de.name ~ "ANC"
define function isImmunisation(de): $de.name ~ "BCG" or $de.name ~ "measles" or $de.name ~ "Penta"
define AncElements:
dataElements
| where isAnc($this) and level >= $MinLevel
| select id, name
define ImmunisationElements:
dataElements
| where isImmunisation($this)
| select id, name
ImmunisationElements | order name asc | limit 10
Running the file executes its terminal pipeline (the last, unnamed one):
Point --define/-d at any named query to run that one instead of the terminal — the rest of the
library (its scalars and functions) is still in scope:
--define works on eval/explain too, and a file with no terminal pipeline is a pure library you
must address with --define. The same file is loadable inline with --file/-f on eval,
explain, and ast (equivalent to run for eval):
d2w query eval --file immunisation.d2ql --define AncElements
d2w query explain -f immunisation.d2ql --define ImmunisationElements
Pushdown — what runs where¶
d2ql does not fetch everything and filter in memory when it does not have to. The planner pushes a
leading run of where filters, then order, then paging, down to DHIS2's list endpoint (compiled
to filter=, order=, pageSize), and runs everything it cannot express — transforms, function
predicates, cross-field expressions — locally over the rows DHIS2 returns. d2w query explain
shows the split:
$ d2w query explain 'dataElements | where domainType = "AGGREGATE" | transform { code: id }'
source: dataElements (resource)
pushed down: filter[AND] domainType:eq:AGGREGATE
order (none); skip None; limit None
local stages: transform
A predicate the server cannot express (for example where name.substring(0, 3) = "ANC") simply
stays local — the result is identical, only the work moves.
count uses the native total¶
When a pipeline is a resource with a leading run of pushable where filters ending in a bare
count — so the only thing left to do locally is the count — the engine asks DHIS2 for its pager
total and returns that number without fetching a single row. count never compiles into the
native filter/order/paging block, so explain still lists it under local stages:
$ d2w query explain 'dataElements | where domainType = "AGGREGATE" | count'
source: dataElements (resource)
pushed down: filter[AND] domainType:eq:AGGREGATE
order (none); skip None; limit None
local stages: count
The fast-path applies only when every stage before count pushed down; add a stage that can't
(transform, a function predicate) and count falls back to counting the fetched rows. Either way
count yields a scalar (e.g. 621), not a one-row list.
One predicate is not just local but meaningless: where field = null. A missing or null field is the
empty collection, so = null matches nothing and is never pushed. Use where field.exists() /
where field.empty() to test presence and absence — see d2path.
See also¶
- d2ql tutorial — learn the language step by step.
- d2path — the expression language used inside every stage.
- Cookbook — ready-to-run recipes (FHIR, GeoJSON, reports).
- API reference:
dhis2w_ql.