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.
Prerequisites
- Python 3.10+ — the code below uses
str | Noneunions, f-strings, and no exotic syntax beyond that. - Pydantic v2 (
pip install "pydantic>=2") — for thefield_validatordecorator that turns the check-digit rule into a structural contract rather than an ad hocifcheck. - 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 tointanywhere 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_lengthacross 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.
Related
- Up to the parent topic: Case & Pallet Aggregation Logic
- Automating Case-to-Pallet Aggregation Validation in Python — the aggregation gate this validator feeds
- Parent-Child Serial Mapping — bidirectional link integrity once a container code is trusted
- Aggregation Hierarchy & Validation Workflows — the broader domain of hierarchy nesting and validation gates