How to Map ICD-10 to SNOMED CT with OMOPHub API

You're probably in the middle of a familiar cleanup job. One source system sends diagnosis data in ICD-10. Another already stores clinical concepts closer to SNOMED CT. Your analytics layer wants one language, your phenotype definitions need standard concepts, and the easy answer, “just map the codes,” stops being easy the moment one ICD-10 code fans out into several clinically different SNOMED options.
That's where production pipelines usually break. Not on the first lookup, but on ambiguity, provenance, and QA. If you need to map ICD-10 to SNOMED CT in a way that survives ETL reruns, audit requests, and clinical review, the work is less about finding a code and more about building a controlled mapping workflow.
Understanding Key Concepts
The first thing to accept is that reverse mapping is not the same problem as forward mapping. Public material has long emphasized SNOMED CT to ICD-10, while the reverse direction remains underexplained and difficult because detail is lost when you start from a broader classification. SNOMED International states that existing content overwhelmingly focuses on the SNOMED CT to ICD-10 direction, leaving the reverse process underserved and technically fraught due to loss of granularity in its overview of mapping directionality.

That matters in multi-hospital data integration. One hospital may code a discharge diagnosis in ICD-10, while another captures a more clinically expressive SNOMED CT concept. If you force a direct one-to-one crosswalk, you can flatten meaningful distinctions that analysts later assume are real. Teams working on broader modernization efforts run into this same issue repeatedly. A useful framing appears in this guide on digital healthcare innovation, where interoperability is treated as an operational design problem, not just a format conversion problem.
Why reverse mapping gets messy
An ICD-10 code often represents a class of diagnosis, not a fully specified clinical statement. In the OHDSI world, the mapping path usually depends on standardized vocabulary relationships such as Maps to, plus ranking logic that decides which target concept is preferred for analytics. That sounds clean until you hit one-to-many ambiguity.
The practical problem looks like this:
- Broad source code: An ICD-10 diagnosis may be less specific than the available SNOMED CT targets.
- Several plausible targets: More than one SNOMED concept can be semantically defensible.
- Different downstream effects: Your cohort logic, counts, and phenotype definitions can shift depending on which concept you keep.
Practical rule: Treat ICD-10 to SNOMED CT as a semantic normalization task, not a string matching task.
Why OMOP semantics matter
In an OMOP-based pipeline, the value isn't only the target concept. It's the relationship path and the standardization policy behind it. When a service can traverse Maps to on the server and apply a consistent vocabulary-preference rule, your ETL code gets simpler and your outputs become more reproducible.
That's also why browsing concept maps manually doesn't scale. The better pattern is to let your pipeline resolve source codes into standard concepts, persist the relationship type, and flag the cases where semantics remain unsettled. If you want a compact refresher on how these mappings behave inside the OMOP ecosystem, this overview of vocabulary concept maps is a good reference point.
Setting Up OMOPHub Access
If you're building an ETL job around vocabulary normalization, setup should be boring. You want credentials, a tested client, and one successful request before you write any orchestration code.

The platform itself is straightforward. OMOPHub is a REST + FHIR API for the OHDSI ATHENA vocabulary set, covering 11 million standardized OMOP concepts across 100+ vocabularies, including SNOMED CT, ICD-10, LOINC, and RxNorm. It exposes search, mapping, hierarchy traversal, and a FHIR terminology surface. It also supports Python, R, and MCP tooling, which matters when your data engineering stack isn't all in one language.
Get credentials and wire authentication
OMOPHub uses Bearer authentication for both its REST API and FHIR endpoints. The basic workflow is:
- Create an API key in the OMOPHub dashboard.
- Store it in your secret manager or CI environment.
- Send it as
Authorization: Bearer oh_your_api_key.
A minimal shell check looks like this:
curl -X POST "https://api.omophub.com/v1/fhir/resolve" \
-H "Authorization: Bearer oh_your_api_key" \
-H "Content-Type: application/json" \
-d '{"system": "http://snomed.info/sct", "code": "44054006", "resource_type": "Condition"}'
That example validates connectivity and auth. It's not the ICD-10 mapping path yet, but it confirms the service is reachable before you build ETL logic around it.
Install the SDK that matches your runtime
Use the client your team will maintain:
- Python SDK: omophub-python on GitHub
- R SDK: omophub-R on GitHub
- MCP and tooling: omophub-mcp on GitHub
Typical install commands are simple:
pip install omophub
install.packages("omophub")
npm install @omophub/mcp
For API-first teams, the most useful product overview is this OMOP vocabulary API summary.
Design around the batch ceiling early
One operational constraint belongs in your ETL design from day one. The API supports batch mapping of up to 100 codes per request and handles Maps to traversal server-side, as documented in the OMOPHub introduction. That has direct implications for chunking strategy, retry logic, and queue sizing.
Don't wait until production to discover your mapper needs batching. Build your transform step around request chunks and idempotent retries from the start.
A few setup tips help immediately:
- Use fixed batch sizes: Keep chunks at or below the documented request limit so retries stay predictable.
- Separate lookup from persistence: Write raw responses to a staging table before flattening them into your final mapping table.
- Test unresolved handling early: Your “happy path” isn't the risky part. Ambiguous or unmapped codes are.
Implementing ICD-10 to SNOMED CT Mapping with OMOPHub
The most stable programmatic pattern is a two-step lookup. First, resolve the source ICD-10 code to its OMOP concept record. Second, map from that concept into the target vocabulary. OMOPHub documents this approach in its MCP tooling: get_concept_by_code returns the source concept_id and cross-vocabulary mappings, then map_concept retrieves equivalent target concepts from ATHENA-backed mappings in the OMOPHub MCP repository.

Python workflow
For ETL pipelines, Python is usually the easiest place to express the mapping lifecycle clearly.
from omophub import OMOPHubClient
import os
client = OMOPHubClient(api_key=os.environ["OMOPHUB_API_KEY"])
# Step 1: Look up the ICD-10-CM source concept
source = client.get_concept_by_code(code="E11.9", vocabulary="ICD10CM")
concept_id = source["concept_id"]
# Step 2: Map the OMOP concept to SNOMED CT
mapped = client.map_concept(concept_id=concept_id, target_vocabulary="SNOMED")
print(mapped)
The point of splitting the flow is control. Your pipeline can persist the original source concept, the mapped target, and the relationship metadata separately. That makes QA much easier than treating mapping as a black-box one-liner.
Interpreting the response shape
When teams first map ICD-10 to SNOMED CT, they often grab the first returned concept and move on. That's risky. OMOPHub applies a strict vocabulary-preference hierarchy for condition-domain mappings, prioritizing SNOMED above ICD-10 and returning a best_match object alongside alternatives and unresolved arrays, as described in the FHIR-to-OMOP workflow guide.
What that means in practice:
best_matchis the default candidate selected by ranking logic.alternativescontains other plausible mappings when multiple targets exist.unresolvedis where you should focus your review queue.mapping_typetells you whether the relationship isMaps to,Equivalent, or another documented semantic link.
If you don't persist
mapping_type, you lose the distinction between a close standardization path and a stronger semantic equivalence.
A simple parser pattern looks like this:
best = mapped.get("best_match")
alts = mapped.get("alternatives", [])
unresolved = mapped.get("unresolved", [])
if best:
target_concept_id = best["concept_id"]
target_code = best["concept_code"]
target_name = best["concept_name"]
relationship = best.get("mapping_type")
else:
target_concept_id = None
relationship = None
R and TypeScript patterns
R teams working on research ETL or cohort authoring can use the same two-step logic:
library(omophub)
client <- OMOPHubClient(api_key = Sys.getenv("OMOPHUB_API_KEY"))
source <- get_concept_by_code(client, code = "E11.9", vocabulary = "ICD10CM")
concept_id <- source$concept_id
mapped <- map_concept(client, concept_id = concept_id, target_vocabulary = "SNOMED")
print(mapped)
For TypeScript or service-layer integrations, the same pattern is clean and explicit:
import { OMOPHubClient } from "@omophub/sdk";
const client = new OMOPHubClient({
apiKey: process.env.OMOPHUB_API_KEY!,
});
const source = await client.getConceptByCode({
code: "E11.9",
vocabulary: "ICD10CM",
});
const mapped = await client.mapConcept({
conceptId: source.concept_id,
targetVocabulary: "SNOMED",
});
console.log(mapped);
If you want exact SDK details or updated examples, the safest habit is to verify against the consolidated OMOPHub LLM documentation export.
A browser-based sanity check also helps during development. The OMOPHub concept lookup tool is useful when a pipeline result looks plausible but you want to inspect the concept manually.
Batch mapping inside ETL
Single-code calls are good for development. Production jobs need chunking, retries, and deterministic writes. A practical ETL pattern is:
- Read distinct ICD-10 codes from your staging diagnosis table.
- Chunk codes into request-sized batches.
- Call the batch mapping endpoint.
- Persist
best_match,alternatives, and unresolved records into separate tables. - Join standardized concepts back onto the encounter or condition load.
This walkthrough gives a visual sense of the workflow before you automate it fully:
A few implementation tips save rework later:
- Keep source and target columns side by side: Analysts need to audit both.
- Store unresolved records as first-class outputs: Don't bury them in logs.
- Deduplicate source codes before calling the API: Vocabulary mapping belongs on unique codes, not every row of fact data.
- Reattach row-level context afterward: Map once, join many.
Validating Mappings and Ensuring Data Quality
Automated mapping is useful, but blind trust is a design flaw. A systematic evaluation of the Athena vocabulary service found that automated conversion is generally reliable but cannot achieve 100% clinical fidelity for all diagnostic categories, with clinically nuanced groups requiring human oversight. The evaluation specifically notes failures in complex conditions such as muscular dystrophies in the OHDSI Europe poster abstract.
That result should change how you structure QA. The right question isn't whether the service works. It's which concepts are safe to accept automatically and which need escalation.
Build a validation ladder
I've had the best results with a layered review model instead of a single pass/fail rule.
-
Structural validation Confirm the source code exists in the expected vocabulary and resolves to one source concept.
-
Semantic validation Inspect the returned relationship type. A mapping tagged as
Equivalentshould not be treated the same as a looserMaps torelationship in reporting logic. -
Ambiguity validation Review any record where alternatives exist or where no
best_matchis returned. -
Clinical validation Escalate high-risk diagnosis groups for domain review, especially when the original ICD-10 code is broad and the target SNOMED options are clinically narrower.
Add round-trip checks where it matters
The reverse-mapping literature gives a useful operational idea here. In a step-by-step reverse mapping method from ICD-10-CA to SNOMED CT, expert teams achieved about 63% direct code matches, leaving nearly 40% of source codes without a precise equivalent unless they applied manual curation or hierarchical inference, according to the CEUR workshop paper on reverse mapping. That same paper describes a mapped-back-to-ICD validity check and warns that coding rules can split meanings by patient age or gender.
You don't need to implement the entire academic method to benefit from the lesson. The practical takeaway is that round-trip logic catches false confidence. If your chosen SNOMED target maps back too broadly, it's a review candidate.
Review heuristic: Send broad diagnoses, clinically nuanced disorders, and unresolved batches into a human review queue before those mappings become part of production cohorts.
Report the failures, not just the successes
A good QA report should produce three views:
- Accepted mappings: Standardized records that passed your rules
- Ambiguous mappings: Cases with alternatives or weak semantics
- Unresolved mappings: Codes with no acceptable target
That reporting discipline matters more than commonly perceived. If unresolved records disappear into logs, analysts will assume your standardized layer is complete. It isn't.
For teams already formalizing ETL QA, this data quality checking reference is worth reading alongside your mapping rules. The strongest pipelines treat vocabulary normalization as a monitored data quality domain, not just a preprocessing step.
Managing Versioning and Provenance
Vocabulary mapping without provenance becomes hard to defend six months later. A code that mapped cleanly last quarter may resolve differently after a vocabulary refresh, and if you didn't capture the release context, you can't explain the change.
What to persist for every mapped code
At minimum, your mapping output table should carry:
- Source vocabulary and code
- Source OMOP concept ID
- Target vocabulary and code
- Target OMOP concept ID
- Relationship or mapping type
- Run timestamp
- Vocabulary release metadata available from the API response or job context
- Pipeline version or ETL job identifier
That schema gives you reproducibility without overcomplicating the load.
A practical provenance pattern
I prefer splitting the data into two linked tables:
| Table | Purpose |
|---|---|
| mapping_results | One row per source code and chosen target concept |
| mapping_run_metadata | One row per ETL execution, including release identifiers, runtime, and config |
Your ETL then writes a mapping_run_id onto each result row. If a clinical analyst later asks why a target concept changed, you can compare runs rather than reverse-engineer logs.
Here's pseudocode that shows the pattern:
mapping_run = {
"run_id": run_id,
"executed_at": executed_at,
"pipeline_version": pipeline_version,
"api_surface": "REST",
"notes": "ICD10CM to SNOMED mapping load"
}
write_run_metadata(mapping_run)
for row in mapped_rows:
write_mapping_result({
"mapping_run_id": run_id,
"source_code": row["source_code"],
"source_vocabulary": row["source_vocabulary"],
"source_concept_id": row["source_concept_id"],
"target_code": row.get("target_code"),
"target_vocabulary": "SNOMED",
"target_concept_id": row.get("target_concept_id"),
"mapping_type": row.get("mapping_type")
})
Detect drift before it surprises analysts
Versioning is only useful if you compare outputs across releases. A lightweight control process works well:
- Run a periodic remap on a fixed benchmark set of common source codes.
- Compare current targets to the prior stored results.
- Flag changed mappings for review before promoting them downstream.
Provenance isn't paperwork. It's the only way to explain why a cohort count changed when the source clinical data didn't.
In environments with stricter governance, keep the raw API payload for a limited retention window in object storage. That gives you an exact reconstruction path when a mapping decision is challenged.
Handling Edge Cases and Performance and Security
The edge cases are where pipelines stop being academic. Reverse mapping can fail because the source code is too broad, because coding rules differ by patient context, or because multiple standard concepts are valid and your logic needs to choose one. OMOPHub's response model helps by returning best_match, alternatives, and unresolved, but that still leaves engineering decisions on caching, security, and escalation.
Edge cases that deserve explicit rules
The reverse-mapping literature describes one especially important pitfall: a single SNOMED concept can correspond to different ICD-10-CM codes depending on age or gender, which means naïve one-to-one mapping logic can produce errors. In a production ETL flow, that means your mapper shouldn't be the only decision-maker when patient context changes code interpretation.
Common edge cases include:
- Broad diagnosis families: The ICD-10 source is less granular than the SNOMED target space.
- Rule-dependent distinctions: Age or gender can alter the clinically correct target.
- One-to-many outputs: The API identifies a preferred target, but alternatives remain meaningful.
- No acceptable target: The code should be routed to manual review, not force-fit.
Performance and Security Guidelines
| Consideration | Recommendation |
|---|---|
| Batch sizing | Keep requests within the documented batch limit and process source codes as distinct values before row-level joins. |
| Caching | Cache stable code-to-concept mappings in your ETL layer so repeated codes don't trigger repeated lookups during the same load cycle. |
| Retry strategy | Make requests idempotent and persist intermediate results so failed batches can resume without remapping completed chunks. |
| Ambiguous results | Store best_match, alternatives, and unresolved outputs separately so review workflows don't block the whole pipeline. |
| Patient-context dependencies | Apply rule-based filters outside the vocabulary call when age, sex, or encounter context affects interpretation. |
| Credentials | Keep API keys in a secrets manager, rotate them on a schedule, and avoid embedding them in notebooks or job definitions. |
| Transport security | Send requests only over HTTPS and keep audit logs for mapping jobs, especially if standardized outputs feed regulated analytics. |
| PHI boundaries | Treat the mapping step as a terminology service. Pass codes and concept identifiers, not unnecessary patient payloads. |
Don't confuse ranking with certainty
The ranking logic is useful, but it isn't a clinical verdict. The returned top candidate is a standardized preference, not proof that your source diagnosis carries enough detail to support a fully specific target.
That distinction is easy to miss when pipeline metrics look clean. A successful API response can still represent a semantically fragile conversion. Your controls should reflect that.
Conclusion and Next Steps with Tips
To map ICD-10 to SNOMED CT well, you need more than a crosswalk. You need a repeatable workflow that resolves source concepts, records relationship semantics, validates ambiguous cases, and preserves provenance across vocabulary updates. The hard part isn't calling an API. It's deciding which outputs can flow straight into analytics and which ones need review.
A few habits make the pipeline sturdier:
- Cache common mappings so recurring diagnosis codes don't create needless lookup volume.
- Track unresolved and ambiguous outputs as operating metrics, not cleanup leftovers.
- Version every run so mapping drift is visible when vocabularies change.
- Send high-risk concepts to clinical review instead of forcing false precision.
- Verify SDK behavior against docs before baking examples into shared ETL libraries.
For deeper implementation details, use the OMOPHub documentation, plus the Python SDK, R SDK, and MCP tooling.
If you want to stop maintaining local vocabulary infrastructure and give your ETL pipeline direct access to OHDSI-standard mappings, OMOPHub is built for exactly that workflow. It provides REST and FHIR access to the ATHENA vocabulary set, supports batch mapping, concept search, hierarchy traversal, and standardized terminology resolution, and lets engineering teams move from spreadsheet crosswalks to production-grade vocabulary services with far less operational overhead.


