ICAO 9303 Check Digits: How They're Calculated (with Code)
The 7-3-1 MRZ check digit algorithm explained step by step, with the full arithmetic on a real passport, the blind spot nobody mentions, and implementations in JavaScript and Python.
An OCR that reads a passport and hands you the data is asking you to trust it. One that validates the MRZ check digits hands you mathematical proof that the reading is self-consistent.
That's the difference between "we think the number is G12345678" and "the number is G12345678, and here's the arithmetic that confirms it." This article covers the whole algorithm, with the numbers on the page.
What a check digit is for
A check digit is an extra digit computed from the others. Recompute it; if it doesn't match what's printed, something was read wrong.
It's the same principle as the check digit on a bank card or an ISBN. What's remarkable on a passport is the density: line 2 carries four check digits in 44 characters, covering every critical field and then all of them together again.
This matters because classic OCR errors are single-character substitutions between lookalike shapes: 0/O, 1/I, 5/S, 8/B, 2/Z.
It's worth being precise about what the scheme guarantees, because it's usually overstated. A substitution goes undetected only if the two characters' values differ by an exact multiple of 10 — and that condition can be checked pair by pair. The pairs a real OCR confuses don't meet it:
| Confusion | Values | Difference | Detected? |
|---|---|---|---|
0 / O | 0 / 24 | 24 | Yes |
1 / I | 1 / 18 | 17 | Yes |
5 / S | 5 / 28 | 23 | Yes |
8 / B | 8 / 11 | 3 | Yes |
2 / Z | 2 / 35 | 33 | Yes |
G / 6 | 16 / 6 | 10 | No |
So every common typographic confusion is covered. The blind spot is pairs differing by exactly 10, 20 or 30 — A/0, G/6, K/A, U/K — and it's a genuine blind spot, not one another digit rescues. More on that below, because it's worth knowing about.
The algorithm, in three rules
Rule 1 — Every character has a value.
| Character | Value |
|---|---|
0-9 | Its own value, 0 to 9 |
A-Z | 10 to 35 (A=10, B=11, … Z=35) |
< | 0 |
Rule 2 — Weights repeat 7, 3, 1.
First character gets 7, second 3, third 1, fourth 7 again, and so on.
Rule 3 — The digit is the sum modulo 10.
Multiply each value by its weight, add it all up, take the remainder on division by 10.
The arithmetic, on a real passport
Take passport number G12345678 from our example MRZ, whose printed check digit is 6:
| Character | G | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| Value | 16 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| Weight | 7 | 3 | 1 | 7 | 3 | 1 | 7 | 3 | 1 |
| Product | 112 | 3 | 2 | 21 | 12 | 5 | 42 | 21 | 8 |
Sum: 112 + 3 + 2 + 21 + 12 + 5 + 42 + 21 + 8 = 226
226 mod 10 = 6 ✓ Matches the printed digit.
Now suppose the OCR read 5 where an 8 stood, in the last position (weight 1). The sum drops by 3 to 223, and the computed digit becomes 3 instead of 6. The error surfaces immediately.
The blind spot, explained
Substitute the leading G with a 6 instead. G is 16, 6 is 6: the sum falls by 10 × 7 = 70, down to 156 — and 156 mod 10 is still 6. The digit matches and the error slips through.
That isn't bad luck, it's arithmetic: a value change of Δ at a character with weight w shifts the sum by Δ·w, and goes undetected when Δ·w ≡ 0 (mod 10). Since 7, 3 and 1 are all coprime with 10, the condition reduces to Δ ≡ 0 (mod 10), regardless of position or weight.
Which leads to something worth knowing and usually told wrong: the composite digit does not rescue these cases. If a substitution is invisible to its own field's check digit, it's invisible to the composite too, because the condition doesn't depend on the weight. An A read as 0 passes both.
What the composite genuinely adds is real, but different — see below.
The other three digits
The same computation applies to:
- Date of birth (
800705→ digit0): 56 + 0 + 0 + 49 + 0 + 5 = 110 → 0 ✓ - Date of expiry (
330705→ digit4): 21 + 9 + 0 + 49 + 0 + 5 = 84 → 4 ✓ - Personal number (14 characters, all
<here → digit0): sum 0 → 0 ✓
The composite digit
The fourth is different. It's computed over the concatenation of the already-verified fields, including their own check digits:
- Positions 1-10 (passport number + its digit)
- Positions 14-20 (date of birth + its digit)
- Positions 22-43 (expiry + its digit + personal number + its digit)
In our example that string is:
G1234567868007050 3307054<<<<<<<<<<<<<<0
(without the space, which is only here for readability)
Applying 7-3-1 across those 39 characters gives 398, and 398 mod 10 = 8 — exactly the last character of line 2. ✓
What no digit covers
It's worth reading the map for what isn't there. The composite spans positions 1-10, 14-20 and 22-43. Left out:
- Nationality (positions 11-13).
- Sex (position 21).
- All of line 1 — document type, issuing country and, above all, the name.
Put plainly: the passport number and the dates arrive with arithmetic proof, but the holder's name is the least-verified field in the MRZ. It has no check digit of any kind.
That's the practical reason a serious extraction doesn't stop at the MRZ: it cross-checks the line 1 name against the one printed in the visual zone. When they agree, the name is backed by a second independent reading. When they don't, there's something to look at.
Implementation
The whole algorithm in JavaScript:
function mrzCheckDigit(input) {
const weights = [7, 3, 1]
let total = 0
for (let i = 0; i < input.length; i++) {
const c = input[i]
let value
if (c === '<') value = 0
else if (c >= '0' && c <= '9') value = c.charCodeAt(0) - 48
else if (c >= 'A' && c <= 'Z') value = c.charCodeAt(0) - 55
else throw new Error(`Invalid MRZ character: ${c}`)
total += value * weights[i % 3]
}
return total % 10
}
mrzCheckDigit('G12345678') // 6
mrzCheckDigit('800705') // 0
In Python:
def mrz_check_digit(value: str) -> int:
weights = (7, 3, 1)
total = 0
for i, c in enumerate(value):
if c == '<':
v = 0
elif c.isdigit():
v = int(c)
elif 'A' <= c <= 'Z':
v = ord(c) - 55
else:
raise ValueError(f'Invalid MRZ character: {c}')
total += v * weights[i % 3]
return total % 10
mrz_check_digit('G12345678') # 6
And validating a full line 2:
function validateLine2(line) {
if (line.length !== 44) return { valid: false, error: 'wrong length' }
const checks = [
{ name: 'passport number', data: line.slice(0, 9), digit: line[9] },
{ name: 'date of birth', data: line.slice(13, 19), digit: line[19] },
{ name: 'date of expiry', data: line.slice(21, 27), digit: line[27] },
{ name: 'personal number', data: line.slice(28, 42), digit: line[42] },
{
name: 'composite',
data: line.slice(0, 10) + line.slice(13, 20) + line.slice(21, 43),
digit: line[43],
},
]
const failed = checks.filter(
(c) => mrzCheckDigit(c.data) !== Number(c.digit)
)
return { valid: failed.length === 0, failed: failed.map((c) => c.name) }
}
A detail of the standard that usually gets skipped
ICAO 9303 allows < to stand in for 0 in a check digit position when the whole field is filler. In practice this shows up mostly in the personal number digit, for countries that don't use that field.
If your validator demands a strict digit there, you'll reject perfectly valid passports. But don't extend that tolerance to the composite digit at position 44: a < there almost always means the transcription lost its last character, not that the issuer chose filler.
What to do when a digit doesn't match
A failing digit doesn't mean the passport is fake. In order of frequency:
- OCR error — the usual cause. The fix is to re-read, not to reject.
- Manual transcription with one character changed.
- A crop that cut off part of the line.
- A genuinely altered document — rare, but the case that justifies validating at all.
A well-built extraction engine treats the failure as a retry signal: if a digit doesn't verify, re-read the zone with a different strategy before responding. That's exactly what we do, and when even that can't produce a reading that verifies, we'd rather return an error and refund the token than hand you data we can't stand behind.
Check it yourself
Our MRZ validator is free, needs no account, and runs this same algorithm: paste an MRZ and you'll see all four check digits recomputed one by one, with the failing one flagged.
If you need the position map first, to know which span feeds which digit, it's in How to read the MRZ line by line. And if you'd rather skip all of this and get validated fields from a photo, the API does it in one request.
Need to extract passport data automatically?
Try our API with 20 free extractions. Integrate in minutes, get results in seconds.
Start for free