Back to Blog

ICD-10 to OMOP Concept Mapping: A Hands-On Guide

Dr. Sarah ChenDr. Sarah Chen
July 29, 2026
13 min read
ICD-10 to OMOP Concept Mapping: A Hands-On Guide

You're staring at a fresh ICD-10-CM column, the business owner wants it in OMOP by tomorrow, and somebody has already asked why the counts don't match the source system. That's the exact moment where most pipelines get sloppy, because ICD-10 doesn't land in OMOP as a literal code copy. In OMOP, diagnosis data has to move through the vocabulary graph first, then into the standard concept layer that analytics use.

Why ICD-10 Codes Cannot Drop Directly into OMOP

A new ICD-10 field in production looks deceptively simple. The table has a code, the code has a name, and the destination table, often condition_occurrence, is already waiting. The trap is assuming the source code itself is the analytic concept, because OMOP's condition domain standardizes on SNOMED CT, not ICD-10 directly, as the All of Us OMOP basics guide explains for Conditions, where source vocabularies such as ICD-9 and ICD-10 map to the OMOP standard vocabulary SNOMED. That means an ICD-10 row has to become a source concept first, then travel through a Maps to relationship before it can sit in the CDM's standard concept field. Understanding OMOP Basics

A diagram illustrating why ICD-10 clinical codes require mapping to standard OMOP concepts for data interoperability.

What actually lands in the CDM

OMOP separates the raw source string, the source concept, and the standard concept. In practice, that means your ETL can't skip the vocabulary layer and still claim OMOP compliance. The reason is structural, not cosmetic. CONCEPT gives every integrated vocabulary code a stable concept_id, and CONCEPT_RELATIONSHIP holds the links that tell you which source concepts map to which standard concepts.

Practical rule: never treat an ICD-10 code as if it were already a CDM-ready concept. If you do, you'll get a load that looks tidy but won't compare cleanly across datasets or tools.

This is the part that surprises people coming from flat code-set warehouses. A code is not yet a concept the analytics layer can trust, and the vocabulary graph determines the meaning you record. The same logic is why the source code can be present, but the analytic concept still needs to be resolved before it gets into a standardized field.

The operational takeaway is simple. If you're mapping diagnoses into OMOP, the question is never “what's the ICD-10 code?” alone. The core question is “what standard OMOP concept does this source code resolve to, and how confidently does it resolve there?”

The Two-Step Lookup Pattern Behind Every ICD-10 Resolution

A reliable ICD-10 to OMOP concept lookup is a two-step move, not a single join. Start by resolving the ICD-10-CM code in CONCEPT and capturing the source concept_id. Then follow the Maps to row in CONCEPT_RELATIONSHIP until you reach the standard concept. That flow matches the OMOP vocabulary model and the same path used by OMOPVocabMapper. OMOP vocabulary model
OMOPVocabMapper workflow

A diagram illustrating the two-step process of resolving ICD-10 medical codes into standardized OMOP concept identifiers.

SELECT
  c1.concept_id AS source_concept_id,
  c1.concept_code AS icd10_code,
  c2.concept_id AS standard_concept_id,
  c2.concept_name AS standard_concept_name
FROM concept c1
JOIN concept_relationship cr
  ON cr.concept_id_1 = c1.concept_id
JOIN concept c2
  ON c2.concept_id = cr.concept_id_2
WHERE c1.vocabulary_id = 'ICD10CM'
  AND c1.concept_code = 'E11.9'
  AND cr.relationship_id = 'Maps to'
  AND c2.standard_concept = 'S';

For E11.9, the OHDSI vocabulary example shows the source concept as 45561952 and the standard SNOMED CT concept as 201826. The same reference shows 201826 as the standard concept for type 2 diabetes mellitus, while 45561952 is the non-standard ICD-10 source concept for E11.9. OMOP vocabulary table preview

What lands in the CDM

OMOP keeps the raw source string, the source concept, and the standard concept separate. In practice, that means your ETL has to use the vocabulary layer if you want a load that behaves like OMOP, not just a table that looks tidy. CONCEPT gives every integrated vocabulary code a stable concept_id, and CONCEPT_RELATIONSHIP holds the links that tell you which source concepts map to which standard concepts.

Practical rule: never treat an ICD-10 code as if it were already a CDM-ready concept. If you do, the load may look clean, but it will not compare cleanly across datasets or tools.

People who come from flat code-set warehouses often miss this split on the first pass. A code is not yet a concept the analytics layer can trust, and the vocabulary graph determines the meaning you record. The source code can be present, but the analytic concept still needs to be resolved before it lands in a standardized field.

The operational question is never just “what's the ICD-10 code?” The question is “what standard OMOP concept does this source code resolve to, and how confidently does it resolve there?”

How OMOPHub Compresses That Pattern into One Call

Manual vocabulary work has a rhythm, but it is not a light one. You pull or query the vocabulary bundle, inspect the source concept, follow Maps to, check the standard target, and then decide whether the target belongs in your domain field. That is workable for exploration, but it gets old fast when the same logic has to run inside an ETL job, a validation notebook, and a terminology service.

OMOPHub exposes that same resolution through POST /v1/fhir/resolve, where a FHIR system URI, a code, and an optional resource_type go in, and the standard concept, domain, mapping type, and CDM target table come back in one call. The API keeps the OMOP model inspectable, so you can validate the mapping path without rebuilding the vocabulary stack yourself. For the broader lookup pattern behind that flow, see the OMOP concept mapping overview.

What the API answers for you

The server-side resolution handles the part that usually sits in your SQL joins. If you send an ICD-10-CM Condition code, it resolves the source concept and traverses Maps to on your behalf, then returns the analytic destination you need to load or validate. The practical difference from a bare code lookup is simple. The API answers both what concept it is and where it belongs in OMOP.

The same pattern also lines up with OMOPHub's map ICD-10 to SNOMED CT guide, which is useful when you want to inspect the source-to-standard path before wiring it into code.

What it does not remove is vocabulary governance. You still need to account for versioning, ambiguous mappings, and the phenotype logic that sits above the concept layer. A resolver helps make the mapping step faster and easier to inspect, but it does not replace the mapping rules themselves.

Operational tip: use the resolver for lookup and validation, but keep your version pinning and ambiguity policy in the pipeline code. If those rules live only in someone's notebook, they will drift after the next vocabulary refresh.

For teams choosing between local dumps and a service, the trade-off is straightforward. The local route gives you full control and offline access, while an API centralizes the vocabulary logic and reduces the plumbing you own. If you want a fuller build-versus-service comparison, return to the OMOP concept mapping overview.

Running a Real ICD-10 Resolution in Python

A working Python call starts with the raw request, because that's where most integration mistakes show up first. The endpoint expects a Bearer token, a FHIR system URI, the code itself, and a resource type that tells OMOP which domain you're asking about. For ICD-10-CM diagnoses, the payload should look like a Condition resolver call.

import requests

url = "https://api.omophub.com/v1/fhir/resolve"
headers = {
    "Authorization": "Bearer oh_your_api_key",
    "Content-Type": "application/json",
}

payload = {
    "system": "http://hl7.org/fhir/sid/icd-10-cm",
    "code": "E11.9",
    "resource_type": "Condition"
}

response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()

print(data)

A typical response shape should expose the concept you can load or review, along with the OMOP metadata around it.

{
  "source_code": "E11.9",
  "standard_concept_id": 201826,
  "domain_id": "Condition",
  "relationship_name": "Maps to",
  "target_table": "condition_occurrence"
}

The same API key works across the REST and FHIR surfaces, so you do not need separate credentials for lookup and terminology service calls. That makes notebook testing and pipeline automation much easier to keep aligned. The docs also include the Python SDK, which trims the request boilerplate when you want to wrap this in reusable code. omophub-python SDK

from omophub import Client

client = Client(api_key="oh_your_api_key")

result = client.fhir.resolve(
    system="http://hl7.org/fhir/sid/icd-10-cm",
    code="E11.9",
    resource_type="Condition",
)

print(result.standard_concept_id)
print(result.domain_id)
print(result.target_table)

For a quick implementation check, compare your call shape against the documentation examples in the docs site and confirm that your payload uses the ICD-10-CM system URI your downstream client expects. If your response doesn't include the standard concept and target table, the problem is usually the request shape or the code system, not the resolver itself. omophub-com docs

Handling Ambiguous Mappings Without Breaking Your Cohort

A source ICD-10 code can resolve to more than one standard concept, and OMOP allows that ambiguity because the vocabulary model is built around relationships, not one-to-one replacement. In practice, Maps to may indicate an exact match or an uphill mapping to a broader concept, so the resolver can return several candidates when ICD-10 and SNOMED CT slice clinical meaning differently. That is why the mapping step has to stay visible in review, even if your ETL later consumes only one target.

The OHDSI Europe 2026 poster shows the cost of assuming every mapping is exact. In manual review, some ICD-10 to SNOMED CT mappings matched the expected result, while others were less specific or incorrect. That split is the part that matters in production, because a code that looks acceptable on paper can still pull the wrong patients into a cohort.

A diagram illustrating how to resolve 1-to-many ambiguous mapping issues between source and standard concepts.

How to treat one source concept with multiple targets

When a source code returns several Maps to targets, do not pick the first one that looks familiar. Record the source code, each candidate standard concept, and the rule that led to the final choice. If the split reflects a real clinical difference, keep both paths available and let the cohort logic decide whether the source should count as one condition or multiple rows.

Field-tested rule: ambiguity belongs in review, not in silence. If you cannot explain why one source code collapsed into one standard concept, your cohort definition is probably doing more guessing than mapping.

The reason is structural. ICD-10 often carries pre-coordinated clinical detail, while SNOMED CT may represent the same idea at a different granularity or as a compositional expression. That difference is why some mappings resolve upward into a broader concept, and why the mapping layer needs human review whenever precision changes cohort membership.

A practical validation pattern is straightforward. Flag multi-target mappings in a dashboard, compare the highest-value codes against chart review, and decide whether the right action is to keep the broader uphill target or split the source into multiple condition rows. If you need a reference for how that review step fits into the larger ICD-10 workflow, the ICD-10 conversion guide is the right place to start. The wrong move is to pretend the ambiguity does not exist, because that is how a clean-looking cohort turns into a biased one.

Wiring OMOPHub into Your ETL Pipeline

The most reliable ETL pattern is plain and repeatable. Resolve codes in batches, cache the results locally, and pin the vocabulary version so the same source file maps to the same standard concepts next month. OMOPHub supports batch requests up to 100 codes per request, which fits the common workflow of mapping a feed before load instead of resolving row by row.

CapabilitySelf-hosted ATHENAOMOPHub REST APIOMOPHub FHIR resolve
SetupLocal vocabulary load and maintenanceAPI key and request callAPI key and FHIR-shaped request
Vocabulary refreshManualAutomatic sync with ATHENAAutomatic sync with ATHENA
Batch mappingBuild your ownUp to 100 codes per requestResolver-based lookup by code
Mapping transparencyFull local tablesServer-side Maps to traversalServer-side Maps to traversal
Best fitAir-gapped or custom environmentsETL and app integrationFHIR-native workflows

The trade-off is simple. A self-hosted setup gives you full local control, while OMOPHub reduces the amount of vocabulary plumbing you have to maintain. If your pipeline already sits behind a stable ETL layer, the API usually fits better because it keeps the mapping step outside the warehouse and still leaves the underlying logic inspectable during validation.

A small cached resolver pattern

A cache keeps you from repeating the same vocabulary lookup over and over, which matters when the same ICD-10 code shows up across many rows.

class CachedResolver:
    def __init__(self, client):
        self.client = client
        self.cache = {}

    def resolve_icd10_condition(self, code):
        if code not in self.cache:
            self.cache[code] = self.client.fhir.resolve(
                system="http://hl7.org/fhir/sid/icd-10-cm",
                code=code,
                resource_type="Condition",
            )
        return self.cache[code]

That cache can stay in memory for a notebook, or it can back a local lookup table in a production ETL. The point is to avoid paying the same latency and validation cost for every repeated row. If your downstream system already speaks FHIR, the CodeableConcept variant fits well because it keeps the source coding structure intact while still returning OMOP-ready metadata.

The same rule applies to version pinning. Record which vocabulary release you mapped against, because mapping logic without a version is hard to reproduce after the next refresh. If you need a quick operational reference while you design the pipeline, keep the OMOPHub ICD-10 conversion guide close by and test a few high-value codes before you switch the full feed.

A Field-Tested Checklist Before You Ship

A production mapping pass should be routine, not heroic. Pin the vocabulary version, resolve source concepts first, and read invalid_reason and standard_concept before you trust any output. If the source concept exists but the standard concept is missing or ambiguous, that is a data quality event, not a cue to guess.

Keep one fast spot-check tool in your browser tabs, too. The OMOPHub Concept Lookup tool is the quickest way to inspect a code before you add it to a phenotype, and the SDKs make the same workflow scriptable in Python, R, or an MCP-driven environment. omophub-R SDK
omophub-mcp server

  • Pin the vocabulary release: keep the lookup source stable so today's mapping matches next quarter's audit.
  • Resolve source concepts first: never guess a standard concept ID from the source string alone.
  • Treat concept_id = 0 seriously: unmapped rows should be counted and reviewed, not ignored.
  • Validate a sample manually: chart review catches over-broad or under-specific mappings before they distort counts.
  • Cache and batch aggressively: use the API in chunks, then persist the results in your ETL reference table.

If you want to operationalize the same workflow with fewer moving parts, OMOPHub exposes the vocabulary lookup, mapping, and FHIR terminology layers in one place. That makes ICD-10 to OMOP concept resolution easier to test, easier to cache, and easier to keep aligned with the vocabulary version your study used.

Share: