Todos los artículos

Django · Zonas horarias · Reservas

El cambio de hora en Django 5: huecos que no existen

El 28 de marzo de 2027, a las dos de la madrugada, en España los relojes saltan a las tres. La hora entre las 02:00 y las 02:59 no existe: ni un solo instante de ese día cae ahí. Si tu aplicación genera huecos de reserva recorriendo un horario de apertura, los va a ofrecer igualmente. Y si alguien reserva el de las 02:00, tendrá cita a las 03:00 sin que nadie le avise.

Lo verifiqué en mi propio sistema de reservas y salió exactamente eso. Este artículo explica por qué pasa, por qué no salta ninguna excepción, y cómo comprobar si te afecta. El diagnóstico está comprobado sobre un sistema real; el arreglo que propongo al final es una decisión de diseño, no una corrección que haya ejecutado en producción.

¿Por qué Django ya no avisa?

Con pytz, la forma habitual de convertir una hora local a un instante absoluto pedía internamente is_dst=None. Ante una hora inexistente lanzaba NonExistentTimeError, y ante una ambigua, AmbiguousTimeError. El diseño era deliberado: ante la duda, no adivines. Tu código petaba, tú te enterabas. Django migró a zoneinfo, la implementación de la biblioteca estándar de Python, y is_dst desapareció. zoneinfo no lanza excepción: resuelve la ambigüedad con el atributo fold del propio datetime, que vale 0 por defecto. Así que una hora que no existe se convierte en un instante perfectamente válido, sin error, sin aviso y sin que nadie lo haya decidido conscientemente.

¿Cómo compruebo si me afecta?

Tres comprobaciones, de menos a más concluyente. La tercera es la única que no admite interpretación.

Las tres comprobaciones

  • Genera los huecos de los dos fines de semana del cambio de hora y cuenta cuántos salen. Si el día del salto tiene los mismos que un día normal, sobra uno.
  • Convierte cada hueco a instante absoluto y comprueba que al volver a hora local sale lo mismo que pediste. Si no coincide, ese hueco miente. Si dos huecos distintos dan el mismo instante, tienes duplicados fantasma.
  • Reserva de verdad los dos huecos sospechosos y mira qué queda en la base de datos. Y revisa las tareas programadas: un cron o un beat de Celery a las 02:30 no se ejecuta el día del cambio de primavera, porque esa hora no llega a existir.

¿Cómo se arregla?

Conceptualmente, dos decisiones que hoy estás tomando sin saberlo. Qué hacer con una hora inexistente: lo correcto casi siempre es no ofrecerla, porque no es un hueco sino un agujero en el calendario. Detectarlo es directo: conviertes la hora local a instante y vuelves; si el resultado no coincide con lo que pediste, esa hora no existe. Qué hacer con una hora ambigua: aquí sí hay dos instantes reales y hay que elegir, o mejor, ofrecer los dos distinguidos con fold=0 y fold=1. La regla general: no generes rangos horarios sumando timedelta sobre horas locales. Convierte a instante absoluto una sola vez, opera sobre instantes, y vuelve a hora local solo para mostrar.

¿Importa esto de verdad?

Depende, y conviene ser honesto. Si tu negocio abre a las nueve, el impacto real es nulo: el cambio de hora ocurre de madrugada y ahí no hay nadie. Un hotel, un aparcamiento, una guardia médica o cualquier sistema con actividad nocturna es otra historia, y ahí sí importa dos veces al año. Pero hay una razón para arreglarlo aunque te dé igual: es un fallo que no aparece en los registros. No hay excepción, no hay error, no hay alerta. Aparece como un cliente que llega a la hora equivocada y una discusión sobre quién se equivocó.

La lección que se lleva uno

Cuando una biblioteca deja de lanzar una excepción, no es que el problema haya desaparecido: es que ahora lo estás decidiendo sin enterarte. pytz te obligaba a elegir. zoneinfo elige por ti y sigue adelante. El comportamiento nuevo es más cómodo y en la mayoría de los casos es correcto, pero convierte un error ruidoso en una decisión silenciosa, y las decisiones silenciosas son las que acaban en producción. Merece la pena revisar qué otras excepciones dejaron de lanzarse en las bibliotecas que usas, y qué está eligiendo alguien en tu nombre.

Django 5
zoneinfo
pytz
DST
PostgreSQL