Code Crosswalks and Mappings Between Coding Systems
The family of official and community-maintained translation tables that link one medical coding system to another — ICD-9-CM to ICD-10-CM via CMS General Equivalence Mappings, NDC to RxNorm via the NLM RxNav API, NDC to HCPCS J-codes via the CMS Average Sales Price crosswalk, and ICD-10-CM to SNOMED CT via the NLM rule-based map — allowing researchers to align code lists across vocabulary transitions or data sources while managing the approximation, one-to-many expansions, and version drift inherent in every translation.
In plain language
Medical claims and electronic health records use different coding systems to label diagnoses, drugs, and procedures — and those systems change over time. A code crosswalk is an official translation table that links a code in one system to the closest match in another, the way a bilingual dictionary links words between languages. The key warning that beginners miss is that these translations are approximations, not exact equivalences — one old code often expands into several new ones, and translating back does not return the original set.
A code crosswalk (also called a code mapping) is an official or curated translation table that associates codes in one medical coding system with the closest equivalent codes in another. Crosswalks exist because the healthcare data ecosystem spans multiple, independently governed vocabularies — ICD-9-CM for legacy diagnoses, ICD-10-CM for current diagnoses, NDC for drug package identity, RxNorm for drug ingredients, HCPCS/CPT for procedures and administered drugs, and SNOMED CT for clinical concepts — and no single coding system covers all data types or all calendar periods. Real-world evidence (RWE) studies routinely span vocabulary transitions or assemble data from sources that use different systems, making crosswalk literacy a foundational skill.
The inventory of major crosswalks
GEMs — General Equivalence Mappings (CMS/NCHS, ICD-9-CM ↔ ICD-10-CM/PCS). GEMs were developed jointly by CMS and the National Center for Health Statistics to support the October 1, 2015 transition from ICD-9-CM to ICD-10-CM (diagnoses) and ICD-10-PCS (inpatient procedures). Two files exist for each direction: the forward map (ICD-9-CM → ICD-10-CM) and the backward map (ICD-10-CM → ICD-9-CM). Each row carries four critical flags: (1) the approximate flag (0 = exact match, 1 = approximate/best available), (2) the no map flag (code has no usable equivalent), (3) the combination flag (the ICD-9 concept requires multiple ICD-10 codes to fully express), and (4) the scenario/choice list flags that group one-to-many alternatives. A single ICD-9-CM code commonly maps forward to 3–10 ICD-10-CM codes, and many maps carry approximate = 1, meaning granularity was genuinely lost or gained in translation. GEMs were last updated for FY2018; they are officially retired but remain the de facto standard tool for any study spanning the pre- and post-October-2015 period. Researchers must freeze the GEM version and document it, because there will be no future updates to reconcile.
NDC ↔ RxNorm (NLM RxNav API, monthly updates). The National Library of Medicine's RxNorm is the standard for drug ingredient, clinical drug (product), and branded product identity in the United States. The NLM RxNav API resolves an 11-digit NDC (as it appears in a claims or pharmacy dispensing record) to its RxNorm Concept Unique Identifier (RXCUI) at the ingredient or clinical drug level. This mapping is critical for two reasons: NDCs change whenever a manufacturer repackages or reformulates a product (a single ingredient can have hundreds of active NDCs at any moment), and NDC lists therefore rot rapidly; defining drug exposure at the RxNorm ingredient level and resolving NDCs through RxNorm insulates a study from NDC churn. The NLM also maintains historical NDC endpoints for mapping retired codes. The RxNorm mapping is updated monthly; a study using a snapshot must document the snapshot date.
NDC ↔ HCPCS (CMS Average Sales Price Drug Pricing crosswalk, quarterly). Medicare Part B covers many drugs administered in clinical settings and reimburses them under HCPCS Level II J-codes. The CMS publishes quarterly ASP (Average Sales Price) Drug Pricing files that include an NDC-to-HCPCS crosswalk, allowing researchers to recover the drug identity behind a J-code claim. This is essential for medical-benefit drug studies: a claim for J9271 (pembrolizumab) is informative on its own, but the NDC crosswalk confirms the specific product and links back to the RxNorm ingredient for pharmacological classification. The crosswalk also resolves "not otherwise classified" NOC codes (e.g., J3490, J9999), which appear when a drug has no dedicated J-code. Because NDCs change with ASP submission cycles, the specific quarterly file version must be documented.
SNOMED CT ↔ ICD-10-CM (NLM rule-based map, for reimbursement derivation). The NLM maintains a rule-based SNOMED CT–to–ICD-10-CM map that supports translation from clinical documentation systems (which may use SNOMED CT) to reimbursement coding (ICD-10-CM). The map is intentionally lossy: SNOMED CT's clinical granularity (laterality, severity, morphology) cannot always survive the translation to ICD-10-CM's billing categories. Researchers should treat SNOMED↔ICD-10-CM as a triage tool, not a reliable phenotype definition, and re-derive code lists natively in each system whenever possible.
CPT/HCPCS ↔ SNOMED CT and ICD-10-PCS ↔ SNOMED CT (partial maps). Partial procedure maps exist between CPT and SNOMED CT and between ICD-10-PCS and SNOMED CT, but coverage is incomplete and maintained by different organizations on different schedules. These are useful for concept-level harmonization across datasets but require the same caveat: verify coverage fractions before relying on them.
UMLS Metathesaurus as the CUI-level hub. The NLM Unified Medical Language System (UMLS) integrates more than 200 biomedical vocabularies under a single Concept Unique Identifier (CUI), enabling lookup from any supported source system to any other. The Metathesaurus connects ICD-9-CM, ICD-10-CM, SNOMED CT, RxNorm, LOINC, MeSH, and many others under one roof. UMLS requires a free UMLS Metathesaurus License; it is not public-domain. The breadth makes it the most comprehensive single hub, but its mappings vary in source and quality — some are algorithmically generated and should be reviewed for the specific concept.
OMOP CONCEPT_RELATIONSHIP "Maps to" as the operational crosswalk hub. Within the OMOP Common Data Model, the CONCEPT_RELATIONSHIP table stores the "Maps to" relationship that connects every source code (ICD-9-CM, ICD-10-CM, NDC, CPT, HCPCS, SNOMED CT) to its standard concept. This is a continuously maintained, versioned crosswalk hub — the OHDSI community updates it regularly, and new vocabulary versions are released quarterly. Researchers using OMOP inherit the crosswalk automatically through the ETL, but must still document the vocabulary version used (it is stored in the VOCABULARY table) and understand that source codes without a "Maps to" relationship fall to concept_id = 0 (unmapped) and are invisible to standard-concept queries.
The methodological core: crosswalks are approximations, not identities
Every crosswalk changes the measurement. This is the most important principle of crosswalk methodology, and the one most frequently violated. The specific failure modes are:
- One-to-many inflation. When a single ICD-9-CM code maps forward to multiple
- Granularity loss on backward maps. When translating from ICD-10-CM (more
- Asymmetry: forward ∘ backward ≠ identity. Applying the forward map and then the
- Version drift. Crosswalks are updated on different schedules (GEMs: frozen at
- Approximate flag is the norm, not the exception. In the forward GEM for ICD-9-CM
Best practice: map the concept, not the code list
The gold standard is to re-derive the code list natively in each coding system — starting from the clinical concept, asking a subject-matter expert to curate the relevant codes independently in ICD-9-CM and in ICD-10-CM — rather than mechanically translating the ICD-9 list forward. Use the GEM as a first-pass triage tool to identify candidate codes in the target system, then clinician-review the candidate set. For transition-spanning trends, run ITS (interrupted time series) diagnostics at 2015-10-01 to distinguish cartographic from biological discontinuities.
Licensing and public-domain status
- GEMs (ICD-9-CM ↔ ICD-10-CM/PCS): public domain, freely downloadable from CMS.
- CMS ASP NDC-HCPCS crosswalk: public domain, freely downloadable from CMS.
- NLM RxNav / RxNorm API: public domain for the API and underlying data.
- UMLS Metathesaurus: requires a free UMLS Metathesaurus License (NLM sign-up).
- SNOMED CT: requires a NRC (National Release Center) license in the US; obtained
Pros, cons, and trade-offs — specific and comparative
- Crosswalk (mechanical translation) vs concept re-derivation (native curation):
- Pinned crosswalk snapshot vs live API:
- GEMs vs OMOP "Maps to" for ICD-9/ICD-10 bridging:
When to use
Use crosswalks whenever: (1) a study spans the ICD-9-to-ICD-10 transition (any study window that crosses 2015-10-01) and a consistent diagnosis phenotype must be applied across the full period; (2) drug exposures defined by NDC must be aggregated by ingredient (RxNorm) or by HCPCS reimbursement code (ASP crosswalk); (3) data from systems using different coding schemes (e.g., a SNOMED-coded EHR and an ICD-10-CM claims file) must be harmonized for a linked or federated analysis; (4) an OMOP CDM is being built and the ETL must specify how each source code maps to a standard concept.
When NOT to use — and when it is actively misleading or dangerous
- As a substitute for clinical concept re-derivation. Applying the GEM forward map
- For trend analysis without ITS diagnostics. Using a crosswalk to translate a
- When the approximate flag is ignored. Selecting only the zero-flag rows from
- When version drift is ignored. Applying a quarterly ASP crosswalk from a
- For MA-only or capitated data. If the claims data derive from capitated
Data-source operational depth
- Claims (FFS commercial / Medicare FFS): ICD-9/ICD-10-CM on the diagnosis fields;
- EHR: Problem lists and encounter diagnoses may carry ICD-10-CM or SNOMED CT
- Registry: Disease-specific registries often use registry-specific codes that
- OMOP-CDM: The ETL handles all source-to-standard mapping through the
Worked example
Scenario
An analyst is building a study on chronic obstructive pulmonary disease (COPD) hospitalizations in US commercial claims. The data span January 2013 through December 2018, which means the cohort crosses the ICD-9-to-ICD-10-CM transition on October 1, 2015. The analyst starts with one representative ICD-9-CM COPD hospitalization code — 491.21 (obstructive chronic bronchitis with acute exacerbation) — and wants to know what the GEM forward map produces and whether applying the backward map would return the starting code.
Dataset
Forward GEM rows for ICD-9-CM 491.21 (obstructive chronic bronchitis with acute exacerbation). Each row is one entry in the CMS FY2018 GEM forward-map file.
| icd9_code | icd10_code | approximate_flag | no_map_flag | combination_flag | scenario | choice_list |
|---|---|---|---|---|---|---|
| 491.21 | J44.1 | 1 | 1 | 1 | ||
| 491.21 | J44.0 | 1 | 1 | 2 |
Steps
The forward GEM for 491.21 returns 2 ICD-10-CM codes (J44.1 COPD with acute exacerbation and J44.0 COPD with acute lower respiratory infection). The approximate flag is 1 on both rows — neither is an exact equivalence.
The scenario flag of 1 and choice_list values of 1 and 2 mean these two codes are alternatives; the GEM is presenting them as options rather than requiring both. A researcher must decide clinically which (or both) to include.
The code count expands: one ICD-9 code becomes 2 candidate ICD-10 codes. If the analyst includes both, every pre-2015 hospitalization coded 491.21 will be matched against 2 ICD-10 codes post-2015 — inflating apparent code frequency at the transition even if COPD hospitalization rates are unchanged.
Now apply the backward GEM to J44.1 (the primary forward-map result). The backward GEM returns 3 ICD-9-CM codes: 491.21, 491.20, and 496. The starting code 491.21 appears, but so do 2 additional codes — the round trip is NOT the original single code.
Asymmetry count: forward map from 1 ICD-9 code produces 2 ICD-10 codes; backward map from the primary result produces 3 ICD-9 codes. 2 - 1 = 1 net inflation in the forward direction; 3 - 1 = 2 additional codes in the backward direction; 2 + 3 = 5 total codes involved in the round trip versus 1 original code.
Result
Starting from 1 ICD-9-CM code (491.21), the forward GEM produces 2 ICD-10-CM candidate codes (approximate = 1 on both). Backward-mapping J44.1 returns 3 ICD-9-CM codes — 2 more than the original 1. The round trip 1 -> 2 -> 3 demonstrates asymmetry: forward does not equal backward, and neither direction produces cardinality = 1. An analyst who naively counts "codes ever assigned to this condition" across the transition window will see an apparent 2 / 1 = 2.0x code-count multiplication at 2015-10-01 that is entirely cartographic.
Timeline Spec
- Title
ICD-9-to-ICD-10 GEM expansion for COPD (491.21) — code cardinality across transition
- Window
- Start
2013-01-01
- End
2018-12-31
- Label
Study window spanning the ICD-10 transition (Oct 1, 2015)
- Events
- Label
ICD-9 era: 491.21 only
- Start
2013-01-01
- Length Days
1004
- Quantity
1 code
- Label
ICD-10 transition (Oct 1, 2015)
- Start
2015-10-01
- Length Days
1
- Quantity
GEM applied
- Label
ICD-10 era: J44.1 + J44.0 (2 codes)
- Start
2015-10-02
- Length Days
1186
- Quantity
2 codes
- Spans
- Kind
covered
- Start
2013-01-01
- End
2015-09-30
- Label
1 ICD-9 code
- Kind
gap
- Start
2015-10-01
- End
2015-10-01
- Label
Transition: 1 -> 2 codes
- Kind
covered
- Start
2015-10-02
- End
2018-12-31
- Label
2 ICD-10 codes (approximate, both flags=1)
- Result
- Label
1 ICD-9 -> 2 ICD-10 (forward); J44.1 -> 3 ICD-9 (backward); round trip 1 -> 2 -> 3
- Value
2.0
Runnable example
python implementation
Applies the CMS FY2018 GEM forward map to an ICD-9-CM code list and reports the full expansion including approximate flags, combination entries, and one-to-many counts. Then applies the backward map to the primary forward result to demonstrate asymmetry...
"""
Code Crosswalk Utilities — GEM expansion + ASP NDC-HCPCS join
=============================================================
Applies the CMS FY2018 GEM forward map to an ICD-9-CM code list,
reports one-to-many expansion and approximate flags, then demonstrates
backward-map asymmetry. Includes an ASP NDC-HCPCS J-code resolver.
Input files (public domain, download from CMS GEMs archive and ASP pages):
gem_forward.tsv — CMS 2018 ICD-9-CM to ICD-10-CM GEM forward map
gem_backward.tsv — CMS 2018 ICD-10-CM to ICD-9-CM GEM backward map
asp_crosswalk.csv — CMS quarterly ASP NDC-HCPCS crosswalk
"""
import pandas as pd
from pathlib import Path
def load_gem(path: str | Path) -> pd.DataFrame:
"""Load a CMS GEM flat file (space-delimited, no header).
Columns (per CMS format): source_code, target_code,
approximate, no_map, combination, scenario, choice_list.
"""
cols = [
"source_code", "target_code",
"approximate", "no_map", "combination",
"scenario", "choice_list",
]
df = pd.read_csv(
path, sep=r"\s+", header=None, names=cols,
dtype=str
)
# Convert flag columns to int for filtering
for c in ["approximate", "no_map", "combination", "scenario", "choice_list"]:
df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0).astype(int)
return df
def apply_forward_gem(
code_list: list[str],
gem_forward: pd.DataFrame,
include_approximate: bool = True,
) -> pd.DataFrame:
"""Expand an ICD-9-CM code list via the GEM forward map.
Returns all matching rows, with one_to_many_count added.
include_approximate=False restricts to exact matches only
(WARNING: drops the majority of rows — use for triage only).
"""
df = gem_forward[gem_forward["source_code"].isin(code_list)].copy()
if not include_approximate:
df = df[df["approximate"] == 0]
# Count how many ICD-10 targets each ICD-9 source maps to
counts = (
df[df["no_map"] == 0]
.groupby("source_code")["target_code"]
.count()
.rename("one_to_many_count")
)
df = df.merge(counts, on="source_code", how="left")
no_map_codes = df[df["no_map"] == 1]["source_code"].unique()
if len(no_map_codes):
print(
f"WARNING: {len(no_map_codes)} code(s) have no_map=1 "
f"(no GEM equivalent): {list(no_map_codes)}"
)
return df
def check_roundtrip_asymmetry(
source_codes: list[str],
gem_forward: pd.DataFrame,
gem_backward: pd.DataFrame,
) -> dict:
"""Apply forward then backward and report asymmetry.
Returns dict with original code count, forward count, roundtrip count.
A round-trip that does NOT return the original set demonstrates asymmetry.
"""
# Forward: ICD-9 -> ICD-10
fwd = apply_forward_gem(source_codes, gem_forward)
icd10_codes = fwd[fwd["no_map"] == 0]["target_code"].unique().tolist()
# Backward: ICD-10 -> ICD-9
bwd = gem_backward[gem_backward["source_code"].isin(icd10_codes)]
icd9_roundtrip = bwd["target_code"].unique().tolist()
original_set = set(source_codes)
roundtrip_set = set(icd9_roundtrip)
added = roundtrip_set - original_set
lost = original_set - roundtrip_set
return {
"original_codes": source_codes,
"original_count": len(source_codes),
"icd10_forward_codes": icd10_codes,
"icd10_forward_count": len(icd10_codes),
"icd9_roundtrip_codes": icd9_roundtrip,
"icd9_roundtrip_count": len(icd9_roundtrip),
"codes_added_by_roundtrip": sorted(added),
"codes_lost_by_roundtrip": sorted(lost),
"is_symmetric": original_set == roundtrip_set,
}
def load_asp_crosswalk(path: str | Path) -> pd.DataFrame:
"""Load the CMS quarterly ASP NDC-HCPCS crosswalk CSV.
CMS publishes these as CSV/Excel; key columns: HCPCS_CD, NDC, LONG_DESC.
Adjust column names to match the actual file header.
"""
df = pd.read_csv(path, dtype=str)
# Normalize column names to lowercase, strip spaces
df.columns = [c.strip().lower().replace(" ", "_") for c in df.columns]
return df
def resolve_jcode_to_ndc(
claims: pd.DataFrame,
asp_crosswalk: pd.DataFrame,
hcpcs_col: str = "hcpcs_cd",
ndc_col: str = "ndc",
) -> pd.DataFrame:
"""Join ASP crosswalk to medical claims on HCPCS code.
Recovers drug identity (NDC and long description) for J-codes and NOC codes.
Returns claims with ndc_from_asp and drug_description columns added.
Note: multiple NDCs may map to one HCPCS code; the join produces one row per
NDC match per claim. Deduplicate using claim-level NDC field if available.
"""
asp_sub = asp_crosswalk[[hcpcs_col, ndc_col, "long_desc"]].rename(
columns={
ndc_col: "ndc_from_asp",
"long_desc": "drug_description_from_asp",
}
)
enriched = claims.merge(asp_sub, on=hcpcs_col, how="left")
unresolved = enriched["ndc_from_asp"].isna().sum()
if unresolved:
print(
f"INFO: {unresolved} claim rows could not be matched to an NDC "
f"via the ASP crosswalk (missing HCPCS or new drug not in this quarter)."
)
return enriched
# ── Example usage ─────────────────────────────────────────────────────────────
if __name__ == "__main__":
# Load GEM files (download from CMS GEMs archive)
# gem_fwd = load_gem("2018_I9gem.txt")
# gem_bwd = load_gem("2018_I10gem.txt")
# COPD exacerbation example (see worked_example above)
copd_icd9 = ["491.21"]
# Demonstrate forward expansion and asymmetry:
# result = check_roundtrip_asymmetry(copd_icd9, gem_fwd, gem_bwd)
# print(result)
# Expected: original_count=1, icd10_forward_count=2, icd9_roundtrip_count=3
# is_symmetric=False (round trip 1 -> 2 -> 3, not 1 -> 1 -> 1)
print("GEM crosswalk utilities loaded. Provide GEM files to run.")r implementation
R implementation applying the FY2018 GEM forward map to an ICD-9-CM code list (data.table or tidyverse), with one-to-many count and approximate flag summaries. Includes a tidy join of the CMS ASP NDC-HCPCS crosswalk to medical claims.
# Code Crosswalk Utilities (R) — GEM + ASP NDC-HCPCS join
# =========================================================
# Applies CMS FY2018 GEM forward map to an ICD-9-CM code list and
# demonstrates backward-map asymmetry. Includes ASP J-code resolver.
#
# Input files (public domain):
# gem_forward_path — CMS 2018 ICD-9 -> ICD-10-CM GEM (space-delimited, no header)
# gem_backward_path — CMS 2018 ICD-10-CM -> ICD-9 GEM (space-delimited, no header)
# asp_crosswalk_path — CMS quarterly ASP NDC-HCPCS crosswalk (CSV)
library(data.table)
library(dplyr)
GEM_COLS <- c("source_code", "target_code",
"approximate", "no_map", "combination",
"scenario", "choice_list")
load_gem <- function(path) {
dt <- fread(path, header = FALSE, col.names = GEM_COLS,
colClasses = "character")
flag_cols <- c("approximate", "no_map", "combination", "scenario", "choice_list")
dt[, (flag_cols) := lapply(.SD, as.integer), .SDcols = flag_cols]
dt
}
apply_forward_gem <- function(code_list, gem_forward,
include_approximate = TRUE) {
# Expand ICD-9-CM code list via GEM forward map.
# include_approximate = FALSE restricts to exact matches (WARNING: loses most rows).
dt <- gem_forward[source_code %in% code_list]
if (!include_approximate) dt <- dt[approximate == 0]
# Count how many ICD-10 targets each source maps to (excluding no_map rows)
counts <- dt[no_map == 0, .(one_to_many_count = .N), by = source_code]
dt <- merge(dt, counts, by = "source_code", all.x = TRUE)
no_map_codes <- unique(dt[no_map == 1, source_code])
if (length(no_map_codes) > 0) {
warning("no_map=1 (no GEM equivalent): ", paste(no_map_codes, collapse = ", "))
}
dt
}
check_roundtrip_asymmetry <- function(source_codes, gem_forward, gem_backward) {
# Forward: ICD-9 -> ICD-10
fwd <- apply_forward_gem(source_codes, gem_forward)
icd10_codes <- unique(fwd[no_map == 0, target_code])
# Backward: ICD-10 -> ICD-9
bwd <- gem_backward[source_code %in% icd10_codes]
icd9_roundtrip <- unique(bwd[, target_code])
added <- setdiff(icd9_roundtrip, source_codes)
lost <- setdiff(source_codes, icd9_roundtrip)
list(
original_codes = source_codes,
original_count = length(source_codes),
icd10_forward_codes = icd10_codes,
icd10_forward_count = length(icd10_codes),
icd9_roundtrip_codes = icd9_roundtrip,
icd9_roundtrip_count = length(icd9_roundtrip),
codes_added = added,
codes_lost = lost,
is_symmetric = setequal(source_codes, icd9_roundtrip)
)
}
load_asp_crosswalk <- function(path) {
dt <- fread(path, colClasses = "character")
setnames(dt, tolower(gsub(" ", "_", names(dt))))
dt
}
resolve_jcode_to_ndc <- function(claims_dt, asp_dt,
hcpcs_col = "hcpcs_cd",
ndc_col = "ndc") {
# Join ASP crosswalk to medical claims on HCPCS code.
# Returns claims with ndc_from_asp and drug_description columns added.
asp_sub <- asp_dt[, .(
hcpcs_cd = get(hcpcs_col),
ndc_from_asp = get(ndc_col),
drug_description = long_desc
)]
enriched <- merge(claims_dt, asp_sub, by.x = hcpcs_col, by.y = "hcpcs_cd",
all.x = TRUE)
n_unresolved <- sum(is.na(enriched$ndc_from_asp))
if (n_unresolved > 0)
message("INFO: ", n_unresolved,
" claim rows not matched via ASP crosswalk")
enriched
}
# ── Example usage ──────────────────────────────────────────────────────────────
# gem_fwd <- load_gem("2018_I9gem.txt")
# gem_bwd <- load_gem("2018_I10gem.txt")
#
# copd_icd9 <- "491.21"
# result <- check_roundtrip_asymmetry(copd_icd9, gem_fwd, gem_bwd)
# stopifnot(!result$is_symmetric) # Asymmetry confirmed: 1 -> 2 -> 3 codes
# cat("Forward count:", result$icd10_forward_count, "\n")
# cat("Roundtrip count:", result$icd9_roundtrip_count, "\n")