NDC parsing regex patterns for Python
ReDoS-safe Python regex plus CMS-correct padding that collapse mixed NDC-10/NDC-11 inputs into one canonical 5-4-2 identity, with audit hashing for DEA logging.
A single regex that “parses an NDC” is where most pharmacy inventory pipelines quietly go wrong. The National Drug Code looks like a fixed numeric token, but a 10-digit NDC carries its segment layout in the hyphen positions — 4-4-2, 5-3-2, and 5-4-1 are all valid 10-digit codes that pad to the canonical 11-digit 5-4-2 form in three different ways. Strip the hyphens and the layout is gone, so any pattern that matches a bare 10-digit string and then pads it to 5-4-2 will silently misattribute roughly a third of inputs to the wrong product. A second, quieter failure is the regex itself: a pattern written with optional, overlapping quantifiers can backtrack catastrophically (ReDoS) on a long malformed scan, stalling a worker that is supposed to be committing controlled-substance records. This page solves exactly those two problems — ambiguous segment padding and unsafe matching — with copy-paste Python. It operates within the parent topic, NDC-11 vs NDC-10 Parsing Standards, and the broader Core Architecture & DEA Compliance Frameworks.
Correct parsing has to answer three questions deterministically for every token: is the input structurally a valid NDC, which segment configuration is it, and what is its unambiguous NDC-11 identity? When those resolve in one place, a malformed or ambiguous NDC becomes a rejected record with an audit trail — not a phantom duplicate that surfaces during an ARCOS reconciliation.
Prerequisites & environment
- Python 3.11+ — the implementation uses
dataclasses(frozen=True),enum.Enum, and the union-type syntax shown below. - Standard library only:
re,hashlib,json,logging,datetime. No third-party dependency is required for parsing; addpytestonly for the test block. - Regulatory context you should already hold:
21 CFR § 1304.21(recordkeeping for controlled substances requires exact product identification),21 CFR § 207.33(NDC structure and segment configurations), andHIPAA § 164.312(b)(audit controls for electronic systems). The CMS 11-digit padding convention (4-4-2,5-3-2,5-4-1→5-4-2) is the rule that makes normalization deterministic. - You should understand why NDC-11 is the storage key rather than NDC-10 — that rationale, and the full format matrix, lives in the parent topic linked above. A normalized NDC-11 is also the join key the DEA Schedule II-V Classification Mapping engine expects downstream.
Implementation: a ReDoS-safe parser with CMS-correct padding
The parser uses three anchored, bounded patterns instead of one clever pattern. Each pattern has fully determined quantifiers, so there is no overlapping optionality for the engine to backtrack across — the match is linear in input length. The hyphenated form is parsed by its segment lengths; a digits-only 11-digit string is sliced directly; a bare 10-digit string is treated as ambiguous and rejected unless the caller supplies the known configuration, because no amount of regex can recover the lost hyphen positions.
import re
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
logger = logging.getLogger("pharmacy.ndc.parser")
# Bounded, fully-anchored patterns. Every quantifier is determined, so the
# regex engine cannot backtrack catastrophically => ReDoS-safe by construction.
NDC_HYPHENATED = re.compile(
r"^(?P<labeler>\d{4,5})-(?P<product>\d{3,4})-(?P<package>\d{1,2})$"
)
NDC_DIGITS_ONLY = re.compile(r"^\d{10,11}$")
# CMS 5-4-2 padding rule, keyed on the (labeler, product, package) length tuple.
# Each 10-digit configuration zero-pads exactly one segment to reach 5-4-2.
_PAD_RULES = {
(4, 4, 2): lambda l, p, k: (l.zfill(5), p, k), # 4-4-2: pad labeler
(5, 3, 2): lambda l, p, k: (l, p.zfill(4), k), # 5-3-2: pad product
(5, 4, 1): lambda l, p, k: (l, p, k.zfill(2)), # 5-4-1: pad package
(5, 4, 2): lambda l, p, k: (l, p, k), # already canonical
}
class NDCParseError(Exception):
"""Raised when an NDC token fails structural or configuration validation."""
class NDCConfig(str, Enum):
"""Explicit 10-digit segment configuration, supplied when hyphens are absent."""
C_4_4_2 = "4-4-2"
C_5_3_2 = "5-3-2"
C_5_4_1 = "5-4-1"
@dataclass(frozen=True)
class NDCRecord:
raw_input: str
ndc_11: str # canonical 11-digit identity, no hyphens
labeler: str # 5 digits
product: str # 4 digits
package: str # 2 digits
configuration: str # source layout, e.g. "5-3-2" or "5-4-2"
audit_hash: str
timestamp_utc: str
flags: dict[str, bool] = field(default_factory=dict)
def _audit_hash(raw: str, ndc_11: str, ts: str) -> str:
"""Tamper-evident SHA-256 binding raw input -> normalized output -> time."""
return hashlib.sha256(f"{raw}|{ndc_11}|{ts}".encode("utf-8")).hexdigest()
def parse_ndc(raw_input: str, config: NDCConfig | None = None) -> NDCRecord:
"""
Validate and normalize one NDC token to canonical NDC-11.
- Hyphenated inputs are self-describing: segment lengths select the pad rule.
- 11-digit digit strings are sliced directly to 5-4-2.
- Bare 10-digit strings are AMBIGUOUS; a config must be supplied or the
record is rejected (never guessed) to satisfy 21 CFR § 1304.21 identity.
"""
if not isinstance(raw_input, str):
raise NDCParseError("Input must be a string.")
cleaned = raw_input.strip()
match = NDC_HYPHENATED.match(cleaned)
if match:
l, p, k = match.group("labeler", "product", "package")
key = (len(l), len(p), len(k))
rule = _PAD_RULES.get(key)
if rule is None:
raise NDCParseError(f"Unsupported hyphenated configuration: {key}")
l, p, k = rule(l, p, k)
elif NDC_DIGITS_ONLY.match(cleaned):
if len(cleaned) == 11:
l, p, k = cleaned[0:5], cleaned[5:9], cleaned[9:11]
elif config is not None:
# Reinsert the caller-asserted layout, then apply the pad rule.
segs = {"4-4-2": (4, 8), "5-3-2": (5, 8), "5-4-1": (5, 9)}[config.value]
l, p, k = cleaned[: segs[0]], cleaned[segs[0]: segs[1]], cleaned[segs[1]:]
l, p, k = _PAD_RULES[(len(l), len(p), len(k))](l, p, k)
else:
logger.warning("NDC_AMBIGUOUS_REJECTED: %s", repr(cleaned))
raise NDCParseError(
"Bare 10-digit NDC is ambiguous; supply NDCConfig or hyphenated form."
)
else:
logger.warning("NDC_PARSE_REJECTED: %s", repr(cleaned))
raise NDCParseError("Input does not match a valid NDC-10/11 structure.")
ndc_11 = f"{l}{p}{k}"
if len(ndc_11) != 11:
raise NDCParseError(f"Normalized length {len(ndc_11)} != 11.")
ts = datetime.now(timezone.utc).isoformat()
record = NDCRecord(
raw_input=cleaned, ndc_11=ndc_11, labeler=l, product=p, package=k,
configuration=f"{len(match.group('labeler')) if match else 5}-"
f"{len(match.group('product')) if match else 4}-"
f"{len(match.group('package')) if match else 2}"
if match else "5-4-2",
audit_hash=_audit_hash(cleaned, ndc_11, ts),
timestamp_utc=ts,
flags={"normalized": True, "from_hyphenated": bool(match)},
)
# Structured, PHI-free audit line: identifiers and hash only, never patient data.
logger.info(
"NDC_NORMALIZED: ndc_11=%s audit_hash=%s flags=%s",
record.ndc_11, record.audit_hash, json.dumps(record.flags),
)
return record
The frozen NDCRecord is immutable, which is what makes it safe to feed an append-only audit ledger: once parsed, the binding of raw input to NDC-11 and its hash cannot be edited in place. That immutability is the same property the Defining audit boundaries for controlled substances work depends on.
Verification & testing
Two things must be proven: that padding is configuration-correct (not just length-correct), and that the patterns cannot be stalled by a hostile input. The assertion block below covers all three 10-digit layouts plus the ambiguity rejection, and times a pathological input to confirm the linear-match property.
import time
import pytest
def test_padding_is_configuration_correct():
# 4-4-2 pads the labeler; 5-3-2 pads the product; 5-4-1 pads the package.
assert parse_ndc("1234-5678-90").ndc_11 == "01234567890"
assert parse_ndc("12345-678-90").ndc_11 == "12345067890"
assert parse_ndc("12345-6789-0").ndc_11 == "12345678900"
# An already-canonical 5-4-2 passes through unchanged.
assert parse_ndc("12345-6789-01").ndc_11 == "12345678901"
# 11 contiguous digits slice directly.
assert parse_ndc("12345678901").ndc_11 == "12345678901"
def test_bare_10_digit_is_rejected_not_guessed():
with pytest.raises(NDCParseError):
parse_ndc("1234567890") # ambiguous: no layout recoverable
# ...but an explicit caller assertion is honored deterministically.
assert parse_ndc("1234567890", config=NDCConfig.C_5_3_2).ndc_11 == "12345067890"
def test_charset_and_redos_safety():
with pytest.raises(NDCParseError):
parse_ndc("12A45-6789-01") # alphabetic contamination
# A long malformed token must match in ~linear time, never stall a worker.
hostile = "1" * 100_000 + "x"
start = time.perf_counter()
with pytest.raises(NDCParseError):
parse_ndc(hostile)
assert time.perf_counter() - start < 0.05
def test_audit_hash_is_stable_and_bound():
rec = parse_ndc("12345-678-90")
assert rec.audit_hash == _audit_hash(rec.raw_input, rec.ndc_11, rec.timestamp_utc)
To validate the compliance trail rather than just the logic, capture the emitted log line and confirm it contains the NDC-11 and hash but no patient identifiers:
INFO pharmacy.ndc.parser NDC_NORMALIZED: ndc_11=12345067890 \
audit_hash=9f2c...e41a flags={"normalized": true, "from_hyphenated": true}
Re-running _audit_hash(raw, ndc_11, ts) for any stored record and comparing it to the persisted audit_hash is the inspection check: a mismatch proves the row was altered after parsing, which is exactly the tamper signal an audit under 21 CFR § 1304.21 is meant to surface.
Gotchas & compliance pitfalls
- Bare 10-digit NDCs are genuinely unrecoverable.
1234567890could be4-4-2,5-3-2, or5-4-1. Any parser that “just pads to 5-4-2” is guessing, and a wrong guess silently maps the record to a different product. Reject and require the configuration; never default. - Leading-zero loss from integer storage. If an NDC was ever stored as an
intor parsed by a CSV reader that strips leading zeros,00536-1099-01arrives as536109901and re-pads incorrectly. Carry NDCs as strings end to end. - ReDoS hides in “flexible” patterns. A token like
^(\d+-?)+$looks convenient and backtracks exponentially on1111...1x. Keep quantifiers bounded and anchored, as above, and add the timing assertion to CI. - Segment swap on unhyphenated 11-digit. Slicing
[0:5][5:9][9:11]is only correct for true5-4-2; a vendor that exports a zero-padded5-4-1as 11 contiguous digits will look canonical but isn’t. Prefer the hyphenated source whenever it is available. - Placeholder and vanity NDCs. Older HL7/NCPDP feeds emit asterisks or
00000-0000-00as fillers. These match the digit pattern but are not real products; quarantine them rather than committing them to the controlled-substance ledger. - Offline ingestion still needs a parser, not a guesser. When tokens arrive during a disconnect, queue the raw string and re-run
parse_ndcon reconnect; the deferred-validation policy is owned by Fallback routing for offline sync, but the same rejection rules must apply on replay.
Frequently Asked Questions
Why not just strip hyphens and pad every 10-digit NDC to 5-4-2?
Because the hyphens are the configuration. Removing them discards the only signal that distinguishes 4-4-2, 5-3-2, and 5-4-1, and each pads a different segment. Stripping then padding produces a confidently wrong NDC-11 for two of the three layouts, which becomes a misidentified product in a controlled-substance record.
Is a single combined regex ever acceptable?
A combined pattern can validate shape, but you still need the segment-length tuple to choose the pad rule, so you gain nothing by collapsing the patterns and you risk introducing overlapping optionality that backtracks. Separate, bounded patterns keep matching linear and keep the configuration explicit.
How do I know my pattern is ReDoS-safe?
Confirm every quantifier is bounded and the pattern is anchored at both ends with no nested or adjacent optional repetition that can match the same characters two ways. The timing assertion in the test block — a 100k-character hostile input that must fail in well under a second — is the empirical guard for CI.
Does this parser touch protected health information?
No. It operates only on the drug code and emits the NDC-11, source configuration, timestamp, and SHA-256 hash. Keeping patient identifiers out of the parser and its logs keeps HIPAA § 164.312(b) audit controls separate from ePHI while still proving each normalization is traceable.
Related
- NDC-11 vs NDC-10 Parsing Standards — parent topic: the full format matrix and normalization rules this recipe implements.
- Core Architecture & DEA Compliance Frameworks — the architectural foundation for the immutable ledger and audit boundaries.
- DEA Schedule II-V Classification Mapping — the engine that consumes the normalized NDC-11 as its join key.
- Fallback routing for offline sync — deferred validation and replay rules for NDCs ingested during a disconnect.