Por qué las zonas horarias son tan difíciles
Las zonas horarias no son solo un offset respecto a UTC. Son reglas políticas que cambian con el tiempo. Países adoptan y abandonan el horario de verano (DST), modifican sus offsets, se dividen en nuevas zonas o unifican zonas existentes. Samoa saltó un día entero en 2011 al cambiar de UTC-11 a UTC+13. Marruecos cambió sus reglas de DST cuatro veces en cinco años. Estas decisiones son políticas, no técnicas.
La base de datos IANA (también llamada tz database u Olson database) es la fuente de verdad para reglas de zonas horarias. Se actualiza varias veces al año cuando gobiernos anuncian cambios. Tu sistema operativo, tu lenguaje de programación y tus librerías de fechas dependen de esta base de datos. Si no la actualizas, tus conversiones serán incorrectas para las zonas que hayan cambiado.
El error conceptual más frecuente es confundir un offset con una zona horaria. "UTC+2" no es una zona horaria, es un offset momentáneo. Madrid está en UTC+1 en invierno y UTC+2 en verano. Si almacenas "+02:00" en lugar de "Europe/Madrid", pierdes la información de que el offset cambiará en octubre. Y si alguien consulta una fecha histórica, no sabrás qué offset aplicaba en esa época.
Esta guía te dará las reglas y patrones para manejar zonas horarias correctamente. No hay atajos: necesitas entender la diferencia entre instantes y tiempo civil, usar la base de datos IANA, y ser explícito sobre qué zona aplica a cada dato temporal en tu sistema.
UTC, offsets y la base de datos IANA
UTC (Coordinated Universal Time) es el estándar de referencia global. No tiene horario de verano, no cambia nunca. Es el punto fijo alrededor del cual todo lo demás se define. Cuando dices "esta reunión es a las 14:00 UTC", no hay ambigüedad posible — es un instante exacto en el tiempo independientemente de dónde estés.
Un offset es la diferencia en horas y minutos respecto a UTC en un momento dado. "UTC+05:30" (India) o "UTC-03:00" (Argentina). Los offsets no siempre son horas enteras: Nepal es UTC+05:45, las Islas Chatham son UTC+12:45. Y un offset puede cambiar para la misma ubicación geográfica cuando se aplica DST.
Los identificadores IANA siguen el formato Región/Ciudad: "America/Buenos_Aires", "Europe/Madrid", "Asia/Tokyo". Cada identificador encapsula toda la historia de reglas para esa zona: cuándo se adoptó DST, cuándo se cambió el offset base, excepciones históricas. "America/Argentina/San_Luis" tiene reglas diferentes a "America/Buenos_Aires" porque esa provincia argentina cambió sus reglas varias veces de forma independiente.
La regla de oro: almacena siempre instantes en UTC y la zona horaria IANA del usuario como dato separado. Nunca almacenes solo la hora local sin zona. Nunca almacenes solo un offset sin el identificador IANA. Con UTC + zona IANA puedes reconstruir la hora local correcta incluso si las reglas cambian en el futuro.
// Un offset NO es una zona horaria
const fecha = new Date('2026-03-15T10:00:00+02:00');
// ¿Es horario de verano de Madrid? ¿Es hora estándar de El Cairo?
// ¿Es Sudáfrica (UTC+2 siempre)? No podemos saberlo.
// La zona IANA resuelve la ambigüedad
const zonaUsuario = 'Europe/Madrid';
// Madrid: UTC+1 en invierno, UTC+2 en verano
// El 15 de marzo ya aplica horario de verano
// Almacenar: instante UTC + zona
const registro = {
timestamp: '2026-03-15T08:00:00Z', // UTC (instante absoluto)
timezone: 'Europe/Madrid', // zona IANA del usuario
};
// Mostrar: convertir UTC a hora local del usuario
const local = new Date(registro.timestamp)
.toLocaleString('es-ES', { timeZone: registro.timezone });
// "15/3/2026, 10:00:00" (UTC+2 por DST)Horario de verano (DST): la fuente de los bugs
El horario de verano (Daylight Saving Time) adelanta los relojes una hora en primavera y los atrasa en otoño. Esto crea dos anomalías: el día del adelanto tiene solo 23 horas (se "pierde" una hora), y el día del atraso tiene 25 horas (una hora se "repite"). Si tu código asume que todos los días tienen 24 horas, fallarás exactamente dos días al año.
El momento del cambio varía por zona. Europa cambia el último domingo de marzo y octubre. Estados Unidos el segundo domingo de marzo y primer domingo de noviembre. Australia (hemisferio sur) cambia en abril y octubre pero en dirección opuesta. Y muchos países no usan DST en absoluto: Japón, China, India, la mayor parte de África y Sudamérica.
El bug clásico: programar una tarea recurrente a las 02:30 hora local. En el día de adelanto (spring forward), las 02:30 no existen — el reloj salta de 01:59 a 03:00. En el día de atraso (fall back), las 02:30 ocurren dos veces. ¿Cuál de las dos ejecutas? Los schedulers ingenuos pueden saltar la ejecución o ejecutar doble.
Otro bug frecuente: calcular "dentro de 24 horas" sumando 86400 segundos. Si estás en un día de transición DST, 24 horas después podría ser las 23:00 o la 01:00 del día siguiente (hora local), no la misma hora. La solución: usa librerías de fecha que entienden zonas horarias (Temporal API, Luxon, date-fns-tz) y suma "1 día" como concepto, no como segundos.
// Demostración del problema de DST en España 2026
// Cambio de horario: último domingo de marzo (29 marzo)
// A las 02:00 → saltan a 03:00
// Las 02:30 del 29 de marzo NO EXISTEN en Europe/Madrid
const problematica = new Date('2026-03-29T02:30:00');
console.log(
problematica.toLocaleString('es-ES', { timeZone: 'Europe/Madrid' })
);
// El navegador "ajusta" — resultado impredecible
// Calcular "mañana a la misma hora" — forma incorrecta
const hoy = new Date('2026-03-28T10:00:00Z'); // Sábado
const mananaIncorrecto = new Date(hoy.getTime() + 86400000); // +24h
// Resultado: 29 mar 11:00 hora local (por el salto DST)
// Forma correcta: usar Temporal (API moderna)
// const manana = Temporal.ZonedDateTime.from({
// timeZone: 'Europe/Madrid',
// year: 2026, month: 3, day: 28, hour: 10
// }).add({ days: 1 });
// Resultado: 29 mar 10:00 hora local (correcto)Almacenamiento correcto en bases de datos
La regla universal: almacena timestamps en UTC. No importa si usas PostgreSQL, MySQL, MongoDB o cualquier otro motor. El instante en que algo ocurrió es un hecho absoluto — almacenarlo en UTC lo hace inequívoco, comparable y ordenable. La zona horaria del usuario es un dato de presentación, no de almacenamiento.
En PostgreSQL, usa TIMESTAMPTZ (timestamp with time zone). A pesar del nombre, no almacena la zona — convierte la entrada a UTC y almacena eso. Al recuperar, el servidor convierte a la zona de la sesión. TIMESTAMP (sin TZ) almacena lo que le des sin interpretación — si le pasas hora local sin zona, tendrás problemas al comparar con otras zonas.
En MySQL, DATETIME almacena literal sin conversión. TIMESTAMP convierte a UTC al almacenar y a la zona de la sesión al recuperar (similar a PostgreSQL). La limitación histórica de TIMESTAMP en MySQL (rango hasta 2038) se resolvió en MySQL 8.0.28+ con soporte extendido. Para aplicaciones nuevas, usa TIMESTAMP o almacena explícitamente en UTC con DATETIME.
Para eventos futuros (reuniones, vuelos, recordatorios), almacena además la zona IANA como texto. Si un país cambia sus reglas de DST después de que el usuario programó la reunión, puedes recalcular la hora UTC correcta. Sin la zona, no sabrás si "10:00 UTC+2" era horario de verano de Madrid o hora estándar de El Cairo.
-- PostgreSQL: uso correcto de TIMESTAMPTZ
CREATE TABLE eventos (
id SERIAL PRIMARY KEY,
nombre TEXT NOT NULL,
-- Almacena en UTC internamente, convierte al mostrar
inicio TIMESTAMPTZ NOT NULL,
fin TIMESTAMPTZ NOT NULL,
-- Zona IANA para eventos futuros (recalcular si cambian reglas)
zona_usuario TEXT NOT NULL DEFAULT 'UTC'
);
-- Insertar con zona explícita (PostgreSQL convierte a UTC)
INSERT INTO eventos (nombre, inicio, fin, zona_usuario)
VALUES (
'Reunión de equipo',
'2026-07-15 10:00:00 Europe/Madrid',
'2026-07-15 11:00:00 Europe/Madrid',
'Europe/Madrid'
);
-- Consultar mostrando en la zona del usuario
SET timezone = 'America/New_York';
SELECT nombre, inicio, fin FROM eventos;
-- Muestra convertido a hora de Nueva York automáticamente
-- Comparar correctamente (todo es UTC internamente)
SELECT * FROM eventos
WHERE inicio > NOW()
ORDER BY inicio;Conversión entre zonas horarias en JavaScript
JavaScript históricamente ha sido terrible para manejar zonas horarias. El objeto Date almacena un instante UTC internamente pero lo muestra en la zona local del sistema. No hay forma nativa de crear un Date "en" una zona específica. Esto ha generado décadas de bugs y la proliferación de librerías como Moment.js (ya obsoleta), Luxon, date-fns y day.js.
La API Temporal es la solución definitiva, disponible en todos los navegadores modernos desde 2025. Introduce conceptos explícitos: Temporal.Instant (un punto absoluto en el tiempo), Temporal.ZonedDateTime (un instante con zona), Temporal.PlainDateTime (fecha y hora sin zona). Esta separación conceptual previene la mayoría de bugs de zonas horarias por diseño.
Mientras Temporal alcanza adopción completa, Intl.DateTimeFormat con la opción timeZone es la alternativa estándar para formateo. toLocaleString("es-ES", { timeZone: "America/Mexico_City" }) convierte y formatea en un solo paso. Para manipulación, date-fns-tz ofrece funciones puras sin mutación que trabajan con zonas IANA.
Un patrón seguro para aplicaciones web: el servidor trabaja exclusivamente en UTC, almacena UTC, compara en UTC. La zona del usuario viaja como encabezado HTTP o se guarda en su perfil. La conversión a hora local ocurre solo en el frontend al mostrar datos, o en el servidor justo antes de enviar la respuesta. Nunca conviertas a hora local para almacenar o comparar.
// Conversión entre zonas con Intl.DateTimeFormat
function convertirZona(fecha, zonaOrigen, zonaDestino) {
// Crear un formatter para la zona de destino
const formatter = new Intl.DateTimeFormat('es-ES', {
timeZone: zonaDestino,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
return formatter.format(fecha);
}
// Ejemplo: ¿Qué hora es en Tokio cuando son las 10:00 en Madrid?
const madridDate = new Date('2026-06-04T10:00:00+02:00');
console.log(convertirZona(madridDate, 'Europe/Madrid', 'Asia/Tokyo'));
// "04/06/2026, 17:00:00" (Tokio = UTC+9, Madrid verano = UTC+2)
// Con Temporal API (recomendado en 2026)
// const madrid = Temporal.ZonedDateTime.from({
// timeZone: 'Europe/Madrid',
// year: 2026, month: 6, day: 4,
// hour: 10, minute: 0
// });
// const tokio = madrid.withTimeZone('Asia/Tokyo');
// tokio.toString(); // "2026-06-04T17:00:00+09:00[Asia/Tokyo]"Errores comunes y cómo evitarlos
Error 1: Asumir que el offset de una zona es fijo. "España es UTC+1" es incorrecto la mitad del año. Si hardcodeas offsets en lugar de usar identificadores IANA, tu código fallará en cada transición DST. Usa siempre la librería del sistema para resolver zonas — nunca calcules offsets manualmente con una tabla.
Error 2: Parsear fechas sin zona como si fueran UTC. new Date("2026-06-04T10:00:00") (sin Z ni offset) se interpreta como hora local del sistema en JavaScript. Si tu servidor está en UTC y tu laptop en UTC+2, el mismo código produce resultados diferentes. Siempre incluye el indicador de zona: "2026-06-04T10:00:00Z" para UTC o "2026-06-04T10:00:00+02:00" para un offset explícito.
Error 3: Calcular duración en días multiplicando por 86400 segundos. Un "día" en hora local no siempre tiene 86400 segundos por el DST. Para calcular "dentro de 3 días", suma 3 días calendario (respetando la zona) en lugar de 259200 segundos. Las librerías de fecha correctas distinguen entre duración absoluta (horas, minutos, segundos) y duración calendario (días, meses, años).
Error 4: Usar la zona del servidor para lógica de negocio. Si tu servidor está en "US/Eastern" y un usuario de Japón crea un recordatorio para "mañana", ¿mañana de quién? Siempre usa la zona del usuario para cualquier concepto que involucre "hoy", "mañana", "esta semana" o "inicio del día". La zona del servidor es irrelevante y no debe afectar la lógica.
// ❌ Error: parsear sin zona
const malo = new Date('2026-06-04T10:00:00');
// ¿UTC? ¿Hora local? Depende del entorno — BUG
// ✅ Correcto: siempre incluir zona
const utc = new Date('2026-06-04T10:00:00Z');
const madrid = new Date('2026-06-04T10:00:00+02:00');
// ❌ Error: sumar días como segundos
const hoy = new Date('2026-03-28T10:00:00Z');
const tresDias = new Date(hoy.getTime() + 3 * 86400 * 1000);
// Si cruza un cambio DST, la hora local será distinta
// ✅ Correcto: usar funciones de fecha que respetan calendario
// Con date-fns-tz:
// import { addDays } from 'date-fns';
// const resultado = addDays(hoy, 3);
// ❌ Error: offset hardcodeado
const offsetEspaña = 1; // ¡Solo en invierno!
// ✅ Correcto: dejar que la librería resuelva
const horaLocal = utc.toLocaleString('es-ES', {
timeZone: 'Europe/Madrid' // Resuelve DST automáticamente
});Zonas horarias en APIs y comunicación entre servicios
El formato estándar para APIs es ISO 8601 con offset explícito o indicador UTC: "2026-06-04T10:00:00Z" o "2026-06-04T10:00:00+02:00". Nunca envíes fechas sin zona en una API — el receptor no puede saber qué zona asumiste. Si tu API acepta entrada del usuario, valida que incluya zona o asume UTC y documéntalo explícitamente.
Para eventos recurrentes en APIs (reuniones semanales, alertas diarias), envía la hora local junto con la zona IANA: { "hora": "10:00", "zona": "Europe/Madrid", "recurrencia": "RRULE:FREQ=WEEKLY" }. El servidor calcula la próxima ocurrencia en UTC considerando las reglas DST vigentes. Si solo almacenas la primera ocurrencia en UTC, el horario se desplazará tras cada cambio de hora.
En arquitecturas de microservicios, establece una convención: todos los timestamps internos son UTC, la conversión a zona local ocurre solo en el servicio de presentación (BFF o frontend). Los mensajes en colas (Kafka, SQS, RabbitMQ) llevan timestamps UTC. Los logs llevan timestamps UTC. No hay excepciones.
Para depuración, incluye siempre la zona del usuario en los logs de negocio relevantes. "Usuario creó orden a 2026-06-04T08:00:00Z (zona: America/Mexico_City, hora local: 03:00)" te da contexto completo cuando investigas un problema. Sin la zona, no puedes saber si una acción a las 03:00 local es sospechosa o normal para el usuario.
// Modelo para eventos recurrentes con zona horaria
interface EventoRecurrente {
id: string;
titulo: string;
// Hora local del evento (no UTC)
horaLocal: string; // "10:00"
// Zona IANA del creador
zona: string; // "Europe/Madrid"
// Regla de recurrencia (iCal RRULE)
recurrencia: string; // "RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR"
// Próxima ocurrencia calculada en UTC (se recalcula)
proximoUtc: string; // "2026-06-04T08:00:00Z"
}
// Calcular próxima ocurrencia respetando DST
function calcularProximaOcurrencia(evento: EventoRecurrente): string {
// Usar la zona IANA para resolver la hora correcta en UTC
// considerando si aplica DST en esa fecha
const opciones: Intl.DateTimeFormatOptions = {
timeZone: evento.zona,
// ... opciones de formato
};
// La librería de recurrencia (rrule.js) genera fechas
// en la zona local, luego convertimos a UTC para almacenar
// Esto garantiza que "10:00 Madrid" siempre sea correcto
// independientemente de si es invierno (UTC+1) o verano (UTC+2)
return new Date(/* cálculo con zona */).toISOString();
}Testing y validación de lógica temporal
Probar código con zonas horarias requiere controlar el "ahora" y la zona del sistema. Si tus tests se ejecutan en un servidor CI en UTC pero tu código asume hora local, los tests pasarán en CI y fallarán en tu máquina (o viceversa). La primera regla: haz que tu código acepte "ahora" como parámetro inyectable en lugar de llamar directamente a Date.now() o new Date().
Escribe tests específicos para transiciones DST. Prueba el día antes, durante y después del cambio de hora. Para la zona Europe/Madrid en 2026: prueba el 28 de marzo (día normal), 29 de marzo (spring forward, 23h) y 30 de marzo (día normal post-cambio). Prueba igualmente el cambio de octubre (fall back, 25h). Estos son exactamente los días donde los bugs se manifiestan.
Usa zonas horarias inusuales en tus tests para exponer asunciones incorrectas: Asia/Kathmandu (UTC+05:45, offset no entero), Pacific/Chatham (UTC+12:45), Pacific/Kiritimati (UTC+14, "mañana" para la mayor parte del mundo). Si tu código funciona con estas zonas extremas, funcionará con cualquiera.
Para tests de integración con base de datos, verifica que almacenas y recuperas correctamente ejecutando el mismo test con diferentes configuraciones de zona en el servidor. En PostgreSQL: SET timezone = "Asia/Tokyo"; luego inserta y recupera un timestamp. El valor UTC almacenado no debe cambiar — solo su representación local.
// Test: verificar comportamiento durante transición DST
describe('Eventos durante cambio de horario', () => {
// Spring forward en Madrid: 29 marzo 2026, 02:00 → 03:00
it('maneja correctamente el día con 23 horas', () => {
const zona = 'Europe/Madrid';
// 01:30 existe
const antes = new Date('2026-03-29T00:30:00Z'); // 01:30 Madrid
expect(horaLocal(antes, zona)).toBe('01:30');
// 02:30 NO existe (se salta a 03:00)
// El sistema debe ajustar a 03:30 o rechazar
const inexistente = '2026-03-29T02:30:00';
expect(() => validarHoraLocal(inexistente, zona))
.toThrow('La hora 02:30 no existe en Europe/Madrid el 29/03/2026');
// 03:30 existe (ya en horario de verano, UTC+2)
const despues = new Date('2026-03-29T01:30:00Z'); // 03:30 Madrid
expect(horaLocal(despues, zona)).toBe('03:30');
});
// Inyectar "ahora" para tests reproducibles
it('calcula "mañana" correctamente cruzando DST', () => {
const ahora = new Date('2026-03-28T22:00:00Z'); // 28 mar, 00:00 Madrid
const manana = calcularManana(ahora, 'Europe/Madrid');
// "Mañana" = 29 mar, misma hora local (00:00)
// Pero en UTC es 22:00 del 28 (no 23:00) por el salto
expect(manana.toISOString()).toBe('2026-03-28T22:00:00.000Z');
});
});