Skip to content

CQL

Who this is for: someone who knows DHIS2 - indicators, program stages, option sets - and has never written a clinical logic library. FHIRPath first: CQL is written on top of it, and this page assumes you know that an expression answers with a collection.

Before you start: uv add dhis2w-fhir-engine, or uv sync inside this repository. No DHIS2 instance, no server, and no generated project - with one exception, held to the end: Run it against a served project needs all three.

You will be able to:

  • read a CQL library header line by line and say what each declaration binds
  • write a retrieve, and know exactly which resources it will reach
  • explain how a ValueSet scopes a retrieve, and where that ValueSet lives
  • bound logic to a reporting period with an interval, and hand a different period in without editing the library
  • find the graded CQL corpus in the package and read it in order

What CQL is, in DHIS2 terms

A DHIS2 indicator is a name, a numerator expression, a denominator expression, and a factor. It is computable, it is stored as metadata, and DHIS2's analytics engine is what runs it.

CQL - Clinical Quality Language - is the same idea with three differences that matter:

  1. It is a document, not a metadata record. A CQL library is a text file with a name and a version, and it is published, reviewed, and cited the way a specification is.
  2. It reaches data itself. A DHIS2 indicator expression names data elements and lets analytics find the values. A CQL expression says [Immunization] and the engine asks a data source for them - so the same library runs against a Bundle in memory, a FHIR server, or a mapped DHIS2 cohort.
  3. It is not tied to one implementation. The library you write here compiles to ELM, and any conforming engine runs the result. That is what makes a published measure computable by whoever receives it, rather than only by the system that authored it.

Nothing in CQL is executed top to bottom. A library is a vocabulary: a set of named definitions, each of which the caller may ask for. Asking for one causes the engine to work out what else it has to evaluate first.

The header, line by line

Here is a complete library. Everything above the first define is the header, and the header is where the outside world gets named.

library ChildImmunisation version '1.0'

using FHIR version '4.0.1'

include FHIRHelpers version '4.0.1'

codesystem SNOMED: 'http://snomed.info/sct'
valueset "Measles Vaccines": 'http://example.org/fhir/ValueSet/measles-vaccines'
code "Measles vaccine": '836383007' from SNOMED display 'Measles vaccine'

parameter "Measurement Period" Interval<DateTime>
    default Interval[@2024-01-01T00:00:00.0, @2024-12-31T23:59:59.999]

define "Children": [Patient]

define "Doses": [Immunization]

define "Children Count": Count([Patient])

define "Any Dose Recorded": exists [Immunization]

define "Reporting Year": start of "Measurement Period"
Line What it binds
library ChildImmunisation version '1.0' The document's own identity. A MeasureReport cites exactly this.
using FHIR version '4.0.1' The data model retrieves are written against.
include FHIRHelpers version '4.0.1' Another library's definitions, reachable from here.
codesystem SNOMED: '...' A code system, named once so codes can cite it by name.
valueset "Measles Vaccines": '...' A published set of codes, named by URL. The URL is not resolved here.
code "Measles vaccine": '836383007' from SNOMED One code, with its system and an optional display.
parameter "Measurement Period" ... A value the caller supplies, with a default when they do not.
define "..." A named expression. Nothing runs until one is asked for.

Compiling that library and reading back what the header declared:

library ChildImmunisation version 1.0

what the header declared:
  using          FHIR 4.0.1
  include        FHIRHelpers 4.0.1
  codesystem     SNOMED
  valueset       Measles Vaccines
  code           Measles vaccine
  parameter      Measurement Period
  define         Children, Doses, Children Count, Any Dose Recorded, Reporting Year

include FHIRHelpers version '4.0.1' needed no library path and no file on disk. FHIRHelpers is a standard library of conversion functions - it is what turns a FHIR element into a value CQL can compare - and the R4 binding ships it, so it resolves out of the box. Pass include_builtins=False to the evaluator to leave it out.

The one header declaration this library does not use is context Patient, which says the definitions below are evaluated once per person rather than once over the whole data set. That is what a quality measure needs, and Quality measures is where it earns its place.

Retrieves: how a library reaches data

[Immunization] is a retrieve, and it is CQL's only way to reach data. It means "every Immunization the data source holds". It is deliberately not a query language: the filtering vocabulary is small, and everything else is done with CQL expressions over what came back.

What a retrieve reaches is decided entirely by the data source the evaluator was given:

from dhis2w_fhir_engine import CQLEvaluator
from dhis2w_fhir_engine.r4 import BundleDataSource

evaluator = CQLEvaluator(data_source=BundleDataSource(bundle))

BundleDataSource indexes one Bundle by resource type. InMemoryDataSource does the same for a list of resources. PatientBundleDataSource is for a Bundle that is about one person. None of them is a network call - retrieves answer out of memory.

A Bundle entry's fullUrl is part of its resource's identity, so a reference that names a resource by its entry URL - relative, absolute, or urn:uuid: - reaches the same resource as Type/id, both when resolving a reference and when a patient context narrows a retrieve.

Against a clinic of four children, four doses and two weights:

retrieves with no context - everything the data source holds of that type:
  [Patient]       -> ['child-1', 'child-2', 'child-3', 'child-4']
  [Immunization]  -> ['dose-1', 'dose-2', 'dose-3', 'dose-4']
  [Observation]   -> ['weight-1', 'weight-2']
  Count([Immunization]) -> 4

return de-duplicates unless you say all

Shaping what a retrieve reached is a query: [Immunization] I return I.patient.reference answers with one reference per dose. Except that it does not - it answers with one reference per child, because distinct is CQL's default return qualifier. Four doses across three children come back as three references.

That surprises people who expect a row per input, and it is the language's rule rather than this engine's: return X and return distinct X mean the same thing, and return all X is the one form that keeps duplicates. Reach for all when the count matters and distinct - or nothing at all - when the set does.

The context narrows a retrieve

Under context Patient the engine evaluates the library once per person, and a retrieve inside that evaluation sees only that person's resources:

library PerPerson version '1.0'
using FHIR version '4.0.1'
context Patient

define "Doses For This Child": [Immunization]
define "Weights For This Child": [Observation]
define "Vaccinated": exists [Immunization]

Evaluated once per child, the same three definitions answer differently:

the same retrieves under `context Patient` - one evaluation per child:
  child-1    1 dose(s), 1 weight(s)  vaccinated
  child-2    2 dose(s), 1 weight(s)  vaccinated
  child-3    1 dose(s), 0 weight(s)  vaccinated
  child-4    0 dose(s), 0 weight(s)  no dose recorded

The narrowing is not magic and it is not hard-coded. The engine follows each resource type's patient reference element - Immunization.patient, Observation.subject - and it reads which element that is off the FHIR version binding rather than knowing it itself. That is the seam The FHIR version binding is about.

From Python, the subject of one evaluation is named with resource=:

evaluator.evaluate_definition("Vaccinated", resource=patient)

Terminology: how a ValueSet scopes a retrieve

Coded data is where logic gets specific. "Vaccinated" is not a field on a resource - it is a vaccineCode matching one of a named set of codes.

A retrieve can be narrowed two ways:

define "Measles Doses": [Immunization: "Measles Vaccines"]     -- by a ValueSet
define "Diphtheria Doses": [Immunization: "Diphtheria vaccine"] -- by one declared code

The single code is self-contained: the library declared it, and the retrieve matches it. The ValueSet is the interesting one, because CQL never inlines the codes. The library declares a name and a URL, and the URL is resolved outside the library, by whatever holds terminology:

from dhis2w_fhir_engine.engine.cql import CQLCode

data_source.add_valueset(
    "http://example.org/fhir/ValueSet/measles-vaccines",
    [CQLCode(code="836383007", system="http://snomed.info/sct", display="Measles vaccine")],
)

Until that line runs, the URL names nothing, and a retrieve citing it refuses rather than running unnarrowed:

the retrieve [Immunization] is scoped to the valueset
'http://example.org/fhir/ValueSet/measles-vaccines', which this data source holds no
expansion for

Once the expansion is registered:

[Immunization]                         -> 4 dose(s)
[Immunization: "Measles Vaccines"]     -> 2 dose(s)  (the ValueSet has one code)
[Immunization: "Diphtheria vaccine"]   -> 2 dose(s)  (the declared code, not a set)

which doses each retrieve reached:
  every dose      ['dose-1', 'dose-2', 'dose-3', 'dose-4']
  measles only    ['dose-1', 'dose-2']
  diphtheria only ['dose-3', 'dose-4']

An unresolved ValueSet is an error, on either side of the split

The separation above is between a declared name and the codes behind it, and neither half is optional.

The name is not: a retrieve citing "Measles Vaccines" in a library whose header has no valueset "Measles Vaccines" line does not fall back to anything. It refuses:

the retrieve [Immunization: "Measles Vaccines"] names a terminology reference the library
does not declare - add `valueset "Measles Vaccines": '<url>'`, or a code or concept
declaration of that name

The codes are not either: a name the library does declare, whose URL nothing ever expanded, refuses too - that is the message shown earlier, naming the URL and the add_valueset call that answers it. A ValueSet expanded to no codes is a different thing entirely and runs: it is a criterion nothing satisfies, so the retrieve reaches nothing.

Both refusals are deliberate, and they are the same refusal. The alternative - dropping a filter the engine cannot resolve - turns a coverage figure for measles into a coverage figure for every immunisation on file, and reports it as though it were the answer asked for. A mistyped ValueSet name and an unexpanded URL are both mistakes worth stopping for, and the second is the easier one to miss, because nothing in the library looks wrong.

This separation is the point, not an inconvenience. The library states the clinical question; the ValueSet states which codes currently answer it. Republish the ValueSet with a second measles code and the same library counts differently - no edit, no recompile, no new version of the logic. That is why a national measure can be adopted unchanged by a country whose code system differs.

The same ValueSet can be asked directly, for a client that wants one code checked rather than a retrieve narrowed:

from dhis2w_fhir_engine.r4 import InMemoryTerminologyService, MemberOfRequest, ValidateCodeRequest

terminology = InMemoryTerminologyService()
terminology.add_value_set(measles_value_set)

terminology.validate_code(ValidateCodeRequest(url=url, code="836383007", system=SNOMED)).result
# True
terminology.member_of(MemberOfRequest(valueSetUrl=url, code="836383007", system=SNOMED)).result
# True

Intervals: saying "during the reporting period"

Almost every measure is bounded by time, and CQL gives that its own first-class type rather than making you write pairs of comparisons.

An interval has a low bound, a high bound, and a bracket on each end: square means the bound is included, round means it is not. Interval[1, 10] holds 10; Interval[1, 10) does not. That distinction is why a period ending "31 December" and one ending "1 January" cover the same days.

properly carries its own weight in the timing vocabulary. includes allows the two intervals to be the same interval; properly includes does not, so a period never properly includes itself. Both appear in the table below.

Evaluated with no library, no data source, and no data at all:

Expression Answer
Interval[1, 10] Interval[1, 10]
5 in Interval[1, 10] True
10 in Interval[1, 10) False
width of Interval[1, 10] 9
start of Interval[@2024-01-01, @2024-12-31] 2024-01-01
end of Interval[@2024-01-01, @2024-12-31] 2024-12-31
@2024-06-11 during Interval[@2024-01-01, @2024-12-31] True
@2025-01-04 during Interval[@2024-01-01, @2024-12-31] False
Interval[1, 3] before Interval[5, 9] True
Interval[1, 5] overlaps Interval[4, 9] True
Interval[1, 10] includes Interval[2, 9] True
Interval[1, 10] properly includes Interval[2, 9] True
Interval[1, 10] properly includes Interval[1, 10] False
Interval[1, 5] intersect Interval[4, 9] Interval[4, 5]
duration in days between @2024-01-01 and @2024-01-15 14

With intervals in hand, "this dose counts for this period" is one clause:

library DosesInPeriod version '1.0'
using FHIR version '4.0.1'
include FHIRHelpers version '4.0.1'

parameter "Measurement Period" Interval<Date>
    default Interval[@2024-01-01, @2024-06-30]

define "Doses In Period":
    [Immunization] I
        where ToDate(I.occurrenceDateTime) during "Measurement Period"

define "Dose Count In Period":
    Count("Doses In Period")

ToDate says out loud what the comparison would do anyway. A FHIR resource carries its dates as strings, and a comparison that puts one of those strings opposite a date - a date literal, a date parameter, an interval of dates - reads it as the date it spells. where I.occurrenceDateTime during "Measurement Period" answers the same as the line above. Write ToDate when you want the conversion visible in the source, leave it out when the comparison already says it.

The reading only happens where a temporal value faces the string. Two strings still compare as strings, and a string that is not a date, dateTime, or time - where P.id during "Measurement Period" - is refused rather than answered:

Cannot compare the string 'p1' to a FHIRDate with 'during':
it is not a date, dateTime, or time

An ordering the data never stated is worth an error, not a False.

The same reading applies to a choice element named without its type. Observation.effective[x] reaches the wire as effectiveDateTime or effectivePeriod, and O.effective finds whichever one the resource wrote.

The payoff is the parameter. The library states the question once, and the caller decides the period:

the library's default "Measurement Period": Interval[2024-01-01, 2024-06-30]
  doses whose occurrenceDateTime falls during it: ['dose-1', 'dose-2', 'dose-3']
  Count -> 3 of 4 recorded doses

the same definition with the caller's own period Interval[2024-07-01, 2024-12-31]:
  -> ['dose-4']

From Python, that is one keyword argument:

evaluator.evaluate_definition("Doses In Period", parameters={"Measurement Period": second_half})

One library, run for January, for a quarter, and for a year.

From the command line

The cql sub-app runs all of the above without Python.

An expression on its own, for checking syntax and arithmetic:

$ d2w-fhir-engine cql eval "1 + 2 * 3"
7

A library checked before it is run - parsed, compiled, and summarised:

$ d2w-fhir-engine cql check coverage.cql
OK Syntax valid
OK Compilation successful

Library: Coverage v1.0
 Definitions   2
 Functions     0
 Value Sets    0
 Code Systems  0
 Codes         0
 Parameters    0

OK All checks passed

The library it read is two definitions, both of them retrieves - it is examples/fhir/engine/coverage.cql, and every transcript below runs from that directory:

library Coverage version '1.0'
using FHIR version '4.0.1'

define "Child Count": Count([Patient])
define "Any Dose": exists [Immunization]

--data decides what a retrieve can reach, and the rule is one line: a Bundle becomes the data source, any other resource becomes the context. The clinic of four children is clinic.json beside it, so [Patient] reaches its entries:

$ d2w-fhir-engine cql run coverage.cql --data clinic.json
Library: Coverage v1.0

        Results
┏━━━━━━━━━━━━━┳━━━━━━━┓
┃ Definition  ┃ Value ┃
┡━━━━━━━━━━━━━╇━━━━━━━┩
│ Child Count │ 4     │
│ Any Dose    │ True  │
└─────────────┴───────┘

Four children and a dose recorded - the same answers cql_retrieves.py prints from Python, off the same clinic.

--definition asks for one of them by name:

$ d2w-fhir-engine cql run coverage.cql --definition "Child Count" --data clinic.json
Library: Coverage v1.0

Child Count: 4

The other half of the rule is a file holding one resource rather than a Bundle: it becomes the subject the evaluation is about, which is what Patient.gender and the retrieves under context Patient read. One person is not a data source, so a retrieve in that run reaches only what the person's own record carries.

Without --data at all there is no data source and no context, and every retrieve correctly answers with nothing:

$ d2w-fhir-engine cql run coverage.cql
Library: Coverage v1.0

        Results
┏━━━━━━━━━━━━━┳━━━━━━━┓
┃ Definition  ┃ Value ┃
┡━━━━━━━━━━━━━╇━━━━━━━┩
│ Child Count │ 0     │
│ Any Dose    │ False │
└─────────────┴───────┘

Worth seeing once, so you recognise a forgotten --data rather than reading it as a clinic with no children in it.

cql eval takes the same --data for a single expression, cql parse, cql ast, cql tokens, and cql show inspect a source without running it, and cql repl is an interactive session that says which half of the rule the file it loaded took. elm eval and elm run read --data by the same rule, so a library and the ELM it compiles to answer alike from the command line.

Run it against a served project

The command line above runs a library over a file it is handed. A running facade runs one over its own data, and the difference is which questions become askable: [Patient] stops meaning "whatever the caller remembered to pass" and starts meaning one person the DHIS2 instance actually holds.

POST /facade/evaluate takes the library and a context naming the one resource it may reach. kind = "registered" names a tracked entity by its DHIS2 UID, and the facade reads it out of the instance and projects it the way GET /Patient/{uid} does:

$ curl -s -X POST http://127.0.0.1:8123/facade/evaluate \
    -H 'Content-Type: application/json' -d @library.json | jq -r '.results[] | .name'
Tracked Entity UID
Register Type
Attributes Recorded
Is A Person Record
First name (Play)

Every define the library declares answers a row, in declaration order, and a define that refuses carries its refusal on its own row so the rest of the library still answers. That context is --live only: a facade serving a compiled guide with no instance behind it refuses it by name.

What a register record carries decides how the library reads. DHIS2 states a person as a tracked entity type plus a bag of tracked entity attribute values, so the projection carries the UID as an identifier, the type as a meta.tag, and each attribute as an extension — there is no Patient.birthDate unless a project nominates one under [ips.identity].

Run it What it shows
examples/fhir/client/evaluate_registered_person.py A chart review of one tracked entity, written from the guide's own published vocabulary rather than from hard-coded UIDs
examples/fhir/client/evaluate_operation_contract.py The same evaluation as the FHIR $evaluate operation, discovered off /metadata and its OperationDefinition
examples/fhir/client/evaluate_compiled_library.py The same library published as ELM and run on the facade as JSON, compared define by define against its source

Serve the guide is how the facade those talk to gets started.

The reading list: the CQL corpus in the package

The package carries CQL you can read, graded from a first look to the full conformance surface. Read it in this order.

What Where Why read it
hello_world.cql packages/dhis2w-fhir-engine/tests/data/cql/ Six lines, no data model, no retrieves: a string, a sum, a comparison. The smallest complete library there is.
simple_measure.cql packages/dhis2w-fhir-engine/tests/data/cql/ The same shape plus using FHIR, context Patient, and the three population definitions. This is the skeleton every measure on the next page is a filled-in version of.
The nine engine examples examples/fhir/engine/ One feature apiece, each with its library inline and its answers printed. cql_library_structure.py, cql_retrieves.py, cql_terminology.py, and cql_intervals.py are this page, runnable.
FHIRHelpers.cql packages/dhis2w-fhir-engine/src/dhis2w_fhir_engine/r4/builtins/ The library your own include line resolves to. Read it when you want to know what ToDate, ToString, and their siblings actually do.
The HL7 compliance suites packages/dhis2w-fhir-engine/tests/compliance/cql/data/cql/ The official HL7 CQL test XML, run as part of the package's own test suite. Thousands of expressions with their expected results, grouped by operator family - the reference for "what does CQL say this means".

Where this goes next

Everything above computes values. A quality measure adds the part that makes those values a report: named populations, a scoring rule, and a FHIR resource to carry the answer. Read Quality measures next.

The runnable versions

File Shows
cql_library_structure.py The header table above, read back off the compiled library
cql_retrieves.py Unscoped retrieves, then the same ones per person
cql_terminology.py A ValueSet narrowing a retrieve, and the terminology service asked directly
cql_intervals.py The interval table, then a period parameter the caller replaces

The Python surface is documented in the dhis2w_fhir_engine API reference.