How to Map GTINs to NDCs for DSCSA Compliance
Pharmaceutical serialization forces two identifier systems to agree on every unit. The FDA mandates the National Drug Code (NDC) for regulatory listing, labeling, and Structured Product Labeling (SPL) submissions, while GS1 governs the Global Trade Item Number (GTIN) that anchors every urn:epc:id:sgtin, Verification Router Service query, and interoperable data exchange. This page — part of the GS1 Standards Implementation work within the broader DSCSA Compliance Architecture & Standards Mapping framework — solves one precise problem: how to convert an NDC to a GTIN-14 (and recover the NDC from a GTIN) as a deterministic, idempotent, audit-ready function under the Drug Supply Chain Security Act (DSCSA). Get this mapping wrong and legitimate units resolve against the wrong regulatory record, trading partners reject your EPCIS events, and inconsistent identifier resolution surfaces as a Form 483 observation.
Figure — The deterministic NDC-to-GTIN-14 pipeline, and the digit-level anatomy of the result: the 11-digit NDC occupies one fixed, reversible window.
Prerequisites
- Python 3.10+ — the snippets use
dict/tuplegenerics,Literaltyping, and structuralstrparsing; no third-party runtime is required for the core conversion. re,typing,functools— standard-library only for the mapping itself; addpydanticv2 if you want the GTIN to enter your pipeline as a validated field on the product-master contract.- A canonical NDC source — the labeler’s NDCs in their original hyphenated form (
4-4-2,5-3-2, or5-4-1), pulled from the ERP/product-master, not re-keyed by hand. - Read access to the FDA active listing — a local mirror of the FDA National Drug Code Directory so mapped identifiers can be cross-referenced against currently listed products.
- DSCSA data prerequisites — the packaging indicator digit convention your facility uses for each saleable-unit and higher-level configuration, and the GS1 Company Prefix that governs your GTIN allocation.
The mapping is deceptively simple in the happy path and unforgiving at the edges. The NDC arrives in three mutually incompatible hyphenation formats, a single lost leading zero silently produces a valid-looking-but-wrong GTIN, and a naive zfill(11) pads the wrong segment for two of the three formats. The steps below treat the conversion as a stateless, format-aware function precisely so those edges cannot leak downstream.
Step-by-Step Solution
Step 1 — Compute the GS1 modulo-10 check digit
Every GTIN ends in a modulo-10 check digit calculated over the preceding 13 digits with alternating weight-3 / weight-1 applied right-to-left. Isolate it as a pure function so both directions of the mapping — construction and validation — share one implementation.
def _calculate_gs1_check_digit(prefix_13: str) -> int:
"""GS1 modulo-10 check digit over a 13-digit prefix."""
if len(prefix_13) != 13 or not prefix_13.isdigit():
raise ValueError("GTIN prefix must be exactly 13 numeric digits.")
total = sum(
int(d) * (3 if i % 2 == 0 else 1)
for i, d in enumerate(reversed(prefix_13))
)
return (10 - (total % 10)) % 10
Rule satisfied: GS1 General Specifications check-digit algorithm — the mandatory integrity digit that lets any downstream reader reject a corrupted GTIN before it enters an EPCIS event.
Step 2 — Normalize the NDC to 11 digits with format-aware padding
The FDA NDC is a 10-digit code segmented as 4-4-2, 5-3-2, or 5-4-1 (Labeler-Product-Package). GS1 embedding requires the 11-digit 5-4-2 form, produced by inserting a single leading zero into the deficient segment — a different segment for each format. This is where zfill(11) betrays you: it pads the whole string, which is only correct for the 4-4-2 case. Parse the hyphenated format first, then pad the right segment.
# Maps hyphenated NDC format code to the segment index that needs padding
# '4-4-2': labeler=4 → pad to 5; '5-3-2': product=3 → pad to 4; '5-4-1': package=1 → pad to 2.
NDC_FORMAT_PADDING: dict[str, tuple[int, int]] = {
"4-4-2": (0, 5), # pad segment 0 (labeler) from 4 to 5 digits
"5-3-2": (1, 4), # pad segment 1 (product) from 3 to 4 digits
"5-4-1": (2, 2), # pad segment 2 (package) from 1 to 2 digits
}
def normalize_ndc_11(ndc_hyphenated: str) -> str:
"""Any of the three FDA hyphenated NDC formats → canonical 11-digit string."""
parts = ndc_hyphenated.split("-")
if len(parts) != 3:
raise ValueError(f"NDC must be hyphenated in three segments, got: {ndc_hyphenated!r}")
format_code = "-".join(str(len(p)) for p in parts)
if format_code not in NDC_FORMAT_PADDING:
raise ValueError(f"Unrecognized NDC format {format_code!r}; expected 4-4-2, 5-3-2, or 5-4-1")
seg_idx, target_len = NDC_FORMAT_PADDING[format_code]
padded = list(parts)
padded[seg_idx] = parts[seg_idx].zfill(target_len)
ndc_11 = "".join(padded)
if not ndc_11.isdigit() or len(ndc_11) != 11:
raise ValueError(f"NDC normalization failed; result: {ndc_11!r}")
return ndc_11
Rule satisfied: FDA 10-digit-to-11-digit NDC conversion — the canonical form GS1 requires before a drug identifier can be embedded in a GTIN, preserving the exact labeler/product/package boundaries the FDA directory keys on.
Step 3 — Construct the GTIN-14 and gate it on the check digit
Prepend the packaging indicator digit (0 for the base/each unit), assemble the 13-digit prefix, append the check digit from Step 1, and never return a value that fails its own modulo-10 gate. The trailing filler digit is the package-level position in the GTIN-14 layout, not the check digit — a distinction that trips up hand-rolled implementations.
def map_ndc_to_gtin14(ndc_hyphenated: str, indicator_digit: int = 0) -> str:
"""Deterministic NDC (hyphenated, any FDA format) → GTIN-14."""
ndc_11 = normalize_ndc_11(ndc_hyphenated)
gtin_prefix = f"{indicator_digit}{ndc_11}0" # indicator + 11-digit NDC + package-level zero = 13
check_digit = _calculate_gs1_check_digit(gtin_prefix)
gtin14 = f"{gtin_prefix}{check_digit}"
# Self-verifying gate: a GTIN that fails its own check digit never leaves this function.
if _calculate_gs1_check_digit(gtin14[:13]) != int(gtin14[13]):
raise ValueError(f"Check-digit gate failed for {gtin14!r}")
return gtin14
Rule satisfied: GS1 GTIN-14 structure — indicator digit, embedded identifier, and modulo-10 check digit — so every urn:epc:id:sgtin your pipeline emits carries a structurally valid, verifiable regulatory equivalent.
Step 4 — Embed the mapping at the data-normalization layer
The conversion belongs upstream of EPCIS 2.0 event generation and Verification Router Service routing, executing immediately after ERP/labeling ingestion inside a streaming processor (Kafka Streams, Kinesis, or an equivalent). The normalized GTIN-14 attaches to the product-master record, propagates to the serialization database, and is referenced during event assembly — so the mapping runs once, deterministically, rather than being recomputed inconsistently at three different call sites. Invalid mappings route to a dead-letter queue with the original payload, a transformation timestamp, and the failure reason; they never silently fall through to a cached override, which is exactly the drift that breaks interoperable tracing.
from functools import lru_cache
@lru_cache(maxsize=100_000)
def cached_ndc_to_gtin14(ndc_hyphenated: str, indicator_digit: int = 0) -> str:
"""Memoized wrapper — safe only because the mapping is pure and stateless.
Invalidate (cache_clear) on every FDA NDC directory refresh."""
return map_ndc_to_gtin14(ndc_hyphenated, indicator_digit)
Rule satisfied: DSCSA unit-level traceability and interoperability — a single deterministic resolution point keeps the GTIN↔NDC relationship consistent across every EPCIS event, VRS lookup, and suspect product investigation query.
Verification
Confirm correctness along three axes: the check digit round-trips, every FDA format normalizes to the same 11-digit anchor, and the mapping is reversible back to the source NDC.
import pytest
def recover_ndc_11(gtin14: str) -> str:
"""Reverse the mapping: strip the indicator digit, package filler, and check digit."""
if len(gtin14) != 14 or not gtin14.isdigit():
raise ValueError("GTIN-14 must be 14 numeric digits.")
return gtin14[1:12] # positions after indicator, before package filler + check digit
@pytest.mark.parametrize("ndc,expected_11", [
("0069-3190-30", "00069319030"), # 4-4-2 → pad labeler
("50242-040-62", "50242004062"), # 5-3-2 → pad product
("0002-7597-01", "00002759701"), # 4-4-2 → pad labeler
])
def test_mapping_is_deterministic_and_reversible(ndc, expected_11):
gtin = map_ndc_to_gtin14(ndc)
assert len(gtin) == 14
assert _calculate_gs1_check_digit(gtin[:13]) == int(gtin[13]) # self-consistent
assert recover_ndc_11(gtin) == expected_11 # round-trips
Then batch-validate mapped GTINs against the FDA active listing to catch discontinued products, labeler-code reassignments, and package-configuration changes — a structurally valid GTIN can still point at an NDC the FDA no longer lists. Cross-check the same identifiers as part of automating DSCSA compliance gap checks with Python so regulatory staleness is caught on a schedule, not during an audit.
Gotchas & Edge Cases
- The three hyphenation formats are not interchangeable.
4-4-2,5-3-2, and5-4-1each pad a different segment. A blanketzfill(11)produces the correct 11-digit code only for4-4-2and silently mislabels the other two — a wrong-but-valid GTIN that passes its check digit and fails at the trading partner. - Leading-zero loss in transit. ERP exports, spreadsheets, and JSON that coerce the NDC to an integer drop leading zeros, so
00069-3190-30arrives as69-3190-30. Always carry the NDC as a string and validate segment lengths before parsing. - The indicator digit is a decision, not a default.
0denotes the base/each unit; case, inner-pack, and pallet configurations use different indicator digits. Hard-coding0collapses distinct packaging levels onto one GTIN and corrupts the parent-child serial mapping hierarchy. - A valid check digit is not a valid product. Modulo-10 verification proves structural integrity, not regulatory currency. Only a cross-reference against the FDA directory confirms the NDC is actively listed.
- Memoization outliving the source data. Caching the pure function is safe; leaving the cache warm across an FDA directory refresh is not. Clear the cache on every directory update so a reassigned labeler code cannot resolve to a stale GTIN.
FAQ
Why not just call str(ndc).zfill(11) to get the 11-digit NDC?
Because zfill pads the front of the whole string, which only matches the 4-4-2 format where the labeler segment is short. For 5-3-2 and 5-4-1 the deficient segment is in the middle or at the end, so blanket padding produces a numerically valid but semantically wrong NDC. You must parse the hyphenated format and pad the specific segment.
Is the NDC-to-GTIN mapping reversible? Yes, and it must be. The 11-digit NDC occupies a fixed window inside the GTIN-14, so you can recover it by stripping the indicator digit, the package-level filler, and the check digit. Reversibility is what lets a Verification Router Service response or a suspect-product query resolve a scanned GTIN back to its FDA regulatory record.
Where should the conversion run in the pipeline? At the data-normalization layer, immediately after ERP/labeling ingestion and before EPCIS event assembly. Running it once at a single point keeps the GTIN↔NDC relationship deterministic across every downstream event; recomputing it ad hoc at multiple call sites is the classic source of compliance drift.
How long must the mapping audit trail be retained? Capture the source NDC, the normalized 11-digit value, the generated GTIN-14, the check digit, the validation status, the pipeline node, and the timestamp for every transformation, and retain those immutable records for at least six years to align with DSCSA recordkeeping mandates.
Related
- GS1 Standards Implementation — the parent guide covering identifier syntax, GTIN allocation, and event correctness.
- Step-by-Step Guide to EPCIS 2.0 Event Formatting — where the mapped GTIN lands as an
urn:epc:id:sgtin. - Suspect Product Investigation Workflows — the investigations that depend on resolving a GTIN back to its NDC.
- DSCSA Compliance Architecture & Standards Mapping — the framework this mapping plugs into.