Patient Query Language
A patient query is the engine behind custom reports and future action rules. It defines which patients belong to a cohort by combining a starting population, code lists, variables derived from patient data, and if/then/else rules that select or reject each patient.
A query decides cohort membership, one patient at a time. Aggregating over the resulting cohort is the job of the report or dashboard that consumes it.
Structure
Every patient query has four sections in order:
<patientQuery>
<name>...</name>
<description>...</description>
<population>...</population> <!-- starting cohort -->
<codeLists>...</codeLists> <!-- named groups of SNOMED CT concept IDs -->
<variables>...</variables> <!-- named values derived from patient data -->
<rules>...</rules> <!-- select or reject logic -->
</patientQuery>
Population
The <population> element specifies the starting set of patients the query evaluates against. Use one of four forms:
| Form | When to use |
|---|---|
<patientQueryReference> | Reference a published patient query by identifier |
<patientQuery> | Embed a sub-query inline (recursive) |
<patientReportReference> | Use the patient set from a published report |
<patientIdList> | Explicit list of patient UUIDs |
The most common form is <patientQueryReference>, referencing a published query that defines the base cohort (for example, all registered patients or all patients with a specific condition).
<population>
<patientQueryReference>nhs-england/ALL</patientQueryReference>
</population>
Code lists
Code lists are named sets of clinical or medicinal concept identifiers. Declare them in <codeLists>, then reference them by name from variable attributes. Each <codeList> has a <name> followed by one of two payloads:
<codeLists>
<!-- Expressions evaluated against the terminology on every run -->
<codeList>
<name>ASTHMA</name>
<codeListDefinition>
<rules>
<inclusion><< 195967001 |Asthma|</inclusion>
</rules>
</codeListDefinition>
</codeList>
<!-- An explicit list of identifiers, not expanded -->
<codeList>
<name>SPECIFIC_CODES</name>
<fixedCodeList>
<codes>
<code>73211009</code>
<code>44054006</code>
</codes>
</fixedCodeList>
</codeList>
</codeLists>
See Code Lists for the full expression syntax, how inclusions and exclusions combine, dm+d and BNF expressions, every variable attribute that accepts a code list, and what is validated when.
Variables
Variables capture named values from patient data. They are referenced by name in rule expressions.
Coded entry variable
Finds coded entries in the patient's care record matching a code list. The result is a date (or set of dates) that can be tested in rules.
<variables>
<codedEntryVariable
name="DIABETES_DAT"
code="DIABETES"
select="clinically_relevant_date"
order="clinically_relevant_date"
take="latest"/>
</variables>
| Attribute | Required | Description |
|---|---|---|
name | Yes | Variable name used in rule expressions |
code | Yes | Name of a code list declared in <codeLists> |
select | Yes | Comma-separated fields to retrieve (e.g. clinically_relevant_date, value_1, code) |
order | Yes | Field to order results by |
take | Yes | latest, earliest, or all |
clinicallyRelevantDate | No | Date range filter expression (e.g. GTEQ ACHV_DAT_MINUS_12_MONTHS AND LTEQ ACHV_DAT) |
value1 | No | Filter expression on the entry's first numeric value (e.g. systolic blood pressure), same syntax as clinicallyRelevantDate (e.g. GTEQ 140) |
value2 | No | Filter expression on the entry's second numeric value (e.g. diastolic blood pressure) |
performedAtLocalOrganisation | No | TRUE to restrict to entries recorded at this practice |
encounterTypeCode | No | Name of a code list declared in <codeLists>; restricts results to entries recorded during an encounter of one of these types |
Fixed date variable
A fixed date value, either hardcoded or supplied at runtime:
<!-- Hardcoded date -->
<fixedDate name="CUTOFF_DAT">20250101</fixedDate>
<!-- Runtime value (supplied when the report is run) -->
<fixedDate name="ACHV_DAT" runtimeValue="true"/>
Derived fixed date
Compute a date by adding or subtracting a period from a fixed date variable:
<derivedFixedDate name="ACHV_DAT_MINUS_12_MONTHS" fixedDateName="ACHV_DAT" operation="-12 months"/>
<derivedFixedDate name="ACHV_DAT_MINUS_15_MONTHS" fixedDateName="ACHV_DAT" operation="-15 months"/>
<derivedFixedDate name="YEAR_START" fixedDateName="ACHV_DAT" operation="first day of this year"/>
Patient demographics variables
| Element | Description |
|---|---|
<patientClinicalSex name="..."/> | Patient's clinical sex |
<patientNamedGp name="..."/> | Patient's named GP |
<patientEntryDate name="..." patientField="date_of_birth|death_date"/> | Date from demographics |
<patientAge name="..." atDate="DATE_VAR" type="years|months"/> | Age at a given date |
Registration variable
Retrieves registration record data:
<patientRegistrationVariable
name="CURRENT_REG"
select="start_date,registration_type"
order="start_date"
take="latest"/>
Prescription and appointment variables
<prescriptionVariable
name="STATIN_RX"
productCode="STATINS"
select="prescription_status"
order="start_date"
take="latest"
status="active"/>
<appointmentVariable
name="LAST_APPT"
select="appointment_date"
order="appointment_date"
take="latest"
status="booked"/>
Patient query variable
Use the result of a published patient query as a boolean variable:
<patientQuery name="IN_REGISTER" queryIdentifier="nhs-england/DIABETES_QOF_REGISTER"/>
Rules
Rules evaluate in order. Each rule has an <if> expression and <then>/<else> outcomes.
Outcomes
| Value | Meaning |
|---|---|
select | Include this patient in the cohort |
reject | Exclude this patient |
next-rule | Continue to the next rule |
Expressions
Expressions compare variable names (or their fields) using operators:
| Operator | Meaning |
|---|---|
NOTEQ | Not equal |
EQ | Equal, comparing both value and type |
EQNS | Equal, comparing value only |
GT | Greater than |
GTEQ | Greater than or equal |
LT | Less than |
LTEQ | Less than or equal |
AND | Logical and |
OR | Logical or |
Special values:
| Token | Meaning |
|---|---|
NULL | No value / not found |
TRUE | Boolean true |
FALSE | Boolean false |
| Numeric literal | Integer or decimal (e.g. 23, 10.5) |
Variable fields are accessed with dot notation:
| Form | Meaning |
|---|---|
VAR NOTEQ NULL | Variable has a result |
VAR.count GT 0 | Variable has at least one result |
VAR.value_1 GTEQ 48 | First numeric value meets threshold |
VAR.value_2 LTEQ 90 | Second numeric value (e.g. diastolic BP) |
Parentheses group sub-expressions:
<if>(SYSTOLIC_DAT.value_1 GTEQ 140 OR DIASTOLIC_DAT.value_2 GTEQ 90) AND REG_DAT NOTEQ NULL</if>
The FIND ANY prefix tests whether any instance of a multi-value variable satisfies a condition:
<if>FIND ANY APPTS WHERE APPTS.status EQ booked</if>
How expressions are evaluated
Four evaluation rules determine what an expression actually selects. Each of them produces a valid cohort rather than an error, so getting one wrong is silent.
AND and OR evaluate strictly left to right. There is no operator precedence: AND does not bind more tightly than OR. A OR B AND C is evaluated as (A OR B) AND C, not as A OR (B AND C). Always parenthesise when both operators appear in one expression.
<!-- Evaluated as (ASTHMA_DAT NOTEQ NULL OR COPD_DAT NOTEQ NULL) AND REVIEW_DAT EQ NULL -->
<if>ASTHMA_DAT NOTEQ NULL OR COPD_DAT NOTEQ NULL AND REVIEW_DAT EQ NULL</if>
<!-- To attach the review-date condition to COPD only, group it explicitly -->
<if>ASTHMA_DAT NOTEQ NULL OR (COPD_DAT NOTEQ NULL AND REVIEW_DAT EQ NULL)</if>
Comparisons against a missing value return false. If either side has no value, every operator except EQ and NOTEQ returns false. A threshold test therefore fails both when the value misses the threshold and when there is no value at all:
<!-- False when the value is 100 or more, AND false when there is no value at all -->
<if>HBA1C.value_1 LT 100</if>
<!-- Test for presence first when the difference matters -->
<if>HBA1C NOTEQ NULL AND HBA1C.value_1 LT 100</if>
Comparisons over multiple values are existential. A variable declared with take="all" holds several results, and a comparison is true if any one of them satisfies it. This matters most for negative comparisons: READINGS.value_1 NOTEQ 5 is true when at least one recorded value is not 5, which is not the same as "none of them is 5".
NOTEQ cannot express "no result matches". Narrow the variable with its own filter attributes so a matching result is never returned, then test it for NULL:
<variables>
<!-- Only returns a result if a qualifying entry exists -->
<codedEntryVariable name="REVIEW_DAT" code="REVIEW_CODES"
select="clinically_relevant_date"
order="clinically_relevant_date"
take="latest"
clinicallyRelevantDate="GTEQ ACHV_DAT_MINUS_12_MONTHS AND LTEQ ACHV_DAT"/>
</variables>
<rules>
<rule>
<!-- No review in the last 12 months -->
<if>REVIEW_DAT EQ NULL</if>
<then>select</then>
<else>reject</else>
</rule>
</rules>
This is the general pattern for any "patient does not have" cohort. Prefer it to NOTEQ whenever the variable can hold more than one result.
EQ is strict, EQNS is not. EQ compares value and type; EQNS compares value only. Whole numbers are converted to decimals first, so numeric literals behave the same either way, but a value whose recorded type you do not control may not match under EQ.
Counting a patient's results
count is the one function available in an expression. Declare the variable with take="all", narrow it with the variable's own filter attributes, then threshold the count in the rule:
<variables>
<fixedDate runtimeValue="true" name="ACHV_DAT"/>
<derivedFixedDate name="ACHV_DAT_MINUS_12_MONTHS" fixedDateName="ACHV_DAT" operation="-12 months"/>
<prescriptionIssueVariable name="DRUG_X_ISSUES" productCode="DRUG_X_COD"
select="patient_id" order="issue_date" take="all"
issueDate="GT ACHV_DAT_MINUS_12_MONTHS"/>
</variables>
<rules>
<rule>
<!-- More than four issues in the last 12 months -->
<if>DRUG_X_ISSUES.count GT 4</if>
<then>select</then>
<else>reject</else>
</rule>
</rules>
Filter on the variable rather than adding a second variable to do the narrowing. The count then covers exactly the rows that survived the filter.
A variable with no matching results is null rather than an empty list, so its count is null rather than zero. Threshold comparisons are unaffected, since GT and GTEQ against null return false and the patient is correctly not selected. count EQ 0 is the exception and never matches any patient, because null is not equal to zero. Test for none with EQ NULL:
<!-- Never matches any patient -->
<if>DRUG_X_ISSUES.count EQ 0</if>
<!-- No issues in the last 12 months -->
<if>DRUG_X_ISSUES EQ NULL</if>
Complete example
A patient report selecting patients who have a diabetes coded entry in the last 12 months:
<?xml version="1.0" encoding="UTF-8"?>
<patientReport schemaVersion="3">
<name>Patients with Recent Diabetes Review</name>
<rows>
<patientQuery>
<name>Diabetes cohort</name>
<description>Patients with a diabetes coded entry in the last 12 months.</description>
<population>
<patientQueryReference>nhs-england/ALL</patientQueryReference>
</population>
<codeLists>
<codeList>
<name>DIABETES</name>
<codeListDefinition>
<rules>
<inclusion><< 73211009 |Diabetes mellitus|</inclusion>
</rules>
</codeListDefinition>
</codeList>
</codeLists>
<variables>
<fixedDate name="ACHV_DAT" runtimeValue="true"/>
<derivedFixedDate name="ACHV_DAT_MINUS_12_MONTHS"
fixedDateName="ACHV_DAT"
operation="-12 months"/>
<codedEntryVariable name="DIABETES_DAT" code="DIABETES"
select="clinically_relevant_date"
order="clinically_relevant_date"
take="latest"
clinicallyRelevantDate="GTEQ ACHV_DAT_MINUS_12_MONTHS AND LTEQ ACHV_DAT"/>
</variables>
<rules>
<rule>
<if>DIABETES_DAT NOTEQ NULL</if>
<then>select</then>
<else>reject</else>
</rule>
</rules>
</patientQuery>
</rows>
<columns>
<demographicInformationColumn headerName="Patient" value="patientName"/>
<demographicInformationColumn headerName="NHS Number" value="patientNhsNumber"/>
<demographicInformationColumn headerName="Date of Birth" value="patientDateOfBirth"/>
<demographicInformationColumn headerName="Named GP" value="namedGP"/>
</columns>
</patientReport>
What a query cannot express
Arithmetic. There are no arithmetic operators. You cannot add, subtract, multiply or divide values in an expression, compute a ratio or percentage, or sum a value across a patient's entries. Scores and indices that combine several recorded values into one number cannot be calculated inside a query.
Some variable types do compute a value before any comparison happens: date variables derive new dates via operation, and <patientAge> derives the patient's age at a date. Both derive from dates the record already holds. There is no equivalent for recorded values.
If you need a computed value, calculate it in your own system and record it against the patient as a coded entry with a numeric value. The query can then filter on it like any other observation.
The size of a change. You can declare one variable for the earliest result and another for the latest and compare them, so "the most recent value is higher than the first" is expressible. The magnitude is not, because there is no subtraction.
Text matching. There is no substring, prefix, wildcard or pattern operator, and no way to search free text, consultation notes or document content. Values in an expression may contain letters, digits, underscores, dots and hyphens only, so quoted strings and values containing spaces are not supported. Match on clinical meaning using code lists instead, which expand through the SNOMED CT hierarchy and are both more precise and more complete than a text match.
Units. Value filters compare the recorded number only. Units are not part of the comparison and are not converted, so a query cannot assert that a value is in the unit the threshold assumes. Scope value comparisons with a code list precise enough that every matching entry is recorded in the same unit.