Table of Contents

Alphanumeric and encoded check digits

The check digits overview covers the decimal schemes and the financial identifiers. This page is the reference for the rest of the family: the schemes whose body is letters, symbols, or an encoded alphabet (Code 39, Crockford Base32, SEDOL, CUSIP, ISIN, ISO 7064), the ones whose check character is not a digit ('X', a Code 39 symbol, a Base32 check symbol), and the two-character ISO 7064 MOD 97-10 codes (IBAN, LEI). It also documents the two decimal newcomers the overview does not - Gumm and Iso7064Mod11_2 - explains the four-level CheckValueAlgorithm hierarchy and its alphabet enums, shows how to author a scheme, and closes with an error-class table measured by exhaustive sweep.

Important

Check digits detect transcription errors. They are not cryptographic, and anyone who can edit an identifier can recompute its check. For tamper detection use a MAC from Bodu.Security.Cryptography.

The four-level hierarchy

Level Type Adds Result surface
Root CheckValueAlgorithm AlgorithmName, CheckLength, Append(ReadOnlySpan<char>), Append(char), Reset() GetCurrentCheckValue() → string of CheckLength characters
Decimal, one digit CheckDigitAlgorithm - GetCurrentCheckDigit() → char
Wider alphabet, one character AlphanumericCheckDigitAlgorithm InputAlphabet, OutputAlphabet GetCurrentCheckDigit() → char
Multi-character code MultiCharCheckDigitAlgorithm InputAlphabet, abstract CheckLength GetCurrentCheckDigits(Span<char>) → count; GetCurrentCheckDigits() → string

Every concrete type also exposes static Compute(body) and IsValid(valueIncludingCheck). GetCurrentCheckValue() is sealed on the three branches and delegates to the branch's own accessor, so a CheckValueAlgorithm-typed variable can validate any identifier without knowing which branch it belongs to. Reading the current check is non-destructive and idempotent; instances are not thread-safe. None of these types derive from NonCryptographicHashAlgorithm - a check character is char-oriented error detection over a constrained alphabet, not a byte digest.

The alphabets

CheckDigitInputAlphabet says what Append accepts; CheckDigitOutputAlphabet says what a single-character scheme can emit. Multi-character schemes emit decimal digits only and do not declare an output alphabet.

Enum value Members Declared by
CheckDigitInputAlphabet.DecimalDigits 0-9 Isbn10, Iso7064Mod11_2
CheckDigitInputAlphabet.AlphanumericUppercase 0-9, A-Z Isin, Sedol, Cusip, Iso7064Mod97_10, Iban, Lei
CheckDigitInputAlphabet.Code39 0-9, A-Z, - . space $ / + % Code39Mod43
CheckDigitInputAlphabet.CrockfordBase32 the 32 Crockford symbols (no I, L, O, U); case-insensitive, I/L → 1, O → 0 Crockford32
CheckDigitOutputAlphabet.DecimalDigits 0-9 Isin, Sedol, Cusip
CheckDigitOutputAlphabet.DecimalDigitsOrX 0-9 plus X for the value ten Isbn10, Iso7064Mod11_2
CheckDigitOutputAlphabet.Code39 the full 43-symbol Code 39 alphabet Code39Mod43
CheckDigitOutputAlphabet.CrockfordBase32Check the 32 symbols plus * ~ $ = U for 32-36 Crockford32

The catalogue

Type Base Body Check Scheme
Code39Mod43 Alphanumeric Code 39 symbols one Code 39 symbol sum of symbol values mod 43
Crockford32 Alphanumeric Crockford Base32 one of 37 symbols encoded value mod 37 (Horner reduction - any length)
Iso7064Mod11_2 Alphanumeric decimal digits digit or X ISO 7064 MOD 11-2 (pure system, e.g. Chinese resident IDs, ORCID)
Isbn10 Alphanumeric 9 digits digit or X weighted mod 11
Sedol Alphanumeric 6 uppercase alphanumerics (no vowels) digit weights 1, 3, 1, 7, 3, 9 mod 10
Cusip Alphanumeric 8 uppercase alphanumerics (*, @, # accepted) digit Luhn-style over expanded values
Isin Alphanumeric 2-letter country + 9 alphanumerics digit Luhn over letters expanded to two digits
Iso7064Mod97_10 Multi-char uppercase alphanumerics two digits ISO 7064 MOD 97-10
Iban Multi-char country code + BBAN (CountryCodeLength = 2) two digits (CheckDigits = 2) MOD 97-10 with the country code rotated to the end
Lei Multi-char 18 alphanumerics (BodyLength = 18) two digits MOD 97-10 (SequenceLength = 20)
Gumm Decimal decimal digits digit dihedral group D₅ with an alternating transform (Gumm, 1985)

Gumm sits on the decimal branch beside Luhn, Damm, and Verhoeff; it appears here because the overview predates it. Iso7064Mod11_2 is on the alphanumeric branch only because its check may be X.

Pattern 1 - compute and validate

using Bodu.IO.Hashing.CheckDigits;

char code39  = Code39Mod43.Compute("CODE39");                // 'W'  → "CODE39W"
char b32     = Crockford32.Compute("16J");                   // 'D'  → "16JD"   (input is case-insensitive: "16j" also → 'D')
char gumm    = Gumm.Compute("236");                          // '9'  → "2369"
char mod11   = Iso7064Mod11_2.Compute("0794");               // '0'  → "07940"
char mod11x  = Iso7064Mod11_2.Compute("079");                // 'X'  - the check value ten
string mod97 = Iso7064Mod97_10.Compute("794");               // "44" → "79444"
char isbn    = Isbn10.Compute("030640615");                  // '2'
char sedol   = Sedol.Compute("B0YBKJ");                      // '7'
char cusip   = Cusip.Compute("03783310");                    // '0'  (Apple, 037833100)
char isin    = Isin.Compute("US037833100");                  // '5'  (US0378331005)
string lei   = Lei.Compute("5493001KJTIIGC8Y1R");            // "12"
string iban  = Iban.Compute("GBWEST12345698765432");         // "82" → GB82 WEST 1234 5698 7654 32

bool ok1 = Code39Mod43.IsValid("CODE39W");                   // true
bool ok2 = Crockford32.IsValid("16JD");                      // true
bool ok3 = Iban.IsValid("GB82WEST12345698765432");           // true
bool ok4 = Iban.IsValid("GB82WEST12345698765433");           // false - one digit off
bool ok5 = Isbn10.IsValid("080442957X");                     // true - 'X' check

Compute takes the body without its check; IsValid takes the full identifier. IBAN's Compute takes the body in its natural order (country code first) and returns the two digits that belong in positions 3-4; IsValid takes the whole IBAN.

Pattern 2 - one code path for every scheme

Because the root type unifies the streaming surface, a validator can hold a CheckValueAlgorithm and pattern-match only when it needs the branch-specific detail:

using Bodu.IO.Hashing.CheckDigits;

var checks = new (CheckValueAlgorithm Algorithm, string Body)[]
{
    (new Luhn(), "7992739871"), (new Code39Mod43(), "CODE39"), (new Crockford32(), "16J"),
    (new Iso7064Mod11_2(), "0794"), (new Iso7064Mod97_10(), "794"), (new Iban(), "GBWEST12345698765432"),
};

foreach (var (algorithm, body) in checks)
{
    algorithm.Reset();
    algorithm.Append(body);
    string check = algorithm.GetCurrentCheckValue();          // "3", "W", "D", "0", "44", "82"

    string alphabets = algorithm switch
    {
        AlphanumericCheckDigitAlgorithm a => $"in={a.InputAlphabet}, out={a.OutputAlphabet}",
        MultiCharCheckDigitAlgorithm m => $"in={m.InputAlphabet}, out=DecimalDigits×{m.CheckLength}",
        _ => "in=DecimalDigits, out=DecimalDigits",
    };
    Console.WriteLine($"{algorithm.AlgorithmName,-20} {body,-22} -> \"{check}\" ({alphabets})");
}

AlgorithmName values are stable identifiers such as "Code 39 Mod 43", "Crockford Base32", "ISO 7064 MOD 11-2", "ISO 7064 MOD 97-10", "IBAN", "LEI", "SEDOL", "CUSIP", "ISIN", "Gumm".

Pattern 3 - streaming a body in chunks

Append accumulates; GetCurrentCheckDigit / GetCurrentCheckDigits read without finalizing; Reset starts over. The multi-character branch writes into a caller span and returns the count, or allocates a string:

using Bodu.IO.Hashing.CheckDigits;

var iban = new Iban();
iban.Append("GB");                       // country code
iban.Append("WEST1234");                 // BBAN, in whatever chunks arrive
iban.Append("5698765432");

char[] check = new char[iban.CheckLength];
int written = iban.GetCurrentCheckDigits(check);          // "82", written == 2
string asString = iban.GetCurrentCheckDigits();            // "82"
string viaRoot = iban.GetCurrentCheckValue();              // "82"

var mod43 = new Code39Mod43();
mod43.Append("CODE");
mod43.Append('3');
mod43.Append('9');
char symbol = mod43.GetCurrentCheckDigit();                // 'W'
mod43.Reset();                                             // empty body → '0'

Pattern 4 - authoring a scheme

Derive from the branch that matches the check character you emit. Implement AlgorithmName, the alphabet properties, Append (validate and accumulate), the result accessor, and Reset; add static Compute / IsValid to match the built-in surface. Below is a weighted mod-10 scheme over an uppercase alphanumeric body (illustrative - it detects fewer errors than the ISO 7064 schemes above).

using Bodu.IO.Hashing.CheckDigits;

public sealed class Mod36WeightedCheck : AlphanumericCheckDigitAlgorithm
{
    private const string Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    private int _sum;
    private int _position;

    public override string AlgorithmName => "Mod 36 weighted";
    public override CheckDigitInputAlphabet InputAlphabet => CheckDigitInputAlphabet.AlphanumericUppercase;
    public override CheckDigitOutputAlphabet OutputAlphabet => CheckDigitOutputAlphabet.DecimalDigits;

    public static char Compute(ReadOnlySpan<char> body)
    {
        var algorithm = new Mod36WeightedCheck();
        algorithm.Append(body);
        return algorithm.GetCurrentCheckDigit();
    }

    public static bool IsValid(ReadOnlySpan<char> valueIncludingCheck) =>
        valueIncludingCheck.Length >= 2 && Compute(valueIncludingCheck[..^1]) == valueIncludingCheck[^1];

    public override void Append(ReadOnlySpan<char> body)
    {
        foreach (char ch in body)
        {
            int value = Alphabet.IndexOf(char.ToUpperInvariant(ch));
            if (value < 0) throw new ArgumentException($"'{ch}' is outside the uppercase alphanumeric alphabet.", nameof(body));
            _sum += value * (_position % 2 == 0 ? 3 : 1);      // alternate weights 3,1,3,1,…
            _position++;
        }
    }

    public override char GetCurrentCheckDigit() => (char)('0' + ((10 - (_sum % 10)) % 10));

    public override void Reset() { _sum = 0; _position = 0; }
}
using Bodu.IO.Hashing.CheckDigits;

char check = Mod36WeightedCheck.Compute("SKU7A");            // '9'
bool valid = Mod36WeightedCheck.IsValid("SKU7A9");           // true
bool typo  = Mod36WeightedCheck.IsValid("SKU7B9");           // false

AlphanumericCheckDigitAlgorithm viaBase = new Mod36WeightedCheck();
viaBase.Append("SKU7A");
string value = viaBase.GetCurrentCheckValue();               // "9"

The repository's Bodu.IO.Hashing.Samples.CustomCheckDigit sample and its .Test companion derive the library's CheckDigitContractTests<TAlgorithm> (the decimal branch); the multi-character branch has MultiCharCheckDigitContractTests<TAlgorithm> in the same test project, and the alphanumeric branch currently has no dedicated contract base - pin your scheme with known-answer rows as the built-in tests do. See the runnable samples.

Error classes - measured

The source documents coverage for the decimal schemes: Luhn misses the 09 ↔ 90 adjacent transposition and twin errors; Damm, Verhoeff, and Gumm detect every adjacent transposition; Code 39's commutative sum cannot see any transposition. To put every scheme on one footing, the table below was produced by exhaustive sweep with the library - every ordered pair ab (a ≠ b) for adjacent transposition, and every aab versus ccb (c ≠ a) for twin errors - over each scheme's own alphabet:

Scheme Body swept Adjacent transpositions missed Twin errors missed
Luhn 2 / 3 decimal digits (90 / 900 cases) 2 (09 ↔ 90) 60
Damm same 0 80
Verhoeff same 0 40
Gumm same 0 400
Iso7064Mod11_2 same 0 0
Crockford32 2 / 3 Base32 symbols (992 / 31 744 cases) 0 0
Code39Mod43 2 / 3 Code 39 symbols (1 806 / 77 658 cases) all 1 806 0
Sedol (last two body positions) 2 / 3 SEDOL symbols 164 5 084
Iso7064Mod97_10 2 / 3 uppercase alphanumerics (1 260 / 45 360 cases) 6 216

Read the table with its definition in mind. "Twin" here is the leading pair of a three-character body changed to another repeated pair, which is one position of one error class; Verhoeff's published figure (all adjacent transpositions, most twins) matches the sweep, and Gumm - which guarantees the same two classes as Verhoeff but nothing beyond them - misses 400 of the 900, the weakest twin coverage of the four decimal schemes. The ISO 7064 schemes catch everything at the digit level (MOD 11-2 is perfect on decimal bodies); MOD 97-10's handful of misses come from character swaps that expand letters into two digits, where a swap is no longer a digit transposition. Code 39's check is a plain sum - it is a substitution detector only. Crockford's check, being a modular reduction of the encoded value, is equivalent to a positional weighting and detects both classes in this sweep.

using Bodu.IO.Hashing.CheckDigits;

// The classic Luhn blind spot beside the schemes that close it.
bool luhn = Luhn.Compute("1209") != Luhn.Compute("1290");          // false - undetected
bool damm = Damm.Compute("1209") != Damm.Compute("1290");          // true
bool gumm = Gumm.Compute("1209") != Gumm.Compute("1290");          // true
bool mod43 = Code39Mod43.Compute("AB") != Code39Mod43.Compute("BA"); // false - a sum cannot see order

API summary

Type Static Instance
CheckValueAlgorithm - AlgorithmName, CheckLength, Append(ReadOnlySpan<char>), Append(char), GetCurrentCheckValue(), Reset()
AlphanumericCheckDigitAlgorithm - InputAlphabet, OutputAlphabet, GetCurrentCheckDigit()
MultiCharCheckDigitAlgorithm - InputAlphabet, CheckLength, GetCurrentCheckDigits(Span<char>), GetCurrentCheckDigits()
Code39Mod43, Crockford32, Iso7064Mod11_2, Isbn10, Sedol, Cusip, Isin, Gumm Compute(body) → char, IsValid(value) as the branch above
Iso7064Mod97_10, Iban, Lei Compute(body) → string, IsValid(value); CheckDigits = 2 as the multi-character branch; Iban.CountryCodeLength, Lei.BodyLength / SequenceLength
CheckDigitInputAlphabet / CheckDigitOutputAlphabet - the alphabet enums above

Where to go next