Schema Care

FHIR DiagnosticReport and Observation for Lab Data

By Schema CarePublished Updated

In FHIR, lab data lives in two resources. Each test result is an Observation holding the test code, the value, the unit, the reference range and any flag. The DiagnosticReport is the report around them: it names the panel or order, the patient, the dates and the lab, and it points to every Observation it contains.

How DiagnosticReport groups lab observations

A FHIR DiagnosticReport is the container for one lab report. It carries the report-level facts, such as the status, the report code, the patient, the collection time, the issue time and the performing lab, and it lists the individual results as references to Observation resources in its result element.

The FHIR R4 DiagnosticReport page describes the resource as the findings and interpretation of diagnostic tests performed on patients. Only two elements are required: status and code. The status takes values such as registered, partial, preliminary and final. The code names the report, and the specification prefers LOINC report codes for it.

The rest of the report maps closely onto a printed lab report. subject points to the patient. effective[x] is the time the observed values relate to, which for a lab is usually the collection time. issued is when this version of the report was made available. performer names the service responsible for the report. specimen lists the specimens the report is based on. presentedForm can hold the whole report as issued, for example the original PDF, which keeps the source next to the structured data.

The result element is where grouping happens. The page says DiagnosticReport references Observation to provide the atomic results, and that results are assembled in groups, nested structures of Observations that labs traditionally call panels or batteries. A basic metabolic panel can therefore be one DiagnosticReport whose result list points to one Observation per analyte.

The DiagnosticReport is not a place for cumulative views. The R4 page says the resource is not meant for cumulative result presentation, so a report that prints six months of potassium values in columns should become one Observation per value with its own date, not one report holding a trend.

In the United States, receivers often expect the US Core DiagnosticReport profile for laboratory results. It requires a status, a category code of LAB, a code that is preferably LOINC, a patient, the effective time and the release time. It lists result as must support rather than mandatory.

One lab result as an Observation, worked example

One extracted lab result becomes one Observation with a status, a LOINC code, a valueQuantity with a UCUM unit, a referenceRange, an interpretation for the printed flag, a subject and an effectiveDateTime. The DiagnosticReport then points to it. The example below shows both resources for a single fictional potassium result.

The FHIR R4 Observation page defines Observation as measurements and simple assertions made about a patient, device or other subject. Only status and code are required. The status value set includes registered, preliminary, final and amended, with corrected nested under amended, plus cancelled, entered-in-error and unknown.

The fictional source line reads as follows. Every value is an example and describes no real person or result.

Printed fieldExample valueObservation element
PatientAvery Samplesubject
Test namePotassium, serumcode.text and code.coding
Result5.4valueQuantity.value
Unitsmmol/LvalueQuantity.unit and code
Reference range3.5-5.1referenceRange.low and high
FlagHinterpretation
Collected2026-09-14 08:10effectiveDateTime
StatusFinalstatus

The LOINC entry for 2823-3 gives its long common name as "Potassium [Moles/volume] in Serum or Plasma". Choosing that code from a printed test name is its own task, covered in the guide to mapping lab test names to LOINC.

Here is the example Observation, built for a fictional patient:

{
  "resourceType": "Observation",
  "id": "example-potassium",
  "status": "final",
  "category": [{
    "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/observation-category", "code": "laboratory" }]
  }],
  "code": {
    "coding": [{ "system": "http://loinc.org", "code": "2823-3" }],
    "text": "Potassium, serum"
  },
  "subject": { "reference": "Patient/example-avery" },
  "effectiveDateTime": "2026-09-14T08:10:00-07:00",
  "valueQuantity": {
    "value": 5.4,
    "unit": "mmol/L",
    "system": "http://unitsofmeasure.org",
    "code": "mmol/L"
  },
  "interpretation": [{
    "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", "code": "H" }],
    "text": "H"
  }],
  "referenceRange": [{
    "low": { "value": 3.5, "unit": "mmol/L", "system": "http://unitsofmeasure.org", "code": "mmol/L" },
    "high": { "value": 5.1, "unit": "mmol/L", "system": "http://unitsofmeasure.org", "code": "mmol/L" },
    "text": "3.5-5.1"
  }]
}

And the example DiagnosticReport that holds it, equally fictional:

{
  "resourceType": "DiagnosticReport",
  "status": "final",
  "category": [{
    "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/v2-0074", "code": "LAB" }]
  }],
  "code": {
    "coding": [{ "system": "http://loinc.org", "code": "24320-4" }],
    "text": "Basic metabolic panel"
  },
  "subject": { "reference": "Patient/example-avery" },
  "effectiveDateTime": "2026-09-14T08:10:00-07:00",
  "issued": "2026-09-14T13:42:00-07:00",
  "result": [{ "reference": "Observation/example-potassium" }]
}

Each code in the example comes from a published list. The laboratory category is in the FHIR observation category code system. The H code, displayed as "High", is in the HL7 v3 ObservationInterpretation code system. The LOINC entry for 24320-4 names it "Basic metabolic 1998 panel - Serum or Plasma", and the LAB category code is in the HL7 v2 table 0074 as published with FHIR R4. The H here is simply the flag the lab printed, carried as data. Mapping it says nothing about what the value means for the patient, and neither should your pipeline.

The example also keeps referenceRange.text alongside the parsed low and high. The R4 page says a reference range must have at least a low, a high or text, and that multiple ranges are read as alternatives. Keeping the printed text protects you when the range on the page has wording that does not parse.

Which Observation value type fits a lab result?

Pick the Observation value type from the shape of the printed result. A number with a unit is valueQuantity. A coded answer such as positive or negative is valueCodeableConcept. Free text that has no code is valueString. A printed range is valueRange, and a titer such as 1:64 is valueRatio.

The R4 Observation page lists the full set of value[x] types: Quantity, CodeableConcept, string, boolean, integer, Range, Ratio, SampledData, time, dateTime and Period. Only one may be present on a given Observation. Lab results use a handful of them, and the work is mostly in recognizing which one a printed value is.

Some values look numeric but are not plain numbers. A result printed as "<0.5" has a comparator. The FHIR R4 data type definitions give Quantity an optional comparator, and say that if it is "<", the real value is less than the stated value. The FHIR R4 data types page says the comparator is not used on a SimpleQuantity, which is the type reference range bounds use. So "<0.5" fits in valueQuantity with a < comparator, but a range bound cannot carry one.

Text results need a decision about coding. "Positive" can stay as valueString if you have no agreed code for it, or become valueCodeableConcept with a code and the printed word as its text. A coded value is more useful downstream, but only if the mapping from word to code is fixed and reviewed. A wrong code is worse than honest text.

A missing value is not a value type. When the page says a test was not performed or the specimen was rejected, leave value[x] out and explain the absence. The R4 page's invariant obs-6 says dataAbsentReason shall only be present if value[x] is not present, so a result never carries both.

How the same printed shapes look in a plain JSON API response, before any FHIR mapping, is set out in the guide to lab report API fields per test result.

Should lab panels use Observation hasMember or component?

Use hasMember for a lab panel and keep component for values that only make sense together. Each test in a panel has its own result, unit, range, flag and status, so each one should be its own Observation, grouped by a panel Observation's hasMember or listed in the DiagnosticReport's result.

The R4 Observation page describes both mechanisms. hasMember marks a group observation, and the page gives a battery, a panel of tests and a set of vital sign measurements as examples. component holds component observations expressed as separate code and value pairs that share the same attributes, and the page's example is the systolic and diastolic parts of one blood pressure reading.

The phrase "share the same attributes" is the test. Components share their parent's status, time, performer and specimen. In a panel, those can differ: one analyte may be amended after the rest went final, or one may be canceled while the others report. Modeling a panel as components would force one status onto every analyte. With separate Observations, each analyte keeps its own lifecycle.

That leaves two valid shapes for a panel. The simple one is a DiagnosticReport whose result list points straight at each analyte Observation, as in the worked example. The nested one adds a panel Observation, coded with the panel's code, whose hasMember points at the analytes, and the DiagnosticReport points at that panel Observation. The nested form helps when one report holds several panels and you want to keep them apart.

There is one more rule to watch with components. The R4 page's invariant obs-7 says that if an Observation's code is the same as a component's code, the Observation's own value shall not be present. A parent should not repeat a value that one of its components already holds.

How panels such as a CBC are laid out on the printed page, and how to read their rows, is covered in the post on extracting panel results like a CBC or CMP.

Lab Observation pitfalls: units without UCUM and missing specimen

The two most common lab Observation faults are a unit with no UCUM code and a result with no specimen. Both can still pass base validation, but the first breaks unit-aware comparisons and profile checks, and the second leaves a reader unable to tell serum from urine for the same analyte.

Units first. The FHIR R4 UCUM page says the URI http://unitsofmeasure.org identifies UCUM codes, that UCUM is the recommended system for Quantity, and that the case-sensitive UCUM symbol is the code. The data types page adds that a unit will often look like valid UCUM text but cannot be assumed to be, and its rule qty-3 says that if a unit code is present, the system shall be present too.

In practice, keep both. Put the printed unit in unit exactly as the lab wrote it, and the UCUM symbol in code with the UCUM system. The US Core Laboratory Result Observation profile goes further: if the value is numeric, its unit code shall come from UCUM. A unit printed as "mEq/L" or "x10E3/uL" needs a deliberate mapping, because a lookalike string in code is a silent error.

Specimen next. The Observation's specimen element points to the specimen used. Labs print the specimen type in different places: in the test name ("Potassium, serum"), in a column, or once at the top of the report. If the extraction keeps only the test name, the specimen is easy to lose. US Core lists the specimen type as a must support element for lab results.

A few other faults are worth a check before sending:

  • Status left blank. status is required, and a printed "preliminary" or "corrected" should map to preliminary or corrected, never default to final.
  • Reference range without units. If the value has a UCUM unit, the range bounds should use the same unit, or a reader comparing them gets the wrong answer.
  • Unreviewed fields. A result the extraction was not sure about should be confirmed by a person before it becomes an Observation, since nothing downstream will question it.

Schema Care reads lab reports into test names, results, units, reference ranges and abnormal flags, scores each field, and sends the uncertain ones to a person, as the human review workflow shows. Schema Care offers no API, SDK, webhooks, FHIR output, 835 output or 837 output, so the Observation and DiagnosticReport building described here would happen in your own integration. If your receiver takes HL7 v2 instead, read HL7 v2 vs FHIR for extracted document data, and if it wants whole clinical documents, read C-CDA vs FHIR.

All posts in APIs, FHIR and Integration