Originally published at doc.cheap/blog/how-mrz-check-digits-work.
Every passport has two lines of angry-looking text at the bottom of the photo page: capital letters, digits and a lot of < signs. That is the machine-readable zone, or MRZ, and it is defined by ICAO Doc 9303, the standard for travel documents. It holds the same core facts as the printed page (name, document number, nationality, date of birth, sex, expiry date), in a form a scanner can read without guessing at fonts or layouts.
It also carries its own error detection. A few of the characters are check digits: each one is computed from a specific field, and one final digit covers several fields at once. If a single character is misread, the digit that protects it usually stops matching. That makes the MRZ one of the few things in identity-document processing you can verify yourself, with twenty lines of code and no trust in anybody's OCR.
This post walks through the algorithm, where the digits sit in each of the three MRZ formats, and a validator in Python and JavaScript that you can paste into a project. Every example uses ICAO's own fictitious specimen, Anna Maria Eriksson of "Utopia" (UTO, a country code that exists only in specimens). No real document appears anywhere.
This is the blog of doc.cheap, a document-recognition API that reads the MRZ and re-checks these digits on the server. Nothing below needs it; the code runs offline.
The alphabet
An MRZ uses exactly 37 characters: 0-9, A-Z and the filler <. There are no lower-case letters, no spaces and no punctuation. Names with accents or non-Latin scripts are transliterated, and spaces inside a field become <. The filler also pads every field to its fixed width, so ERIKSSON<<ANNA<MARIA<<<<<<< is "surname ERIKSSON, given names ANNA MARIA", with the double << separating surname from given names.
The algorithm: weights 7, 3, 1
A check digit is computed the same way for every field of every format:
-
Turn each character into a number. A digit is its own value. A letter is its position in the alphabet plus 9, so
A= 10,B= 11, …Z= 35. The filler<is 0. - Multiply by a repeating weight of 7, 3, 1, 7, 3, 1, … starting from the first character of the field.
- Add the products and take the remainder modulo 10. That single digit is the check digit.
Worked through on the specimen passport number, L898902C3, whose printed check digit is 6:
character L 8 9 8 9 0 2 C 3
value 21 8 9 8 9 0 2 12 3
weight 7 3 1 7 3 1 7 3 1
product 147 24 9 56 27 0 14 36 3
sum = 316 316 mod 10 = 6 the zone prints 6
Why 7-3-1? The weights are chosen so that the most common reading errors change the sum: a single wrong character, and many swaps of two adjacent characters. It is not a cryptographic checksum. Anyone can compute it, so a matching digit proves only that the zone is internally consistent, not that the document is genuine.
The three formats
ICAO 9303 defines three MRZ layouts. The number of lines and the characters per line tell them apart:
| Format | Lines × characters | Where you meet it |
|---|---|---|
| TD1 | 3 × 30 | ID cards, residence permits |
| TD2 | 2 × 36 | Older ID cards and some travel documents |
| TD3 | 2 × 44 | Passport booklets |
The specimens used below:
TD3 P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<10
TD2 I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
D231458907UTO7408122F1204159<<<<<<<6
TD1 I<UTOD231458907<<<<<<<<<<<<<<<
7408122F1204159UTO<<<<<<<<<<<6
ERIKSSON<<ANNA<MARIA<<<<<<<<<<
Read the TD3 second line left to right: L898902C3 document number, 6 its check digit, UTO nationality, 740812 date of birth (YYMMDD), 2 its check digit, F sex, 120415 expiry date, 9 its check digit, ZE184226B<<<<< optional data (often a personal number), 1 its check digit, and finally 0, the composite check digit.
The MRZ parser has the position of every field and check digit in all three as a reference table, and the MRZ formats page walks through each layout.
Where each check digit sits
Positions are 0-based, so they drop straight into slice. Each field's check digit sits immediately after the field.
| Field | TD3 (line 2) | TD2 (line 2) | TD1 |
|---|---|---|---|
| Document number | 0–8, digit at 9 | 0–8, digit at 9 | line 1: 5–13, digit at 14 |
| Date of birth | 13–18, digit at 19 | 13–18, digit at 19 | line 2: 0–5, digit at 6 |
| Expiry date | 21–26, digit at 27 | 21–26, digit at 27 | line 2: 8–13, digit at 14 |
| Optional data | 28–41, digit at 42 | none | none |
| Composite | digit at 43 | digit at 35 | line 2: digit at 29 |
The composite digit is where most home-made validators go wrong, because it does not cover the whole line:
- TD3: positions 0–9, 13–19 and 21–42 of line 2. It skips the nationality (10–12) and the sex (20).
- TD2: positions 0–9, 13–19 and 21–34 of line 2. Same skips.
- TD1: it spans two lines: line 1 positions 5–29, then line 2 positions 0–6, 8–14 and 18–28.
Each range includes the per-field check digits inside it, which is what makes the composite catch errors in the digits themselves.
A validator in Python
No dependencies. It detects the format from the shape, checks every field digit and the composite, and returns a dict of results.
WEIGHTS = (7, 3, 1)
def char_value(c):
if c.isdigit():
return int(c)
if "A" <= c <= "Z":
return ord(c) - ord("A") + 10
if c == "<":
return 0
raise ValueError(f"not an MRZ character: {c!r}")
def check_digit(data):
return sum(char_value(c) * WEIGHTS[i % 3] for i, c in enumerate(data)) % 10
def digit_ok(data, printed):
# A field made only of fillers may print "<" as its check digit.
expected = 0 if printed == "<" else int(printed)
return check_digit(data) == expected
# (name, line index, start, end, check-digit position) per format
LAYOUTS = {
"TD3": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
("expiry date", 1, 21, 27, 27), ("personal number", 1, 28, 42, 42)],
"TD2": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
("expiry date", 1, 21, 27, 27)],
"TD1": [("document number", 0, 5, 14, 14), ("birth date", 1, 0, 6, 6),
("expiry date", 1, 8, 14, 14)],
}
def composite(fmt, lines):
if fmt == "TD3":
l = lines[1]
return l[0:10] + l[13:20] + l[21:43], l[43]
if fmt == "TD2":
l = lines[1]
return l[0:10] + l[13:20] + l[21:35], l[35]
a, b = lines[0], lines[1]
return a[5:30] + b[0:7] + b[8:15] + b[18:29], b[29]
def detect(lines):
shape = (len(lines), len(lines[0]))
fmt = {(2, 44): "TD3", (2, 36): "TD2", (3, 30): "TD1"}.get(shape)
if fmt is None or any(len(l) != shape[1] for l in lines):
raise ValueError(f"unknown MRZ shape: {[len(l) for l in lines]}")
return fmt
def validate(lines):
fmt = detect(lines)
results = {}
for name, li, start, end, pos in LAYOUTS[fmt]:
results[name] = digit_ok(lines[li][start:end], lines[li][pos])
data, printed = composite(fmt, lines)
results["composite"] = digit_ok(data, printed)
return fmt, results
if __name__ == "__main__":
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C36UTO7408122F1204159ZE184226B<<<<<10"]))
print(*validate(["I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<",
"D231458907UTO7408122F1204159<<<<<<<6"]))
print(*validate(["I<UTOD231458907<<<<<<<<<<<<<<<",
"7408122F1204159UTO<<<<<<<<<<<6",
"ERIKSSON<<ANNA<MARIA<<<<<<<<<<"]))
# One misread character: 3 read as 4 in the document number
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))
Output:
TD3 {'document number': True, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': True}
TD2 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD1 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD3 {'document number': False, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': False}
The last line is the point of the whole exercise: one character misread as a neighbour, and both the field digit and the composite flag it.
The same validator in JavaScript
Plain ES module, runs in Node or a browser.
const WEIGHTS = [7, 3, 1];
function charValue(c) {
if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48;
if (c >= "A" && c <= "Z") return c.charCodeAt(0) - 55; // A = 10
if (c === "<") return 0;
throw new Error(`not an MRZ character: ${JSON.stringify(c)}`);
}
export function checkDigit(data) {
let sum = 0;
for (let i = 0; i < data.length; i++) sum += charValue(data[i]) * WEIGHTS[i % 3];
return sum % 10;
}
const digitOk = (data, printed) => checkDigit(data) === (printed === "<" ? 0 : Number(printed));
const LAYOUTS = {
TD3: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27], ["personal number", 1, 28, 42]],
TD2: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27]],
TD1: [["document number", 0, 5, 14], ["birth date", 1, 0, 6], ["expiry date", 1, 8, 14]],
};
function composite(fmt, [a, b]) {
if (fmt === "TD3") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 43), b[43]];
if (fmt === "TD2") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 35), b[35]];
return [a.slice(5, 30) + b.slice(0, 7) + b.slice(8, 15) + b.slice(18, 29), b[29]];
}
export function validate(lines) {
const fmt = { "2x44": "TD3", "2x36": "TD2", "3x30": "TD1" }[`${lines.length}x${lines[0].length}`];
if (!fmt || lines.some((l) => l.length !== lines[0].length)) throw new Error("unknown MRZ shape");
const results = {};
// The check digit sits right after the field it protects.
for (const [name, li, start, end] of LAYOUTS[fmt]) {
results[name] = digitOk(lines[li].slice(start, end), lines[li][end]);
}
const [data, printed] = composite(fmt, lines);
results.composite = digitOk(data, printed);
return { format: fmt, results };
}
console.log(validate([
"P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C36UTO7408122F1204159ZE184226B<<<<<10",
]));
node mrz.mjs prints format: 'TD3' and true for all five checks.
The traps
Do not trim the fillers. The < characters are part of the data the digits are computed over. Strip trailing < from a line and the composite fails on a perfectly good document.
Do not rebuild the zone from parsed fields. If you parse the MRZ into fields, normalise them (dates to ISO, names with spaces) and then re-serialise to check the digits, you are checking your own serialiser. Check the raw lines as they were read.
Normalise OCR output before you validate, carefully. OCR engines like to return lower-case letters, spaces, or « for <. Upper-casing and stripping whitespace is safe. Replacing O with 0 "because document numbers are numeric" is not: document numbers can contain letters, which is exactly what L898902C3 shows.
A passing digit is not a real date. 740812 passes its check digit whether or not the 12th of August 1974 is plausible, and YYMMDD has no century. Decide the century from context: a birth date is in the past, an expiry date usually in the future.
Long document numbers on TD1. ICAO lets a TD1 document number longer than nine characters overflow into the optional-data field, with a < in the normal check-digit position and the check digit after the last character of the number. The validator above does not handle that case. If you process ID cards from issuers that use it, add a branch; the MRZ parser handles it, if you want something to compare against.
Check digits are not authenticity. Anyone who can edit an image can compute a valid digit. The MRZ tells you the zone was read correctly and is internally consistent, not that the document is genuine. Comparing the MRZ against the printed visual zone is a stronger signal, and even that is not a forgery check.
Test data without real passports
You should never need a real person's passport to test this code. Two options:
- The ICAO specimens above, which are published for exactly this purpose.
- Generate your own: the MRZ generator builds a synthetic TD3 zone with correct check digits from values you type, in the browser. Change one character afterwards and you have a failing case.
For the opposite direction, paste any zone (TD1, TD2 or TD3) into the MRZ parser: it detects the format, reads every field and shows each computed check digit beside the printed one, all in the browser. That is handy when your implementation and someone else's disagree.
Where this fits in a real pipeline
If you read MRZs with your own OCR, run these checks on every read and treat a failure as "re-photograph", not "reject the person": glare over one character, a worn laminate or a creased page are far more common than fraud.
If you use a hosted recognition API instead, re-run the digits yourself anyway when the result decides money or access. It is the one part of the answer you can check without trusting the vendor. That includes us: the doc.cheap response publishes the zone verbatim as mrz.lines and mrz.text (the lines joined with nothing between them) next to its own mrz.status verdict, precisely so you can feed it to a function like the one above. Check an MRZ in the docs covers that flow.
If you spot a case the validator gets wrong, write to admin@doc.cheap.
Written with the help of AI. Both code blocks were run and their output is pasted as printed; every statement about doc.cheap was checked against its code.
Top comments (0)