FHIR to OMOP Vocabulary Mapping: A How-To Guide

You already have the FHIR feed. The EHR team can export Condition, Observation, MedicationRequest, and enough encounter context to make the analytics team hopeful. Then the hard part starts.
FHIR to OMOP vocabulary mapping is where most pipelines either become reliable or drift into junk data. The problem is not JSON parsing. It is not flattening nested resources. It is deciding what a source code means, what OMOP domain it belongs to, whether it should become a standard concept, and how to preserve lineage when the answer is not clean.
Spreadsheets do not survive this job. A folder full of CSV crosswalks, hand-edited by three people over six months, turns into an untestable dependency. If you want a pipeline you can rerun, audit, and explain to a clinical reviewer, use code, versioned mapping rules, and API-driven vocabulary access.
Bridging FHIR and OMOP for Clinical Analytics
Your team pulls a clean Condition resource from a FHIR endpoint, sees an ICD-10-CM code, and assumes the hard part is done. It is not. The hard part starts when that code has to become an OMOP concept with the right domain, the right standard mapping, the original source value preserved, and enough lineage to explain the decision six months later during validation.
FHIR and OMOP solve different problems. FHIR is designed to exchange operational data between systems. OMOP is designed to make those same facts analyzable across sites and studies. A direct field copy fails quickly because a FHIR resource can carry multiple codings, free text, partial dates, profile-specific extensions, and context that does not map cleanly into a single OMOP event record.

The practical consequence is vocabulary work. A diagnosis code in FHIR often needs to resolve to a standard SNOMED concept in OMOP. A lab result with a local code may belong nowhere until you align it to LOINC and confirm it really belongs in measurement instead of observation. Medication data is worse. I regularly see feeds that mix RxNorm, NDC-like strings, and internal formulary identifiers in the same field, which means the ETL has to classify the code system before it can map anything correctly.
This is why I push teams away from spreadsheet crosswalks and local vocabulary babysitting. An auditable pipeline queries terminology services at runtime, stores the mapping decision with vocabulary version context, and reruns the same logic in test and production. That is the difference between a pipeline you can defend and one that changes behavior when someone updates a CSV.
API-first automation fits this problem better than manual curation. If your ingestion already starts from FHIR endpoints, carry the same design into terminology resolution with a FHIR API integration approach that stays code-driven end to end. OMOPHub is useful here because it lets teams treat vocabulary resolution as a service in the pipeline instead of as a side project in a local database that only one engineer knows how to maintain.
Mapping quality also sets the ceiling for every downstream analytic use case. Cohort definitions, phenotypes, utilization metrics, and outcome models all inherit whatever ambiguity or inconsistency you leave in the vocabulary layer. Teams planning compliant analytics programs should also understand the downstream operating model, and this overview of HIPAA-ready data analysis tools for healthcare outcomes is a good reference point.
Tip: If a mapping decision cannot be reproduced from code plus versioned vocabulary state, it is not production-ready.
Core Mapping Strategies Before You Code
The first architectural decision is simple to state and painful to change later. Are you mapping source codes directly to OMOP standard concepts at transform time, or are you formalizing reusable source-to-concept logic that your ETL resolves consistently across runs?
Both approaches work. They fail in different ways.
Direct mapping versus persisted source mapping
Direct mapping means the ETL receives a FHIR coding, resolves it to a standard concept, and writes the target concept IDs into the destination tables. This is fast to prototype and often fine for well-behaved vocabularies like SNOMED CT, LOINC, and RxNorm when the source coding is already close to OMOP expectations.
A persisted source mapping approach treats the source code as a first-class input to the mapping layer. You preserve source system, code, text, and sometimes profile context, then resolve through a reusable mapping table or service. This adds overhead, but it gives you auditability and consistent reruns.
Here is the decision table I use with teams.
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| Direct mapping to standard concepts | Simple ETL flow, fewer moving parts, fast for clean standard-coded inputs | Harder to audit historical decisions, brittle when source coding varies, easy to duplicate logic across jobs | Narrow pipelines, controlled source systems, prototypes |
SOURCE_TO_CONCEPT_MAP oriented workflow | Reusable decisions, better lineage, easier remediation of local or unstable source codes | More design work upfront, requires strong governance, can become messy if unmanaged | Enterprise ETL, multi-site feeds, local code systems, long-lived research platforms |
The mistake I see most often is pretending you only need one. In practice, mature pipelines use both. Standard codes with clean vocabulary support can map directly. Local or organization-specific codes should go through a governed source mapping layer.
Pre-coordination and post-coordination are not academic details
The thorniest design choice is how you handle clinical meaning that does not fit neatly into one target concept. The FHIR to OMOP Cookbook notes that the challenge of pre- vs. post-coordinated concept mapping lacks consensus-driven best practices, and that the trade-off can become either combinatorial explosion in OMOP or meaningful data loss during domain assignment (FHIR to OMOP Cookbook).
Pre-coordination is attractive because it simplifies downstream querying. One concept, one route, one destination table. But it breaks down when the source expresses nuance with multiple atomic parts.
Post-coordination preserves nuance by composing meaning from multiple elements. That sounds cleaner until you own the ETL, the validation rules, the concept routing, and the analyst documentation.
A practical framework:
- Use pre-coordination when the source code already expresses the clinical concept at the granularity your study needs.
- Use post-coordination when collapsing source detail would alter study logic, phenotype definition, or domain routing.
- Refuse false precision when a source code or text fragment cannot support a clinically defensible target concept.
What usually works
Three rules hold up well in production:
-
Map for the analytical question, not for aesthetic symmetry. OMOP does not owe FHIR a one-resource-to-one-table mirror.
-
Preserve source coding even after successful standardization. You will need it for debugging and for remapping when vocabularies change.
-
Separate domain assignment from code lookup. A valid coding match does not automatically imply the right OMOP table.
Tip: If analysts care about reproducibility, store the original FHIR
system,code,display, and enough lineage to re-run the mapping decision later.
Hands-On Mapping with OMOPHub APIs and SDKs
This is the part where spreadsheet-based workflows collapse. A FHIR CodeableConcept is structured data. Treat it that way.
Take a common case. You receive a FHIR Condition with multiple codings. One of them is ICD-10-CM. Your ETL needs the standard OMOP concept that should drive condition_concept_id, while still preserving source fields for lineage.
The clean pattern is:
- Extract all codings.
- Normalize system identifiers.
- Prefer the coding systems your governance rules trust most.
- Resolve candidate concepts programmatically.
- Filter to standard concepts in the correct domain.
- Log the full decision path.
Start with the source coding, not the display string
A minimal FHIR example:
condition = {
"resourceType": "Condition",
"id": "cond-1",
"code": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/icd-10-cm",
"code": "E11.9",
"display": "Type 2 diabetes mellitus without complications"
}
],
"text": "Type 2 diabetes mellitus without complications"
}
}
Do not map from text unless you are explicitly using an NLP or fallback workflow. The coding is the contract.
Query vocabulary services from code
For quick validation during development, the Concept Lookup tool is useful because it lets you inspect candidate OMOP concepts interactively before you bake assumptions into ETL rules.
For programmatic ETL, use a client library. The publisher provides API access to ATHENA-synced vocabularies, plus SDKs for Python and R, through omophub-python and omophub-R. That is one practical way to avoid standing up and maintaining a local vocabulary database just to perform lookups and relationship traversal.
A Python pattern for a single-code lookup:
import os
from omophub import OMOPHub
client = OMOPHub(api_key=os.environ["OMOPHUB_API_KEY"])
source_coding = condition["code"]["coding"][0]
results = client.concepts.search(
query=source_coding["code"],
vocabulary_ids=["ICD10CM"],
standard_concept=None
)
for concept in results.items:
print({
"concept_id": concept.concept_id,
"concept_name": concept.concept_name,
"vocabulary_id": concept.vocabulary_id,
"domain_id": concept.domain_id,
"standard_concept": concept.standard_concept,
"concept_code": concept.concept_code
})
The important part is not the exact first response. The important part is the filtering logic you apply next.
Filter for standard target concepts
You usually want a standard OMOP concept in the target domain, not just any matching source concept.
def pick_standard_condition(candidates):
filtered = [
c for c in candidates.items
if c.standard_concept == "S" and c.domain_id == "Condition"
]
if not filtered:
return None
return filtered[0]
If the first search returns only source concepts, traverse mappings or concept relationships rather than forcing the source concept into condition_concept_id.
source_matches = client.concepts.search(
query="E11.9",
vocabulary_ids=["ICD10CM"]
)
source_concept = source_matches.items[0]
relationships = client.concepts.relationships(
concept_id=source_concept.concept_id
)
standard_targets = [
rel for rel in relationships.items
if getattr(rel, "standard_concept", None) == "S"
and getattr(rel, "domain_id", None) == "Condition"
]
print(standard_targets[0] if standard_targets else "No standard condition target found")
The pattern matters more than any one endpoint. Search the source vocabulary first. Then traverse to the standard concept that OMOP analytics expect.
Build a reusable function for FHIR codings
def map_fhir_codeable_concept_to_omop(codeable_concept, expected_domain):
codings = codeable_concept.get("coding", [])
for coding in codings:
system = coding.get("system")
code = coding.get("code")
if not system or not code:
continue
vocabulary_ids = []
if system == "http://hl7.org/fhir/sid/icd-10-cm":
vocabulary_ids = ["ICD10CM"]
elif system == "http://snomed.info/sct":
vocabulary_ids = ["SNOMED"]
elif system == "http://loinc.org":
vocabulary_ids = ["LOINC"]
elif system == "http://www.nlm.nih.gov/research/umls/rxnorm":
vocabulary_ids = ["RxNorm"]
else:
continue
matches = client.concepts.search(
query=code,
vocabulary_ids=vocabulary_ids
)
standard = [
c for c in matches.items
if c.standard_concept == "S" and c.domain_id == expected_domain
]
if standard:
return {
"target_concept_id": standard[0].concept_id,
"target_concept_name": standard[0].concept_name,
"source_code": code,
"source_system": system,
"mapping_status": "mapped"
}
return {
"target_concept_id": 0,
"target_concept_name": None,
"source_code": None,
"source_system": None,
"mapping_status": "unmapped"
}
That gives you a durable building block for Condition, Observation, Procedure, and medication-related resources.
If your source is Epic-derived FHIR, details in exported codings and encounter context often need source-specific handling. This write-up on https://omophub.com/blog/fhir-epic is worth reading because Epic-flavored payloads often expose exactly the kind of local variation that breaks naive mapping logic.
Tip: Build your mapper to return a structured decision object, not just a
concept_id. You need mapping status, source coding, and failure reason for audit and retries.
Navigating Multiple Code Systems and Versions
A real FHIR feed gets messy fast. One Condition arrives with SNOMED CT and ICD-10-CM. The next Observation uses LOINC plus a site-specific code. A medication record carries RxNorm in one environment and only local formulary identifiers in another. If your ETL assumes one coding system per resource, it will fail in production.

Treat vocabulary resolution as a decision pipeline. Some codings map directly to OMOP standard concepts. Some require a hop through source-to-standard relationships. Some belong in a queue for local curation because the code system is valid but your organization has not defined how to resolve it yet. Silent coercion is the failure mode to avoid.
The model mismatch matters here. FHIR is happy to carry several codings for the same clinical fact, including local and operational identifiers. OMOP wants one analytically coherent concept in the right domain, backed by reproducible vocabulary logic. That means the job is not "find a code." The job is "choose the right coding, resolve it with the right vocabulary version, and record how that decision was made."
Build precedence rules before you process volume
Multiple codings on one resource are normal. Handle them with explicit ranking logic, not whichever array element happens to come first.
A practical rule set looks like this:
- Prefer standard clinical vocabularies over local systems when both are present.
- Prefer codings that resolve into the expected OMOP domain.
- Keep every original coding in lineage, even when only one drives the target
concept_id. - Mark ties and domain conflicts for review instead of guessing.
That last point saves a lot of cleanup later.
For example, a FHIR Observation.code might include both LOINC and a local lab code. Use the LOINC coding for OMOP concept resolution if it lands in the Measurement domain. Keep the local code in your source tracking fields or sidecar audit table. If the LOINC candidate resolves outside the expected domain, do not force it through. Record the conflict and send it to validation.
If you are using OMOPHub APIs, this logic belongs in code, not a spreadsheet tab someone updates on Fridays. The API-first pattern works well here because you can centralize vocabulary search, relationship traversal, and version pinning behind one service contract. That removes a lot of local database drift between environments.
Local codes need a governed path
Local codes are common in Epic exports, interface-engine payloads, and older departmental systems. Treat them as first-class inputs with a separate resolution path.
Do three things every time:
- Store
system,code, and display text exactly as received. - Attempt a local-to-standard mapping only through approved crosswalks or governed lookup services.
- Return an explicit unmapped status when no rule exists.
Do not collapse unresolved local codes into a nearby standard concept just to keep row counts high. Analysts would rather see a controlled unmapped bucket than bad phenotype logic.
A small decision object helps:
{
"source_system": "http://hospital.example.org/lab-codes",
"source_code": "HBGA1C",
"mapping_status": "unmapped_local",
"failure_reason": "no approved local crosswalk",
"vocabulary_version": "2025-01-31"
}
That structure is easier to test, easier to audit, and easier to route into a review queue.
Version drift changes results
Vocabulary updates are good for analytics and dangerous for reproducibility. A concept can become invalid. A replacement can change. A source code that failed last month can map cleanly after the next vocabulary refresh. If you do not pin vocabulary state, the same FHIR payload can produce different OMOP rows on different runs.
Persist enough metadata to answer three questions later:
- Which vocabulary release was active?
- Which source coding won the precedence check?
- Which mapping path produced the final standard concept?
I strongly prefer storing that information next to the ETL run metadata and in row-level mapping audit logs. It turns "why did this count change?" from a long meeting into a diff.
This is also where automated QA pays off. Pair your mapper with data quality checks for OMOP ETL outputs so version changes surface as measurable shifts in domain distribution, unmapped rates, and concept validity, not analyst complaints two weeks later.
Version-aware mapping is extra work up front. It is still the cheaper option. Without it, every vocabulary refresh becomes an uncontrolled experiment.
Avoiding Common Pitfalls and Validating Your Mappings

A mapper returns a concept ID. Analysts still get the wrong table, the wrong date, or a source code that should have been rejected. That gap is where FHIR to OMOP projects usually fail.
The pattern is familiar. A batch run finishes cleanly. API calls succeed. Row counts look reasonable. Then someone notices diagnoses showing up in observation, medication events routed through fallback logic, or dates that were invented because the source only carried year or year-month precision.
Date precision needs explicit policy
FHIR date fields can arrive as 2024, 2024-05, or full dateTime values. OMOP expects you to place those values into stricter columns. If your pipeline fills the gap with undocumented assumptions, you create fake certainty.
Set the rule in code and log the decision every time.
A policy like this is easy to reason about:
- Year only stays year-only in provenance. Do not turn it into an exact event date unless your ETL contract allows imputation.
- Year-month gets one deterministic conversion rule, such as first day of month or end of month, and that rule is attached to the row-level audit trail.
- Missing or null source dates remain missing. Do not default to
1900-01-01, file load date, or any other placeholder that analysts will mistake for a real clinical event.
I prefer storing both the transformed OMOP date and the original FHIR value, plus a date_precision or date_handling_rule field in the mapping log. That makes debugging simple:
{
"source_date": "2024-05",
"source_precision": "month",
"omop_date": "2024-05-01",
"date_rule": "month_start_imputation",
"provenance_status": "imputed"
}
Without that record, nobody can explain later why the event landed on the first of the month.
Domain routing deserves the same scrutiny as concept lookup
A code match is only half the job. The destination table has to make clinical sense.
The destination table has to make clinical sense, a point at which spreadsheet-based review starts to break down. A human can confirm a few examples. A production pipeline needs to evaluate every mapped row against expected domain behavior and fail fast when routing drifts. API-first mapping helps because the lookup result, vocabulary metadata, and routing logic can all be captured in one decision record instead of copied across local notebooks and CSV files.
Check these patterns aggressively:
- Conditions should map into
condition_occurrenceunless your rule explicitly sends them elsewhere. - LOINC measurements should not fall through to generic observation handling because a fallback path was easier to code.
- Drug source codes should not land in procedure or observation tables because the mapper accepted the first valid-looking concept.
- Retired or non-standard concepts should be rejected or remapped before load, not inserted.
In practice, I want each mapping call to produce enough evidence to review the decision without rerunning the job:
{
"resource_type": "Condition",
"source_code": "44054006",
"source_system": "http://snomed.info/sct",
"target_concept_id": 320128,
"target_domain_id": "Condition",
"target_standard_concept": "S",
"target_table": "condition_occurrence",
"mapping_status": "mapped"
}
If resource_type = Condition and target_table = observation, that row should enter an exception queue unless a documented rule says otherwise.
Validation should run in layers
Manual spot checks help, but they do not scale. The pipeline needs automated validation at three levels.
1. Structural validation
Check what the database can enforce and what it cannot.
- Required OMOP fields are populated
concept_idvalues exist and are valid for the pinned vocabulary release- Source values and source vocabularies are preserved
- Date conversion follows your policy
- Duplicate event creation is blocked
2. Semantic validation
Test whether the mapping means what you think it means.
Review samples by resource type, source system, and destination table. Compare the original FHIR coding, the selected standard concept, and the final OMOP table in one screen. This is a good place to use OMOPHub API responses directly in your QA workflow instead of exporting another review sheet that goes stale the next day.
3. Aggregate validation
Watch distributions, not just individual rows.
Track mapped versus unmapped rates, domain distribution by source vocabulary, reassignment counts, and invalid concept usage across runs. Teams that already run OMOP ETL data quality checks catch mapping regressions earlier because they treat vocabulary behavior as part of data quality, not as a separate cleanup task after load.
A compact validation matrix keeps the work grounded:
| Check type | What to verify | Failure signal |
|---|---|---|
| Structural | Required OMOP fields, valid concept IDs, source lineage, pinned vocabulary version | Null target fields, invalid concept references, missing provenance |
| Semantic | Source coding aligns with target concept and destination table | Diagnoses in wrong domain, implausible standard concepts, incorrect routing |
| Aggregate | Distribution by domain, source system, and mapping status across runs | Unmapped spikes, domain shifts, sudden drops after vocabulary or rule changes |
One more rule matters. Log every unmapped code, every domain override, every rejected concept, and every date normalization decision. Those records are not noise. They are the remediation queue, the audit trail, and the fastest way to turn a fragile mapper into an API-driven pipeline you can trust.
Scaling from Scripts to a Production ETL Pipeline
A single script can prove the mapping logic. It cannot carry a health system pipeline by itself.
Production design starts with the assumption that volume, retries, and auditability matter as much as correctness. That assumption is justified. A pilot FHIR-to-OMOP pipeline reported processing over 10.5 million FHIR resource rows and mapping 51,022 data elements, which is the scale you should design for if your source is a large health information network (pilot report).
The production patterns that matter
-
Batch by resource type and vocabulary path Conditions, labs, and medications fail differently. Separate them.
-
Cache deterministic lookups Vocabulary lookups are ideal cache candidates when the source code, vocabulary version, and expected domain are fixed.
-
Make retries idempotent Reprocessing a bundle should not duplicate events or mutate prior mapping decisions.
-
Persist decision lineage Keep source coding, target concept, mapping status, vocabulary version, and transformation notes.
-
Promote logs to first-class data Unmapped codes, ambiguous domains, and malformed dates should feed a remediation workflow, not disappear into application logs.
The architectural shift is straightforward. Stop thinking of vocabulary mapping as a helper function inside ETL. Treat it as a governed service boundary with versioning, observability, and explicit contracts.
That is how mapping code becomes a durable research data asset instead of a brittle conversion script.
If you are building or refactoring a FHIR to OMOP vocabulary mapping pipeline, OMOPHub is one practical option for API-based access to OMOP vocabularies and relationship traversal without running local ATHENA infrastructure. It fits teams that want version-aware terminology lookups, SDK support, and auditable automation as part of ETL rather than as a separate manual process.

