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.

Deterministic NDC-to-GTIN-14 mapping pipeline and GTIN-14 digit anatomy Top: a five-stage left-to-right pipeline. Stage one, a 10-digit NDC in one of three hyphenated formats (4-4-2, 5-3-2, or 5-4-1). Stage two, normalize by padding the deficient segment to an 11-digit NDC. Stage three, assemble a 13-digit prefix from the indicator digit, the 11-digit NDC, and a package filler. Stage four, the GS1 modulo-10 check-digit gate. Stage five, the finished GTIN-14. Bottom: the fourteen digits of an example GTIN-14 derived from NDC 0069-3190-30, shown as labelled cells. Position 1 is the indicator digit, positions 2 through 12 hold the 11-digit NDC as a fixed reversible window, position 13 is the package filler, and position 14 is the modulo-10 check digit. 10-digit NDC 4-4-2 / 5-3-2 / 5-4-1 Normalize pad deficient seg → 11 Assemble ind + NDC + filler = 13 Check digit GS1 mod-10 gate GTIN-14 14 digits · reversible example: NDC 0069-3190-30 (4-4-2) → 0 0 0 0 6 9 3 1 9 0 3 0 0 7 indicator 11-digit NDC — fixed, reversible window filler check

Prerequisites

  • Python 3.10+ — the snippets use dict/tuple generics, Literal typing, and structural str parsing; no third-party runtime is required for the core conversion.
  • re, typing, functools — standard-library only for the mapping itself; add pydantic v2 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, or 5-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, and 5-4-1 each pad a different segment. A blanket zfill(11) produces the correct 11-digit code only for 4-4-2 and 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-30 arrives as 69-3190-30. Always carry the NDC as a string and validate segment lengths before parsing.
  • The indicator digit is a decision, not a default. 0 denotes the base/each unit; case, inner-pack, and pallet configurations use different indicator digits. Hard-coding 0 collapses 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.