FHIR Terminology

Overview

FHIR provides a self-contained terminology infrastructure: three resources (CodeSystem, ValueSet, ConceptMap) and four operations ($expand, $lookup, $validate-code, $translate). Together they let FHIR servers and clients define coded vocabularies, curate subsets of codes, map between code systems, and validate coded data at runtime.

This article is about the machinery — how FHIR represents and validates coded data. It is not about which vocabulary to use for a given clinical domain; that is a healthcare semantics question. What it covers is: how does a Coding relate to a CodeSystem? How does a ValueSet binding get validated? What does an $expand response look like and why does it matter?

If you work with any FHIR profile that uses coded elements — and every meaningful profile does — you need to understand this layer.


The Three Coding Data Types

Before the resources, the data types. Coded data appears in FHIR in one of three forms:

code

A simple string that is meaningful only in context. The context (which code system is implied) is fixed by the element definition.

{ "status": "final" }

Observation.status is typed as code. The code system is http://hl7.org/fhir/observation-status — the spec defines it, and you never have to write the system in the instance. Use code for elements where the value space is small, closed, and defined by the spec itself.

Coding

A code paired with the system that defines it, plus an optional display string.

{
  "system": "http://loinc.org",
  "code": "1975-2",
  "display": "Bilirubin.total [Mass/volume] in Serum or Plasma"
}

system is the globally unique URI identifying the code system. It is required for validation to work. display is informational — it is not used for matching.

CodeableConcept

An array of Coding elements plus a free-text text field.

{
  "coding": [
    {
      "system": "http://loinc.org",
      "code": "1975-2",
      "display": "Bilirubin.total [Mass/volume] in Serum or Plasma"
    },
    {
      "system": "http://snomed.info/sct",
      "code": "259659006",
      "display": "Bilirubin (substance)"
    }
  ],
  "text": "Total Bilirubin"
}

The multiple coding entries represent the same concept in different code systems. A validator checks whether any coding entry satisfies the bound ValueSet — not whether all of them do.

text captures what the user typed or the source system sent when no code was available. It is a fallback, not a substitute for coded data.


CodeSystem

A CodeSystem resource defines a vocabulary: its canonical URI, its concepts, the hierarchy between them, and any properties attached to concepts.

{
  "resourceType": "CodeSystem",
  "id": "observation-category",
  "url": "http://terminology.hl7.org/CodeSystem/observation-category",
  "version": "1.0.0",
  "name": "ObservationCategoryCodes",
  "status": "active",
  "content": "complete",
  "concept": [
    {
      "code": "laboratory",
      "display": "Laboratory",
      "definition": "The results of observations generated by laboratories."
    },
    {
      "code": "vital-signs",
      "display": "Vital Signs",
      "definition": "Clinical measurements such as weight, blood pressure, temperature."
    },
    {
      "code": "survey",
      "display": "Survey",
      "definition": "Assessment tool or questionnaire observations."
    }
  ]
}

CodeSystem.url is the system URI — the value you put in Coding.system. This is the globally unique identity of the vocabulary. It is not the URL you retrieve the CodeSystem from (which is [base]/CodeSystem/observation-category); it is the semantic identifier. These are different things, and conflating them is a common mistake.

content tells you whether the CodeSystem resource contains all the concepts (complete), a subset (fragment), or just the definition without concepts (not-present). Large vocabularies like SNOMED CT and LOINC are typically distributed as not-present — the CodeSystem resource defines the URI and metadata, but the actual concepts are loaded into a terminology server separately.


ValueSet

A ValueSet describes a curated subset of codes drawn from one or more CodeSystems. It has two representations: the compose (the definition of what’s in the set) and the expansion (the resolved list of every matching code).

{
  "resourceType": "ValueSet",
  "id": "vital-signs-category",
  "url": "http://hl7.org/fhir/ValueSet/observation-vitalsignresult",
  "version": "4.0.1",
  "name": "VitalSignsValueSet",
  "status": "active",
  "compose": {
    "include": [
      {
        "system": "http://loinc.org",
        "concept": [
          { "code": "85353-1", "display": "Vital signs, weight, height, head circumference, oxygen saturation and BMI panel" },
          { "code": "9279-1",  "display": "Respiratory rate" },
          { "code": "8867-4",  "display": "Heart rate" },
          { "code": "59408-5", "display": "Oxygen saturation in Arterial blood by Pulse oximetry" },
          { "code": "8480-6",  "display": "Systolic blood pressure" },
          { "code": "8462-4",  "display": "Diastolic blood pressure" },
          { "code": "8310-5",  "display": "Body temperature" },
          { "code": "29463-7", "display": "Body weight" },
          { "code": "8302-2",  "display": "Body height" }
        ]
      }
    ]
  }
}

The compose can also use filter elements to include codes by property (all SNOMED descendants of a given concept, for example), or exclude to remove specific codes from an included set.

Expansion is the resolved list of every code that satisfies the compose at a specific point in time. When a validator checks a coded value against a ValueSet, it checks against the expansion — not the compose definition. This matters because compose filters that rely on CodeSystem hierarchies produce different expansions as the CodeSystem evolves.


ConceptMap

A ConceptMap maps concepts between code systems. The structure is a group of source concepts each mapped to one or more target concepts, with an equivalence code describing the relationship.

{
  "resourceType": "ConceptMap",
  "id": "icd10-to-snomed-sample",
  "url": "http://example.org/fhir/ConceptMap/icd10-to-snomed",
  "status": "active",
  "sourceCanonical": "http://hl7.org/fhir/ValueSet/icd-10",
  "targetCanonical": "http://snomed.info/sct?fhir_vs",
  "group": [
    {
      "source": "http://hl7.org/fhir/sid/icd-10-cm",
      "target": "http://snomed.info/sct",
      "element": [
        {
          "code": "E11.9",
          "display": "Type 2 diabetes mellitus without complications",
          "target": [
            {
              "code": "44054006",
              "display": "Diabetes mellitus type 2",
              "equivalence": "equivalent"
            }
          ]
        },
        {
          "code": "E11.65",
          "display": "Type 2 diabetes mellitus with hyperglycemia",
          "target": [
            {
              "code": "44054006",
              "display": "Diabetes mellitus type 2",
              "equivalence": "wider",
              "comment": "SNOMED concept does not capture hyperglycemia complication"
            }
          ]
        }
      ]
    }
  ]
}

Equivalence codes express the relationship quality:

  • equivalent — same meaning in both systems
  • wider — source is more specific than target (information loss in translation)
  • narrower — source is less specific than target (target is more precise)
  • inexact — overlap but not equivalent
  • unmatched — no suitable target concept exists
  • disjoint — concepts are explicitly not related

The comment field is where you document why a mapping is imperfect. Always fill it in for non-equivalent mappings — it’s the only place to capture the clinical nuance.


Binding Strength

Every coded element in a FHIR profile has a binding to a ValueSet, and that binding has a strength. Binding strength is where most terminology-related validation failures originate.

StrengthRuleWhat validators do
requiredCode MUST come from this ValueSet, no exceptionsReject if no coding matches
extensibleUse a code from the ValueSet if one exists; use your own code only if the ValueSet doesn’t cover itWarn if no coding matches and no explanation
preferredShould use a code from the ValueSet, but any code is acceptableInformational warning only
exampleThe ValueSet is a hint; use whatever is appropriateNo validation check

The required vs extensible distinction matters enormously in practice.

required means the exact code must appear. Observation.status is required-bound — you cannot invent a new status value. If your system has a concept that doesn’t map to any value in the required ValueSet, you have a design problem.

extensible means you must look first. If the ValueSet contains an appropriate code, use it. Only if no code in the ValueSet covers your concept may you use an outside code. This is commonly misread as “optional” — it is not. An extensible binding on Observation.code means you must use LOINC when LOINC has a code for what you’re measuring.

preferred is a genuine should — there’s no enforcement if you don’t. But downstream systems that assume you followed the preference will behave correctly; ones that receive unexpected codes may not.

example is documentation, not constraint.


The Four Terminology Operations

$expand

Resolves a ValueSet to its full expansion — the complete list of included codes.

GET [base]/ValueSet/observation-vitalsignresult/$expand?_count=10&offset=0

Response:

{
  "resourceType": "ValueSet",
  "expansion": {
    "timestamp": "2024-03-15T12:00:00Z",
    "total": 9,
    "offset": 0,
    "contains": [
      {
        "system": "http://loinc.org",
        "code": "8867-4",
        "display": "Heart rate"
      }
    ]
  }
}

Parameters worth knowing: url (expand a ValueSet by canonical URL without a resource ID), valueSetVersion (pin to a specific ValueSet version), filter (filter display text for typeahead), offset and count (page through large expansions).

Do not call $expand on every request that needs validation. Expansions for large ValueSets (SNOMED hierarchies can contain thousands of codes) are expensive. Expand once, cache the result, and refresh on ValueSet version changes.

$lookup

Retrieves properties, designations, and relationships for a single concept in a CodeSystem.

GET [base]/CodeSystem/$lookup?system=http://loinc.org&code=1975-2

Returns the display name, all designations (translations), and any defined properties. Useful for displaying human-readable labels for codes without maintaining your own copy of a code system’s content.

$validate-code

Checks whether a code or CodeableConcept is valid in a given ValueSet or CodeSystem.

POST [base]/ValueSet/$validate-code
Content-Type: application/fhir+json

{
  "resourceType": "Parameters",
  "parameter": [
    { "name": "url", "valueUri": "http://hl7.org/fhir/ValueSet/observation-vitalsignresult" },
    { "name": "system", "valueUri": "http://loinc.org" },
    { "name": "code", "valueCode": "8867-4" }
  ]
}

Response:

{
  "resourceType": "Parameters",
  "parameter": [
    { "name": "result", "valueBoolean": true },
    { "name": "display", "valueString": "Heart rate" },
    { "name": "version", "valueString": "2.74" }
  ]
}

When result is false, the response includes a message parameter explaining why. Use $validate-code in ingestion pipelines to catch terminology errors before they propagate into your data store.

$translate

Applies a ConceptMap to translate a concept from one code system to another.

POST [base]/ConceptMap/$translate
Content-Type: application/fhir+json

{
  "resourceType": "Parameters",
  "parameter": [
    { "name": "url", "valueUri": "http://example.org/fhir/ConceptMap/icd10-to-snomed" },
    { "name": "system", "valueUri": "http://hl7.org/fhir/sid/icd-10-cm" },
    { "name": "code", "valueCode": "E11.9" }
  ]
}

Response includes result (true/false), and match parameters with the target code, system, and equivalence. When equivalence is not equivalent, examine the response before using the translation — information loss may be clinically significant.


Terminology Servers

A terminology server is a FHIR server dedicated to hosting CodeSystems and ValueSets and supporting the four operations above. Your main FHIR server may or may not be a capable terminology server.

Options:

  • HAPI FHIR: Open source, widely deployed, can load standard terminology packages
  • Ontoserver: CSIRO’s commercial terminology server, strong SNOMED support
  • tx.fhir.org: HL7’s public reference terminology server — useful for development and testing, not for production load

When your FHIR server delegates terminology operations to an external terminology server, it specifies that server’s URL in its CapabilityStatement. Validators typically need a reachable terminology server to validate coded elements in profiles with required or extensible bindings.


Implementation Considerations

Cache expansions. A $expand call against a large ValueSet that includes SNOMED descendants can take seconds. Expand once per ValueSet version, store the expansion, and serve it locally. Invalidate when the ValueSet or CodeSystem version changes.

Version-pin your ValueSet references. A canonical URL without a version (http://hl7.org/fhir/ValueSet/condition-code) resolves to whatever the server’s current version is. That changes when the ValueSet is updated. For stable validation, use versioned references: http://hl7.org/fhir/ValueSet/condition-code|4.0.1.

Local code systems need a canonical URI. If you define your own codes (internal lab instrument codes, facility-specific order codes), they must have a canonical URI you control — typically http://[your-domain]/fhir/CodeSystem/[name]. Do not use OIDs, meaningless UUIDs, or unqualified local names as system URIs.

Don’t use display text for validation. The Coding.display field is informational. A code is valid if it’s in the ValueSet regardless of display; it’s invalid regardless of display if it’s not. Never write validation logic that checks display strings.


Common Mistakes

Wrong system URI for SNOMED CT. The correct URI is http://snomed.info/sct. Not an OID, not http://www.snomed.org/sct, not a namespace URL. This is one of the most common errors in FHIR implementations.

Using display instead of code for validation. Display is rendered text, not the code. Validators check the code against the ValueSet expansion.

Assuming extensible means optional. It means “required if a code exists in the ValueSet.” The distinction matters for interoperability — a receiver that assumes extensible-bound elements use standard codes will fail on unexpected local codes.

Missing version in ConceptMap. A ConceptMap without a version on its source and target CodeSystem references is ambiguous when those code systems release updates. Pin versions in ConceptMaps used for production translation pipelines.

Expanding large ValueSets at runtime. Calling $expand on every validation request for a ValueSet that contains 5,000 SNOMED codes will destroy your response times. Expand and cache.

Section: fhir Content Type: reference Audience: technical
FHIR Versions:
R4 R5
Published: 22/01/2024 Modified: 04/02/2026 14 min read
Keywords: FHIR terminology CodeSystem ValueSet ConceptMap binding strength $expand $validate-code $translate terminology server
Sources: