Toda data em uma zona de leitura mecânica tem seis dígitos: dois para o ano, dois para o mês e dois para o dia. Não há século. O passaporte de exemplo que a OACI publica traz 740812 como data de nascimento, e nada na zona diz se isso é 1974 ou 2074. Você sabe que é 1974 porque ninguém tem passaporte antes de nascer, e esse único raciocínio é a regra inteira. Acertá-la com exatidão, inclusive no dia em que isso importa, exige um pouco mais de cuidado do que um ano de corte fixo.

Este post traz a regra para datas de nascimento e para datas de validade, os casos-limite que quebram parsers simples e uma função em Python e em JavaScript com uma tabela de vetores de teste que você pode colocar na sua própria suíte. Todos os exemplos usam o espécime fictício da OACI ou valores sintéticos; nenhum documento real aparece.

Este é o blog do doc.cheap, uma API de OCR para passaportes e documentos de identidade que lê a zona para você. Nada do que vem a seguir depende dela; o código não tem dependências.

Onde ficam as datas

O Doc 9303 da OACI, a norma para documentos de viagem de leitura mecânica, dá a cada formato dois campos de data: data de nascimento e data de validade, cada um escrito como YYMMDD e seguido do seu próprio dígito verificador. As posições começam em 0, prontas para slice:

Formato Data de nascimento Data de validade
TD3 (passaportes), linha 2 13–18, dígito verificador em 19 21–26, dígito verificador em 27
TD2, linha 2 13–18, dígito verificador em 19 21–26, dígito verificador em 27
TD1 (carteiras de identidade), linha 2 0–5, dígito verificador em 6 8–13, dígito verificador em 14

Na linha TD3 do espécime, L898902C36UTO7408122F3404159ZE184226B<<<<<16, isso dá 740812 como data de nascimento e 340415 como validade.

Por que um ano de corte fixo falha

O atalho comum é um pivô: anos de dois dígitos abaixo de certo número vão para os anos 2000, o resto para os 1900. O próprio time.strptime do Python funciona assim com %y. A documentação diz: "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."

É um padrão razoável para um arquivo de log. Para uma data de nascimento, está errado nas duas direções. Por essa regra, uma pessoa nascida em 12 de abril de 1968 (680412) nasce em 2068. Mova o pivô e você só move o bug: qualquer número fixo está errado para alguém, e fica mais errado a cada ano que o código passa em produção. A resposta certa depende da data de hoje, então a regra precisa receber a data de hoje como entrada.

Datas de nascimento: o século mais recente que não está no futuro

Leia o ano como 2000 + YY. Se a data completa resultante for posterior a hoje, subtraia 100 anos. É só isso.

Três detalhes decidem se uma implementação está de fato correta:

  • Compare a data inteira, não só o ano. Nos exemplos abaixo, hoje é 24 de setembro de 2026. 261224 é 24 de dezembro de 2026 na leitura dos anos 2000, daqui a três meses, então a pessoa nasceu em 24 de dezembro de 1926. Comparar só 26 com 26 a trataria como recém-nascida.
  • Hoje conta como passado. Uma pessoa nascida hoje nasceu neste século, não no anterior. 260924 continua sendo 24 de setembro de 2026; 260925, um dia depois, volta para 1926. Use "posterior a", não "posterior ou igual a".
  • Pegue "hoje" em UTC, ou em um fuso fixo da sua escolha, e passe como argumento. Uma função que lê o relógio sozinha não pode ser testada, e um servidor em um fuso e um navegador em outro podem discordar sobre a data durante parte de cada dia.

Datas de validade: este século

Uma data de validade é lida como 2000 + YY e fica assim. Um passaporte que venceu em 2012 continua sendo uma data de 2012, não de 2112, e um que vence em 2032 é de 2032. A leitura dos anos 1900 só estaria certa para um documento que venceu antes de 2000, e nenhum documento assim ainda está em uso. Essa regra vale até a década de 2090, quando um documento emitido na época poderia vencer nos anos 2100; o código vai precisar de uma revisão antes disso, não agora.

Os casos-limite que merecem um teste

29 de fevereiro na virada do século. Escolha o século primeiro e depois verifique se a data existe. 280229 como data de nascimento é posterior a hoje na leitura dos anos 2000, então vira 29 de fevereiro de 1928, que existe porque 1928 é bissexto. 000229 é 29 de fevereiro de 2000, que existe porque 2000 é bissexto; em 1900, que não foi bissexto, os mesmos seis dígitos não seriam data nenhuma. 00 é o único ano de dois dígitos em que o século muda a resposta para "este ano é bissexto?". Valide a data antes de escolher o século e um desses casos dá errado.

Uma data de nascimento mais adiante neste ano. Já visto acima: 261224 é 1926, não 2026, e um teste deve fixar isso com um "hoje" fixo.

Caracteres de preenchimento na data. Uma zona pode trazer < em uma data de nascimento quando parte da data é desconhecida, por exemplo 94<<08. Não há data de calendário para devolver. A função abaixo não devolve nada para qualquer valor que não tenha seis dígitos e deixa que quem a chama mostre o texto bruto.

Um dígito verificador válido em uma data impossível. O dígito verificador protege os caracteres, não o calendário. Com os pesos 7, 3, 1, 741312 soma 88, então seu dígito verificador é 8, e 7413128 passa. O mês 13 não existe. O dígito verificador e a data são duas verificações separadas e ambas precisam passar; como funcionam os dígitos verificadores da MRZ cobre a primeira em detalhe.

Pessoas com mais de 100 anos. Uma pessoa nascida em 1925 e um bebê nascido em 2025 têm os mesmos seis dígitos. A zona sozinha não consegue separá-los, e a regra acima escolhe a leitura mais jovem. Se seus usuários incluem centenários, compare com a data impressa na página de dados, que é onde aparece um ano de quatro dígitos quando o documento o imprime.

A função em Python

Sem dependências. today é um datetime.date passado por quem chama.

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())

O padrão é [0-9], e não \d ou str.isdigit(), de propósito: ambos aceitam dígitos de outras escritas, e uma MRZ só contém dígitos ASCII. A comparação usa uma tupla para que um mês impossível ainda seja comparado sem erro antes de date() rejeitá-lo.

A mesma função em JavaScript

today é uma string YYYY-MM-DD. Duas strings assim se comparam corretamente como texto simples, o que deixa a regra do século em uma linha.

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() está sempre em UTC, e é isso que o torna uma fonte segura para today.

Vetores de teste

Cada linha abaixo foi calculada à mão a partir da regra, com hoje fixado em 24 de setembro de 2026. As duas funções devem devolver o valor da coluna "Esperado" (None em Python, null em JavaScript para "nenhuma data").

Entrada Tipo Esperado Motivo
740812 birth 1974-08-12 espécime da OACI
340415 expiry 2034-04-15 espécime da OACI
120415 expiry 2012-04-15 vencido, continua 2012
940308 birth 1994-03-08 2094 está no futuro
150101 birth 2015-01-01 2015 está no passado
300101 birth 1930-01-01 2030 está no futuro
261224 birth 1926-12-24 mais adiante neste ano, então século passado
260925 birth 1926-09-25 amanhã, então século passado
260924 birth 2026-09-24 hoje conta como passado
260923 birth 2026-09-23 ontem
261224 expiry 2026-12-24 a validade fica nos anos 2000
320310 expiry 2032-03-10 a validade fica nos anos 2000
280229 birth 1928-02-29 1928 é bissexto
000229 birth 2000-02-29 2000 é bissexto, 1900 não foi
270229 birth nenhuma 1927 não é bissexto
230229 expiry nenhuma 2023 não é bissexto
240229 expiry 2024-02-29 2024 é bissexto
941308 birth nenhuma não existe mês 13
94<<08 birth nenhuma caracteres de preenchimento, não uma data

Um executor de testes para a versão em Python é um laço sobre essas linhas com today = date(2026, 9, 24), comparando result.isoformat() (ou None) com a coluna do esperado. Mantenha "hoje" fixo nos testes: com o relógio real, linhas como 261224 mudam de resposta em 25 de dezembro.

O que o nosso parser de MRZ faz

O parser de MRZ deste site segue a mesma regra: o ano começa como 2000 + YY, uma data de nascimento cuja data completa é posterior a hoje (em UTC) volta 100 anos, a verificação do calendário vem depois de escolher o século e as datas de validade ficam nos anos 2000. Os próprios testes dele cobrem uma data mais adiante neste ano, o dia de hoje e 29 de fevereiro dos dois lados do século. Para um valor que não consegue transformar em data, ele mostra o texto bruto, com um ? onde a zona tem um caractere de preenchimento, e quando seis dígitos passam no dígito verificador mas não correspondem a nenhuma data do calendário, ele avisa. Uma data de validade é marcada como vencida ou não vencida em relação à data de hoje em UTC. Ele roda no navegador, então você pode colar uma zona sintética e comparar a leitura dele com a sua própria implementação.

Onde isso entra em um pipeline

Se você usa um serviço de reconhecimento hospedado, o século já foi escolhido por você nos campos de data; a questão é se a regra dele é a que você quer. A resposta do doc.cheap devolve holder.birth_date e document.expiry_date em ISO YYYY-MM-DD e, ao lado deles, a zona exatamente como foi lida em mrz.lines, para que você mesmo possa extrair os seis dígitos e aplicar a função acima quando uma data decide acesso ou dinheiro. Se ainda está escolhendo um serviço, a comparação de APIs de OCR de passaporte coloca os preços publicados lado a lado.

Se encontrar uma entrada em que a regra erra, escreva para admin@doc.cheap.