← Methods repository
concept

FHIR and Healthcare Interoperability for RWE

HL7 FHIR (Fast Healthcare Interoperability Resources) is the REST/JSON standard for exchanging clinical data between EHRs, payers, and research systems — not an analysis format but an exchange protocol whose mandatory adoption under US federal interoperability rules (21st Century Cures Act, CMS Interoperability Rule) now makes it a practical on-ramp for prospective RWE collection, patient- mediated data access, payer claims feeds, and EHR-to-research pipelines via bulk FHIR $export.

Data_Standardfhirinteroperabilitydata-standardehrclaimsrest-apismart-on-fhirbulk-export
Methods reference only. Use primary source citations and local policy before applying this in a study protocol, regulatory submission, payer dossier, or clinical decision.

In plain language

FHIR (pronounced "fire") is the modern web standard that hospitals, insurers, and apps use to share patient data with each other — each piece of clinical information (a diagnosis, a prescription, a lab result) is packaged as a small JSON document called a "resource" and delivered over a standard internet connection. US federal rules passed in 2020 and 2021 now require hospitals and insurers to offer FHIR connections, which means researchers can reach prescription histories, lab results, and payer claims in a structured, machine-readable way without starting from scratch at every site. For an RWE analyst, FHIR is most relevant when designing prospective data-collection pipelines, patient-mediated data-sharing studies, or decentralized trials — for most retrospective studies it is background plumbing that the data-engineering team has already handled before the records reach your desk. One honest caveat: receiving data in FHIR format does not guarantee the fields you need are populated — completeness still varies by source and must always be checked.

What FHIR is — and is not

HL7 FHIR (Fast Healthcare Interoperability Resources, pronounced "fire") is a standard published by Health Level Seven International (HL7) that defines how clinical data are exchanged over modern web APIs. FHIR structures clinical information as self-contained JSON or XML documents called resources — Patient, Condition, MedicationRequest, Observation, DiagnosticReport, Encounter, and roughly 150 others — each with a fixed schema and each retrievable via standard HTTP calls (GET /fhir/r4/Patient/P001). FHIR is not an analysis model: it makes no promises about completeness, it allows many fields to be optional, and the same clinical concept can appear in different resource types depending on how a site configured its system. Its job is exchange; what the analyst does with the data after receiving them is a separate problem that requires an ETL step and an analytic data model.

Core FHIR resources for RWE

Four resource types cover most RWE-relevant clinical data:

  • Patient — demographics: birth date, sex, address (useful for area-level SES linkage), identifiers.
  • Condition — diagnoses coded in ICD-10-CM or SNOMED CT, with a `clinicalStatus`, a
  • MedicationRequest — prescription orders including the RxNorm-coded drug concept, the `authoredOn`
  • Observation — laboratory results (LOINC codes), vitals, smoking status, and social history. A

A minimal MedicationRequest resource illustrating the key fields an exposure pipeline must parse:

```json { "resourceType": "MedicationRequest", "id": "med-001", "status": "active", "intent": "order", "medicationCodeableConcept": { "coding": [{ "system": "http://www.nlm.nih.gov/research/umls/rxnorm", "code": "860975", "display": "metformin 500 mg oral tablet" }] }, "subject": { "reference": "Patient/P001" }, "authoredOn": "2023-01-05", "dispenseRequest": { "expectedSupplyDuration": { "value": 30, "unit": "days" } } } ```

Regulatory tailwind: why FHIR is now mandatory in the US

Two US regulations made FHIR APIs a legal requirement and created the infrastructure RWE can tap:

1. 21st Century Cures Act (2016) and ONC Interoperability Rule (2020): EHR developers must expose patient data via a standardized FHIR R4 API. The US Core Implementation Guide specifies which USCDI (United States Core Data for Interoperability) data classes must be supported — medications, conditions, allergies, lab results, vitals, demographics, and care-team members. As of 2023, all ONC-certified EHRs must meet US Core v3.1.1 or later. 2. CMS Interoperability and Patient Access Rule (2020, effective 2021): Medicare Advantage, Medicaid, CHIP, and most commercial payers must expose member claims data via a FHIR R4 API using the CARIN Blue Button 2.0 implementation guide. This is the first mandatory, standardized payer-side claims API — giving patients and authorized apps machine-readable access to their own payer data including drug fills, inpatient encounters, and outpatient services.

Together these rules mean that by 2024, nearly every US EHR and major payer exposes FHIR APIs, creating a standardized infrastructure that did not exist before 2021. For RWE, this matters because the data-access negotiation no longer has to start from scratch at each site — the API exists, the authentication model (SMART on FHIR OAuth 2.0) is standardized, and the data structure is specified.

Why FHIR matters to an RWE analyst today

FHIR enters RWE practice through four channels:

1. Patient-mediated data access via SMART on FHIR: Patients can authorize a research app (using the SMART on FHIR OAuth 2.0 flow) to pull their own EHR or payer data and donate it to a study. This enables hybrid prospective-retrospective designs — participants share their existing records at enrollment without requiring a site-level data-use agreement for each health system the patient has visited. 2. Payer claims via CARIN Blue Button: A patient can authorize a research or care-management app to retrieve their claims history directly from their insurer. The feed is structurally similar to a traditional claims extract — encounters, drug fills, diagnoses as ExplanationOfBenefit resources — but patient-authorized rather than requiring a payer contract. 3. EHR bulk data ($export): FHIR's asynchronous bulk-export operation returns population-level data as flat NDJSON (newline-delimited JSON) files, one file per resource type, with one resource per line. A researcher granted bulk authorization can download a cohort's MedicationRequest, Condition, and Observation files and parse them directly into analytic tables — closer to a traditional database extract than to patient-by-patient API polling. 4. Prospective and decentralized trials: FHIR-native electronic data capture (EDC) platforms use MedicationRequest and Observation resources to auto-populate case report forms from EHR data, reducing transcription error and enabling near-real-time safety monitoring in decentralized or virtual study designs.

FHIR vs OMOP — the honest comparison

FHIR and OMOP address different problems and are complementary, not competing. FHIR is an exchange standard: it defines how data travel from system A to system B in a format each system already speaks. OMOP is an analysis model: it defines how data are reorganized into a person-centric, vocabulary- standardized schema optimized for running the same SQL query across dozens of databases.

The typical research pipeline is linear:

`Source EHR/Payer → FHIR API → ETL → OMOP CDM (or flat analytic tables) → Analysis`

This means most RWE analysts working on retrospective cohort studies never interact with FHIR directly. Their data have already been ETL'd into OMOP or a similar analytic CDM by the time they write their first query. FHIR becomes analyst-visible when: (a) the team is building a prospective or hybrid collection pipeline, (b) data are being ingested via patient-mediated donation, or (c) the study uses tokenized linkage where FHIR is the transport for de-identified record exchange.

FHIR-to-OMOP mapping follows a domain-routing logic parallel to native OMOP ETL: MedicationRequest maps to DRUG_EXPOSURE, Condition to CONDITION_OCCURRENCE, Observation to MEASUREMENT or OBSERVATION. Coding systems carried in FHIR (RxNorm for drugs, ICD-10-CM for conditions, LOINC for labs) align directly with OMOP standard vocabularies, so the vocabulary translation step is straightforward when codes are present. The complication is that FHIR permits multiple coding systems in a single resource and the ETL must apply a priority ordering to select the standard concept when multiple codings compete.

The optionality problem: "FHIR-compatible" does not mean "analyzable"

US Core profiles narrow FHIR's optionality but do not eliminate it. A MedicationRequest is valid FHIR R4 without a `days_supply` value. A Condition resource is valid without an onset date. Two APIs can both be US Core compliant while returning very different field population rates for the same data element. Receiving a FHIR feed does not guarantee analytic-quality data — the same data-completeness challenges that plague claims and EHR persist inside FHIR, wrapped in JSON. The analysis validity of a FHIR-fed pipeline depends on the field population rates of the specific source system and must be evaluated empirically (field-level missingness reports, value-set conformance checks) before any analytic conclusions are drawn.

Pros, cons, and trade-offs

Pros of FHIR for RWE: Mandatory regulatory adoption creates a standardized, legally guaranteed access pathway that did not exist before 2021 — especially for patient-mediated and payer-side data. REST/JSON is the lingua franca of modern software, lowering the technical barrier versus bespoke database extracts or HL7 v2 feeds. SMART on FHIR OAuth enables patient consent and data donation at scale without a site-level DUA for every health system. Bulk $export brings population-level extraction feasibility without per-patient API looping. FHIR coding (RxNorm, SNOMED, LOINC) maps cleanly into OMOP vocabularies, reducing the vocabulary harmonization burden in ETL.

Cons and limitations: FHIR is a point-of-exchange format, not an analysis model — data arrive heterogeneous and require explicit ETL into an analytic structure. The optionality of FHIR profiles means "FHIR-available" is not "analysis-ready." Bulk $export requires group-level authorization and a data-use agreement, just like any secondary data source. Access to bulk operations is not universal — many EHR FHIR implementations support only individual SMART Individual Launch queries, not group or bulk export. For the majority of retrospective RWE analysts, FHIR is entirely invisible plumbing.

When to use

Use FHIR-based data pipelines when: (a) building a prospective or hybrid prospective-retrospective study that will collect EHR or payer data at participant enrollment; (b) designing a patient-mediated data-sharing protocol (SMART on FHIR, Apple Health Records donation); (c) consuming payer claims via CARIN Blue Button for a patient-level cohort study; (d) extracting a research cohort from an EHR system that exposes a bulk $export endpoint; (e) building a tokenized linkage pipeline that uses FHIR as the transport for de-identified feeds; (f) evaluating data feasibility from a source with a FHIR API before a full DUA is negotiated. For prospective registry-style collection, FHIR-native EDC reduces transcription burden and enables automated safety signal extraction.

When NOT to use — and common misconceptions

  • "We're using FHIR so the data-quality problem is solved." FHIR defines structure, not completeness.
  • "I can run my OMOP analysis directly on FHIR resources." FHIR and OMOP have different schemas,
  • "Patient-authorized FHIR data are complete for drug exposure." A patient's payer FHIR feed covers
  • "All FHIR APIs support bulk $export." Bulk export is a capability layered on FHIR, not guaranteed
  • Using repeated individual patient-level API calls as a population extraction strategy: polling

Interpreting the output

In the worked example, parsing three FHIR MedicationRequest resources for patient P001 yields three rows in an analytic exposure table — one row per resource — each carrying the RxNorm code (860975), the authoredOn prescription date used as fill_date, and the days_supply extracted from `dispenseRequest.expectedSupplyDuration.value`. Total days_supply across three rows = 90.

(1) Formal interpretation. Each analytic row is a 1:1 extract of one FHIR MedicationRequest resource: no aggregation, no period-collapsing, and no assumption about whether the drug was actually dispensed or taken. The `authoredOn` field is the prescriber order date — the closest proxy to a pharmacy fill date available in the MedicationRequest resource but not equivalent to a dispensing date. The days_supply sum of 90 represents cumulative intended treatment duration across three sequential orders; gaps between fills (visible in the date sequence) are not yet evaluated. Adherence calculation requires a downstream analytic step (PDC or MPR) that applies a treatment window and denominator.

(2) Practical interpretation. When a data engineer delivers three exposure rows with the same RxNorm code on dates roughly 30 days apart, the analyst should verify before proceeding: (a) that `days_supply` was actually populated in all rows — it is optional in FHIR and the pipeline must document what happens when it is null; (b) that `intent` equals "order" not "proposal" or "plan"; (c) whether a corresponding MedicationDispense or pharmacy claim confirming the fill is available. These verifications are the FHIR-pipeline analog of source-data audit and should be specified in the statistical analysis plan before the pipeline is built, not discovered during analysis.

Worked example

Scenario

A data engineer is building an exposure-extraction pipeline for a new-user cohort study on metformin initiation. The study site exposes a FHIR R4 bulk $export endpoint. For patient P001, the engineer retrieves three MedicationRequest resources from the NDJSON export file, extracts four fields from each resource, and assembles a three-row analytic exposure table. The goal is to confirm that the 1-to-1 resource-to-row mapping is exact and to sum the intended days of supply across all three rows.

Dataset

Four fields extracted from three FHIR MedicationRequest resources for patient P001. The rxnorm_code comes from medicationCodeableConcept.coding where system equals the RxNorm URI; authoredOn is the prescription order date; days_supply comes from dispenseRequest.expectedSupplyDuration.value. RxNorm code 860975 = metformin 500 mg oral tablet (illustrative).

resource_idauthoredOnrxnorm_codedays_supply
med-0012023-01-0586097530
med-0022023-02-0686097530
med-0032023-03-0986097530

Steps

  • Parse each NDJSON line as a MedicationRequest JSON object; confirm resourceType equals "MedicationRequest" and status equals "active" before extracting any fields — resources with status "cancelled" or "entered-in-error" are excluded.

  • Extract the RxNorm code from medicationCodeableConcept.coding by selecting the entry whose system equals "http://www.nlm.nih.gov/research/umls/rxnorm"; all three resources carry code 860975 (metformin 500 mg oral tablet).

  • Extract the prescription date from the authoredOn field; the three dates are 2023-01-05, 2023-02-06, and 2023-03-09 — these are order dates, not confirmed dispensing dates.

  • Extract the intended supply duration from dispenseRequest.expectedSupplyDuration.value; all three resources carry a value of 30 days. Note that this field is optional in FHIR — if absent, days_supply is null and must be imputed or the row flagged for review.

  • Write one analytic exposure row per MedicationRequest resource; 3 resources produce 3 rows because the mapping is exactly 1-to-1 at extraction — no aggregation or deduplication is applied.

  • Sum total intended days of exposure: 30 + 30 + 30 = 90 days. This is the raw maximum intended exposure across three sequential orders, not an adherence measure.

Result

3 MedicationRequest resources produce exactly 3 analytic exposure rows (1-to-1 mapping confirmed). RxNorm code 860975 appears in all three rows. Total days_supply = 30 + 30 + 30 = 90 days of intended exposure for patient P001. Gaps between order dates (Feb 4 to Feb 5; Mar 8) are visible in the date sequence but are not yet evaluated — adherence calculation is a downstream analytic step.

Timeline Spec

Title

Three FHIR MedicationRequest resources for patient P001 (metformin 500 mg, Q1 2023)

Window
Start

2023-01-01

End

2023-04-30

Label

Observation window: Jan-Apr 2023

Events
  • Label

    MedReq med-001

    Start

    2023-01-05

    Length Days

    30

    Quantity

    30 days_supply

  • Label

    MedReq med-002

    Start

    2023-02-06

    Length Days

    30

    Quantity

    30 days_supply

  • Label

    MedReq med-003

    Start

    2023-03-09

    Length Days

    30

    Quantity

    30 days_supply

Spans
  • Kind

    exposed

    Start

    2023-01-05

    End

    2023-02-03

    Label

    30-day supply (med-001)

  • Kind

    gap

    Start

    2023-02-04

    End

    2023-02-05

    Label

    2-day gap

  • Kind

    exposed

    Start

    2023-02-06

    End

    2023-03-07

    Label

    30-day supply (med-002)

  • Kind

    gap

    Start

    2023-03-08

    End

    2023-03-08

    Label

    1-day gap

  • Kind

    exposed

    Start

    2023-03-09

    End

    2023-04-07

    Label

    30-day supply (med-003)

Result
Label

3 resources to 3 rows; total days_supply = 30 + 30 + 30 = 90

Value

90

Runnable example

python implementation

Parse FHIR MedicationRequest resources from a simulated NDJSON bulk-export string into a pandas DataFrame. Extracts RxNorm code, prescription date (authoredOn), and intended days_supply from each resource. Handles the optional dispenseRequest block...

import json
import pandas as pd
from io import StringIO

# ── Simulated NDJSON bulk export: 3 MedicationRequest resources (one per line) ──
ndjson_data = (
    '{"resourceType":"MedicationRequest","id":"med-001","status":"active","intent":"order",'
    '"medicationCodeableConcept":{"coding":[{"system":"http://www.nlm.nih.gov/research/umls/rxnorm",'
    '"code":"860975","display":"metformin 500 mg oral tablet"}]},'
    '"subject":{"reference":"Patient/P001"},"authoredOn":"2023-01-05",'
    '"dispenseRequest":{"expectedSupplyDuration":{"value":30,"unit":"days"}}}\n'
    '{"resourceType":"MedicationRequest","id":"med-002","status":"active","intent":"order",'
    '"medicationCodeableConcept":{"coding":[{"system":"http://www.nlm.nih.gov/research/umls/rxnorm",'
    '"code":"860975","display":"metformin 500 mg oral tablet"}]},'
    '"subject":{"reference":"Patient/P001"},"authoredOn":"2023-02-06",'
    '"dispenseRequest":{"expectedSupplyDuration":{"value":30,"unit":"days"}}}\n'
    '{"resourceType":"MedicationRequest","id":"med-003","status":"active","intent":"order",'
    '"medicationCodeableConcept":{"coding":[{"system":"http://www.nlm.nih.gov/research/umls/rxnorm",'
    '"code":"860975","display":"metformin 500 mg oral tablet"}]},'
    '"subject":{"reference":"Patient/P001"},"authoredOn":"2023-03-09",'
    '"dispenseRequest":{"expectedSupplyDuration":{"value":30,"unit":"days"}}}\n'
)

RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm"

def extract_rxnorm_code(resource):
    codings = resource.get("medicationCodeableConcept", {}).get("coding", [])
    for c in codings:
        if c.get("system") == RXNORM_SYSTEM:
            return c.get("code")
    return None

def extract_days_supply(resource):
    return (resource
            .get("dispenseRequest", {})
            .get("expectedSupplyDuration", {})
            .get("value"))

rows = []
for line in StringIO(ndjson_data):
    line = line.strip()
    if not line:
        continue
    resource = json.loads(line)
    if resource.get("resourceType") != "MedicationRequest":
        continue
    if resource.get("status") not in ("active", "completed"):
        continue
    person_id = resource["subject"]["reference"].split("/")[-1]
    rows.append({
        "person_id":   person_id,
        "resource_id": resource["id"],
        "fill_date":   resource.get("authoredOn"),
        "rxnorm_code": extract_rxnorm_code(resource),
        "days_supply": extract_days_supply(resource),
    })

exposure_df = pd.DataFrame(rows)
print(exposure_df.to_string(index=False))
# person_id resource_id  fill_date rxnorm_code  days_supply
#      P001    med-001  2023-01-05      860975         30.0
#      P001    med-002  2023-02-06      860975         30.0
#      P001    med-003  2023-03-09      860975         30.0

total_days = int(exposure_df["days_supply"].sum())
print(f"Resource count: {len(exposure_df)}")   # 3
print(f"Total days_supply: {total_days}")       # 90
assert len(exposure_df) == 3, "Expected 3 rows from 3 MedicationRequest resources"
assert total_days == 90, "Expected 30 + 30 + 30 = 90"
r implementation

Parse FHIR MedicationRequest resources from NDJSON using jsonlite. Extracts RxNorm code, authoredOn date, and days_supply from each resource into a data frame. Mirrors the Python worked example — 3 resources to 3 rows. For production use with large NDJSON...

library(jsonlite)

# ── Simulated NDJSON lines: one JSON object per element ──
ndjson_lines <- c(
  paste0('{"resourceType":"MedicationRequest","id":"med-001","status":"active",',
         '"intent":"order","medicationCodeableConcept":{"coding":[{"system":',
         '"http://www.nlm.nih.gov/research/umls/rxnorm","code":"860975",',
         '"display":"metformin 500 mg oral tablet"}]},"subject":{"reference":',
         '"Patient/P001"},"authoredOn":"2023-01-05","dispenseRequest":{',
         '"expectedSupplyDuration":{"value":30,"unit":"days"}}}'),
  paste0('{"resourceType":"MedicationRequest","id":"med-002","status":"active",',
         '"intent":"order","medicationCodeableConcept":{"coding":[{"system":',
         '"http://www.nlm.nih.gov/research/umls/rxnorm","code":"860975",',
         '"display":"metformin 500 mg oral tablet"}]},"subject":{"reference":',
         '"Patient/P001"},"authoredOn":"2023-02-06","dispenseRequest":{',
         '"expectedSupplyDuration":{"value":30,"unit":"days"}}}'),
  paste0('{"resourceType":"MedicationRequest","id":"med-003","status":"active",',
         '"intent":"order","medicationCodeableConcept":{"coding":[{"system":',
         '"http://www.nlm.nih.gov/research/umls/rxnorm","code":"860975",',
         '"display":"metformin 500 mg oral tablet"}]},"subject":{"reference":',
         '"Patient/P001"},"authoredOn":"2023-03-09","dispenseRequest":{',
         '"expectedSupplyDuration":{"value":30,"unit":"days"}}}')
)

RXNORM_SYSTEM <- "http://www.nlm.nih.gov/research/umls/rxnorm"

extract_rxnorm_code <- function(resource) {
  codings <- resource[["medicationCodeableConcept"]][["coding"]]
  if (is.null(codings)) return(NA_character_)
  rxnorm_rows <- codings[codings$system == RXNORM_SYSTEM, ]
  if (nrow(rxnorm_rows) == 0) return(NA_character_)
  rxnorm_rows$code[1]
}

extract_days_supply <- function(resource) {
  val <- resource[["dispenseRequest"]][["expectedSupplyDuration"]][["value"]]
  if (is.null(val)) return(NA_real_)
  as.numeric(val)
}

rows <- lapply(ndjson_lines, function(line) {
  resource <- fromJSON(line, simplifyVector = TRUE)
  if (resource$resourceType != "MedicationRequest") return(NULL)
  if (!resource$status %in% c("active", "completed")) return(NULL)
  person_id <- sub("Patient/", "", resource$subject$reference)
  data.frame(
    person_id    = person_id,
    resource_id  = resource$id,
    fill_date    = resource$authoredOn,
    rxnorm_code  = extract_rxnorm_code(resource),
    days_supply  = extract_days_supply(resource),
    stringsAsFactors = FALSE
  )
})

exposure_df <- do.call(rbind, Filter(Negate(is.null), rows))
print(exposure_df)
#   person_id resource_id  fill_date rxnorm_code days_supply
# 1      P001    med-001 2023-01-05      860975          30
# 2      P001    med-002 2023-02-06      860975          30
# 3      P001    med-003 2023-03-09      860975          30

total_days <- sum(exposure_df$days_supply, na.rm = TRUE)
cat(sprintf("Resource count: %d\n", nrow(exposure_df)))  # 3
cat(sprintf("Total days_supply: %d\n", total_days))      # 90
stopifnot(nrow(exposure_df) == 3)
stopifnot(total_days == 90)