Map ICD-10-CM to CPT Using Python Dictionaries

Problem: a diagnosis-to-procedure pairing must be validated in-memory before the service line reaches the SV1 segment of an X12 837P claim — a linear scan of a flat table or a per-claim database round-trip cannot keep up with batch scrubbing, and a naive dict blows past its RAM ceiling once the crosswalk crosses six figures of rows. This page builds a nested Python dictionary that answers “is this ICD-10-CM → CPT pairing valid for this payer?” in O(1) average time, with memory bounded by sys.intern and every miss routed deterministically instead of raising an unhandled KeyError.

The lookup lives inside the ICD-10-CM to CPT crosswalk mapping tier of the scrubbing pipeline — after payer resolution, before service-line assembly. A miss here is not a crash; it is a routing decision handed to the same fallback routing logic for invalid codes state machine that catches an unknown payer or a bad CPT.

Prerequisites

Spec Reference: What the Lookup Keys On

The resolver keys on three values and returns a payer-scoped rule payload. Only these fields participate in the dictionary structure; everything else is carried inside the rule payload.

Value Source Format Role in lookup
ICD-10-CM code HI01-2 diagnosis (837P HI segment) 3–7 alphanumerics, no decimal in the segment Top-level dictionary key
CPT / HCPCS code Service line procedure 5 numeric (CPT) or 1 alpha + 4 numeric (HCPCS II) Second-level key
Payer identifier NM109 in Loop 2010BB (NM1*PR) Payer ID / CMS Plan ID Third-level key
Rule payload Crosswalk / contract table dict (modifiers, effective dates, override flags) Leaf value

The output of a successful lookup feeds one composite element: in the 5010 837P, SV101 carries the procedure qualifier and code (e.g. HC:99213), while diagnosis pointer indices that reference HI entries by position live in SV107SV110, not alongside the CPT code. The resolver builds the SV101 composite; the caller populates the pointers from the claim’s HI ordering.

Nested crosswalk dictionary and resolve() branchingThe resolver keys a diagnosis-to-procedure-to-payer lookup through three chained hash maps. The top-level dictionary is keyed on the ICD-10-CM code from HI01-2; the value is a second dictionary keyed on the CPT or HCPCS code; that value is a third dictionary keyed on the payer ID from NM109; the leaf is a rule payload of modifiers, effective dates, and override flags. Each hop is average-case O(1). A resolve() call that lands on a leaf is a hit and returns an SV101 HC:CPT composite for the 837P service line. Any missing key at any level is a miss: it is routed to the fallback queue with a PHI-masked warning and never raises a KeyError to the caller. LEVEL 1 · HI01-2 LEVEL 2 · SERVICE LINE LEVEL 3 · NM109 CROSSWALK dict keyed on ICD-10-CM "E11.9" "M54.5" … N interned keys O(1) CPT dict value of "M54.5" "99213" "97110" alpha OK — "E0114" O(1) payer dict value of "99213" "AETNA01" "CIGNA02" payer-scoped rules rule payload leaf (modifiers, resolve(icd, cpt, payer) .get().get() — never KeyError key at every level? hit SV101 composite HC:99213 → 837P service line miss (any level) fallback queue NO_MAPPING_FOUND · CrosswalkResult PHI-SAFE BOUNDARY — every miss is redacted before logging redact_phi() strips SSN · MRN · DOB → [REDACTED] before any code value reaches a log sink
Three chained hash maps resolve a diagnosis-to-procedure-to-payer pairing in average O(1) per hop: a leaf hit emits the SV101 HC:CPT composite, while a missing key at any level routes to the fallback queue with a PHI-masked log — never a bare KeyError.

Step-by-Step Implementation

Step 1 — Structure the nested crosswalk for O(1) lookups

A flat {(icd, cpt): rule} table cannot express the many-to-many, payer-scoped reality of medical-necessity rules. Model the crosswalk as three nested dictionaries — {ICD10: {CPT: {payer_id: rule_payload}}} — so each level is an average-case O(1) hash lookup and the same ICD-10 code can fan out to many procedures and payers without duplication.

from typing import Dict, Tuple, Optional
import sys
import logging

# Type aliases for clarity
ICD10Code = str
CPTCode = str
PayerID = str
RulePayload = Dict[str, object]

# Core crosswalk structure: {ICD10: {CPT: {payer_id: rule_payload}}}
CROSSWALK: Dict[ICD10Code, Dict[CPTCode, Dict[PayerID, RulePayload]]] = {}

def load_crosswalk_chunk(raw_records: list[Tuple[str, str, str, RulePayload]]) -> None:
    """Memory-optimized ingestion using string interning on every code key."""
    for icd, cpt, payer, rule_data in raw_records:
        # Intern strings to collapse duplicate object overhead in memory
        icd_key = sys.intern(icd)
        cpt_key = sys.intern(cpt)
        payer_key = sys.intern(payer)
        CROSSWALK.setdefault(icd_key, {}).setdefault(cpt_key, {})[payer_key] = rule_data

Step 2 — Bound the memory footprint

A 100,000+ row mapping loaded into plain Python dicts can consume 150–300 MB of RAM through per-object overhead, which is fatal when many worker processes each hold a copy. Four measures keep the footprint bounded:

  1. Call sys.intern() on every code string at ingestion (step 1) so the millions of repeated ICD-10, CPT, and payer strings collapse to single interned objects.
  2. Wrap completed inner dicts with types.MappingProxyType where the rules are immutable at runtime — this prevents accidental mutation of shared rule payloads, though it does not itself reduce memory.
  3. Hydrate lazily via sqlite3 with an LRU cache layer, loading only the dictionary slices for the payer batches currently in flight rather than the whole table.
  4. Enforce a ceiling in CI/CD by profiling with sys.getsizeof() so a code-set release that balloons the table fails the build instead of the pod.

The same memory discipline underpins the CPT modifier validation matrix, which loads NCCI PTP/MUE tables the same way; keep both structures on the same profiling budget.

Step 3 — Resolve a pairing with PHI-safe fallback routing

A scrubbing pipeline must never leak Protected Health Information into logs, and it must never halt a batch on a single unmapped code. Redact before logging, and return a structured result — never a bare exception — so a miss becomes a routing decision.

import re
from dataclasses import dataclass

@dataclass(frozen=True)
class CrosswalkResult:
    is_valid: bool
    mapped_cpt: Optional[str]
    payer_rule: Optional[RulePayload]
    error_code: Optional[str] = None
    masked_log_msg: Optional[str] = None

def redact_phi(raw_text: str) -> str:
    """Strip SSN, MRN, and DOB patterns before anything reaches a log sink."""
    patterns = [
        r'\b\d{3}-\d{2}-\d{4}\b',   # SSN
        r'\bMRN[:\s]*\w{6,12}\b',   # MRN
        r'\b\d{2}/\d{2}/\d{4}\b',   # DOB
    ]
    sanitized = raw_text
    for pat in patterns:
        sanitized = re.sub(pat, '[REDACTED]', sanitized)
    return sanitized

def resolve_mapping(icd10: str, cpt: str, payer_id: str) -> CrosswalkResult:
    """Lookup with explicit fallback routing and PHI-masked logging — never raises to the caller."""
    try:
        payer_rules = CROSSWALK.get(icd10, {}).get(cpt, {})
        if payer_id in payer_rules:
            return CrosswalkResult(is_valid=True, mapped_cpt=cpt, payer_rule=payer_rules[payer_id])

        # Fallback routing for invalid / unmapped codes — a miss is a decision, not a crash
        logging.warning(
            redact_phi(f"Crosswalk miss: ICD10={icd10}, CPT={cpt}, Payer={payer_id}")
        )
        return CrosswalkResult(
            is_valid=False, mapped_cpt=None, payer_rule=None,
            error_code="NO_MAPPING_FOUND",
            masked_log_msg="Routed to payer fallback queue",
        )
    except Exception as exc:
        logging.error(redact_phi(f"Crosswalk resolution failure: {exc}"))
        return CrosswalkResult(
            is_valid=False, mapped_cpt=None, payer_rule=None,
            error_code="INTERNAL_LOOKUP_ERROR",
            masked_log_msg="Escalated to exception handler",
        )

Step 4 — Emit the 837P SV1 payload

Wrap the resolver in an engine that enforces the payer’s NCCI-driven modifier requirements and returns an EDI-ready dict for SV1 construction. The lock guards the shared crosswalk and validation cache when workers share the structure.

import threading
from typing import List

class CrosswalkEngine:
    def __init__(self) -> None:
        self._lock = threading.RLock()
        self._validation_cache: Dict[str, bool] = {}

    def validate_and_map(
        self,
        icd10: str,
        procedure_code: str,
        payer_id: str,
        modifiers: Optional[List[str]] = None,
    ) -> dict:
        """
        Validate the ICD-10 / CPT pairing and return an EDI-ready payload for
        837P SV1 construction. Diagnosis pointer indices (SV107-SV110) are
        populated separately from the claim's HI ordering, not here.
        """
        with self._lock:
            result = resolve_mapping(icd10, procedure_code, payer_id)

            if not result.is_valid:
                return {"status": "FALLBACK", "error": result.error_code, "log": result.masked_log_msg}

            rule = result.payer_rule
            # Enforce NCCI modifier requirements before the line is accepted
            required_mods = rule.get("required_modifiers", []) if rule else []
            if modifiers and required_mods and not set(required_mods).issubset(set(modifiers)):
                return {
                    "status": "DENY",
                    "error": "MODIFIER_MISMATCH",
                    "log": redact_phi(f"Missing modifiers {required_mods} for {procedure_code}"),
                }

            # Return EDI-compatible data for the SV101 composite
            return {
                "status": "VALID",
                "sv101_qualifier": "HC",
                "sv101_code": procedure_code,
                "sv1_modifiers": modifiers or [],
                "payer_override": rule.get("override_flag", False) if rule else False,
                "effective_date": rule.get("effective_date", "2024-01-01") if rule else None,
            }

Verification

Confirm the resolver behaves correctly before wiring it into service-line assembly:

  • Known pairing — load a record via load_crosswalk_chunk and call validate_and_map with its ICD-10, CPT, and payer; expect "status": "VALID" and "sv101_code" equal to the procedure, with no WARNING line.
  • Unmapped pairing — call with a fabricated CPT; expect a single Crosswalk miss WARNING, "status": "FALLBACK", and "error": "NO_MAPPING_FOUND".
  • Modifier gate — supply a rule with required_modifiers and omit one; expect "status": "DENY" and "error": "MODIFIER_MISMATCH", with the missing-modifier log already PHI-masked.
  • No PHI leak — grep the log stream for any SSN, MRN, or DOB pattern from your test fixtures; it must never appear — only [REDACTED] should be present.
  • Downstream acks — a VALID payload that builds a well-formed SV101 should return a clean 999 (accepted) and 277CA (accepted-for-processing); a FALLBACK line should never have been transmitted, so no reject is generated.

Common Gotchas

  • Uninterned keys cause silent misses. If any code reaches resolve_mapping without passing through the sys.intern() ingestion path, an equal-but-not-identical string still hashes correctly, so this is not usually the miss source — but a stale cache after a payer-table update is: invalidate the validation cache whenever you reload a crosswalk slice, or a KeyError-adjacent miss will look like a bad code.
  • The CPT does not carry the diagnosis pointer. A frequent 837P SV1 rejection comes from developers packing pointer indices next to the code; populate SV107SV110 with 1-based HI-segment indices instead, exactly as the X12 837P segment architecture guide specifies, and keep the composite in SV101 limited to HC:<CPT>.
  • HCPCS Level II codes are alphanumeric. DMEPOS and supply lines carry a leading alpha character (e.g. E0114), so never coerce the second-level key to int; the same string handling that the HCPCS Level II integration patterns page uses applies here.
  • Effective dating drifts against CMS quarterly releases. A pairing valid last quarter can be retired this quarter; pin every rule payload to an effective_date and re-run regression tests against each CMS code-set release so a retired pairing fails in CI, not in the payer’s front-end edits — and reconcile any resulting adjustment against the X12 835 remittance structure so a denied line maps back to the ledger.

Up: ICD-10-CM to CPT Crosswalk Mapping