Una fecha local no siempre identifica un instante único. Los cambios de horario pueden repetir o saltar horas. zoneinfo, de la biblioteca estándar, aplica reglas históricas mediante nombres IANA como Europe/Madrid.
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
ahora_utc = datetime.now(UTC)
madri = ahora_utc.astimezone(ZoneInfo("Europe/Madrid"))
bogota = ahora_utc.astimezone(ZoneInfo("America/Bogota"))
Convierte con astimezone(). No reemplaces tzinfo mecánicamente en una fecha que ya representa otra zona, porque la reinterpreta sin convertirla.
Guarda instantes ocurridos como valores conscientes en UTC. Para “cada día a las 9 en Madrid”, conserva también la zona IANA porque el offset puede cambiar. fold diferencia dos ocurrencias durante una hora repetida.
La guía de APScheduler explica por qué el scheduler necesita una zona explícita.
La documentación oficial de zoneinfo, consultada el 22 de julio de 2026, cubre datos IANA, fold y el fallback tzdata. Prueba fechas reales de transición para cada zona soportada.
Instante y hora civil
Un instante es un punto global; la hora civil es el valor visto en una región. Un datetime consciente posee tzinfo capaz de determinar su offset. Un valor ingenuo tiene tzinfo=None, por lo que Python no sabe si representa UTC, el servidor o el usuario. Exige ese contexto en la entrada.
Usa identificadores IANA como Europe/Madrid, America/Bogota y Asia/Tokyo. Las siglas son ambiguas, y un offset fijo pierde reglas estacionales e históricas. Valida el identificador:
from zoneinfo import ZoneInfoNotFoundError
def obtener_zona(nombre: str) -> ZoneInfo:
permitidas = {"Europe/Madrid", "America/Bogota", "Asia/Tokyo"}
if nombre not in permitidas:
raise ValueError("zona no admitida")
try:
return ZoneInfo(nombre)
except ZoneInfoNotFoundError as exc:
raise RuntimeError("datos de zonas no disponibles") from exc
Una lista permitida sirve para mercados definidos. Si se admite cualquier zona, crea el selector con zoneinfo.available_timezones() y rechaza valores ajenos al conjunto.
Convertir sin reinterpretar
astimezone() conserva el instante y cambia su representación. replace(tzinfo=...) mantiene el reloj y le da otro significado:
reunion_utc = datetime(2026, 10, 20, 15, 0, tzinfo=UTC)
reunion_madrid = reunion_utc.astimezone(ZoneInfo("Europe/Madrid"))
## Significa 15:00 en Madrid; no es una conversión.
otro_instante = reunion_utc.replace(tzinfo=ZoneInfo("Europe/Madrid"))
Adjuntar una zona con replace() es correcto si una entrada fiable declara una hora local y otro campo indica la región. No sirve para cambiar la visualización de un instante.
Huecos, repeticiones y fold
Cuando el reloj avanza, ciertas horas no existen. Cuando retrocede, un intervalo ocurre dos veces. PEP 495 introdujo fold: cero selecciona el offset anterior y uno, el posterior.
zona = ZoneInfo("America/New_York")
primera = datetime(2026, 11, 1, 1, 30, tzinfo=zona, fold=0)
segunda = datetime(2026, 11, 1, 1, 30, tzinfo=zona, fold=1)
assert primera.timestamp() != segunda.timestamp()
Crear un valor con ZoneInfo no rechaza automáticamente una entrada inexistente o ambigua. Para reservas, pagos y recordatorios médicos, declara la política: pregunta qué ocurrencia se desea, desplaza según una regla documentada o rechaza. Una validación convierte a UTC y de vuelta, comparando reloj y fold.
Guardar datos suficientes
Guarda hechos ocurridos como UTC consciente o epoch sin ambigüedad y convierte solo al mostrar. Las API deben emitir ISO 8601 con Z u offset.
Los horarios civiles futuros necesitan hora local y zona IANA. Convertir “días laborables a las 09:00 en Madrid” una vez a UTC y sumar 24 horas puede mostrar 08:00 o 10:00 tras una transición. Un registro adecuado conserva:
hora_local = 09:00
zona = Europe/Madrid
recurrencia = dias_laborables
Resuelve cada ejecución con las reglas disponibles al calcularla. Si importa la reproducibilidad jurídica o financiera, guarda también el instante UTC y define si las actualizaciones modifican ejecuciones ya generadas.
Interpretar y serializar
datetime.fromisoformat() acepta timestamp con offset, pero el offset no equivale a zona IANA. Si una API recibe hora y zona por separado, valida ambas:
local = datetime.fromisoformat("2026-12-03T09:00:00")
if local.tzinfo is not None:
raise ValueError("se esperaba hora local sin offset")
consciente = local.replace(tzinfo=obtener_zona("Europe/Madrid"))
instante = consciente.astimezone(UTC)
Usa isoformat() en la salida y no elimines el offset. UTC facilita correlacionar logs; conservar la zona original permite mostrar la fecha y etiqueta esperadas.
Datos y pruebas en producción
zoneinfo busca la base del sistema y luego el paquete oficial tzdata. Windows y contenedores mínimos pueden carecer de IANA. Declara tzdata cuando sea necesario y prueba una consulta en el health check. Los objetos ZoneInfo usan caché; tras actualizar los datos, reinicia la aplicación y prueba horarios futuros.
Incluye en las pruebas un hueco de avance, una repetición de retroceso, una región sin cambio estacional y zonas que cambian en fechas distintas. Compara instantes UTC, no abreviaturas. Los gobiernos cambian reglas, por lo que el offset actual no es una premisa permanente.
Antes de publicar, confirma que cada entrada se clasifica como instante u hora local, UTC es consciente, las conversiones usan astimezone() y los eventos civiles conservan IANA. PEP 495, consultada el 28 de julio de 2026, detalla horas ambiguas y fold.
Revisar el flujo completo
Considera un formulario con 2026-11-01 01:30 y America/New_York. El controlador valida formato y zona admitida. La capa de dominio detecta que la hora ocurre dos veces y solicita la ocurrencia deseada, sin elegir en silencio. Después crea el valor consciente, lo convierte a UTC y guarda tanto el instante como el nombre IANA. La API responde con timestamp que contiene offset; la interfaz convierte el instante guardado a la zona elegida por el lector.
Este flujo evita aplicar la zona local del servidor cuando el cliente omite la región. Rechazar datos incompletos es más fiable que hacer depender el resultado del lugar donde se ejecuta un contenedor.
Para eventos recurrentes, prueba la generación, no solo la primera ejecución. Genera fechas que crucen al menos una transición y comprueba reloj local, instante UTC y política de fold. Si el producto permite cambiar la zona, define si significa “mismo instante, otra visualización” o “misma hora local en otra región”. Son operaciones distintas.
Las fechas procedentes de bases de datos, JSON y colas también necesitan contrato. Un driver puede devolver datetime ingenuo aunque una columna represente UTC. Compruébalo en persistencia y normaliza de forma consciente. Al consumir otro servicio, confirma que Z, offset y precisión sobreviven al recorrido completo.
Para mostrar fechas, evita depender solo de la abreviatura, que puede ser ambigua. Una etiqueta como “09:00, hora de Madrid” comunica mejor. Guarda la preferencia del usuario separada de la zona de negocio del evento.
Los logs estructurados deben usar instantes UTC e incluir el nombre IANA en otro campo. Durante una investigación, registra versiones de aplicación y tzdata. Esa evidencia distingue un defecto de conversión de una actualización intencional.
Una tarea periódica puede revisar eventos futuros tras actualizar la base. No debe cambiar citas confirmadas en silencio: identifica diferencias, aplica la política y comunica cambios relevantes. Esta precaución importa especialmente en transporte, pagos y plazos legales.
Incluye estas decisiones en la documentación funcional y en los mensajes del formulario. El usuario debe entender qué zona eligió y cómo se resolverá una hora excepcional antes de confirmar una operación importante.