Cada fecha de una zona de lectura mecánica tiene seis dígitos: dos para el año, dos para el mes y dos para el día. No hay siglo. El pasaporte de muestra que publica la OACI indica 740812 como fecha de nacimiento, y nada en la zona dice si eso es 1974 o 2074. Usted sabe que es 1974 porque nadie tiene pasaporte antes de nacer, y ese único razonamiento es toda la regla. Aplicarla con total exactitud, también el día en que importa, exige algo más de cuidado que un año de corte fijo.

Este artículo da la regla para las fechas de nacimiento y para las de caducidad, los casos límite que rompen los parsers sencillos y una función en Python y en JavaScript con una tabla de vectores de prueba que puede incorporar a su propia batería de tests. Todos los ejemplos usan el espécimen ficticio de la OACI o valores sintéticos; no aparece ningún documento real.

Este es el blog de doc.cheap, una API de OCR para pasaportes y documentos de identidad que lee la zona por usted. Nada de lo que sigue la necesita; el código no tiene dependencias.

Dónde están las fechas

El Doc 9303 de la OACI, la norma para documentos de viaje de lectura mecánica, da a cada formato dos campos de fecha: fecha de nacimiento y fecha de caducidad, cada una escrita como YYMMDD y seguida de su propio dígito de control. Las posiciones empiezan en 0, listas para slice:

Formato Fecha de nacimiento Fecha de caducidad
TD3 (pasaportes), línea 2 13–18, dígito de control en 19 21–26, dígito de control en 27
TD2, línea 2 13–18, dígito de control en 19 21–26, dígito de control en 27
TD1 (documentos de identidad), línea 2 0–5, dígito de control en 6 8–13, dígito de control en 14

En la línea TD3 del espécimen, L898902C36UTO7408122F3404159ZE184226B<<<<<16, eso da 740812 como fecha de nacimiento y 340415 como caducidad.

Por qué falla un año de corte fijo

El atajo habitual es un pivote: los años de dos dígitos por debajo de cierto número van a los 2000 y el resto a los 1900. El propio time.strptime de Python funciona así con %y. Su documentación dice: "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."

Es un valor por defecto razonable para un archivo de log. Para una fecha de nacimiento es erróneo en ambas direcciones. Según esa regla, una persona nacida el 12 de abril de 1968 (680412) nace en 2068. Si mueve el pivote, solo mueve el error: cualquier número fijo es erróneo para alguien, y lo es cada año más mientras el código siga en producción. La respuesta correcta depende de la fecha de hoy, así que la regla tiene que recibir la fecha de hoy como entrada.

Fechas de nacimiento: el siglo más reciente que no esté en el futuro

Lea el año como 2000 + YY. Si la fecha completa resultante es posterior a hoy, reste 100 años. Eso es todo.

Tres detalles deciden si una implementación es realmente correcta:

  • Compare la fecha completa, no solo el año. En los ejemplos de abajo, hoy es el 24 de septiembre de 2026. 261224 es el 24 de diciembre de 2026 en la lectura de los 2000, dentro de tres meses, así que la persona nació el 24 de diciembre de 1926. Comparar solo 26 con 26 la tomaría por un recién nacido.
  • Hoy cuenta como pasado. Una persona nacida hoy nació en este siglo, no en el anterior. 260924 sigue siendo el 24 de septiembre de 2026; 260925, un día después, vuelve a 1926. Use "posterior a", no "posterior o igual a".
  • Tome "hoy" en UTC, o en una zona horaria fija de su elección, y páselo como argumento. Una función que lee el reloj por sí misma no se puede probar, y un servidor en una zona y un navegador en otra pueden discrepar sobre la fecha durante parte de cada día.

Fechas de caducidad: este siglo

Una fecha de caducidad se lee como 2000 + YY y se deja así. Un pasaporte que caducó en 2012 sigue siendo una fecha de 2012, no de 2112, y uno que caduca en 2032 es de 2032. La lectura de los 1900 solo sería correcta para un documento que caducó antes de 2000, y ningún documento así sigue en uso. Esta regla vale hasta la década de 2090, cuando un documento emitido entonces podría caducar en los 2100; el código necesitará una revisión antes de eso, no ahora.

Los casos límite que merecen un test

El 29 de febrero a través del siglo. Elija primero el siglo y luego compruebe que la fecha existe. 280229 como fecha de nacimiento es posterior a hoy en la lectura de los 2000, así que pasa a ser el 29 de febrero de 1928, que existe porque 1928 es bisiesto. 000229 es el 29 de febrero de 2000, que existe porque 2000 es bisiesto; en 1900, que no fue bisiesto, los mismos seis dígitos no serían ninguna fecha. 00 es el único año de dos dígitos en el que el siglo cambia la respuesta a "¿es bisiesto?". Valide la fecha antes de elegir el siglo y uno de estos casos saldrá mal.

Una fecha de nacimiento más adelante en este año. Visto arriba: 261224 es 1926, no 2026, y un test debería fijarlo con un "hoy" fijo.

Caracteres de relleno en la fecha. Una zona puede llevar < en una fecha de nacimiento cuando parte de la fecha se desconoce, por ejemplo 94<<08. No hay ninguna fecha de calendario que devolver. La función de abajo no devuelve nada para cualquier valor que no sean seis dígitos y deja que quien la llama muestre el texto en bruto.

Un dígito de control correcto en una fecha imposible. El dígito de control protege los caracteres, no el calendario. Con los pesos 7, 3, 1, 741312 suma 88, así que su dígito de control es 8, y 7413128 lo supera. El mes 13 no existe. El dígito de control y la fecha son dos comprobaciones separadas y ambas deben superarse; cómo funcionan los dígitos de control de la MRZ explica la primera en detalle.

Personas de más de 100 años. Una persona nacida en 1925 y un bebé nacido en 2025 tienen los mismos seis dígitos. La zona por sí sola no puede distinguirlos, y la regla de arriba elige la lectura más joven. Si entre sus usuarios hay centenarios, compare con la fecha impresa en la página de datos, que es donde aparece un año de cuatro dígitos cuando el documento lo imprime.

La función en Python

Sin dependencias. today es un datetime.date que pasa quien llama.

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

El patrón es [0-9], no \d ni str.isdigit(), a propósito: ambos aceptan dígitos de otras escrituras, y una MRZ solo contiene dígitos ASCII. La comparación usa una tupla para que un mes imposible se compare sin problemas antes de que date() lo rechace.

La misma función en JavaScript

today es una cadena YYYY-MM-DD. Dos cadenas así se comparan correctamente como texto, lo que reduce la regla del siglo a una línea.

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() siempre está en UTC, y eso lo convierte en una fuente segura para today.

Vectores de prueba

Cada fila de abajo está calculada a mano a partir de la regla, con hoy fijado en el 24 de septiembre de 2026. Ambas funciones deberían devolver el valor de la columna "Esperado" (None en Python, null en JavaScript para "ninguna fecha").

Entrada Tipo Esperado Motivo
740812 birth 1974-08-12 espécimen de la OACI
340415 expiry 2034-04-15 espécimen de la OACI
120415 expiry 2012-04-15 caducado, sigue siendo 2012
940308 birth 1994-03-08 2094 está en el futuro
150101 birth 2015-01-01 2015 está en el pasado
300101 birth 1930-01-01 2030 está en el futuro
261224 birth 1926-12-24 más adelante este año, así que siglo anterior
260925 birth 1926-09-25 mañana, así que siglo anterior
260924 birth 2026-09-24 hoy cuenta como pasado
260923 birth 2026-09-23 ayer
261224 expiry 2026-12-24 la caducidad se queda en los 2000
320310 expiry 2032-03-10 la caducidad se queda en los 2000
280229 birth 1928-02-29 1928 es bisiesto
000229 birth 2000-02-29 2000 es bisiesto, 1900 no lo fue
270229 birth ninguna 1927 no es bisiesto
230229 expiry ninguna 2023 no es bisiesto
240229 expiry 2024-02-29 2024 es bisiesto
941308 birth ninguna no existe el mes 13
94<<08 birth ninguna caracteres de relleno, no una fecha

Un ejecutor de tests para la versión en Python es un bucle sobre estas filas con today = date(2026, 9, 24) que compara result.isoformat() (o None) con la columna de lo esperado. Mantenga "hoy" fijo en los tests: con el reloj real, filas como 261224 cambian de respuesta el 25 de diciembre.

Qué hace nuestro parser de MRZ

El parser de MRZ de este sitio sigue la misma regla: el año empieza como 2000 + YY, una fecha de nacimiento cuya fecha completa es posterior a hoy (en UTC) retrocede 100 años, la comprobación del calendario llega después de elegir el siglo y las fechas de caducidad se quedan en los 2000. Sus propios tests cubren una fecha más adelante en este año, el día de hoy y el 29 de febrero a ambos lados del siglo. Para un valor que no puede convertir en fecha muestra en su lugar el texto en bruto, con un ? donde la zona tiene un carácter de relleno, y cuando seis dígitos superan su dígito de control pero no corresponden a ninguna fecha del calendario, lo indica con un aviso. Una fecha de caducidad se marca como caducada o no caducada respecto a la fecha de hoy en UTC. Se ejecuta en el navegador, así que puede pegar una zona sintética y comparar su lectura con su propia implementación.

Dónde encaja esto en un pipeline

Si usa un servicio de reconocimiento alojado, el siglo ya se ha elegido por usted en sus campos de fecha; la pregunta es si su regla es la que usted quiere. La respuesta de doc.cheap devuelve holder.birth_date y document.expiry_date en ISO YYYY-MM-DD y, junto a ellos, la zona exactamente como se leyó en mrz.lines, para que pueda extraer usted mismo los seis dígitos y aplicar la función de arriba cuando una fecha decide un acceso o dinero. Si todavía está eligiendo un servicio, la comparativa de APIs de OCR de pasaportes pone los precios publicados uno al lado del otro.

Si encuentra una entrada en la que la regla falla, escriba a admin@doc.cheap.