Zonas horarias: deja de mover el reloj

Casi todos los bugs de fechas empiezan confundiendo un momento con la lectura de un reloj de pared. Esta guía le pone nombre a cada tipo de tiempo y luego muestra exactamente qué guardar, qué enviar, cómo agendar y qué inspeccionar cuando producción está una hora o un día desfasada.

🎙️ Publicado y grabado: ·

01Un instante no es una hora local

Un instante es un punto en la línea de tiempo global. Una fecha-hora local es lo que muestra un reloj de pared en algún lugar. "El veinticinco de julio a las nueve" no es un instante hasta que le agregas una zona horaria. El mismo instante puede ser sábado por la tarde en Londres y domingo por la mañana en Tokio.

# One instant, three displays
2026-07-25T15:00:00Z       UTC
2026-07-25T11:00:00-04:00  New York display
2026-07-26T00:00:00+09:00  Tokyo display

# Not enough information to identify an instant
2026-07-25 09:00
Nombra el valor antes de tocarloPregunta: ¿esto es cuándo pasó algo, lo que debe mostrar un reloj, una fecha de calendario, o una regla humana que se repite? Si el requisito del producto dice solo "enviar a las nueve", el código no está listo. Pregunta las nueve dónde, y si las nueve deben seguir siendo las nueve cuando cambie el horario de verano.

02UTC, offsets y zonas IANA son cosas distintas

UTC es la línea de tiempo de referencia. Un offset, como menos cuatro horas, describe una relación con UTC en un instante. Una zona IANA, como America barra New York, es un reglamento con los cambios de offset históricos y futuros. Un offset no es una zona horaria. No puede decirte cuál será el offset en marzo próximo.

UTC                         reference: Z or +00:00
-04:00                      fixed offset, no DST rules
America/New_York            IANA zone with rule history
Etc/GMT+4                   fixed offset; sign is reversed by convention
EST                         ambiguous abbreviation; do not store it

Usa identificadores IANA en las fronteras del producto. "CST" puede significar el Central de Norteamérica, la hora estándar de China o la de Cuba. Los nombres de zona de Windows son otro sistema de nombres y a menudo necesitan un mapeo explícito. Adivinar a partir del offset actual del usuario también está mal: muchas zonas comparten offset hoy y se separan más adelante.

03ISO 8601: haz visible el offset

Para un instante en una API, envía una cadena ISO ocho seis cero uno con Z o con un offset numérico. Z significa UTC. La letra T separa fecha y hora. Los segundos fraccionarios son opcionales. Una cadena de fecha-hora sin offset está incompleta a propósito, y cada runtime puede interpretarla como hora local, como UTC, o rechazarla.

2026-07-25T15:04:05Z          # unambiguous UTC instant
2026-07-25T11:04:05-04:00     # same style, explicit offset
2026-07-25                   # calendar date, not midnight UTC
2026-07-25 11:04:05          # ambiguous; no offset or zone

# JavaScript: serialize an instant in UTC
new Date("2026-07-25T11:04:05-04:00").toISOString()
"2026-07-25T15:04:05.000Z"
Contrato de API, no ruleta de parsersEspecifica si cada campo es un instante, una fecha-hora local, una fecha o una zona IANA. Rechaza las cadenas sin offset donde se requiere un instante. No documentes todos los campos temporales como "fecha ISO"; esa frase esconde justo la distinción que causa el bug.

04El horario de verano crea un hueco y un solape

Cuando los relojes se adelantan, hay un rango de horas locales que nunca ocurre. Eso es un hueco. Cuando se atrasan, un rango ocurre dos veces. Eso es un solape. El ocho de marzo de dos mil veintiséis en Nueva York, el reloj salta de la una cincuenta y nueve a las tres. Las dos y media son imaginarias. El primero de noviembre, la una y media pasa dos veces con dos offsets distintos.

# America/New_York, 2026
2026-03-08 01:59:59-05:00
                 ↓ next second
2026-03-08 03:00:00-04:00
2026-03-08 02:30 does not exist

2026-11-01 01:30:00-04:00  # first occurrence
2026-11-01 01:30:00-05:00  # second occurrence
"Está exactamente una hora mal"Síntoma de ejemplo: Expected 09:00, got 10:00. Uno: registra el instante, la zona IANA y el offset resuelto en ambos caminos. Dos: revisa si un camino sumó veinticuatro horas fijas o reusó el offset de ayer. Tres: suma días de calendario en la zona destino para agendas de reloj de pared; suma segundos transcurridos solo para duraciones. Cuatro: prueba las dos transiciones del horario de verano. No parches la salida restando una hora.

05Guarda el significado, no una forma universal

Para eventos que ya pasaron, guarda un instante en un tipo de base de datos con semántica UTC clara y convierte al mostrar. Conserva además el offset original cuando la auditoría o la presentación legal lo exijan. Mi postura sin adornos: "guarda todo en UTC" es buen consejo para eventos pasados y mal consejo para cumpleaños, horarios de atención y agendas locales futuras. Esos valores todavía no son instantes.

# PostgreSQL shapes
occurred_at  timestamptz   # instant; normalized internally
birth_date   date          # calendar date
opens_at     time          # local wall time, paired with business zone
starts_local timestamp     # local intent
zone_id      text          # e.g. Europe/Paris

# Persist an explicit contract
{"occurredAt":"2026-07-25T15:04:05Z"}
{"startsLocal":"2027-03-28T09:00:00","timeZone":"Europe/Paris"}

El timestamp with time zone de PostgreSQL guarda un instante, no el nombre de zona que enviaste. MySQL y SQLite se comportan distinto. Lee las reglas reales de tipos de tu base de datos y fija la zona de la conexión o de la sesión de forma explícita. Nombres de columna como created_at_utc son documentación baratísima.

06Las agendas futuras necesitan intención más reglas

Un vuelo, una cita o un "cada día laboral a las nueve" pertenecen a una regla local en una zona con nombre. Guarda la fecha y hora local, la zona IANA y la política para huecos y solapes. Puedes cachear el próximo instante UTC para ejecutar rápido, pero recalcula las ocurrencias futuras cuando cambien los datos de zonas horarias. Los gobiernos cambian las reglas del reloj con menos aviso que tu periodo de retención de datos.

{
  "localStart": "2027-10-31T01:30:00",
  "timeZone": "Europe/London",
  "foldPolicy": "later",
  "gapPolicy": "shift-forward"
}

# Define recurrence in calendar terms
weekdays at 09:00 in Europe/London
not: every 86,400 seconds forever
Elige la política; no heredes el accidente de una libreríaPara una hora que no existe, recházala o córrela hacia adelante y avísale al usuario. Para una hora ambigua, elige la primera o la segunda ocurrencia y deja registrada esa decisión. Los valores por defecto de las librerías difieren, así que una política implícita puede cambiar cuando el código se mueve entre JavaScript, Python y la base de datos.

07Un valor de solo fecha debe seguir siendo solo fecha

Los cumpleaños, las fechas de factura y las fechas de salida de un hotel son fechas de calendario. Convertirlas a medianoche UTC inventa un instante. Muestra ese instante inventado al oeste de UTC y la fecha se corre hacia atrás. Mantén YYYY-MM-DD como tipo fecha o como cadena validada hasta que una regla de negocio real le asigne hora y zona.

# The classic browser bug in an America/Los_Angeles environment
new Date("2026-07-25").toString()
"Fri Jul 24 2026 17:00:00 GMT-0700 ..."

Expected 2026-07-25, got 2026-07-24
# Correct date-only handling
const birthday = "2026-07-25"; # validate, store, display as a date
Error de un día, resuelto paso a pasoUno: revisa el valor crudo de la API antes de parsearlo. Dos: si es solo fecha, deja de construir un instante Date. Tres: mantenlo en un tipo de solo fecha en toda la API y la base de datos. Cuatro: formatea año, mes y día sin aplicar conversión de zona. Sumar doce horas es un disfraz frágil, no una solución.

08Timestamps Unix: segundos contra milisegundos

Un timestamp Unix cuenta el tiempo transcurrido desde la época Unix, normalmente ignorando los segundos intercalares. Python suele aceptar segundos. El constructor Date de JavaScript acepta milisegundos. Hoy, un timestamp en segundos tiene unos diez dígitos; en milisegundos, unos trece. Deducir por la cantidad de dígitos sirve mientras depuras, pero el contrato de la API tiene que nombrar la unidad.

1753455845       # seconds
1753455845000    # milliseconds

# JavaScript
new Date(seconds * 1000)
new Date(milliseconds)

# Python, aware UTC datetime
datetime.fromtimestamp(seconds, tz=timezone.utc)
RangeError: Invalid time valueRangeError: Invalid time value aparece a menudo cuando una entrada inválida llega a toISOString(). Uno: registra el valor original y su tipo. Dos: rechaza null, texto vacío y NaN. Tres: confirma si son segundos o milisegundos. Cuatro: construye la fecha y verifica que Number.isNaN(date.getTime()) sea falso antes de formatear. No captures la excepción para emitir la fecha de hoy.

09Depura entre Python, JavaScript, APIs y SQL

No empieces por el formato. Captura el valor en cada frontera: texto crudo, tipo parseado, valor de época, offset, zona IANA, tipo de base de datos, zona de sesión y zona final de visualización. Compara instantes como instantes. Convierte solo en el borde. Una captura de pantalla que dice "tres de la tarde" es evidencia débil; una cadena ISO con zona y offset es evidencia útil.

# Python: create aware values
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
now = datetime.now(timezone.utc)
local = now.astimezone(ZoneInfo("America/New_York"))

TypeError: can't compare offset-naive and offset-aware datetimes

# JavaScript: inspect the instant, then display in a named zone
console.log(date.toISOString(), date.getTime())
new Intl.DateTimeFormat("en", {timeZone:"America/New_York",
  dateStyle:"full", timeStyle:"long"}).format(date)
Naive contra aware, resuelto paso a pasoPara TypeError: can't compare offset-naive and offset-aware datetimes, uno: imprime el repr y el tzinfo de cada valor. Dos: decide qué zona pretendía usar la fuente naive; no asumas UTC porque es cómodo. Tres: adjunta esa zona de origen con ZoneInfo, manejando la política de hueco o solape. Cuatro: convierte los dos valores a UTC y compáralos. replace(tzinfo=UTC) reetiqueta el reloj; no lo convierte.

Orden sistemático: reproduce con un instante conocido; fija las zonas del proceso, de la base de datos y del navegador; registra la entrada cruda y la época; revisa el offset de la API; revisa la columna SQL y la zona de sesión; convierte una sola vez para mostrar; y luego agrega casos de regresión para la medianoche UTC y las dos transiciones del horario de verano.

10Cheat sheet de zonas horarias

Usa esta tabla de decisión antes de elegir un tipo o escribir una conversión.

Already happened?       → instant; store UTC semantics
Display for a user?     → instant + chosen IANA zone
Future local schedule?  → local datetime + IANA zone + gap/fold policy
Birthday/invoice date?  → date only; never invent midnight UTC
Recurring at 09:00?     → calendar recurrence in its IANA zone
Elapsed for 24 hours?   → duration, not “same time tomorrow”

API instant             → 2026-07-25T15:04:05Z
API date                → 2026-07-25
Zone                    → America/New_York, not EST
Unix input              → unit stated: seconds or milliseconds

One hour wrong          → DST rule, fixed offset, or double conversion
One day wrong           → date-only parsed as an instant near UTC midnight
Wild historical result  → seconds/milliseconds or stale zone data

Debug: raw → parsed type → epoch → offset → IANA zone
       → DB type/session zone → API string → display zone

La regla que conservo: nunca convertir un valor hasta poder decir qué significa. UTC es una línea de tiempo, no una interfaz universal para usuarios. Las zonas IANA son reglas, no adornos. Las fechas no son medianoches. En cuanto esas tres distinciones sobreviven a las fronteras de tu base de datos y de tu API, la mayoría de los "misterios" de fechas deja de ser misteriosa.

Tell me what missed

A correction is more useful than a compliment. This goes straight to the person who writes SwiftGrasp.

Was this page useful?
0/1000

Please do not include passwords, private keys, or personal information.