DEV Community

Teoh Chin Heng for doc.cheap

Posted on Originally published at doc.cheap

MRZ dates have no century: reading YYMMDD without year-2000 bugs

Originally published at MRZ dates have no century: reading YYMMDD without year-2000 bugs

Every date in a passport's machine-readable zone (MRZ) is six digits: two for the year, two for the month, two for the day. There is no century. ICAO's specimen passport says 740812 for a date of birth, and nothing in the zone tells you whether that is 1974 or 2074. You know it is 1974 because nobody holds a passport before they are born – and that one piece of reasoning is the whole rule. Getting it exactly right, including on the day it matters, takes a little more care than a fixed cut-off year.

Below: the rule for birth dates and for expiry dates, the edge cases that break simple parsers, and a function in Python and in JavaScript with test vectors you can drop into your own suite. Every example uses ICAO's fictitious specimen or synthetic values.

Disclosure: this was written for the blog of doc.cheap, which reads identity documents. Nothing below needs it; the code has no dependencies.

Where the dates sit

ICAO Doc 9303 gives each MRZ format two date fields, date of birth and date of expiry, each written as YYMMDD and each followed by its own check digit. Positions are 0-based, ready for slice:

Format Date of birth Date of expiry
TD3 (passports), line 2 13–18, check digit at 19 21–26, check digit at 27
TD2, line 2 13–18, check digit at 19 21–26, check digit at 27
TD1 (ID cards), line 2 0–5, check digit at 6 8–13, check digit at 14

On the TD3 specimen line L898902C36UTO7408122F3404159ZE184226B<<<<<16, that gives 740812 for the date of birth and 340415 for the expiry.

Why a fixed cut-off year breaks

The usual shortcut is a pivot: two-digit years below some number go to the 2000s, the rest to the 1900s. Python's time.strptime does exactly that for %y: "values 69–99 are mapped to 1969–1999, and values 0–68 are mapped to 2000–2068."

Fine for a log file. For a date of birth it is wrong in both directions: by that rule someone born on 12 April 1968 (680412) is born in 2068. Move the pivot and you only move the bug – any fixed number is wrong for somebody, and it gets more wrong every year the code stays in production. The right answer depends on today's date, so the rule has to take today's date as an input.

Birth dates: the latest century that is not in the future

Read the year as 2000 + YY. If the resulting full date is later than today, subtract 100 years. That's it.

Three details decide whether an implementation is actually correct:

  • Compare the whole date, not just the year. With today fixed at 24 September 2026, 261224 reads as 24 December 2026 – three months away – so the person was born on 24 December 1926. Comparing only 26 with 26 would call that a newborn.
  • Today counts as the past. 260924 stays 24 September 2026; 260925, one day later, goes back to 1926. Use "later than", not "later than or equal to".
  • Take "today" in UTC (or one fixed zone) and pass it in. A function that reads the clock itself cannot be tested, and a server and a browser in different zones disagree about the date for part of every day.

Expiry dates: this century

An expiry date is 2000 + YY, full stop. A passport that expired in 2012 is still a 2012 date, not 2112. The 1900s reading would only be right for a document that expired before 2000, and none of those is still in use. The rule holds until the 2090s, when a document could expire in the 2100s.

The edge cases worth a test

29 February across the century. Choose the century first, then check that the date exists. 280229 as a birth date becomes 29 February 1928, which exists (1928 is a leap year). 000229 is 29 February 2000, which exists because 2000 is a leap year; in 1900 it would be no date at all. Validate before choosing the century and one of these goes wrong.

Fillers in the date. A zone can carry < where part of the date is unknown, for example 94<<08. There is no calendar date to return; return nothing and let the caller show the raw text.

A passing check digit on an impossible date. The check digit protects the characters, not the calendar. With weights 7, 3, 1, 741312 sums to 88, so its check digit is 8, and 7413128 passes – but month 13 does not exist. The check digit and the date are two separate checks and both have to pass.

People over 100. Someone born in 1925 and a baby born in 2025 have the same six digits. The zone alone cannot separate them, and the rule picks the younger reading. If your users include centenarians, compare with the date printed on the data page.

Python

No dependencies. today is a datetime.date passed in by the caller.

import re
from datetime import date, datetime, timezone

SIX_DIGITS = re.compile(r"[0-9]{6}")

def read_mrz_date(raw, kind, today):
    """Turn an MRZ YYMMDD field into a date, or None if it is not one.

    kind is "birth" or "expiry"; today is a datetime.date.
    """
    if kind not in ("birth", "expiry"):
        raise ValueError(f"unknown kind: {kind!r}")
    if not SIX_DIGITS.fullmatch(raw):
        return None  # fillers, letters or the wrong length
    yy, mm, dd = int(raw[0:2]), int(raw[2:4]), int(raw[4:6])
    year = 2000 + yy
    if kind == "birth" and (year, mm, dd) > (today.year, today.month, today.day):
        year -= 100  # the 2000s reading is in the future
    try:
        return date(year, mm, dd)  # checked after the century is chosen
    except ValueError:
        return None  # 13th month, 31 April, 29 February in a common year

# In production:
# read_mrz_date("740812", "birth", datetime.now(timezone.utc).date())
Enter fullscreen mode Exit fullscreen mode

The pattern is [0-9], not \d or str.isdigit(), on purpose: both of those accept digits from other scripts, and an MRZ only ever contains ASCII ones.

JavaScript

today is a YYYY-MM-DD string. Two such strings compare correctly as plain strings, which keeps the century rule to one line.

export function readMrzDate(raw, kind, today /* "YYYY-MM-DD", UTC */) {
  if (kind !== "birth" && kind !== "expiry") throw new Error(`unknown kind: ${kind}`);
  if (!/^[0-9]{6}$/.test(raw)) return null; // fillers, letters or the wrong length
  const month = Number(raw.slice(2, 4));
  const day = Number(raw.slice(4, 6));
  let year = 2000 + Number(raw.slice(0, 2));
  const iso = (y) => `${y}-${raw.slice(2, 4)}-${raw.slice(4, 6)}`;
  if (kind === "birth" && iso(year) > today) year -= 100; // the 2000s reading is in the future
  // Date.UTC rolls 31 April over to 1 May; a changed month or day means no such date.
  const d = new Date(Date.UTC(year, month - 1, day));
  if (month < 1 || month > 12 || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) {
    return null;
  }
  return iso(year);
}

// In production:
// readMrzDate("740812", "birth", new Date().toISOString().slice(0, 10));
Enter fullscreen mode Exit fullscreen mode

Test vectors

Worked out by hand with today fixed at 24 September 2026. "none" means None in Python and null in JavaScript.

Input Kind Expected Why
740812 birth 1974-08-12 ICAO specimen
340415 expiry 2034-04-15 ICAO specimen
120415 expiry 2012-04-15 expired, still 2012
940308 birth 1994-03-08 2094 is in the future
150101 birth 2015-01-01 2015 is in the past
261224 birth 1926-12-24 later this year, so last century
260925 birth 1926-09-25 tomorrow, so last century
260924 birth 2026-09-24 today counts as the past
261224 expiry 2026-12-24 expiry stays in the 2000s
280229 birth 1928-02-29 1928 is a leap year
000229 birth 2000-02-29 2000 is a leap year, 1900 was not
270229 birth none 1927 is not a leap year
240229 expiry 2024-02-29 2024 is a leap year
941308 birth none there is no month 13
94<<08 birth none fillers, not a date

Keep "today" fixed in tests: with the real clock, rows such as 261224 change their answer on 25 December.

If a service reads the zone for you

A hosted recognition service has already chosen the century in its date fields; the question is whether its rule is the one you want. The doc.cheap response, for example, returns holder.birth_date and document.expiry_date as ISO YYYY-MM-DD and, next to them, the zone exactly as read in mrz.lines – so you can slice the six digits out yourself and apply the function above when a date decides access or money. The browser-only doc.cheap MRZ parser follows the same century rule, so you can paste a synthetic zone and compare its reading with your own implementation.

A question for you: how does your code choose the century of a two-digit year, and has it ever chosen wrong? Tell me in the comments.

Top comments (0)