← Methods repository
concept

ICD-10-CM Diagnosis Codes

The US clinical modification of the World Health Organization's ICD-10 system, maintained by NCHS, that encodes diagnoses and health conditions on every US claim and encounter record; adopted for HIPAA-covered transactions on 2015-10-01, replacing ICD-9-CM, with approximately 70,000 billable codes organized in alphanumeric hierarchies that encode condition, laterality, encounter type, and episode stage.

Data_Standardcoding-systemdata-standardprimitiveicd-10diagnosis-codesclaimsphenotypingrwe-infrastructure
Methods reference only. Use primary source citations and local policy before applying this in a study protocol, regulatory submission, payer dossier, or clinical decision.

In plain language

ICD-10-CM is the standardized code system that US doctors and hospitals use to label every diagnosis on an insurance claim or hospital record -- each condition gets a unique alphanumeric code, like I50.9 for unspecified heart failure. Researchers studying health outcomes use these codes to identify which patients have a disease, because codes appear consistently across millions of records nationwide. The system replaced an older version called ICD-9-CM on October 1, 2015, so studies that span that date must account for the change in code format. A key practical rule is that codes in claims data usually appear without a decimal point, so "I509" in the data file means I50.9 in the official code book.

ICD-10-CM

(International Classification of Diseases, 10th Revision, Clinical Modification) is the US-specific diagnosis coding system that appears on every reimbursement claim, hospital encounter record, and structured EHR problem list in the United States. It is maintained by the National Center for Health Statistics (NCHS) within CDC, which adapts the WHO ICD-10 base to US clinical practice and adds granularity not present in the international version. CMS enforces its use for all HIPAA-covered electronic transactions. The transition from ICD-9-CM took effect on 2015-10-01 (federal fiscal year 2016), and the code set is updated annually, with new versions effective each October 1 (and, since fiscal year 2023, a mid-year April update for emerging conditions). Any research code list must specify which fiscal-year version it targets, because codes added or retired between versions can silently expand or shrink a phenotype.

Code structure and hierarchy

Every ICD-10-CM code begins with a letter, followed by two digits, making a three-character category (e.g., M20 for acquired deformities of fingers and toes). The category is a header: it is NOT a billable code and will be rejected if submitted on a claim. Subcategories extend the category with a decimal point and one or more additional characters (M20.0 = deformity of finger(s)), and the full billable code reaches its highest level of specificity, typically four to seven characters (M20.001 = deformity of right index finger). Claims data frequently stores codes in flat format (no decimal point): "M20001" means M20.001. A prefix search on the flat code "M20" captures the entire category including all subcategories and billable codes beneath it; a search on "M200" narrows to subcategory M20.0. This distinction matters critically for phenotype construction: an exact-match list of only billable codes will never accidentally match a non-billable header, but a startswith filter on a three-character category string will capture every descendant code.

Seventh-character extensions, placeholders, and laterality

Many code categories require a mandatory seventh character that encodes episode of care: A (initial encounter, meaning the patient is actively receiving treatment for the condition), D (subsequent encounter, for routine care after the active phase), and S (sequela, for late effects). Fracture codes, wound codes, and injury codes follow this scheme rigorously — a patient with a broken wrist will have an "A" code at the emergency visit, "D" codes at follow-up, and potentially an "S" code if a complication persists. When the code structure requires a seventh character but the code is fewer than six characters, the letter "X" fills the intervening positions as a placeholder (e.g., S52.001A has seven characters without a placeholder, while T14.91XA uses X as the sixth character before the A). Laterality — left, right, bilateral, unspecified — is encoded in the fifth or sixth character of many musculoskeletal, ophthalmologic, neurologic, and other codes. This is a major gain over ICD-9-CM, which had no laterality encoding; RWE studies on orthopedic conditions, stroke, or cancer can now distinguish sides in claims data without NLP or chart review. Failing to account for laterality positions (e.g., treating "M20.001" and "M20.002" as separate conditions rather than right and left variants of the same condition) is a common phenotype construction error.

Scale and public-domain status

ICD-10-CM contains approximately 70,000+ diagnosis codes across its annual update cycles, compared with roughly 14,000 in ICD-9-CM. The code set is in the public domain: NCHS distributes the tabular list, index, and guidelines freely, and researchers may reproduce code lists in publications and protocols without licensing restrictions. This contrasts sharply with CPT (Current Procedural Terminology), which is copyrighted by the American Medical Association and requires a license to reproduce. ICD-10-PCS, the companion procedure coding system used by inpatient facilities, is similarly public domain and maintained by CMS — it is a wholly separate system from ICD-10-CM and should not be confused with it.

Relationship to related systems

ICD-10-CM is a US clinical modification of the WHO ICD-10 international base; the WHO updates the international version on its own cycle, and NCHS selectively incorporates those updates while adding US-specific codes. ICD-9-CM (retired for US claims on 2015-09-30) maps to ICD-10-CM via the General Equivalence Mappings (GEMs) produced by CMS and NCHS, but GEMs are approximate: many ICD-9-CM codes map to multiple ICD-10-CM codes (and vice versa), and some mappings have no clean equivalent. Studies spanning the 2015 transition must handle the code-system break explicitly. SNOMED CT is a clinical terminology used in EHR problem lists and clinical decision support; it has a maintained mapping to ICD-10-CM that allows EHR-sourced diagnoses to be translated to claim-compatible codes, but the mapping is not one-to-one. In OMOP CDM, ICD-10-CM appears as a source vocabulary; the OMOP ETL maps source ICD-10-CM codes to SNOMED standard concepts via the concept_relationship table, so concept sets built in OMOP ATLAS operate on SNOMED ancestors, not on raw ICD-10-CM prefixes.

RWE and phenotype construction implications

ICD-10-CM codes appear on both institutional claims (UB-04 / facility form, fields FL67–FL67Q for principal and secondary diagnoses) and professional claims (CMS-1500 / 837P, boxes 21.A–L). A patient with heart failure will generate ICD-10-CM codes on both the hospital bill and the cardiologist's office visit. Phenotype algorithms must specify which claim types are searched, which diagnosis positions count (principal/primary vs any position), and whether they use flat or decimal format. The standard pattern for RWE code-list construction is: (1) obtain the NCHS tabular list for the target fiscal year; (2) identify the relevant category codes (three-character headers) and all billable descendants; (3) express the list in flat format for claims matching; (4) decide whether to use exact-match (only specific billable codes) or prefix-match (entire category). Prefix-matching on a category like "I50" captures all heart-failure codes including I50.1, I50.20, I50.21, I50.22, I50.30, I50.31, I50.32, I50.41, I50.42, I50.43, I50.810, I50.811, I50.812, I50.813, I50.814, I50.9, and any future codes added under that branch — which is both its strength (forward-compatible) and its risk (captures future codes the researcher may not have reviewed). Pre-specified, version-dated exact lists are more reproducible for regulatory submissions; prefix-based lists are more practical for exploratory work.

The 2015 ICD-9-to-ICD-10 transition is the most consequential structural break in US claims research. Studies with data spanning October 1, 2015 must either restrict to a post-transition period, restrict to a pre-transition period, or use GEMs crosswalks to harmonize codes across the break while acknowledging GEMs imprecision as a source of misclassification. Trend studies of incidence or prevalence that include 2014 and 2016 data will show apparent code-driven discontinuities that have nothing to do with the underlying clinical reality. Some conditions gained specificity (e.g., lateralized fractures), some lost apparent specificity due to code reorganization, and some rare conditions got new dedicated codes that will appear to spike from zero in 2015.

Pros, cons, and trade-offs

- vs ICD-9-CM (for legacy data linkage): ICD-10-CM has greater clinical granularity (laterality, episode type, ~5x more codes), is the only option for claims post-2015, and aligns with international coding. The cost is the 2015 break in trend continuity and the need for GEMs crosswalks in multi-era studies. Prefer ICD-10-CM for all post-2015 research; use GEMs with explicit sensitivity analyses to bridge the transition for longitudinal studies. - vs SNOMED CT (in EHR phenotyping): ICD-10-CM is billing-optimized, reimbursement-driven, and may over- or under-code relative to clinical truth (rule-out codes, upcoding). SNOMED CT is clinically precise, hierarchy-rich, and designed for EHR documentation but does not appear on US claims. In linked claims-EHR datasets, SNOMED provides the gold-standard clinical label while ICD-10-CM provides population-scale coverage. Prefer ICD-10-CM when working with claims at scale; prefer SNOMED when clinical precision in EHR data is the priority. - vs CPT / ICD-10-PCS for procedure capture: ICD-10-CM encodes diagnoses only; procedures on inpatient claims use ICD-10-PCS, and procedures on professional claims use CPT/HCPCS Level II. Mixing procedure systems is a common error; always confirm which claim type and which code field you are querying. - vs unstructured clinical notes (NLP): Code-based phenotyping at scale is fast, reproducible, and auditable; NLP adds sensitivity for conditions that are documented but not coded, and can distinguish "rule-out" from confirmed diagnoses. The cost of NLP is computational complexity and system-specific training. Prefer ICD-10-CM code lists as the primary phenotyping layer; add NLP when positive predictive value of codes alone is known to be inadequate.

When to use

- As the primary diagnosis identifier in any US claims-based RWE study (outcomes, covariates, cohort-entry diagnoses, comorbidities, contraindications). - As the source vocabulary for phenotype algorithms in OMOP CDM, where ICD-10-CM codes map to SNOMED standard concepts via the ETL. - For constructing Elixhauser or Charlson comorbidity indices from administrative data (use the Quan 2005 ICD-10 adaptation or the updated Quan 2011 weights). - When building code lists for multi-database studies on OMOP or Sentinel: define the ICD-10-CM codes in flat format, document the fiscal year version, and test against the NCHS tabular list. - For any analysis spanning 2015 onward on US payers (commercial, Medicare, Medicaid).

When NOT to use — and when ICD-10-CM coding is actively misleading or dangerous

- As a direct proxy for clinical confirmation. A diagnosis code means a clinician (or their coder) billed for that diagnosis — not that the diagnosis was verified by lab, imaging, or chart review. Rule-out codes (chest pain evaluated for MI), screening codes, and historical codes can appear in any diagnosis position. Pre-specify position (principal only vs any) and validate positive predictive value in the target population. - For procedure identification. ICD-10-CM is a diagnosis system; using it to find surgical procedures or imaging studies will fail — use ICD-10-PCS (inpatient facility claims) or CPT/ HCPCS Level II (professional/outpatient claims). - Across the 2015 ICD-9/ICD-10 transition without harmonization. Trend studies using the same code list on both sides of October 1, 2015 will see artifactual breaks driven by coding change, not epidemiologic reality. GEMs crosswalks are required, and their imprecision must be acknowledged. - Without version-locking the code list. Annual updates add and retire codes. A phenotype that was valid for the FY2019 code set may silently under-count when applied to FY2024 data if new codes were added under the same category. Lock code lists to a specific fiscal year and re-audit when updating. - When Medicare Advantage enrollees' codes cannot be validated. HCC risk-adjustment incentives in Medicare Advantage drive higher coding intensity than fee-for-service, so code-based comorbidity counts will be systematically elevated in MA patients — a confounder for any code-count covariate. Sensitivity analyses stratified by plan type are essential. - For non-US data. ICD-10-CM is a US-specific modification. International studies use national variants (ICD-10-CA in Canada, ICD-10-AM in Australia, ICD-10-GM in Germany) that differ in code structure, extension logic, and update cycles.

Data-source operational depth

- Medicare FFS claims: Diagnosis codes appear in the MedPAR principal diagnosis field and secondary diagnosis fields (up to 25 additional), on carrier/professional claims (up to 12 diagnosis fields), and on outpatient facility claims. Always confirm the fiscal-year code version against the claim service date. Part A covers inpatient, skilled nursing, and hospice; Part B covers outpatient and professional. Diagnosis position on the professional claim (pointer field) indicates which diagnosis justifies each service line. - Commercial claims (MarketScan, Optum, etc.): Same UB-04 and CMS-1500 field structure. Benefit design affects which services generate claims: carved-out behavioral health or specialty pharmacy may have no claim record. Diagnosis codes reflect the treating provider's coding practice, which varies by specialty and region. - EHR: ICD-10-CM codes appear on encounter diagnoses (billing), problem lists (active/chronic conditions), and referral orders. Problem-list entries may be historical and not encounter-driven. NLP on clinical notes can supplement or correct coded diagnoses. The OMOP ETL maps source ICD-10-CM to SNOMED standard concepts; always confirm mapping completeness for your target conditions. - Registry / linked data: Disease registries typically adjudicate diagnoses independently of ICD-10-CM codes, but registry submissions often include the billing code as a data element. Linking registry diagnoses to claims-based ICD-10-CM code lists requires reconciling registry inclusion criteria with code-based algorithms — they will not be identical.

Worked example

Scenario

A researcher is building a claims-based cohort of patients with rheumatoid arthritis (RA) using commercial insurance data from 2022. She obtains the NCHS FY2022 ICD-10-CM tabular list, identifies the RA category M05 (seropositive RA) and M06 (other RA), and needs to confirm that the flat codes in her claims file match the expected billable codes -- and that she is not accidentally including non-billable header codes. The table below shows a small excerpt of claims rows and the codes as they appear in the raw data field.

Dataset

Sample medical claim rows from a commercial database showing the diagnosis code field (DX1) in flat format (no decimal), claim type, and service date. The researcher must verify which rows represent billable RA codes versus header codes.

claim_idperson_idservice_dateclaim_typeDX1_flatDX1_decodedbillable
C00110012022-03-15professionalM0500M05.00yes
C00210012022-06-10professionalM0500M05.00yes
C00310022022-04-01professionalM05M05 (category header)no
C00410032022-09-20professionalM0610M06.10yes
C00510032022-11-05professionalM0610M06.10yes

Steps

  • Insert the decimal back into each flat code to verify it against the NCHS tabular list: M0500 becomes M05.00 (seropositive RA, unspecified site) -- a valid billable code. M05 alone (claim C003) is the three-character category header and is not billable.

  • Apply the standard 1-inpatient-or-2-outpatient phenotype rule: person 1001 has two professional claims with M05.00 on different service dates (2022-03-15 and 2022-06-10), so they qualify as an RA case. Person 1002 has only one claim and it carries a non-billable header code, so they do NOT qualify. Person 1003 has two professional claims with billable M06.10 on different dates (2022-09-20 and 2022-11-05), so they qualify.

  • Count qualifying RA cases using only billable codes and the 2-outpatient rule: 2 cases qualify (persons 1001 and 1003); 1 person does not qualify (person 1002 has only a non-billable header code). Cases identified = 2 out of 3 persons screened.

  • Confirm prefix-match coverage: a startswith filter on flat prefix 'M05' in the DX1_flat field would match C001, C002, and C003; a filter on exact billable codes ['M0500', 'M0610', ...] would match C001, C002, C004, C005 but not C003. The exact-match approach correctly excludes the non-billable header while the prefix match includes it -- use exact lists for phenotypes submitted to regulators.

Result

Cases = 2 out of 3 persons screened: persons 1001 and 1003 each have 2 professional claims with distinct billable ICD-10-CM RA codes on different service dates. Person 1002 is excluded because their single claim carries a non-billable category header (M05) that would be rejected by a payer and does not constitute confirmed coding.

Runnable example

python implementation

Validate ICD-10-CM code format, convert between flat and decimal representations, and safely filter a claims DataFrame using an exact billable-code list or a category prefix. Pitfalls covered: non-billable three-character headers, X placeholders, missing...

import re
import pandas as pd

# ICD-10-CM code format: letter + 2 digits + optional decimal + up to 4 more characters
# Flat format (claims): letter + 2 digits + up to 4 chars, NO decimal, up to 7 chars total
ICD10CM_FLAT_PATTERN = re.compile(r'^[A-Z]\d{2}[A-Z0-9]{0,4}$')
ICD10CM_DECIMAL_PATTERN = re.compile(r'^[A-Z]\d{2}(\.[A-Z0-9]{1,4})?$')

def to_flat(code: str) -> str:
    """Remove decimal point from a decoded ICD-10-CM code to get claims-storage format."""
    return code.strip().upper().replace('.', '')

def to_decimal(flat: str) -> str:
    """Insert decimal after the third character of a flat ICD-10-CM code.

    Note: only valid for codes with >3 characters. Three-character codes
    are category headers (non-billable) and have no decimal in the published tabular list.
    """
    flat = flat.strip().upper()
    if len(flat) <= 3:
        return flat  # return as-is; do NOT add a decimal to a category header
    return flat[:3] + '.' + flat[3:]

def is_valid_flat(code: str) -> bool:
    """Return True if code matches the ICD-10-CM flat format (1 letter + 2 digits + up to 4 chars)."""
    return bool(ICD10CM_FLAT_PATTERN.match(code.strip().upper()))

def is_billable_flat(code: str, billable_set: set) -> bool:
    """Return True if the flat code is in the pre-specified billable code set.

    The billable_set should contain only codes at their highest specificity,
    obtained from the NCHS tabular list for the target fiscal year.
    Non-billable three-character headers (e.g., 'I50', 'M05') must NOT be in billable_set.
    """
    return code.strip().upper() in billable_set

def filter_by_exact_list(df: pd.DataFrame,
                          dx_col: str,
                          billable_codes: set,
                          strip_whitespace: bool = True) -> pd.DataFrame:
    """Filter a claims DataFrame to rows where dx_col is in the exact billable code set.

    Pitfall: claims ETLs sometimes pad codes with trailing spaces. strip_whitespace=True
    mitigates this. The billable_codes set should use the same flat format as the data column.
    """
    col = df[dx_col].str.strip().str.upper() if strip_whitespace else df[dx_col].str.upper()
    return df[col.isin(billable_codes)].copy()

def filter_by_prefix(df: pd.DataFrame,
                     dx_col: str,
                     prefixes: list,
                     strip_whitespace: bool = True) -> pd.DataFrame:
    """Filter claims to rows where dx_col starts with any of the given flat-format prefixes.

    Pitfall: a 3-character prefix like 'I50' will match the non-billable header 'I50'
    if it appears in the data. Validate that your data source only stores billable codes,
    OR use exact lists for regulatory submissions. A prefix like 'I500' safely targets
    subcategory I50.0 and all billable descendants.
    """
    col = df[dx_col].str.strip().str.upper() if strip_whitespace else df[dx_col].str.upper()
    mask = col.apply(lambda c: any(c.startswith(p.upper()) for p in prefixes))
    return df[mask].copy()

# --- Example usage ---
# Heart failure phenotype (FY2022 subset for illustration)
HF_BILLABLE = {
    'I501', 'I5020', 'I5021', 'I5022', 'I5030', 'I5031', 'I5032',
    'I5040', 'I5041', 'I5042', 'I5043', 'I50810', 'I50811', 'I50812',
    'I50813', 'I50814', 'I5082', 'I5083', 'I5084', 'I5089', 'I509'
}

claims = pd.DataFrame({
    'claim_id': ['C01', 'C02', 'C03', 'C04'],
    'person_id': [1001, 1001, 1002, 1003],
    'DX1': ['I509 ', 'I509', 'I50', 'I5022'],  # C01 has trailing space; C03 is non-billable header
    'service_date': ['2022-03-01', '2022-06-15', '2022-04-01', '2022-07-10']
})

hf_exact = filter_by_exact_list(claims, 'DX1', HF_BILLABLE)
# Result: C01 (I509, after strip), C02 (I509), C04 (I5022) -- C03 (I50 header) excluded
# person_ids with >= 2 qualifying claims on different dates: 1001 (C01 + C02) -> HF case

hf_prefix = filter_by_prefix(claims, 'DX1', ['I50'])
# Result: C01, C02, C03 (the header!), C04 -- prefix matching picks up the non-billable header
# Demonstrates why exact-match is preferred for regulatory submissions

print(f"Exact-match HF claims: {len(hf_exact)}")   # 3 claims
print(f"Prefix-match HF claims: {len(hf_prefix)}")  # 4 claims (includes non-billable header)
r implementation

Validate ICD-10-CM codes, convert flat to decimal format, and filter a claims data frame using an exact billable-code list or prefix matching. Pitfalls covered: non-billable category headers, trailing whitespace from ETL padding, case sensitivity in...

library(dplyr)
library(stringr)

# ---- Format validation helpers ----
icd10cm_flat_pattern <- "^[A-Z]\\d{2}[A-Z0-9]{0,4}$"
icd9cm_flat_pattern  <- "^\\d{3}[0-9A-Z]{0,2}$|^[VEve]\\d{2}[0-9A-Z]{0,2}$"

is_valid_flat_icd10 <- function(code) {
  str_detect(str_to_upper(str_trim(code)), icd10cm_flat_pattern)
}

# ---- Flat <-> decimal conversion ----
to_decimal <- function(flat_code) {
  # For codes longer than 3 characters, insert decimal after position 3.
  # For 3-character category headers, return as-is (they have no decimal in the tabular list).
  flat_code <- str_to_upper(str_trim(flat_code))
  ifelse(nchar(flat_code) > 3,
         paste0(substr(flat_code, 1, 3), ".", substr(flat_code, 4, nchar(flat_code))),
         flat_code)
}

to_flat <- function(decimal_code) {
  str_to_upper(str_remove_all(str_trim(decimal_code), "\\."))
}

# ---- ICD version detection from service date ----
# US claims: ICD-9-CM used before 2015-10-01; ICD-10-CM from 2015-10-01 onward
icd_version <- function(service_date) {
  # service_date: Date or character in ISO format
  cutoff <- as.Date("2015-10-01")
  ifelse(as.Date(service_date) < cutoff, "ICD-9-CM", "ICD-10-CM")
}

# ---- Exact-match filter (preferred for regulatory submissions) ----
filter_exact <- function(df, dx_col, billable_codes) {
  # billable_codes: character vector of flat-format codes at highest specificity
  # Pitfall: strip whitespace and upper-case before matching to avoid ETL padding issues
  df %>%
    filter(str_to_upper(str_trim(.data[[dx_col]])) %in% str_to_upper(billable_codes))
}

# ---- Prefix (startswith) filter (use with caution -- see notes in description) ----
filter_prefix <- function(df, dx_col, prefixes) {
  # Pitfall: a 3-char prefix matches non-billable headers if they appear in the data.
  # Prefer 4+ char prefixes to stay within a subcategory.
  pattern <- paste0("^(", paste(str_to_upper(prefixes), collapse = "|"), ")")
  df %>%
    filter(str_detect(str_to_upper(str_trim(.data[[dx_col]])), pattern))
}

# ---- Example usage ----
# Rheumatoid arthritis phenotype (seropositive M05, other M06, select subcategories)
ra_codes <- c(
  "M0500", "M0501", "M0502", "M0503", "M0504", "M0505", "M0506", "M0509",
  "M0510", "M0511", "M0512", "M0513", "M0514", "M0515", "M0516", "M0519",
  "M0600", "M0601", "M0602", "M0603", "M0604", "M0605", "M0606", "M0609",
  "M0610", "M0611", "M0612", "M0613", "M0614", "M0615", "M0616", "M0619"
)

claims <- data.frame(
  claim_id = c("C001", "C002", "C003", "C004", "C005"),
  person_id = c(1001L, 1001L, 1002L, 1003L, 1003L),
  DX1 = c("M0500", "M0500", "M05", "M0610", "M0610"),  # C003 is non-billable header
  service_date = as.Date(c("2022-03-15", "2022-06-10", "2022-04-01", "2022-09-20", "2022-11-05")),
  stringsAsFactors = FALSE
)

# Check ICD version (all post-2015 -> ICD-10-CM)
claims$icd_version <- icd_version(claims$service_date)

# Exact-match filter (excludes the non-billable header "M05")
ra_exact <- filter_exact(claims, "DX1", ra_codes)
# -> C001, C002, C004, C005 (4 claims; C003 with "M05" header excluded)

# 2-outpatient case-finding (two qualifying claims on different dates)
ra_cases <- ra_exact %>%
  group_by(person_id) %>%
  summarise(
    n_claims = n(),
    n_dates  = n_distinct(service_date),
    qualifies = n_dates >= 2,
    .groups = "drop"
  ) %>%
  filter(qualifies)
# Result: person 1001 (2 dates), person 1003 (2 dates) -> 2 RA cases

cat("RA cases identified:", nrow(ra_cases), "\n")  # 2
cat("Codes validated as flat ICD-10-CM:\n")
print(sapply(ra_codes[1:4], is_valid_flat_icd10))   # all TRUE
cat("Category header 'M05' valid as billable flat code:", is_valid_flat_icd10("M05"), "\n") # FALSE