The name is the least predictable field in a machine-readable zone. The dates are six digits, the document number is nine characters with a check digit, and the codes come from a fixed list. The name is whatever a person is called, squeezed into a fixed number of capital letters, with no spaces, no accents, no punctuation and no check digit to tell you when a character was misread.
This post covers how the zone writes a name, how to split it back into surname and given names, what transliteration does to it, how to tell that it may have been cut short, and how to compare it with the name printed on the page. Every name below is invented or is ICAO's own specimen, Anna Maria Eriksson; no real person appears.
This is the blog of doc.cheap, a Passport and ID OCR API. Nothing below needs it; the code has no dependencies.
Where the name sits
ICAO Doc 9303, the standard for machine-readable travel documents, gives the name a field of fixed width in each of its three layouts. Positions are 1-based here, the way the standard counts them:
| Format | Where the name is | Width |
|---|---|---|
| TD3 (passports), 2 × 44 | line 1, positions 6–44 | 39 characters |
| TD2, 2 × 36 | line 1, positions 6–36 | 31 characters |
| TD1 (ID cards), 3 × 30 | line 3, the whole line | 30 characters |
The first five characters of a TD3 or TD2 line 1 are the document code and the issuing state; the name takes everything after them. TD1 gives the name a line of its own, and it is still the narrowest of the three. The MRZ formats page walks through each layout field by field.
On the TD3 specimen, line 1 is P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<. Drop P<UTO and the name field is ERIKSSON<<ANNA<MARIA followed by fillers up to 39 characters.
The grammar: <<, < and the padding
The zone's alphabet is A–Z, 0–9 and the filler <. A name uses three conventions on top of it:
- The primary identifier comes first. That is usually the surname.
- Two fillers,
<<, end the primary identifier. What follows is the secondary identifier, usually the given names. - One filler,
<, separates words inside either part.ANNA<MARIAis two given names;VAN<DER<BERGis a three-word surname.
Whatever is left of the field after the name is filled with <. So reading a name back is three steps: strip the trailing fillers, split at the first <<, and turn the remaining single < into spaces.
Two shapes look odd and are not errors. A person with one name has only a primary identifier, so the field is the name and then fillers, with no << before them: OKONKWO<<<<…. A field written with the separator and nothing after it, NAKAMURA<<<<…, reads exactly the same once the trailing fillers are stripped. Either way the secondary identifier is empty, not missing.
What does not survive is punctuation and the space itself. A hyphen, an apostrophe or a space cannot be written in the zone, so a printed Smith-Jones arrives as SMITH<JONES, indistinguishable from a two-word surname. If you need the punctuation, it is on the printed page, not in the zone.
Transliteration: from any script to 26 letters
Every name has to end up in A–Z. For a name printed in Latin letters with diacritics, that means mapping each accented letter to plain ones. For a name in Greek, Arabic, Cyrillic, Han or any other script, it means writing the whole name in Latin letters.
Doc 9303 Part 3, the part common to every format, carries recommended transliterations for both cases. The decision is the issuing state's, though, and states do not all choose the same way. Three consequences matter for code:
- One printed name can have more than one zone spelling. A printed
MÜLLERmay appear in a zone asMUELLERor asMULLER, depending on the issuer's rules. Code that expects one fixed mapping will call a correct zone wrong. - The mapping does not reverse.
MULLERin a zone could come fromMULLER,MÜLLERorMÚLLERon the page. A zone tells you the letters it holds, not the name the person writes. - The Latin name on the page can differ from the zone. A document printed in a national script often prints a Latin version too, and that printed Latin spelling is not always letter for letter the one in the zone. Treat a difference as something to look at, not as proof of a misread.
The practical rule: compare a zone name with a printed name after reducing both to bare letters, allow for the spellings your issuers actually use, and keep the printed spelling for anything a person reads.
Truncation: when the name does not fit
A long name does not wrap to another line. It is shortened to fit the field, and the full name stays on the printed page. The standard gives rules for how to shorten a name, and a shortened name normally runs to the very last character of its field.
That gives the one signal a parser has: if the last character of the name field is a letter rather than <, the name may have been truncated. It is "may", not "was": a name that is exactly as long as the field also ends on a letter. But a field that ends in fillers was certainly not cut.
The width matters. Take an invented holder, Konstantin Friedrich Hartmann Schoenebeck. In a TD3 field of 39 characters:
HARTMANN<SCHOENEBECK<<KONSTANTIN<FRIEDR
The last character is R, a letter, so the name may be cut, and it was: FRIEDRICH lost three letters. The same name cut at a TD1 width of 30 keeps only HARTMANN<SCHOENEBECK<<KONSTANT. A system that stores the zone name as the holder's name stores KONSTANT as a first name.
Two habits follow. Never use the zone name as the display name when the field is full; use the printed one. And when you match a full printed name against a zone name, a printed name that starts with the zone name is a match if the field was full.
An issuer may also shorten a name in other ways than cutting the end, for example by reducing a second given name to its initial. A prefix test reports such a name as a mismatch. Route that case to a person rather than rejecting the document.
No check digit covers the name
The check digits cover the document number, the two dates, the optional personal number on TD3, and a composite over most of the lower line. None of them covers the name. On TD3 and TD2 the name is on line 1, which no digit touches at all; on TD1 it is line 3, and the composite stops at line 2.
So a misread in a name is silent. An M read as N, or a filler read as a letter, still gives a zone whose arithmetic balances. The only cross-check for a name is the printed page: read both and compare them, which is what the comparison function below is for. How MRZ check digits work has the arithmetic for the fields that are covered.
Splitting the field in Python
No dependencies. The function takes the name field alone, already sliced out of its line (line1[5:44] on TD3, line1[5:36] on TD2, line3 on TD1).
import re
def words(part):
return " ".join(word for word in part.split("<") if word)
def read_mrz_name(field):
"""Split an MRZ name field into (primary, secondary, may_be_truncated)."""
if not re.fullmatch(r"[A-Z<]+", field):
raise ValueError("a name field holds only A-Z and the filler <")
may_be_truncated = not field.endswith("<")
body = field.rstrip("<")
primary, _separator, secondary = body.partition("<<")
return words(primary), words(secondary), may_be_truncated
partition splits at the first << only and returns an empty secondary part when there is none, which is the single-name case. The pattern refuses digits on purpose: a 0 in a name field is an O misread, and it should reach a person rather than be printed as a name.
The same split in JavaScript
export function readMrzName(field) {
if (!/^[A-Z<]+$/.test(field)) throw new Error("a name field holds only A-Z and the filler <");
const mayBeTruncated = !field.endsWith("<");
const body = field.replace(/<+$/, "");
const at = body.indexOf("<<");
const words = (part) => part.split("<").filter(Boolean).join(" ");
if (at < 0) return { primary: words(body), secondary: "", mayBeTruncated };
return { primary: words(body.slice(0, at)), secondary: words(body.slice(at + 2)), mayBeTruncated };
}
Comparing the zone with the printed name
The printed name comes from the visual zone, the part of the page a person reads, with its accents and its punctuation. The function below reduces it to bare capital letters and compares it with one part of the zone name. It returns one of three words: match, mismatch, or unknown when the printed name holds a letter that does not reduce to A–Z on its own, such as Ø, Æ or Ł.
import re
import unicodedata
# The spellings to try besides plain removal of the accent. These three are
# an example; put in the ones your issuers use.
EXPANSIONS = {"Ä": "AE", "Ö": "OE", "Ü": "UE"}
def compare_name(zone_part, printed_part, truncated=False, expansions=EXPANSIONS):
"""'match', 'mismatch' or 'unknown' for one name part: zone against printed."""
zone = zone_part.replace(" ", "")
upper = unicodedata.normalize("NFC", printed_part).upper()
candidates = {upper, "".join(expansions.get(c, c) for c in upper)}
verdict = "unknown"
for candidate in candidates:
letters = "".join(
c for c in unicodedata.normalize("NFKD", candidate)
if c.isalpha() and not unicodedata.combining(c)
)
if not re.fullmatch(r"[A-Z]*", letters):
continue # a letter with no plain A-Z form: leave it to a person
if letters == zone or (truncated and letters.startswith(zone)):
return "match"
verdict = "mismatch"
return verdict
NFKD splits an accented letter into the plain letter and its accent, and the accent is dropped. Spaces, hyphens and apostrophes are dropped too, because the comparison is on letters only: Smith-Jones and SMITH JONES both become SMITHJONES. For a name printed only in a non-Latin script, compare the zone with the Latin version the page prints, not with the national-script one; the national script would return unknown here every time.
Test vectors
Every name is invented except ICAO's specimen. In the first table, each field is padded with < to the TD3 width of 39 (in Python, name.ljust(39, "<")), except the last row, which is exactly 39 characters already.
| Field before padding | Primary | Secondary | May be truncated |
|---|---|---|---|
ERIKSSON<<ANNA<MARIA |
ERIKSSON | ANNA MARIA | no |
VAN<DER<BERG<<PIETER |
VAN DER BERG | PIETER | no |
SMITH<JONES<<MARY |
SMITH JONES | MARY | no |
OKONKWO |
OKONKWO | (empty) | no |
NAKAMURA<< |
NAKAMURA | (empty) | no |
HARTMANN<SCHOENEBECK<<KONSTANTIN<FRIEDR |
HARTMANN SCHOENEBECK | KONSTANTIN FRIEDR | yes |
And for compare_name:
| Zone part | Printed part | Truncated | Expected |
|---|---|---|---|
ANNA MARIA |
Anna Maria |
no | match |
SMITH JONES |
Smith-Jones |
no | match |
MULLER |
Müller |
no | match |
MUELLER |
Müller |
no | match (through the expansion) |
MILLER |
Müller |
no | mismatch |
KONSTANTIN FRIEDR |
Konstantin Friedrich |
yes | match |
KONSTANTIN FRIEDR |
Konstantin Friedrich |
no | mismatch |
SORENSEN |
Sørensen |
no | unknown |
The last row is deliberate. Whether Ø became O or OE is the issuer's choice, and a function that guesses will be wrong for somebody; add the form your issuers use to the expansions instead.
What our MRZ parser does
The MRZ parser on this site reads a name the same way: it strips the trailing fillers, splits at the first <<, and turns single fillers into spaces; a field with no << is read as a surname alone. When the name runs to the last character of its field, it shows a warning that the name may have been truncated to fit, with the positions it fills. It runs in the browser, so you can paste a synthetic zone and compare its reading with your own code.
Where this fits in a pipeline
A hosted recognition service reads both the zone and the printed page, so the question is which name it hands you. In the doc.cheap response, holder.surname, holder.given_names and holder.full_name carry the merged Latin reading. A name printed in a national script is a separate entry of fields: each reading has its own entry, the default Latin one has an id ending in @0, and the national-script one names its language, for example Greek. The zone itself is in mrz.lines, exactly as read, so you can slice the name field out and run the two functions above on it. The guide on non-Latin scripts shows how to group the readings and store both spellings. If you are still choosing a service, the passport OCR API comparison sets the published prices side by side.
If you find a name the code above reads wrongly, write to admin@doc.cheap.
A question for you: which transliteration surprised you most when you compared a zone with the printed name? Tell us in the comments below.