FHIRPath

FHIRPath

FHIRPath is the expression language FHIR uses wherever it needs to navigate and test resource content: profile invariants, search parameter definitions, mapping rules, and questionnaire logic all use the same syntax. Understanding it makes the difference between treating validation errors as black boxes and being able to read a constraint, understand what it’s checking, and diagnose why your data fails it.

Scope of this article: FHIRPath syntax, collection semantics, and common functions. For how invariants are structured inside a StructureDefinition, see FHIR Profiling. For how search parameters are defined and queried, see FHIR Search.

Quick reference:

  • FHIRPath always operates on collections — even a single value is a one-element collection
  • Navigation uses . (path step) and [] (indexing, rarely needed)
  • Everything is lazy: expressions don’t fail on missing data, they return empty
  • where() filters, select() projects, exists() checks presence

Where it is used

FHIRPath appears in several places in the FHIR spec. Knowing the context tells you what an expression is supposed to do.

Invariants and validation

Profile invariants are FHIRPath expressions attached to a resource or element via ElementDefinition.constraint. A validator evaluates the expression against the resource instance; if it returns false (or empty, which is treated as false), the constraint fails.

{
  "key": "us-core-8",
  "severity": "error",
  "human": "Patient.name.given or Patient.name.family or both SHALL be present",
  "expression": "family.exists() or given.exists()"
}

This expression runs against each element in Patient.name[]. If a name entry has neither family nor given, the invariant fires. Because validators evaluate this per-element, a Patient with two names where one is missing both fields will fail — even if the other name is complete.

Invariants are where FHIRPath’s collection semantics matter most and where most confusion originates. See the Pitfalls section before writing or reading invariants.

Search parameter definitions and implementations

SearchParameter.expression defines which element(s) in a resource a search parameter indexes:

SearchParameter for Observation.code:
  expression: "Observation.code"

SearchParameter for subject across multiple resources:
  expression: "AllergyIntolerance.patient | CarePlan.subject | ... | Observation.subject"

The | union operator lets a single search parameter cover the same concept across multiple resource types. When you see a multi-resource search parameter, it’s usually built with union.

FHIRPath in search parameter definitions tells you exactly what gets indexed — which is useful when debugging why a search isn’t returning expected results.

Mapping and transformation (adjacent tooling)

FHIR Mapping Language (FML) and StructureMap use a FHIRPath-like syntax for source expressions. FluentPath (a precursor) and CQL (Clinical Quality Language) share significant overlap with FHIRPath.

The practical consequence: if you work in mapping engines, CQL rules, or questionnaire logic, you’ll encounter FHIRPath or near-relatives often enough that investing in understanding the core model pays off across all of them.

Syntax

FHIRPath navigation uses . to step into child elements. Starting from a resource root:

Patient.name
Patient.name.family
Patient.identifier.where(system = 'http://hospital.example.org/mrn').value

Everything is a collection. Patient.name returns a collection of all HumanName elements on that Patient — potentially zero, one, or many. Patient.name.family returns a collection of all family name strings across all HumanName entries. This is not the same as “the family name of the first name entry.”

[] indexing is available but rarely useful in real expressions:

Patient.name[0].family   -- first name's family (fragile; prefer exists()/where())

Avoid index-based navigation in invariants and search parameters — it assumes a specific ordering that isn’t guaranteed.

The . path step auto-flattens. If Patient.name returns three HumanName elements, Patient.name.given returns all given name strings from all three — not a list of lists.

Types and casting

FHIRPath is typed. Primitive types (string, integer, decimal, boolean, date, dateTime, time, Quantity) behave as expected. FHIR-specific types like Coding, CodeableConcept, Reference, and HumanName are structured objects.

Type checking with is and casting with as / ofType():

-- Check if a value is an integer
value is integer

-- Cast (returns empty if wrong type)
value as Quantity

-- Filter a collection to only elements of a given type
Observation.value.ofType(Quantity)

ofType() is essential for choice types — see Pitfalls: Choice Types.

Arithmetic and comparisons work on compatible types:

birthDate <= today() - 18 years   -- age check
value > 0
name.family.length() > 0

Date arithmetic in FHIRPath uses UCUM-style duration literals (1 year, 6 months, 30 days). today() and now() return the current date and dateTime respectively.

Existence and Boolean semantics

FHIRPath has a three-valued logic: true, false, and empty (analogous to null). Empty propagates through most operations — if any operand is empty, the result is usually empty.

Key existence functions:

-- Returns true if collection has at least one element
name.exists()

-- Returns true if collection is empty
telecom.empty()

-- Returns true if ALL items match the criteria
identifier.all($this.system.exists())

-- Returns true if ANY item matches
identifier.where(system = 'http://example.org').exists()

The not() trap: empty.not() returns true — the negation of empty is not false, it’s true in FHIRPath. Write value.exists() rather than value.empty().not() for clarity, but understand they are equivalent.

In boolean contexts (invariants), an empty result is treated as false. This means an expression that navigates to a missing element doesn’t error — it just returns empty and the constraint fires (for error-severity invariants). For warning-severity invariants, this has a different implication: a missing element silently passes if the invariant only checks properties of that element.

Common functions

where(), select(), exists(), empty()

where(criteria) filters a collection, returning only elements where the criteria evaluates to true:

-- All identifiers from a specific system
identifier.where(system = 'http://hospital.example.org/mrn')

-- All observations with a specific code
-- (inside a Bundle context, for example)
entry.resource.where(resourceType = 'Observation').where(code.coding.where(system = 'http://loinc.org' and code = '8867-4').exists())

select(projection) transforms each element in a collection, returning a new collection:

-- Get all identifier values (regardless of system)
identifier.select(value)

-- Get family name + first given name (concatenated)
name.select(family + ', ' + given.first())

exists() with a criteria argument is shorthand for where(criteria).exists():

-- These are equivalent:
identifier.where(system = 'http://hospital.example.org/mrn').exists()
identifier.exists(system = 'http://hospital.example.org/mrn')

Use the shorthand when you only need presence — it’s more readable. Use where() when you need the filtered collection for further navigation.

first(), last(), count()

-- First element (returns empty if collection is empty, never errors)
name.first()
name.first().family

-- Last element
identifier.last().value

-- Count of elements
name.count() >= 1
telecom.where(system = 'phone').count() <= 3

count() always returns an integer (0 for empty collections). This makes it safe to use in arithmetic comparisons without empty-propagation risk.

first() and last() return empty collections when applied to an empty input — they don’t throw. This means name.first().family on a Patient with no name entries returns empty, not an error.

String and date/time helpers

Strings:

-- Concatenation
family + ', ' + given.first()

-- Contains / starts with / ends with / matches
identifier.value.startsWith('MRN-')
telecom.value.matches('^[0-9\\-\\+\\ \\(\\)]+$')

-- Length
note.text.length() > 500

-- Substring, upper, lower
system.substring(0, 4)
name.family.lower() = 'smith'

Date and time:

-- Comparisons (FHIR date/dateTime)
birthDate < today()
period.start >= @2020-01-01
period.end.exists().not() or period.end >= today()

-- Duration arithmetic
birthDate <= today() - 18 years
meta.lastUpdated >= now() - 7 days

Date literal syntax uses @ prefix: @2024-01-01, @2024-01-01T00:00:00Z. Partial dates (@2024, @2024-01) compare as ranges in FHIRPath — @2024-01-01 = @2024 evaluates to true because January 1, 2024 is within the year 2024.

Pitfalls

These are the parts that catch implementers repeatedly. They’re worth reading even if you only encounter FHIRPath in error messages.

Collections vs singletons

The most common mistake: treating a navigation result as a single value when it’s a collection.

-- WRONG INTUITION: "get the patient's family name and check it"
-- This actually returns ALL family names across ALL HumanName entries
Patient.name.family

-- If the patient has two HumanName entries (official + nickname),
-- this returns a collection of two strings.
-- Comparisons against collections work differently than against singletons.

Patient.name.family = 'Smith'
-- Returns TRUE if ANY element in the collection equals 'Smith'
-- This is often what you want, but it may not be

The implicit “any” behavior of equality comparisons on collections is by design, but it surprises people coming from SQL or other languages.

-- If you need exactly one family name to equal 'Smith' (no others):
Patient.name.family.count() = 1 and Patient.name.family = 'Smith'

-- If you need the official name's family to equal 'Smith':
Patient.name.where(use = 'official').family = 'Smith'

In invariants, this matters when you write element.value > 0 — that’s true if ANY value in the collection is greater than zero. Write element.all($this.value > 0) if you need all values to satisfy the condition.

Null vs empty

FHIRPath doesn’t have null — it has empty collections. But FHIR resources do have missing elements, and the two interact in ways that aren’t always obvious:

-- Missing element → empty collection (not null, not error)
Patient.deceasedBoolean   -- empty if not present

-- Boolean from empty: empty.not() = true (!)
Patient.deceasedBoolean.not()
-- If deceased is absent, this returns TRUE, not FALSE or empty
-- This is counterintuitive but correct FHIRPath

-- Safer: check existence first
Patient.deceasedBoolean.exists() and Patient.deceasedBoolean = false

For invariants that should only fire when an element is present, use the implies operator:

-- "If period.start exists, it must be before period.end (if end exists)"
period.start.exists() implies (period.end.empty() or period.start <= period.end)

implies returns true when the left side is empty or false — it only evaluates the right side when the left is true. This prevents “constraint fires on missing data” bugs.

Choice types (value[x])

FHIR choice elements like Observation.value[x] can hold any of several types (valueQuantity, valueString, valueBoolean, etc.). In FHIRPath, you navigate to the specific type:

-- WRONG: this returns empty always (no element named 'value')
Observation.value

-- CORRECT: navigate to the specific type
Observation.valueQuantity
Observation.valueString

-- CORRECT: use ofType() to handle multiple types
Observation.value.ofType(Quantity)
Observation.value.ofType(string)

When writing invariants that apply regardless of which type is present:

-- "A value must exist (any type)"
Observation.value.exists()
-- This doesn't work! 'value' isn't a real element path.

-- CORRECT: check any possible value type
(Observation.valueQuantity | Observation.valueString | Observation.valueBoolean |
 Observation.valueInteger | Observation.valueRange | Observation.valueRatio |
 Observation.valueSampledData | Observation.valueTime | Observation.valueDateTime |
 Observation.valuePeriod | Observation.valueAttachment | Observation.valueCodeableConcept).exists()

-- SHORTHAND (R4+): polymorphic navigation
Observation.value.exists()
-- Works in some FHIRPath implementations that support polymorphic shortcuts
-- but not in strict FHIRPath 1.0; check your validator's behavior

In practice, invariants that involve value[x] are some of the most implementation-specific — different validators handle polymorphic navigation differently. Test against your actual validator, not just the expression syntax.

Examples

”All observations with LOINC X”

A common use case: filter Observations by code system and code, for use in a search parameter expression or a CQL measure denominator.

-- In a SearchParameter expression context, navigating from the Observation root:
code.coding.where(system = 'http://loinc.org' and code = '8867-4').exists()

-- As a filter in a Bundle (navigating from Bundle root):
entry.resource.ofType(Observation).where(
  code.coding.where(system = 'http://loinc.org' and code = '8867-4').exists()
)

-- Same, more readable with exists() shorthand:
entry.resource.ofType(Observation).where(
  code.coding.exists(system = 'http://loinc.org' and code = '8867-4')
)

Note that ofType(Observation) in a Bundle context is necessary because entry.resource is polymorphic — it can be any resource type. Without the type filter, subsequent navigation would try to evaluate code.coding on every resource type and fail on those that don’t have a code element.

”Patient has at least one name”

The US Core invariant us-core-8 requires that at least one HumanName entry has either a family or a given name. This is more nuanced than “at least one name exists”:

-- The actual us-core-8 expression (simplified):
name.where(family.exists() or given.exists()).exists()

Breaking this down:

  1. name — the collection of all HumanName entries
  2. .where(family.exists() or given.exists()) — filter to names with at least one of family or given
  3. .exists() — at least one must pass the filter

A Patient with a name entry that only has use = 'official' and no family or given would fail this — the HumanName entry exists but is empty of usable name data. This is the right behavior.

Compare with the naive version that would be wrong:

-- NAIVE (wrong): passes if any name has family, AND any name has given
-- They don't need to be the same name entry
name.family.exists() or name.given.exists()

-- CORRECT: the same name entry must have at least one of the two
name.where(family.exists() or given.exists()).exists()

The naive version would pass for a Patient where one name entry has only family and a different name entry has only given — which is technically wrong (neither entry is usable). The correct version requires at least one name entry with useful content.

See also

Section: fhir Content Type: reference Audience: technical
FHIR Versions:
R4 R5
Published: 18/04/2023 Modified: 14/01/2026 18 min read
Keywords: FHIRPath expressions validation profiling
Sources: