Skip to content

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.

Doc 9303 is published by the International Civil Aviation Organization and is amended: Part 3 has carried amendments dated 2022 and 2024. The layout below reflects the consolidated eighth edition, and the authority for any detail is Doc 9303 Part 3, published by ICAO for the parts common to all travel documents and Part 4 for the passport-specific TD3 layout. Every example on this page is synthetic and uses the fictitious issuing state code UTO.

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-43

Note 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:

  1. Map each character to a value. Digits 0 to 9 map to 0 to 9; letters A to Z map to 10 to 35; the filler < maps to 0.
  2. Multiply each value by a weight, cycling 7, 3, 1 from the left of the substring.
  3. 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 as K, «, or a run of angle brackets collapsed to one. Because filler maps to 0, a < read as K changes 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. YYMMDD gives you 74 and 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 MUELLER against a printed Mü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

  1. 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.
  2. 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.
  3. Assert the length of each line. If it is not 44, do not parse; route the image for a rescan.
  4. Slice the fields by position and run all five check digits. Keep the five results separately.
  5. On a single-field failure, retry that field’s substring with a confusable substitution pass — O to 0, I and l to 1, S to 5, B to 8, K to < — 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.
  6. 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.