Validate a Container Check Digit in Python (ISO 6346)

Every shipping container number ends in a digit computed from the other ten characters. Here is the full algorithm — letter values, doubling weights, mod 11 — as tested Python for logistics scripts and EDI pipelines.

Anatomy of a container number

An ISO 6346 container number like CSQU3054383 has four parts: a three-letter owner code (CSQ), a one-letter equipment category (U for freight containers, J for detachable equipment, Z for trailers and chassis — in practice almost always U), a six-digit serial (305438), and one check digit (3). Container numbers are constantly rekeyed — gate logs, EDI messages, OCR at terminals — and one misread character can send a box, or an invoice, to the wrong place; the check digit catches that. To check a handful of numbers, use the container number validator in the browser; this guide is for when you need the logic inside your own code. (New to checksums? Start with how check digits work.)

The algorithm: letter values, doubling weights, mod 11

Three rules define the ISO 6346 check digit:

  1. Map each character to a number. Digits keep their value. Letters run from A=10 to Z=38, skipping 11, 22 and 33. That skip is not decoration: the checksum works mod 11, and a letter worth a multiple of 11 would contribute zero to the sum no matter where it sits — the checksum would be blind to it. So: A=10, B=12, C=13 … K=21, L=23 … U=32, V=34 … Z=38.
  2. Weight by powers of two. The first character is multiplied by 20=1, the second by 21=2, up to the tenth by 29=512. Sum the products.
  3. Take the sum mod 11. The remainder is the check digit — with one wrinkle covered below: a remainder of 10 is written as 0.

The Python implementation

This code is self-contained (standard library only) and was run against the examples in this article before publishing:

import re

# Build the ISO 6346 letter table: A=10 ... Z=38, skipping 11, 22, 33
LETTER_VALUES = {}
value = 10
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
    if value % 11 == 0:      # skip multiples of 11
        value += 1
    LETTER_VALUES[letter] = value
    value += 1

def char_value(ch):
    return int(ch) if ch.isdigit() else LETTER_VALUES[ch]

def container_check_digit(unit):
    """Check digit for the first 10 characters (4 letters + 6 digits)."""
    unit = unit.strip().upper().replace(" ", "").replace("-", "")
    if not re.fullmatch(r"[A-Z]{4}\d{6}", unit):
        raise ValueError(f"expected 4 letters + 6 digits, got {unit!r}")
    total = sum(char_value(ch) * (2 ** i) for i, ch in enumerate(unit))
    return (total % 11) % 10   # remainder 10 becomes check digit 0

def is_valid_container(number):
    """True if an 11-character container number passes ISO 6346."""
    number = number.strip().upper().replace(" ", "").replace("-", "")
    if not re.fullmatch(r"[A-Z]{4}\d{7}", number):
        return False
    return container_check_digit(number[:10]) == int(number[10])

print(container_check_digit("CSQU305438"))   # 3
print(is_valid_container("CSQU3054383"))     # True
print(is_valid_container("CSQU3054384"))     # False

Note that the letter table is generated, not hard-coded. Building it with the same skip rule the standard uses means one less place for a typo, and the % 11 == 0 line documents why K jumps to 21 but L is 23.

Worked example: CSQU3054383

Take the reference number CSQU3054383. Strip the check digit and process the first ten characters:

CharCSQU305438
Value13302832305438
Weight1248163264128256512
Product13601122564803205127684096

Sum: 13 + 60 + 112 + 256 + 48 + 0 + 320 + 512 + 768 + 4096 = 6185. Then 6185 = 562 × 11 + 3, so 6185 mod 11 = 3 — which matches the final digit. CSQU3054383 is valid; change the last digit to 4 and validation fails, exactly as the code above prints.

The remainder-10 wrinkle: why 0 is ambiguous

Mod 11 produces remainders 0–10, but the check digit must be a single digit. ISBN-10 solved this with the letter X; ISO 6346 instead writes a remainder of 10 as 0 — hence the (total % 11) % 10 in the code.

Consequence: a final 0 can mean a remainder of 0 or 10, so the checksum is slightly weaker for those numbers. The standard recommends owners simply not issue serials whose remainder is 10 — but real boxes carry them anyway, so your validator must accept them. Constructed example: for MSKU000008 the weighted sum is 24 + 60 + 84 + 256 + (8 × 512) = 4520, and 4520 mod 11 = 10 — the full number is written MSKU0000080, which the code above confirms as valid. Reject remainder-10 numbers and you will bounce legitimate containers at your gate.

Edge cases that bite in real pipelines

Checksum vs. registry: what a pass actually proves

A valid check digit means the eleven characters are self-consistent — nothing more. It does not prove the owner prefix is registered with the BIC (Bureau International des Containers), nor that a container with that serial was ever built or is in service. Checksum validation is the cheap first gate that stops typos and OCR misreads from propagating; anything contractual needs a registry or carrier confirmation on top. Our methodology page spells out this distinction for every code type we cover.

Validating at scale?

If you are checking whole manifests rather than single numbers, the CodeClassify API validates up to 100 container numbers per call and returns the parsed owner code, category and serial along with the verdict — and the expected check digit when one fails:

curl -X POST "https://codeclassify-api.rosariovitale0096.workers.dev/v1/container/validate" \
  -H "X-Api-Key: ccl_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{"containers":["CSQU3054383","MSKU0000080","CSQU3054384"]}'
{"ok":true,"count":3,"valid":2,"standard":"ISO 6346","results":[
  {"input":"CSQU3054383","container":"CSQU3054383","owner":"CSQ","category":"U",
   "serial":"305438","valid":true},
  {"input":"MSKU0000080","container":"MSKU0000080","owner":"MSK","category":"U",
   "serial":"000008","valid":true},
  {"input":"CSQU3054384","container":"CSQU3054384","owner":"CSQ","category":"U",
   "serial":"305438","valid":false,"expected_check_digit":3}]}

The free tier includes 10 calls per month with no card required — grab a key on the API page. For validating a CSV export in one go, see bulk-validating a CSV against the API.

FAQ

Why is A worth 10 in ISO 6346, and why are 11, 22 and 33 skipped?

Letters start at 10 so they never collide with the digits 0–9, and the values 11, 22 and 33 are skipped because they are multiples of 11, the checksum's modulus. A letter worth a multiple of 11 would contribute zero to the sum regardless of its position, making it invisible to the check — errors involving it could never be detected. So A=10, B=12, K=21, L=23, U=32 and so on up to Z=38.

What happens when the ISO 6346 remainder is 10?

The check digit is a single character, so a remainder of 10 is written as 0. That makes 0 ambiguous: it can mean a remainder of 0 or of 10. The standard advises owners to avoid issuing serials whose remainder is 10, but such numbers do exist on real containers, so a correct validator must compute (sum % 11) % 10 and accept a final 0 for either remainder.

Does a valid check digit mean the container actually exists?

No. A correct check digit only proves the number is internally consistent — it was not mistyped or misread. It does not prove the owner prefix is registered with the BIC (Bureau International des Containers) or that a box with that serial is in service. Checksum validation is a cheap first filter; confirming a container is real requires a registry or carrier lookup.

Check a container number in one click

Paste any ISO 6346 number into the container number validator to verify the check digit and see the owner code, category and serial parsed out instantly.

This guide is for general information only. Check digit validation confirms internal consistency of a container number, not that it is registered, assigned, or in service. Owner codes are administered by the BIC (Bureau International des Containers); consult the BIC register or the carrier for authoritative ownership data.