FHIRPath Tutorial: Querying FHIR Resources, with Tested Examples

Learn FHIRPath from the ground up — collections, path navigation, where/select/exists, choice types with ofType, equality vs equivalence, extensions and Bundles — with every example's real output and the mistakes that trip people up.

By the FHIR Toolbox team, Omindra Labs · 6 min read ·

Checked against the official specifications listed under Standards and references at the end of this article.

FHIRPath is the expression language used throughout FHIR. The specification's invariants are written in it, search parameters are defined with it, and SQL on FHIR, CQL and most mapping tools build on it. If you can read FHIRPath, you can read the rules the validator applies. If you can write it, you can pull values out of resources without writing traversal code.

Every expression in this tutorial was evaluated with fhirpath.js, the reference JavaScript implementation, against the resources below. The results shown are its real output.

The sample data

{
  "resourceType": "Patient",
  "id": "example",
  "active": true,
  "name": [
    { "use": "official", "family": "Shaw", "given": ["Amy", "V."] },
    { "use": "maiden", "family": "Baxter", "given": ["Amy"] }
  ],
  "telecom": [
    { "system": "phone", "value": "+1-555-0100", "use": "mobile" },
    { "system": "email", "value": "amy.shaw@example.com" }
  ],
  "gender": "female",
  "birthDate": "1987-02-20",
  "extension": [
    { "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex", "valueCode": "F" }
  ]
}

Later examples also use a blood pressure Observation (systolic 128, diastolic 82 in component) and a heart rate Observation (valueQuantity 72 /min).

Everything is a collection

The single most important idea: every FHIRPath expression returns a collection, an ordered list of zero or more items. There are no nulls and no "not found" errors. A path that matches nothing returns an empty collection.

ExpressionResult
Patient.name.given["Amy", "V.", "Amy"]
Patient.name.count()[2]
Patient.address.city[]

Patient.name.given flattens: it visits every name and collects every given name from all of them. That's why the result has three items, including both "Amy"s.

Patient.address.city returns empty because the patient has no address. It doesn't fail. Empty propagates through most operations: Patient.address.city = 'Tulsa' also returns [], not false. This three-valued logic (true, false, empty) is how FHIR invariants avoid failing on optional elements.

Start with the resource type (or omit it when the context is already a Patient) and follow element names with dots:

ExpressionResult
Patient.birthDate["1987-02-20"]
Patient.name[1].family["Baxter"]
Patient.name.first().given.join(' ')["Amy V."]

Indexes start at 0. first(), last(), tail(), skip(n) and take(n) select by position.

Filtering with where()

where(criteria) keeps items for which the criteria is true. Inside the parentheses, paths are relative to each item:

ExpressionResult
Patient.name.where(use = 'official').given.first()["Amy"]
Patient.telecom.where(system = 'phone').value["+1-555-0100"]
Patient.telecom.value.where($this.startsWith('+1'))["+1-555-0100"]

$this refers to the current item, which you need when the items are primitives rather than objects.

Projecting with select()

select(expression) evaluates an expression for each item and collects the results. It's how you build a value from several fields:

ExpressionResult
Patient.name.select(given.first() + ' ' + family)["Amy Shaw", "Amy Baxter"]

Testing with exists(), empty() and all()

ExpressionResult
Patient.deceased.exists()[false]
Patient.name.all(family.exists())[true]
iif(Patient.active, 'active', 'inactive')["active"]

exists(criteria) is shorthand for where(criteria).exists(). all(criteria) is true when every item matches, and also when the collection is empty, so check exists() too if you need at least one item.

Choice types: value[x]

In JSON, a choice element appears with its type in the name: valueQuantity, valueString, effectiveDateTime. In FHIRPath, refer to it by its base name and pick the type with ofType():

Expression (heart rate)Result
Observation.value.ofType(Quantity).value[72]
(Observation.value as Quantity).unit["beats/minute"]
Observation.value is Quantity[true]

fhirpath.js also accepts Observation.valueQuantity.value, but the base-name form is what the specification defines and what invariants use, so it's the one to learn. The same applies to deceased (deceasedBoolean or deceasedDateTime) and effective.

Components work the same way. Systolic blood pressure:

Observation.component.where(code.coding.code = '8480-6').value.ofType(Quantity).value

Result: [128]. Add > 140 to the end and the result is [false]. That's the kind of expression a decision-support rule or a data-quality check uses.

Equality vs equivalence, and the collection trap

= is equality: exact and case-sensitive. ~ is equivalence: it ignores case and whitespace for strings, and is more forgiving for other types:

ExpressionResult
Patient.name.where(use='official').family = 'shaw'[false]
Patient.name.where(use='official').family ~ 'shaw'[true]

Now the trap that catches almost everyone. When you compare collections, = compares the whole list:

ExpressionResult
Patient.name.where(use='official').given = 'Amy'[false]
Patient.name.given contains 'Amy'[true]
'V.' in Patient.name.given[true]

The official name's given is ["Amy", "V."], a two-item list, so it doesn't equal the single item 'Amy'. To ask "is Amy one of the given names?", use contains or in.

A related error: string functions like matches(), startsWith() and lower() expect a single string. Patient.name.given.matches('^A') fails with *"expected singleton of type String"* because given has three items. Filter first: Patient.name.given.where($this.matches('^A')).

Dates

Date and time literals start with @, and arithmetic uses calendar durations:

ExpressionResult
Patient.birthDate < @2000-01-01[true]
Patient.birthDate <= today() - 18 years[true] (the patient is an adult)

Comparisons between values of different precision (@2026 vs @2026-03-05) can return empty rather than true or false, because the answer is genuinely unknown.

Extensions

extension(url) is a FHIR-specific function that filters extensions by URL:

Patient.extension('http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex').value

Result: ["F"]. It's equivalent to extension.where(url = '...'), but shorter and clearer.

Working across a Bundle

With a Bundle as the context, entry.resource gives you every resource, and ofType() picks a resource type:

ExpressionResult
Bundle.entry.resource.ofType(Patient).count()[1]
Bundle.entry.resource.where(resourceType = 'Observation' and status = 'final').count()[2]
Bundle.entry.resource.ofType(Observation).code.coding.code.distinct()["85354-9", "8867-4"]

Find the heart rate value anywhere in the Bundle:

Bundle.entry.resource.ofType(Observation)
  .where(code.coding.exists(system = 'http://loinc.org' and code = '8867-4'))
  .value.ofType(Quantity).value

Result: [72]. Matching on both system and code matters, because a bare code could exist in more than one code system.

Reading the spec's invariants

Once you know the basics, the specification's constraints become readable. Observation's obs-6 says *"dataAbsentReason SHALL only be present if Observation.value[x] is not present"*:

dataAbsentReason.empty() or value.empty()

Against the blood pressure Observation, which has neither, the result is [true], so the invariant passes. Paste the invariant from any validation error into a FHIRPath playground with your resource, and you'll see exactly which part fails.

Common mistakes

  1. Using = on a list — use contains, in or exists().
  2. Using the JSON name of a choice element (valueQuantity) where portable FHIRPath is needed — use value.ofType(Quantity).
  3. Calling string functions on collections — filter or take first() first.
  4. Matching codes without the system — code.coding.exists(system = '...' and code = '...').
  5. Expecting false from a missing element — comparisons with empty return empty. Use exists() when you mean "is present".
  6. Mixing FHIR versions — element names differ between R4 and R5 (for example Encounter period vs actualPeriod), so an expression can be valid in one and silently return empty in the other.

Try it

Every expression above works in the FHIRPath Debugger. Paste a resource, type an expression, and see the result, its type and cardinality, and where in the JSON each result came from. Switch between R4, R4B and R5 to evaluate against each version's model. It runs locally with fhirpath.js. To see the element definitions behind a path, open it in the Resource Inspector.

Standards and references

FHIR Toolbox is a free collection of HL7 FHIR tools by Omindra Labs. The tools process your data in your browser; it is not uploaded for normal tool operations.