Extracting a Passport's Machine-Readable Zone Into Structured Fields
10 min read · updated August 11, 2026
Almost every document in this cluster is extracted and then hoped about. A machine-readable zone is not. It is fixed-width, its character set has 37 members, and four of its characters are check digits over defined substrings — so a bank or a border-adjacent business that lawfully holds a passport scan can prove an extraction correct with arithmetic instead of putting it in a review queue.
Why this one is different
The MRZ exists because it was designed to be read by a machine at speed under bad conditions. ICAO Doc 9303 specifies the typeface (OCR-B), the character set (the 26 Latin capitals, the ten digits, and the filler <), the number of lines and their length, the position of every field, and the arithmetic that validates four of them. Three document sizes are defined: TD1, three lines of 30 characters, used for most ID-card-sized documents; TD2, two lines of 36; and TD3, two lines of 44, which is the passport data page.
The consequence for a pipeline is that model output is a hypothesis and the check digits are the test. You are not choosing a confidence threshold; you are computing a value that either matches a printed character or does not. That moves the whole problem out of the territory covered by extraction confidence and into the ordinary validation of a checksum-validated identifier field, which is a much better place to be.
The TD3 layout, position by position
Two lines, 44 characters each, with no exceptions:
P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<10
Line 1
1 document code: P for passport
2 type at the discretion of the issuing state, or filler
3-5 issuing state or organisation (three letters)
6-44 name: primary identifier, then "<<", then secondary
identifiers separated by "<", padded to 44 with "<"
Line 2
1-9 document number
10 check digit over positions 1-9
11-13 nationality (three letters)
14-19 date of birth, YYMMDD
20 check digit over positions 14-19
21 sex: M, F, or "<"
22-27 date of expiry, YYMMDD
28 check digit over positions 22-27
29-42 optional personal data
43 check digit over positions 29-42
44 composite check digit over 1-10, 14-20 and 22-43Note what the composite digit does and does not cover. It spans the document number and its check digit, the birth date and its check digit, and everything from the expiry date through the optional data check digit. It skips positions 11 to 13 and position 21 — nationality and sex are not protected by any check digit at all. That is not an oversight to work around; it is the specification, and it means those two fields need a different validation strategy from the rest.
The check digit, worked
The rule is the same everywhere in Doc 9303:
- Map each character to a value. Digits
0to9map to 0 to 9; lettersAtoZmap to 10 to 35; the filler<maps to 0. - Multiply each value by a weight, cycling 7, 3, 1 from the left of the substring.
- Sum the products and take the result modulo 10.
Take the document number from the specimen above, positions 1 to 9 of line 2, which is L898902C3:
pos char value weight product running 1 L 21 7 147 147 2 8 8 3 24 171 3 9 9 1 9 180 4 8 8 7 56 236 5 9 9 3 27 263 6 0 0 1 0 263 7 2 2 7 14 277 8 C 12 3 36 313 9 3 3 1 3 316 316 mod 10 = 6 printed check digit at position 10: 6 OK
The other three fall out the same way. Date of birth 740812 gives 2, matching position 20. Expiry 120415 gives 9, matching position 28. The optional data field ZE184226B<<<<< gives 1, matching position 43. And the composite is the concatenation of positions 1–10, 14–20 and 22–43 — a 39-character string beginning L898902C36 — which gives 0, matching position 44.
const value = (c) =>
c === "<" ? 0 : c >= "0" && c <= "9" ? c.charCodeAt(0) - 48 : c.charCodeAt(0) - 55;
const W = [7, 3, 1];
const checkDigit = (s) =>
[...s].reduce((acc, c, i) => acc + value(c) * W[i % 3], 0) % 10;
function validateTd3(l2) {
if (l2.length !== 44) return { ok: false, reason: "line length" };
return {
documentNumber: checkDigit(l2.slice(0, 9)) === +l2[9],
birthDate: checkDigit(l2.slice(13, 19)) === +l2[19],
expiryDate: checkDigit(l2.slice(21, 27)) === +l2[27],
optionalData: checkDigit(l2.slice(28, 42)) === +l2[42],
composite:
checkDigit(l2.slice(0, 10) + l2.slice(13, 20) + l2.slice(21, 43)) === +l2[43],
};
}The pattern of failures is diagnostic in a way a single boolean is not. If exactly one field digit fails and the composite also fails, the error is inside that field. If every field passes but the composite fails, the error is in nationality or sex — the two the composite skips — or in one of the check digits themselves. If everything fails, the line was misaligned and you are checking the wrong substrings. Return the five booleans, not their conjunction.
What actually goes wrong
- The filler is misread.
<in OCR-B is frequently returned asK,«, or a run of angle brackets collapsed to one. Because filler maps to 0, a<read asKchanges a term by 20 and the check digit catches it — but a dropped filler shortens the line, shifts every subsequent field, and fails everything at once. - The line is not 44 characters. Check the length before anything else. It is the single most informative assertion in the whole routine and it costs nothing.
- The two-digit year has no century.
YYMMDDgives you74and nothing else. Resolve a birth date with an explicit window rule — a date that would place the holder in the future belongs to the previous century — and record which rule you applied. An expiry date is bounded differently: passports are issued for a limited validity, so an expiry more than a decade forward is more likely a misread than a real date. - The MRZ name is not the full name. Doc 9303 defines truncation for names too long for the field, and transliteration of national characters into the 26 Latin capitals. So an MRZ reading
MUELLERagainst a printedMüller, or a shortened list of given names, is the specification working correctly. The visual inspection zone is authoritative for the full name; the MRZ is authoritative for the machine-readable form. A validator that requires the two to match string-for-string will reject valid documents. - A long document number overflows. Doc 9303 defines a convention for document numbers longer than the nine-character field, which moves the overflow into the optional data area and changes where the real check digit sits. If your issuing states include any that use long numbers, read Part 4 on this specifically rather than assuming the simple layout.
- The zone is cropped or skewed. A photograph of a data page taken by a customer on a phone routinely loses the last characters of a line to the page edge. Detect this from the length check and ask for a rescan rather than parsing a truncated line.
One thing this page deliberately does not cover: none of the above says anything about whether the document is genuine. Check digits confirm that the characters you read are internally consistent, which is a transcription test, not an authenticity test. Authenticity of an electronic travel document rests on the cryptographic data in its chip and on the issuing authority, and it is not something to infer from a scan.
The pipeline
- Locate the zone before reading it. It is the bottom band of the data page, two lines of monospaced OCR-B. Cropping to it and rescaling improves recognition far more than any prompt change, because the model is then not choosing between the MRZ and the printed fields above it.
- Read the two lines as raw character strings. Ask for the characters, not for fields — a model asked directly for “the passport number” will return a plausible one and you lose the ability to check it.
- Assert the length of each line. If it is not 44, do not parse; route the image for a rescan.
- Slice the fields by position and run all five check digits. Keep the five results separately.
- On a single-field failure, retry that field’s substring with a confusable substitution pass —
Oto0,Iandlto1,Sto5,Bto8,Kto<— and accept a candidate only if exactly one substitution makes the check digit pass. More than one candidate means you cannot decide, and guessing is worse than routing to a human. - Cross-check the MRZ against the printed fields for date of birth, expiry and document number only. Expect the name to differ legitimately, and treat a mismatch on the three dates or the number as the strongest signal in the whole document that something needs a person to look at it.
Step 6 is the closest thing to a free accuracy gain available on a passport. The same facts are printed twice on the data page in two different typefaces, read by two different processes, and agreement between them is far stronger evidence than either alone. Every other document in this cluster would love to have that property and does not.