Validating SSCC Check Digits in Python

A case or pallet’s entire chain of custody rests on one 18-digit number printed in its GS1 barcode: the Serial Shipping Container Code (SSCC), carried under Application Identifier (00). This is the identifier the Case & Pallet Aggregation Logic workflow — part of the broader Aggregation Hierarchy & Validation Workflows domain — depends on to bind an entire hierarchy tree to one scannable container, and a transposed digit or a corrupted scan buffer can silently substitute one container for another without ever raising an obvious error. GS1’s General Specifications build a self-checking modulo-10 digit into every SSCC precisely so a scanner, a middleware service, or a Python validation gate can catch that corruption before the code is trusted as a parent identifier in an AggregationEvent. This page is the concrete implementation: the SSCC’s four-part structure, the check-digit algorithm itself, and a Pydantic v2 validator that rejects a malformed container code at the boundary — before it can ever enter aggregation.

SSCC structure and the GS1 modulo-10 check digit computation An 18-digit SSCC splits into four segments: a one-digit extension digit, a variable-length GS1 Company Prefix, a serial reference that fills the remaining body, and a final check digit at position 18. Below the structure, a flow shows the modulo-10 algorithm: the first seventeen digits are multiplied by alternating weights of 3 and 1 counted from the rightmost digit, the products are summed, and the check digit is derived as ten minus the sum's last digit, modulo ten. A dashed arrow feeds that result back up into the check digit segment. 18-DIGIT SSCC STRUCTURE — AI (00) pos 1 positions 2–17 (variable split) pos 18 Extension digit GS1 Company Prefix Serial Reference Check Digit 0 0614141 000000010 4 GS1 MODULO-10 CHECK DIGIT ALGORITHM 17-digit body positions 1–17 ×3 / ×1 weights alternating, right → left sum the 17 products 10 − (sum mod 10) → mod 10 = check digit (position 18)

Prerequisites

  • Python 3.10+ — the code below uses str | None unions, f-strings, and no exotic syntax beyond that.
  • Pydantic v2 (pip install "pydantic>=2") — for the field_validator decorator that turns the check-digit rule into a structural contract rather than an ad hoc if check.
  • A configured GS1 Company Prefix length — GS1 does not self-encode where the company prefix ends and the serial reference begins; your organization’s GS1 Company Prefix allocation (typically 7–10 digits) is a deployment constant you must supply if you need to decompose an SSCC into its parts rather than just validate it.
  • DSCSA data prerequisites — a managed SSCC serial pool for cases and pallets, distinct from the SGTIN pool used for saleable units, and an understanding of which container tier (case vs. pallet) a given scan represents so the extension digit convention stays consistent across your packaging lines.

Step-by-Step Solution

Step 1 — Implement the GS1 modulo-10 check-digit algorithm

Every SSCC’s 18th digit is derived from the first 17 by a fixed weighting rule: starting from the rightmost digit of the 17-digit body, multiply alternating digits by 3 and 1, sum the products, and subtract the sum’s last digit from 10. This single function is the algorithmic core everything else in this page wraps.

def compute_sscc_check_digit(body: str) -> int:
    """Compute the GS1 modulo-10 check digit for a 17-digit SSCC body.

    `body` is the extension digit + GS1 Company Prefix + Serial Reference,
    concatenated to exactly 17 digits before the check digit is appended.
    """
    if len(body) != 17 or not body.isdigit():
        raise ValueError("SSCC body must be exactly 17 numeric digits")
    total = 0
    for i, digit in enumerate(reversed(body)):
        weight = 3 if i % 2 == 0 else 1
        total += int(digit) * weight
    return (10 - (total % 10)) % 10

DSCSA/GS1 note: this is the identical modulo-10 algorithm GS1 uses for GTIN (01) and GLN check digits — only the body length differs (13 digits for a GTIN-14’s body, 17 for an SSCC’s) — so a single, well-tested weighting routine can back every GS1 key your pipeline handles.

Step 2 — Validate a full 18-digit SSCC

With the check-digit computation isolated, verifying a candidate SSCC is a matter of splitting off the last digit and comparing it against what the first 17 digits should produce.

def sscc_check_digit_ok(sscc: str) -> bool:
    """Return True only if the 18th digit of `sscc` matches the computed check digit."""
    if len(sscc) != 18 or not sscc.isdigit():
        return False
    body, check_digit = sscc[:-1], int(sscc[-1])
    return compute_sscc_check_digit(body) == check_digit

DSCSA/GS1 note: returning a plain bool rather than raising keeps this helper usable both inside a field_validator (which wants to raise) and inside quick membership filters (which want a predicate) — one routine, two call sites, no duplicated arithmetic.

Step 3 — Wrap the rule in a Pydantic v2 validator

A scanned container code should never reach the aggregation tree without passing through a typed contract. The model below normalizes whitespace, enforces the 18-digit numeric shape, and rejects anything whose check digit fails — the exact failure mode a transposed digit or a truncated scan buffer produces.

from pydantic import BaseModel, field_validator


class ContainerCode(BaseModel):
    sscc: str  # AI (00), scanned raw off a case or pallet label

    @field_validator("sscc", mode="before")
    @classmethod
    def _strip(cls, v: str) -> str:
        return v.strip() if isinstance(v, str) else v

    @field_validator("sscc")
    @classmethod
    def _validate_sscc(cls, v: str) -> str:
        if not v.isdigit():
            raise ValueError("SSCC (AI 00) must contain only digits")
        if len(v) != 18:
            raise ValueError(f"SSCC (AI 00) must be exactly 18 digits, got {len(v)}")
        if not sscc_check_digit_ok(v):
            raise ValueError("SSCC failed GS1 modulo-10 check digit validation")
        return v

    @property
    def extension_digit(self) -> str:
        return self.sscc[0]

    def company_prefix_and_serial(self, prefix_length: int) -> tuple[str, str]:
        """Split the body into GS1 Company Prefix and Serial Reference.

        `prefix_length` is a deployment constant — GS1 does not encode it in
        the SSCC itself, so the caller must supply the allocation length.
        """
        body = self.sscc[1:-1]
        return body[:prefix_length], body[prefix_length:]

DSCSA/GS1 note: validation happens before decomposition — a code that fails the check digit is rejected outright and never gets far enough to be split into a company prefix and serial reference, so a corrupted SSCC can never masquerade as belonging to a valid company prefix range.

Step 4 — Reject malformed container codes before aggregation

A ValidationError on its own is not actionable for an aggregation gate; it needs a structured, machine-readable code so the failure can be routed to quarantine rather than halting the packaging line. This wrapper converts Pydantic’s error into the same ERR_* shape used by the aggregation gate described in Automating Case-to-Pallet Aggregation Validation in Python.

from pydantic import ValidationError


class ContainerCodeError(Exception):
    def __init__(self, code: str, detail: str):
        self.code = code
        self.detail = detail
        super().__init__(f"{code}: {detail}")


def parse_container_code(raw: str) -> ContainerCode:
    """Validate a raw scanned SSCC before it may become an AggregationEvent parentID.

    Raises ContainerCodeError with a structured code so an aggregation gate
    can route the failure straight to quarantine without inspecting
    Pydantic internals.
    """
    try:
        return ContainerCode(sscc=raw)
    except ValidationError as exc:
        message = exc.errors()[0]["msg"]
        if "18 digits" in message:
            code = "ERR_SSCC_LENGTH"
        elif "only digits" in message:
            code = "ERR_SSCC_NON_NUMERIC"
        else:
            code = "ERR_SSCC_CHECK_DIGIT"
        raise ContainerCodeError(code, message) from exc

DSCSA/GS1 note: a malformed SSCC must never become a parentID in an AggregationEvent — accepting one would let a downstream trading partner scan a pallet code that resolves to nothing, or worse, to someone else’s container. Quarantining by structured code, rather than dropping the payload silently, is what keeps the failure investigable per the Parent-Child Serial Mapping integrity rules that govern the rest of the hierarchy.

Step 5 — Mint a valid SSCC when your own system assigns them

Validation is only half the workflow. Whenever your serialization system — not a trading partner — issues a new SSCC for a case or pallet from its own serial pool, it must assemble the body correctly and compute the check digit itself, rather than guess.

def assign_sscc(extension_digit: str, company_prefix: str, serial_reference: str) -> str:
    """Assemble a new, valid SSCC from its structural components.

    `extension_digit` is one digit; `company_prefix` plus `serial_reference`
    must together supply exactly 16 digits so the assembled body is 17
    digits wide before the check digit is appended.
    """
    body = extension_digit + company_prefix + serial_reference
    if len(body) != 17 or not body.isdigit():
        raise ValueError(
            "extension digit + company prefix + serial reference must total 17 digits"
        )
    check_digit = compute_sscc_check_digit(body)
    return f"{body}{check_digit}"

DSCSA/GS1 note: deriving the check digit at assignment time, rather than at scan time only, guarantees every SSCC your own pool issues is self-consistent the moment it is printed — a printer or label-design defect that corrupts one digit is caught the instant the code round-trips back through sscc_check_digit_ok, long before it reaches a trading partner.

Verification

Confirm the algorithm and the validator agree using a known-good vector, then confirm rejection on the same vector with a single digit corrupted:

import pytest
from pydantic import ValidationError

VALID_SSCC = "006141410000000104"  # extension 0, prefix 0614141, serial 000000010, check 4


def test_compute_check_digit_matches_known_vector():
    body, check_digit = VALID_SSCC[:-1], int(VALID_SSCC[-1])
    assert compute_sscc_check_digit(body) == check_digit


def test_valid_sscc_passes():
    code = ContainerCode(sscc=VALID_SSCC)
    assert code.extension_digit == "0"


def test_corrupted_check_digit_is_rejected():
    corrupted = VALID_SSCC[:-1] + str((int(VALID_SSCC[-1]) + 1) % 10)
    with pytest.raises(ValidationError):
        ContainerCode(sscc=corrupted)


def test_parse_container_code_reports_structured_error():
    with pytest.raises(ContainerCodeError) as exc_info:
        parse_container_code("12345")  # wrong length entirely
    assert exc_info.value.code == "ERR_SSCC_LENGTH"


def test_assign_and_validate_round_trip():
    minted = assign_sscc("0", "0614141", "000000010")
    assert minted == VALID_SSCC
    assert sscc_check_digit_ok(minted)

For a production readiness check, run the validator over a sample of recently printed labels pulled from the label-design or vision system and confirm every one both parses and re-derives its own check digit — a 100% pass rate on freshly printed stock is the signal that the printer, the label template, and the serial-pool assignment logic are all in agreement. Any single failure in that batch should be treated as a printing or serial-pool defect, not a fluke, and investigated before the run continues.

Gotchas & Edge Cases

  • Leading zeros are data, not decoration. An SSCC that starts with 0 (a common extension digit or company prefix leader) must stay an 18-character string end to end. Casting it to int anywhere in the pipeline silently drops the leading digit and produces a 17-character value that fails every downstream check.
  • Company prefix length is not self-describing. Two trading partners with 7-digit and 10-digit GS1 Company Prefixes produce SSCCs that look identical in length and check digit but decompose completely differently. Never hard-code a single prefix_length across all partners — it must be a per-partner configuration value.
  • Confusing the SSCC check digit with the GTIN check digit. Both use the same modulo-10 weighting, but they operate over different body lengths (17 digits for SSCC vs. 13 for a GTIN-14’s body) and must never be validated with a routine sized for the other identifier.
  • Extension digit reuse across case and pallet tiers. GS1 does not forbid the same extension digit appearing on both a case and its parent pallet — uniqueness comes from the full 18-digit value, not the extension digit alone. Do not treat the extension digit as a tier discriminator without also checking the managed serial pool.
  • Scanner truncation and whitespace. Barcode scanners in keyboard-wedge mode occasionally drop a trailing digit under load or append a stray carriage return. Strip and length-check before running the check-digit test, or a truncated 17-digit read will fail with a misleading “check digit” error when the real defect is length.