Every date in a machine-readable zone is six digits: two for the year, two for the month, two for the day. There is no century. The specimen passport ICAO publishes 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.

This post gives 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 a table of test vectors you can drop into your own suite. Every example uses ICAO's fictitious specimen or synthetic values; no real document appears.

This is the blog of doc.cheap, a Passport and ID OCR API that reads the zone for you. Nothing below needs it; the code has no dependencies.

Where the dates sit

ICAO Doc 9303, the standard for machine-readable travel documents, gives each 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 own time.strptime works that way for %y. Its documentation says: "When 2-digit years are parsed, they are converted according to the POSIX and ISO C standards: values 69–99 are mapped to 1969–1999, and values 0–68 are mapped to 2000–2068."

That is a reasonable default for a log file. For a date of birth it is wrong in both directions. By that rule a person 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 is all.

Three details decide whether an implementation is actually correct:

  • Compare the whole date, not just the year. Today is 24 September 2026 in the examples below. 261224 is 24 December 2026 in the 2000s reading, which is 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. A person born today was born in this century, not the last one. 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 in one fixed zone of your choice, and pass it in. A function that reads the clock itself cannot be tested, and a server in one zone and a browser in another can disagree about the date for part of every day.

Expiry dates: this century

An expiry date is read as 2000 + YY and left there. A passport that expired in 2012 is still a 2012 date, not 2112, and one that expires in 2032 is 2032. The 1900s reading would only be right for a document that expired before 2000, and no such document is still in use. This rule holds until the 2090s, when a document issued then could expire in the 2100s; the code will need a look before that, not now.

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 is later than today in the 2000s reading, so it becomes 29 February 1928, which exists because 1928 is a leap year. 000229 is 29 February 2000, which exists because 2000 is a leap year; in 1900, which was not a leap year, the same six digits would be no date at all. 00 is the one two-digit year where the century changes the answer to "is this a leap year?". Validate the date before choosing the century and one of these goes wrong.

A birth date later this year. Covered above: 261224 is 1926, not 2026, and a test should pin that down with a fixed "today".

Fillers in the date. A zone can carry < in a date of birth where part of the date is unknown, for example 94<<08. There is no calendar date to return. The function below returns nothing for any value that is not six digits, and leaves it to the caller to 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. Month 13 does not exist. The check digit and the date are two separate checks and both have to pass; how MRZ check digits work covers the first one in full.

People over 100. A person born in 1925 and a baby born in 2025 have the same six digits. The zone alone cannot separate them, and the rule above picks the younger reading. If your users include centenarians, compare with the date printed on the data page, which is where a four-digit year appears when the document prints one.

The function in 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())

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. The comparison uses a tuple so that an impossible month still compares cleanly before date() rejects it.

The same function in 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));

toISOString() is always in UTC, which is what makes it a safe source for today.

Test vectors

Every row below is worked out by hand from the rule, with today fixed at 24 September 2026. Both functions should return the value in the last column (None in Python, null in JavaScript for "no date").

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
300101 birth 1930-01-01 2030 is in the future
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
260923 birth 2026-09-23 yesterday
261224 expiry 2026-12-24 expiry stays in the 2000s
320310 expiry 2032-03-10 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
230229 expiry none 2023 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

A test runner for the Python version is a loop over these rows with today = date(2026, 9, 24), comparing result.isoformat() (or None) with the expected column. Keep "today" fixed in tests: with the real clock, rows such as 261224 change their answer on 25 December.

What our MRZ parser does

The MRZ parser on this site follows the same rule: the year starts as 2000 + YY, a date of birth whose full date is later than today (in UTC) moves back 100 years, the calendar check comes after the century is chosen, and expiry dates stay in the 2000s. Its own tests cover a date later this year, today, and 29 February on both sides of the century. For a value it cannot turn into a date it shows the raw text instead, with a ? where the zone has a filler, and when six digits pass their check digit but name no calendar date, it says so in a warning. An expiry date is marked as expired or not expired against today's date in UTC. It runs in the browser, so you can paste a synthetic zone and compare its reading with your own implementation.

Where this fits in a pipeline

If you use a hosted recognition service, the century has already been chosen for you in its date fields; the question is whether its rule is the one you want. The doc.cheap response 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. If you are still choosing a service, the passport OCR API comparison sets the published prices side by side.

If you find an input the rule gets wrong, write to admin@doc.cheap.