DEV Community

Cover image for Columbia Británica fija el horario y desfasa citas guardadas en timestamptz
lu1tr0n
lu1tr0n

Posted on • Originally published at elsolitario.org

Columbia Británica fija el horario y desfasa citas guardadas en timestamptz

El 8 de marzo de 2026, la provincia canadiense de Columbia Británica adelantó sus relojes una hora y decidió no volver a atrasarlos nunca más. Lo que parece un cambio administrativo menor tiene una consecuencia directa para cualquier base de datos: los valores timestamptz de Postgres que guardan citas futuras en la zona America/Vancouver pueden quedar desfasados exactamente una hora.

No es un fallo de Postgres. Es una propiedad de cómo el motor convierte fechas usando reglas de zona horaria que cambian con el tiempo. Vamos a ver qué pasó, por qué importa y cómo blindar tu esquema antes de que un paciente llegue a su cita una hora tarde.

TL;DR

  • El 8 de marzo de 2026 Columbia Británica adoptó horario de verano todo el año: America/Vancouver es ahora UTC-7 permanente.
  • Los timestamptz de Postgres no guardan zona: guardan un instante UTC y aplican las reglas de tzdata al leer.
  • Citas futuras de noviembre a marzo guardadas antes de actualizar tzdata pueden devolverse una hora tarde.
  • Una consulta con to_char y AT TIME ZONE revela si tu paquete tzdata ya incorporó el cambio.
  • Ubuntu actualiza tzdata cada pocos meses; tras instalarlo hay que reiniciar Postgres.
  • El patrón de columna dual (hora local + nombre IANA + UTC calculado por trigger) sobrevive a cambios de reglas.
  • Usá timestamptz simple para el pasado o instantes autoritativos (logs, pagos); columna dual solo para intención local futura.

Qué pasó en Columbia Británica

Como casi toda Norteamérica, Columbia Británica venía alternando dos veces al año entre horario estándar (PST, UTC-8 en invierno) y horario de verano (PDT, UTC-7 en verano). En marzo de 2026 hizo el clásico salto hacia adelante de una hora, pasando a UTC-7. La diferencia es lo que decidió para noviembre: no va a atrasar los relojes. A partir de ahora, el desfase de America/Vancouver respecto a UTC queda congelado en UTC-7 durante todo el año.

Para la mayoría de la gente esto es una buena noticia: se acaba el ritual de cambiar los relojes y los argumentos de que los cambios de hora afectan el sueño y la productividad. Para quien administra una base de datos, en cambio, significa que una regla que el software daba por estable acaba de cambiar. Y los sistemas que calculan tiempos confiando en reglas antiguas van a producir resultados incorrectos sin lanzar un solo error.

El detalle importante es cuándo aprende tu sistema el cambio. Las nuevas reglas de zona horaria viven en la base de datos IANA (la que alimenta el paquete tzdata de tu sistema operativo). Hasta que no actualices ese paquete y reinicies Postgres, el motor sigue convirtiendo fechas como si Vancouver fuera a volver a UTC-8 en noviembre.

Un cambio de regla horaria altera cálculos guardados meses atrás.

Cómo almacena el tiempo Postgres (timestamptz)

Acá está el malentendido más común y la raíz del problema. El tipo timestamptz no guarda una zona horaria. A pesar de su nombre (timestamp with time zone), internamente Postgres almacena un único instante en UTC: un número que representa un momento absoluto en la línea de tiempo, igual para todo el planeta.

La zona horaria solo entra en juego en dos momentos: al insertar, para convertir tu hora local a UTC, y al leer, para convertir ese UTC de vuelta a la zona que pidas. Si guardás una cita futura como timestamptz usando la zona America/Vancouver, Postgres aplica las reglas de conversión vigentes en el momento de guardar. Cuando consultás esa cita meses después, vuelve a convertir usando las reglas vigentes en el momento de consultar. Si las reglas cambiaron en el medio, la hora local que recuperás ya no es la que el usuario quiso.

💭 Clave: timestamptz es perfecto para registrar cuándo ocurrió algo. Es traicionero para representar cuándo ocurrirá algo en hora de pared, porque la regla que lo traduce puede mudarse antes de esa fecha.

El desfase en acción: un ejemplo

Imaginá una clínica en Vancouver. A principios de 2026, un paciente reserva una cita para las 10:00 del 10 de noviembre de 2026. La aplicación la guarda así:

INSERT INTO appointments (patient_id, starts_at)
VALUES (42, '2026-11-10T10:00:00-08:00');
-- En ese momento las reglas decian que noviembre es UTC-8.
-- Postgres lo almacena como: 2026-11-10 18:00:00+00 (UTC)
Enter fullscreen mode Exit fullscreen mode

En abril de 2026 sale la actualización de tzdata con la nueva regla: Vancouver ya no vuelve a UTC-8 en noviembre, se queda en UTC-7. El instante UTC guardado (18:00) no cambia: es un valor absoluto. Lo que cambia es cómo se traduce de vuelta. El 10 de noviembre, el paciente llega a las 10:00 según su calendario, pero la consulta dice otra cosa:

SELECT starts_at AT TIME ZONE 'America/Vancouver' AS local_time
FROM appointments
WHERE patient_id = 42;
-- devuelve: 2026-11-10 11:00:00
Enter fullscreen mode Exit fullscreen mode

El sistema cree que la cita es a las 11:00. ¿Por qué? Porque 18:00 UTC con la nueva regla UTC-7 da las 11:00 locales, no las 10:00. El recordatorio automático sale a la hora equivocada, la agenda muestra un horario distinto al del paciente y nadie nota nada hasta que alguien llega y la sala está ocupada. Lo peor es que el problema es invisible: no hay error, ni log, ni excepción. Solo datos silenciosamente corridos una hora.

Cómo saber si tu tzdata ya cambió

Antes de arreglar nada conviene saber en qué estado está tu instalación. Sorprendentemente, los paquetes tzdata de distribuciones como Ubuntu se actualizan cada pocos meses, así que dos servidores tuyos pueden tener reglas distintas. Esta consulta te dice qué reglas tiene cargadas tu Postgres ahora mismo:

SELECT to_char(
  '2026-12-01 10:00:00'::timestamp AT TIME ZONE 'America/Vancouver',
  'HH24:MI:SS OF'
) AS diciembre_2026_vancouver;
Enter fullscreen mode Exit fullscreen mode

Interpretá el resultado así:

  • 17:00:00 +00 → tu tzdata ya está actualizado y aplica UTC-7 en invierno. Bien para las citas nuevas, pero tenés que auditar las guardadas antes del cambio.
  • 18:00:00 +00 → tu tzdata todavía usa la regla vieja (UTC-8). Tus datos aún no están partidos entre dos reglas, pero el día que actualices se moverán.

⚠️ Ojo: saber que tzdata cambió no te dice qué citas se crearon antes o después del cambio. Para eso vas a tener que cruzar la fecha de creación de cada registro con la fecha en que actualizaste el paquete. Por eso conviene guardar siempre un created_at.

El patrón de columna dual: la solución que sobrevive

La cura no es guardar la hora local como texto plano y rezar. Es separar dos preguntas distintas que normalmente mezclamos en una sola columna: ¿qué quiso el usuario? y ¿a qué instante UTC corresponde eso ahora mismo?. El patrón de columna dual guarda ambas (en realidad tres columnas):

CREATE TABLE appointments (
  id              bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  local_time      timestamp   NOT NULL,  -- hora de pared, sin zona
  timezone_name   text        NOT NULL,  -- nombre IANA: 'America/Vancouver'
  starts_at_utc   timestamptz NOT NULL,  -- calculado por trigger
  created_at      timestamptz NOT NULL DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

Las columnas local_time y timezone_name juntas responden la intención del usuario: las 10:00 en Vancouver. Son la fuente de verdad y solo cambian si el usuario reprograma. La columna starts_at_utc es derivada: la calculamos a partir de las otras dos y es la que indexás, consultás y usás para detectar colisiones o disparar notificaciones.

Lo ideal sería una columna generada, pero Postgres no permite timestamptz en columnas generadas porque la conversión no se considera inmutable (justamente porque las reglas cambian). La solución es un trigger que recalcula el valor en cada inserción y actualización:

CREATE OR REPLACE FUNCTION calc_starts_at_utc()
RETURNS trigger AS $$
BEGIN
  NEW.starts_at_utc := NEW.local_time AT TIME ZONE NEW.timezone_name;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_calc_utc
BEFORE INSERT OR UPDATE ON appointments
FOR EACH ROW EXECUTE FUNCTION calc_starts_at_utc();
Enter fullscreen mode Exit fullscreen mode

El flujo queda así: la intención manda, el UTC se deriva, y si un día cambian las reglas solo tenés que recalcular starts_at_utc con un UPDATE masivo, sin perder lo que el usuario realmente quiso.

graph LR
  A["Hora local + zona IANA"] --> B["Trigger calcula UTC"]
  B --> C["starts_at_utc en UTC"]
  C --> D["Índices, consultas y alertas"]
Enter fullscreen mode Exit fullscreen mode

La intención local manda; el instante UTC se recalcula cuando hace falta.

No te pases de la raya. El patrón de columna dual agrega costo y complejidad, y solo vale la pena cuando la intención local futura es lo autoritativo: citas, entregas, plazos legales, eventos de calendario. Para eventos del pasado o cuando el instante UTC exacto es la verdad —entradas de log, transacciones financieras, lecturas de sensores— usá un timestamptz simple y listo.

Actualizar tzdata en Windows, macOS y Linux

Mantener tzdata al día es parte de la higiene operativa. Después de actualizar el paquete hay que reiniciar Postgres para que recargue las reglas. Según tu sistema:

# Linux (Debian / Ubuntu)
sudo apt-get update
sudo apt-get install --only-upgrade tzdata
sudo systemctl restart postgresql

# Linux (RHEL / Fedora / Rocky)
sudo dnf update tzdata
sudo systemctl restart postgresql

# macOS (Homebrew)
brew update
brew upgrade
brew services restart postgresql@17

# Windows
# El instalador de EDB empaqueta su propia tzdata.
# Actualiza a la ultima version menor de Postgres y reinicia el servicio:
net stop postgresql-x64-17
net start postgresql-x64-17
Enter fullscreen mode Exit fullscreen mode

Después del reinicio, repetí la consulta de verificación de la sección anterior. Si el valor pasó de 18:00 a 17:00, las reglas nuevas ya están activas y toca auditar las citas creadas antes de ese momento.

Impacto y qué sigue

Columbia Británica no es un caso aislado. La tendencia global a abolir el cambio de hora —discutida en Estados Unidos, en la Unión Europea y en varios países de LATAM— significa que las reglas de tzdata van a seguir cambiando en los próximos años. México, por ejemplo, eliminó el horario de verano en gran parte del país en 2022, y cada uno de esos ajustes obliga a una nueva versión del paquete. Cualquier sistema que guarde fechas futuras en hora local está expuesto.

La lección para equipos en LATAM es doble. Primero, tratá tzdata como una dependencia más: monitoreá su versión, actualizala de forma controlada y documentá cuándo lo hiciste. Segundo, decidí conscientemente qué columnas necesitan preservar intención local futura y cuáles solo necesitan un instante absoluto. Esa decisión, tomada al diseñar el esquema, es la diferencia entre un UPDATE de mantenimiento y un incidente de producción con pacientes en la sala de espera equivocada.

Si ya tenés citas guardadas como timestamptz y trabajás con Vancouver, el primer paso es inventariar: ¿cuántos registros futuros caen entre noviembre y marzo? ¿Se crearon antes o después de actualizar tzdata? Con esa información podés migrar al patrón de columna dual reconstruyendo local_time y timezone_name a partir de lo que sabés de cada cita, y dejar que el trigger recalcule el UTC correcto.

📖 Resumen en Telegram: Ver resumen

Preguntas frecuentes

¿timestamptz guarda la zona horaria que le paso?

No. A pesar del nombre, timestamptz guarda únicamente un instante en UTC. La zona que indicás solo se usa para convertir a UTC al insertar y de vuelta a hora local al leer. La cadena de zona no se almacena.

¿Por qué no usar una columna generada para el UTC?

Porque Postgres exige que las expresiones de columnas generadas sean inmutables, y la conversión a timestamptz no lo es: depende de las reglas de zona horaria, que cambian con el tiempo. Por eso se usa un trigger BEFORE INSERT OR UPDATE.

¿Cuándo me conviene timestamptz simple en vez de columna dual?

Cuando el instante UTC exacto es la verdad: logs, transacciones financieras, lecturas de sensores y, en general, cualquier evento del pasado. El patrón de columna dual solo vale la pena para intención local futura, como citas o plazos.

¿Cada cuánto se actualiza tzdata?

Depende de la distribución, pero en Ubuntu suele actualizarse cada pocos meses, cada vez que IANA publica una revisión. Por eso dos servidores de la misma flota pueden tener reglas distintas si no los actualizás de forma coordinada.

¿Tengo que reiniciar Postgres tras actualizar tzdata?

Sí. Postgres carga las reglas de zona horaria al iniciar. Después de instalar el nuevo paquete tzdata hay que reiniciar el servicio para que el motor use las reglas actualizadas.

¿Cómo audito qué citas quedaron desfasadas?

Cruzá la columna created_at de cada registro con la fecha en que actualizaste tzdata. Las citas futuras en la zona afectada creadas antes de esa fecha son las candidatas a estar corridas una hora.

Referencias

📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.

Top comments (0)