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.
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_id | authoredOn | rxnorm_code | days_supply |
|---|---|---|---|
| med-001 | 2023-01-05 | 860975 | 30 |
| med-002 | 2023-02-06 | 860975 | 30 |
| med-003 | 2023-03-09 | 860975 | 30 |
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)