← Methods repository
concept

ICD-9-CM Legacy Diagnosis and Procedure Codes

The US clinical modification of the WHO's Ninth Revision of the International Classification of Diseases, used for diagnosis (Volumes 1-2) and inpatient procedure coding (Volume 3) in all US HIPAA-covered healthcare transactions from 1979 through 30 September 2015; any RWE study whose data window touches pre-October-2015 encounters must handle ICD-9-CM code lists alongside their ICD-10 successors.

Data_Standardcoding-systemdata-standardprimitiveicd-9icd-9-cmclaimsdiagnosis-codesprocedure-codes
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-9-CM is the older US diagnosis and procedure coding system that was used on all hospital and insurance billing records from the early 1980s until 30 September 2015, when the US switched to ICD-10-CM and ICD-10-PCS. Every diagnosis code in a pre-2015 insurance claim is an ICD-9-CM code — a short number like 250.00 meaning type 2 diabetes — and researchers studying anything that happened before October 2015 must include those older codes in their search lists alongside the newer ICD-10 codes. Leaving out the ICD-9-CM codes is one of the most common silent errors in claims research: the database returns no result, which looks exactly like a patient who did not have the condition, when in fact the analyst simply searched with the wrong code set for that time period.

ICD-9-CM

(International Classification of Diseases, 9th Revision, Clinical Modification) was the mandatory coding system for all US HIPAA-covered electronic health transactions from the early 1980s through 30 September 2015. On 1 October 2015, the US transitioned to ICD-10-CM for diagnoses and ICD-10-PCS for inpatient procedures. Because the overwhelming majority of administrative claims databases extend back well before that date, every analyst working with pre-October-2015 data — trend analyses, interrupted time series, long lookback windows, or any cohort that accrued members before the transition — will encounter ICD-9-CM codes in the raw data and must handle them correctly.

Code structure and format

ICD-9-CM has three volumes. Volumes 1-2 are the tabular and alphabetic index of diagnosis codes: a 3-to-5-character string whose core is a 3-digit numeric chapter (001-999) with up to two additional decimal digits of specificity. In raw claims files the decimal point is almost never stored: the code "25000" in the `DGNS_CD` field means 250.00 (diabetes mellitus, type 2, not stated as uncontrolled), not the integer 25000. Analysts must normalize between the flat and the decimal representation before any matching. Volumes 1-2 also contain two supplementary classifications that use letter prefixes: V codes (V01-V91), which capture factors influencing health status and contact with health services (e.g., V58.x = aftercare following surgery; V45.x = postsurgical state), and E codes (E000-E999), which classify the external cause of injury or poisoning. E codes require their own format validation because their numeric range overlaps with the 3-digit diagnosis chapters. In total, ICD-9-CM contains approximately 14,000 diagnosis codes — far fewer, and far less specific, than ICD-10-CM's 70,000+: there is no laterality (no left vs right), no encounter type (no "initial encounter" vs "subsequent encounter" vs "sequela"), and no seventh-character extension. This specificity gap is not just aesthetic; it is an accuracy limitation that validated phenotype algorithms must account for.

Volume 3 procedure codes

ICD-9-CM Volume 3 is the inpatient procedure coding system used exclusively on UB-04 institutional claims (i.e., facility/hospital claims). Codes are 2 to 4 digits (2-digit category plus up to 2 decimal digits), stored without the decimal in claims data: "8154" = 81.54 (total knee replacement). Volume 3 covered procedures across all body systems and was the only procedure coding system on inpatient institutional claims until it was replaced by ICD-10-PCS on 1 October 2015. Volume 3 codes do not appear on professional (physician) claims; those used CPT/HCPCS Level II codes throughout. When building procedure phenotypes from inpatient claims before October 2015, analysts must include Volume 3 codes alongside CPT codes sourced from line-item professional claims.

Why a current RWE analyst still needs ICD-9-CM

The retirement date was 1 October 2015, but the data do not disappear. Any study with one of the following design characteristics requires era-aware code-list management:

1. Pre/post transition data: A cohort accruing members between 2012 and 2018 will have index dates on both sides of the October 2015 cutoff. Baseline covariates for a 2014 index date require ICD-9-CM code lists; the outcome window may span into ICD-10-CM era records. Applying only ICD-10-CM code lists will silently drop all pre-October 2015 events, deflating baseline comorbidity counts and creating apparent outcome gaps. 2. Trend and interrupted time series analyses: ITS models that include pre-2015 data show an artifactual discontinuity at the transition: the apparent rate of many conditions changes not because the underlying incidence changed but because ICD-9-CM and ICD-10-CM have different levels of specificity, and because clinicians and coders adjusted their behavior during the transition. This is a form of measurement artifact that must be modeled (e.g., as a step indicator at the transition) or the ITS estimator will absorb the coding change into the intervention effect estimate. 3. Lookback windows for prevalent conditions: A study with a 2016 or 2017 index date and a 12-month baseline lookback window will have baseline diagnosis history entirely or partly in the ICD-9-CM era. Omitting the ICD-9-CM codes for diabetes, hypertension, heart failure, or cancer from the lookback query will undercount comorbidity burden. 4. Algorithm transfer: A validated ICD-9-CM phenotype algorithm (e.g., the Quan 2005 comorbidity adaptation of the Elixhauser index) does not automatically transfer to ICD-10-CM. The General Equivalence Mappings (GEMs) produced by CMS approximate one-to-many cross-walks between the two systems, but GEMs are not a substitute for re-validation because (a) the code structures differ fundamentally, (b) some ICD-9-CM codes have no direct ICD-10-CM equivalent, and (c) clinical coding practices shifted at transition. Algorithm developers must re-derive and re-validate phenotype definitions separately for each era.

Pros, cons, and trade-offs — specific and comparative

  • vs ICD-10-CM/PCS (post-October 2015): ICD-10-CM offers 5× more diagnosis codes,
  • vs CPT/HCPCS for procedures: CPT codes cover both inpatient and outpatient procedures
  • vs OMOP SNOMED standard concepts: OMOP maps ICD-9-CM source codes to SNOMED standard

When to use

Use ICD-9-CM code lists whenever: - Any part of the study window (index date, baseline window, or follow-up) falls before 1 October 2015. - The lookback window for a 2015-2017 cohort extends into the pre-transition era. - Running an interrupted time series or trend analysis whose pre-period includes claims from before October 2015. - Applying or adapting a published phenotype algorithm that was originally validated against ICD-9-CM data (Charlson, Elixhauser, Quan comorbidity adaptations, most published AHRQ Patient Safety Indicators). - Comparing diagnoses or procedures across historical eras to assess natural history, drug safety, or secular trend.

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

- Post-October 2015 data only: For a study whose entire enrollment and follow-up falls after 1 October 2015, ICD-9-CM codes will never appear in raw claims. Building ICD-9-CM code lists is wasted effort that introduces confusion and the risk of accidentally mixing code systems. - As a substitute for re-validation: Mapping ICD-9-CM codes via GEMs and treating the result as a validated ICD-10-CM phenotype is not re-validation. GEMs are approximate and asymmetric (the forward and backward maps are not inverses of each other). Use GEMs as a starting point, then empirically evaluate the derived code list in the data of interest. - When applied without era-conditioning: A query that applies an ICD-9-CM code list to a post-2015 claim or an ICD-10-CM code list to a pre-2015 claim will return zero matching rows for that era — which looks identical to a true absence of the condition. The failure is silent. Always condition code lists on the claim's service date relative to the transition cutoff. - For external-cause coding (E codes) without recognizing their format: E codes (E000-E999) overlap numerically with the 3-digit chapter codes if the "E" prefix is dropped during extraction. A process that strips the first character from all codes will misclassify E codes. Always validate that E and V codes are preserved with their prefixes in the extract.

Data-source operational depth

- Medicare FFS (MedPAR/institutional + carrier/professional): ICD-9-CM diagnosis codes appear in `DGNS_CD_1` through `DGNS_CD_25` on both inpatient (MedPAR) and outpatient (carrier/outpatient) claims up to the 30 September 2015 service date. Volume 3 procedure codes appear in `ICD_PRCDR_CD_1` through `ICD_PRCDR_CD_6` on MedPAR (inpatient) only. Admissions that straddle the transition date (admitted pre-October 2015, discharged post-October 2015) may have been coded in either system depending on the fiscal year rules applied by CMS; treat discharge date as the coding-system determinant. Principal diagnosis is in position 1; secondary diagnoses in positions 2+. The "present on admission" (POA) indicator distinguishes pre-admission from hospital-acquired diagnoses on inpatient records and is important for outcome ascertainment and patient safety studies. - Commercial claims (MarketScan, Optum): Same ICD-9-CM field structure; the transition date applies uniformly. Some vendors retain the decimal point in the stored code; verify the format contract for the specific dataset before writing matching logic. - EHR problem lists and encounter diagnoses: EHR records may carry ICD-9-CM codes on historical problem list entries even after the transition, because problems entered before October 2015 were not necessarily back-coded to ICD-10-CM. Cross-sectional extracts of EHR diagnosis data therefore require era-aware handling: use service/encounter date, not extract date, to determine the applicable coding system. - Registry linkage: Disease registries that link to claims (SEER-Medicare, tumor registries) retain the ICD-9-CM codes from the linked claims records. SEER tumor site coding uses ICD-O-3 (not ICD-9-CM), but cause-of-death and comorbidity data in the linked claims file are subject to the same era logic.

Relationship to GEMs and crosswalk mapping

CMS published the General Equivalence Mappings (GEMs) to facilitate the transition. The forward GEM maps each ICD-9-CM code to one or more ICD-10-CM codes; the backward GEM maps ICD-10-CM codes back to ICD-9-CM. Most ICD-9-CM codes map to multiple ICD-10-CM codes (one-to-many), and many ICD-10-CM codes map to multiple ICD-9-CM codes (many-to-one in the backward direction). GEMs are published on the CMS website and are the standard starting point for cross-era code translation, but they are approximate and should be reviewed clinically for each phenotype of interest.

Worked example

Scenario

A pharmacoepidemiology team is studying hospitalization rates for diabetes-related complications in a commercial claims database from January 2014 through December 2017. This window spans the ICD-9-CM-to-ICD-10-CM transition (1 October 2015). The team wants to count all inpatient hospitalizations with a primary diagnosis of type 2 diabetes with uncontrolled hyperglycemia. Before the transition this is ICD-9-CM 250.02 (diabetes mellitus, type 2, uncontrolled); after the transition the nearest ICD-10-CM equivalent is E11.65 (type 2 diabetes mellitus with hyperglycemia). If the team queries only ICD-10-CM E11.65, they will miss all events from 2014 through September 2015; if they query only ICD-9-CM 250.02, they will miss all events from October 2015 through December 2017.

Dataset

Monthly inpatient hospitalization counts from a claims database, 2014-2017. "Pre" = service date before 1 Oct 2015 (ICD-9-CM era); "Post" = service date on or after 1 Oct 2015 (ICD-10-CM era). Counts shown for three query strategies.

YearEraICD-9-CM only (250.02)ICD-10-CM only (E11.65)Dual-era (correct)
2014Pre120120
2015 Jan-SepPre9090
2015 Oct-DecPost2828
2016Post112112
2017Post115115

Steps

  • The study window covers 48 months (Jan 2014 - Dec 2017). The ICD-9-CM era covers 21 months (Jan 2014 - Sep 2015); the ICD-10-CM era covers 27 months (Oct 2015 - Dec 2017).

  • ICD-9-CM-only query total = 120 + 90 + 0 + 0 + 0 = 210 hospitalizations, all from the pre-transition era; zero events are captured after Oct 2015.

  • ICD-10-CM-only query total = 0 + 0 + 28 + 112 + 115 = 255 hospitalizations, all from the post-transition era; zero events are captured before Oct 2015.

  • Dual-era (correct) query total = 120 + 90 + 28 + 112 + 115 = 465 hospitalizations, because the correct ICD-9-CM code is applied to pre-transition claims and the correct ICD-10-CM code is applied to post-transition claims.

  • The ICD-9-CM-only strategy captures 210 / 465 = 0.45 of all true events — missing 255 events (all of 2016-2017 and Q4 2015).

  • The ICD-10-CM-only strategy captures 255 / 465 = 0.55 of all true events — missing 210 events (all of 2014 through September 2015).

  • Neither single-era query is usable for a rate trend. The ICD-9-CM-only rate would appear to drop to zero in October 2015 — a step artifact at the coding transition, not a true clinical change.

Result

Correct dual-era total = 210 + 255 = 465 hospitalizations over 48 months. ICD-9-CM-only captures 210 / 465 = 0.45 (45%) of true events. ICD-10-CM-only captures 255 / 465 = 0.55 (55%) of true events. Using a single-era code list in a multi-era study introduces a measurement artifact equivalent to ignoring 45-55% of the outcome events depending on which era is omitted.

Timeline Spec

Title

ICD-9-CM vs ICD-10-CM coding eras in a 2014-2017 study window

Window
Start

2014-01-01

End

2017-12-31

Label

48-month study window (Jan 2014 - Dec 2017)

Events
  • Label

    ICD-9-CM era (Volumes 1-2 diagnosis, Volume 3 procedure)

    Start

    2014-01-01

    Length Days

    638

    Quantity

    21 months of ICD-9-CM coded claims

  • Label

    ICD-10-CM/PCS era (diagnosis + inpatient procedure)

    Start

    2015-10-01

    Length Days

    822

    Quantity

    27 months of ICD-10-CM coded claims

Spans
  • Kind

    exposed

    Start

    2014-01-01

    End

    2015-09-30

    Label

    ICD-9-CM era: 210 events (use code 250.02)

  • Kind

    unexposed

    Start

    2015-10-01

    End

    2017-12-31

    Label

    ICD-10-CM era: 255 events (use code E11.65)

Result
Label

Dual-era correct total: 465 events; single-era queries miss 45-55%

Value

465

Runnable example

python implementation

Era-aware ICD-9-CM / ICD-10-CM code dispatch: flat-to-decimal normalization, regex format validators for diagnosis codes, V codes, E codes, and Volume 3 procedure codes, and an era-conditioned lookup function that applies the correct code list based on a...

import re
from datetime import date

# ---------------------------------------------------------------------------
# ICD-9-CM transition cutoff (US HIPAA enforcement date)
# ---------------------------------------------------------------------------
ICD9_CUTOFF = date(2015, 10, 1)   # claims with service_date < this date use ICD-9-CM

# ---------------------------------------------------------------------------
# Regex validators — all operate on the DECIMAL form (with dot)
# ---------------------------------------------------------------------------
# Numeric diagnosis codes: 3-digit chapter, optional 1-2 decimal digits
_DX_NUMERIC = re.compile(r"^\d{3}(\.\d{1,2})?$")
# V codes: V followed by 2 digits, optional 1-2 decimal digits
_DX_V       = re.compile(r"^V\d{2}(\.\d{1,2})?$", re.IGNORECASE)
# E codes: E followed by 3 digits, optional 1 decimal digit
_DX_E       = re.compile(r"^E\d{3}(\.\d)?$", re.IGNORECASE)
# Volume 3 procedure codes: 2 digits, optional 1-2 decimal digits
_PX_VOL3    = re.compile(r"^\d{2}(\.\d{1,2})?$")


def flat_to_decimal(code: str) -> str:
    """Convert a flat ICD-9-CM code stored without decimal to decimal form.

    Rules:
      - V codes: insert decimal after V + 2 digits (e.g., V5811 -> V58.11)
      - E codes: insert decimal after E + 3 digits (e.g., E8100 -> E810.0)
      - Volume 3 procedure (2 leading digits, <=4 total): insert decimal after 2nd digit
        (e.g., 8154 -> 81.54)
      - Numeric diagnosis: insert decimal after 3rd digit (e.g., 25000 -> 250.00)
    """
    c = code.strip().upper()
    if not c:
        return c

    if c.startswith("V"):
        digits = c[1:]
        if len(digits) > 2:
            return "V" + digits[:2] + "." + digits[2:]
        return c   # bare V + 2 digits, no further specificity

    if c.startswith("E"):
        digits = c[1:]
        if len(digits) > 3:
            return "E" + digits[:3] + "." + digits[3:]
        return c   # bare E + 3 digits (e.g., E812 is valid 3-char E code)

    # Numeric — could be Volume 3 (2-digit chapter) or diagnosis (3-digit chapter)
    # Volume 3: exactly 2 leading digits before specificity; max 4 digits total
    # Diagnosis: 3 leading digits; max 5 digits total
    # Distinguish by first character (Volume 3 chapters 01-99, diagnoses 001-999)
    # A reliable heuristic: raw flat code length determines which decimal position to use.
    if len(c) <= 4 and c[:2].isdigit() and int(c[:2]) <= 99:
        # Ambiguous — caller should specify context; default to Volume 3 if <=4 chars
        # and likely procedure context (passed via flag); here we default to diagnosis
        pass
    if len(c) >= 3:
        return c[:3] + ("." + c[3:] if c[3:] else "")
    return c


def decimal_to_flat(code: str) -> str:
    """Remove the decimal point: '250.00' -> '25000', 'V58.11' -> 'V5811'."""
    return code.strip().replace(".", "").upper()


def is_valid_icd9_dx(code: str) -> bool:
    """Return True if `code` (decimal form) is a syntactically valid ICD-9-CM diagnosis code."""
    c = code.strip().upper()
    return bool(_DX_NUMERIC.match(c) or _DX_V.match(c) or _DX_E.match(c))


def is_valid_icd9_px(code: str) -> bool:
    """Return True if `code` (decimal form) is a syntactically valid Volume 3 procedure code."""
    return bool(_PX_VOL3.match(code.strip()))


# ---------------------------------------------------------------------------
# Era-aware code dispatch
# ---------------------------------------------------------------------------
def era_code_filter(
    service_date: date,
    icd9_codes: set[str],
    icd10_codes: set[str],
) -> set[str]:
    """Return the code set appropriate for the service_date.

    Args:
        service_date: Date of the claim's service.
        icd9_codes:   Set of ICD-9-CM codes (flat form, no decimal).
        icd10_codes:  Set of ICD-10-CM/PCS codes (stored form in the database).

    Returns:
        The era-appropriate code set. Applying the returned set to claims from
        the same era guarantees the correct vocabulary is used.

    Pitfalls: never pass both sets to a single SQL IN() clause that spans eras.
    """
    if service_date < ICD9_CUTOFF:
        return icd9_codes
    return icd10_codes


# ---------------------------------------------------------------------------
# Example: diabetes type 2 hyperglycemia, dual-era
# ---------------------------------------------------------------------------
T2DM_HYPERGLYCEMIA_ICD9  = {"25002"}  # 250.02 flat (uncontrolled type 2 diabetes)
T2DM_HYPERGLYCEMIA_ICD10 = {"E1165"}  # E11.65 flat (type 2 DM with hyperglycemia)

def count_events(claims: list[dict]) -> int:
    """Count hospitalizations matching the era-correct diabetes hyperglycemia code."""
    n = 0
    for claim in claims:
        svc_date = date.fromisoformat(claim["service_date"])
        valid_codes = era_code_filter(svc_date, T2DM_HYPERGLYCEMIA_ICD9, T2DM_HYPERGLYCEMIA_ICD10)
        if decimal_to_flat(claim["principal_dx"]) in valid_codes:
            n += 1
    return n

# Smoke test
if __name__ == "__main__":
    test_claims = [
        {"service_date": "2014-06-01", "principal_dx": "250.02"},  # ICD-9, decimal form
        {"service_date": "2015-09-30", "principal_dx": "25002"},   # ICD-9, flat form
        {"service_date": "2015-10-01", "principal_dx": "E1165"},   # ICD-10 (transition day)
        {"service_date": "2016-03-15", "principal_dx": "E11.65"},  # ICD-10, decimal form
    ]
    assert count_events(test_claims) == 4, "Expected 4 matching events"
    assert flat_to_decimal("25000") == "250.00"
    assert flat_to_decimal("V5811") == "V58.11"
    assert flat_to_decimal("E8100") == "E810.0"
    assert flat_to_decimal("8154")  == "81.54"
    assert is_valid_icd9_dx("250.00")
    assert is_valid_icd9_dx("V58.11")
    assert is_valid_icd9_dx("E810.0")
    assert not is_valid_icd9_dx("E11.65")   # that's ICD-10
    print("All assertions passed.")
r implementation

Era-aware ICD-9-CM / ICD-10-CM dispatch in R for use with claims data frames. Includes flat-to-decimal normalization, regex validators for diagnosis code types (numeric, V, E), Volume 3 procedure validators, and an era-conditioned lookup that applies the...

library(lubridate)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
ICD9_CUTOFF <- as.Date("2015-10-01")   # service_date < this -> ICD-9-CM era

# ---------------------------------------------------------------------------
# Normalization: flat (no decimal) <-> decimal form
# ---------------------------------------------------------------------------
#' Convert a flat ICD-9-CM code to decimal form.
#' Examples: "25000" -> "250.00", "V5811" -> "V58.11", "E8100" -> "E810.0"
flat_to_decimal_icd9 <- function(code) {
  code <- trimws(toupper(code))
  ifelse(
    startsWith(code, "V") & nchar(code) > 3,
    paste0(substr(code, 1, 3), ".", substr(code, 4, nchar(code))),
    ifelse(
      startsWith(code, "E") & nchar(code) > 4,
      paste0(substr(code, 1, 4), ".", substr(code, 5, nchar(code))),
      ifelse(
        grepl("^[0-9]", code) & nchar(code) > 3,
        paste0(substr(code, 1, 3), ".", substr(code, 4, nchar(code))),
        code  # already 3 chars or has a prefix we don't recognize
      )
    )
  )
}

#' Remove the decimal point from a code: "250.00" -> "25000".
decimal_to_flat_icd9 <- function(code) {
  gsub("\\.", "", trimws(toupper(code)))
}

# ---------------------------------------------------------------------------
# Validators (operate on decimal form)
# ---------------------------------------------------------------------------
is_valid_icd9_dx <- function(code) {
  c <- trimws(toupper(code))
  grepl("^\\d{3}(\\.\\d{1,2})?$", c) |        # numeric diagnosis
    grepl("^V\\d{2}(\\.\\d{1,2})?$", c) |      # V codes
    grepl("^E\\d{3}(\\.\\d)?$", c)             # E codes
}

is_valid_icd9_vol3 <- function(code) {
  grepl("^\\d{2}(\\.\\d{1,2})?$", trimws(code))   # Volume 3 procedure
}

# ---------------------------------------------------------------------------
# Era-aware dispatch
# ---------------------------------------------------------------------------
#' Given a vector of service dates and paired code columns, return the
#' era-appropriate match indicator (TRUE if the code is in the correct era set).
#'
#' @param service_date  Date vector.
#' @param code          Character vector of codes from the claims data (flat form).
#' @param icd9_codes    Character vector of ICD-9-CM codes to match (flat form).
#' @param icd10_codes   Character vector of ICD-10-CM codes to match (flat form).
#' @return Logical vector: TRUE when the claim's era-correct code set contains the code.
era_match <- function(service_date, code, icd9_codes, icd10_codes) {
  svc_date <- as.Date(service_date)
  is_icd9_era <- svc_date < ICD9_CUTOFF
  flat_code   <- decimal_to_flat_icd9(code)
  (is_icd9_era  & flat_code %in% decimal_to_flat_icd9(icd9_codes)) |
    (!is_icd9_era & flat_code %in% decimal_to_flat_icd9(icd10_codes))
}

# ---------------------------------------------------------------------------
# Example: type 2 diabetes with hyperglycemia, dual-era
# ---------------------------------------------------------------------------
T2DM_ICD9  <- c("25002")   # 250.02 (type 2 DM, uncontrolled)
T2DM_ICD10 <- c("E1165")   # E11.65 (type 2 DM with hyperglycemia)

# Usage on a claims data frame:
# claims$dx_match <- era_match(claims$service_date, claims$principal_dx,
#                               T2DM_ICD9, T2DM_ICD10)
# event_count <- sum(claims$dx_match, na.rm = TRUE)

# Smoke test
test_df <- data.frame(
  service_date = as.Date(c("2014-06-01", "2015-09-30", "2015-10-01", "2016-03-15")),
  principal_dx = c("250.02", "25002", "E1165", "E11.65"),
  stringsAsFactors = FALSE
)
test_df$match <- era_match(test_df$service_date, test_df$principal_dx,
                           T2DM_ICD9, T2DM_ICD10)
stopifnot(all(test_df$match))
cat("All R assertions passed.\n")