AT TIME ZONE es una herramienta con dos caras. Lo que hace depende por completo del tipo de su entrada: para un timestamptz representa el momento como la hora de pared en la zona elegida, mientras que para un timestamp naive hace lo contrario, declara que el valor ya estaba en esa zona y devuelve el instante absoluto.
Dos modos, un operador
La regla es corta: AT TIME ZONE siempre invierte el tipo al opuesto.
timestamptz AT TIME ZONE 'zona' -> timestamp (hora de pared en esa zona).
timestamp AT TIME ZONE 'zona' -> timestamptz (interpreta el valor naive como hora local de esa zona).
SELECT TIMESTAMPTZ '2026-06-17 12:00:00+00' AT TIME ZONE 'Europe/Moscow';
SELECT TIMESTAMP '2026-06-17 15:00:00' AT TIME ZONE 'Europe/Moscow';
Las dos expresiones son espejo una de la otra y se revierten sin perdida. La primera responde "que hora es en Moscu en este momento"; la segunda responde "a que instante absoluto corresponden las 15:00 de Moscu".
Convertir UTC almacenado a la zona del usuario
El patron canonico: almacenas created_at como timestamptz (que internamente es UTC) y quieres mostrarlo en la zona de un usuario concreto.
SELECT
o.id,
o.amount,
o.created_at,
o.created_at AT TIME ZONE u.tz AS local_created_at
FROM orders o
JOIN users u ON u.id = o.user_id;
Aqui u.tz es una columna de texto con un nombre de zona como 'America/Sao_Paulo'. Usa siempre zonas IANA con nombre, nunca desplazamientos fijos como '+03': un nombre de zona conoce todo el historial de transiciones de horario de verano, un desplazamiento crudo no.
Agrupar pedidos por el "dia local" del usuario es igual de directo, primero convertir, luego truncar:
SELECT
DATE_TRUNC('day', o.created_at AT TIME ZONE u.tz) AS local_day,
SUM(o.amount) AS revenue
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'paid'
GROUP BY 1
ORDER BY 1;
El horario de verano, donde de verdad muerde
La razon principal para no sumar desplazamientos a mano es el DST. Toma Nueva York alrededor de la transicion de primavera de 2026, cuando los relojes saltan de las 02:00 directo a las 03:00.
SELECT TIMESTAMPTZ '2026-03-08 06:30:00+00' AT TIME ZONE 'America/New_York' AS before_dst,
TIMESTAMPTZ '2026-03-08 07:30:00+00' AT TIME ZONE 'America/New_York' AS after_dst;
Paso exactamente una hora de tiempo absoluto entre los dos momentos, pero el reloj de pared salto de la 01:30 a las 03:30, la hora 02:xx simplemente no existe esa noche. Un desplazamiento fijo de -05 daria la respuesta equivocada en la segunda fila.
Trampas
- Trampa: confusion de modos. Aplicar
AT TIME ZONE a un valor que ya es una columna timestamp naive no lo "convierte", declara que estaba en esa zona. Revisa primero el tipo de la columna.
- Aplicarlo dos veces hace ida y vuelta.
(ts AT TIME ZONE 'Europe/Moscow') da un timestamp naive; aplica AT TIME ZONE de nuevo y recuperas un timestamptz, util a veces para "recambiar de zona", pero mas a menudo un error.
- Usa nombres IANA. Zonas como
'Europe/Moscow' sobreviven a los cambios de reglas de DST; desplazamientos como '+03' no.
MySQL y ClickHouse
Ningun motor tiene la sintaxis AT TIME ZONE. En MySQL usa CONVERT_TZ(ts, 'UTC', 'Europe/Moscow') (las tablas de zonas con nombre deben estar cargadas). En ClickHouse usa toTimeZone(ts, 'Europe/Moscow') para un DateTime, recordando que alli la zona es solo un atributo de visualizacion sobre un timestamp basado en UTC.
AT TIME ZONEes una herramienta con dos caras. Lo que hace depende por completo del tipo de su entrada: para untimestamptzrepresenta el momento como la hora de pared en la zona elegida, mientras que para untimestampnaive hace lo contrario, declara que el valor ya estaba en esa zona y devuelve el instante absoluto.Dos modos, un operador
La regla es corta:
AT TIME ZONEsiempre invierte el tipo al opuesto.timestamptz AT TIME ZONE 'zona'->timestamp(hora de pared en esa zona).timestamp AT TIME ZONE 'zona'->timestamptz(interpreta el valor naive como hora local de esa zona).-- timestamptz -> timestamp: what the clock on the wall in Moscow shows SELECT TIMESTAMPTZ '2026-06-17 12:00:00+00' AT TIME ZONE 'Europe/Moscow'; -- 2026-06-17 15:00:00 -- timestamp -> timestamptz: this naive value WAS Moscow local time SELECT TIMESTAMP '2026-06-17 15:00:00' AT TIME ZONE 'Europe/Moscow'; -- 2026-06-17 12:00:00+00Las dos expresiones son espejo una de la otra y se revierten sin perdida. La primera responde "que hora es en Moscu en este momento"; la segunda responde "a que instante absoluto corresponden las 15:00 de Moscu".
Convertir UTC almacenado a la zona del usuario
El patron canonico: almacenas
created_atcomotimestamptz(que internamente es UTC) y quieres mostrarlo en la zona de un usuario concreto.SELECT o.id, o.amount, o.created_at, -- absolute moment (UTC) o.created_at AT TIME ZONE u.tz AS local_created_at -- wall time for the user FROM orders o JOIN users u ON u.id = o.user_id;Aqui
u.tzes una columna de texto con un nombre de zona como'America/Sao_Paulo'. Usa siempre zonas IANA con nombre, nunca desplazamientos fijos como'+03': un nombre de zona conoce todo el historial de transiciones de horario de verano, un desplazamiento crudo no.Agrupar pedidos por el "dia local" del usuario es igual de directo, primero convertir, luego truncar:
SELECT DATE_TRUNC('day', o.created_at AT TIME ZONE u.tz) AS local_day, SUM(o.amount) AS revenue FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'paid' GROUP BY 1 ORDER BY 1;El horario de verano, donde de verdad muerde
La razon principal para no sumar desplazamientos a mano es el DST. Toma Nueva York alrededor de la transicion de primavera de 2026, cuando los relojes saltan de las 02:00 directo a las 03:00.
SELECT TIMESTAMPTZ '2026-03-08 06:30:00+00' AT TIME ZONE 'America/New_York' AS before_dst, TIMESTAMPTZ '2026-03-08 07:30:00+00' AT TIME ZONE 'America/New_York' AS after_dst; -- before_dst = 2026-03-08 01:30:00 (offset -05) -- after_dst = 2026-03-08 03:30:00 (offset -04, the 02:00 hour never exists)Paso exactamente una hora de tiempo absoluto entre los dos momentos, pero el reloj de pared salto de la 01:30 a las 03:30, la hora 02:xx simplemente no existe esa noche. Un desplazamiento fijo de
-05daria la respuesta equivocada en la segunda fila.Trampas
AT TIME ZONEa un valor que ya es una columnatimestampnaive no lo "convierte", declara que estaba en esa zona. Revisa primero el tipo de la columna.(ts AT TIME ZONE 'Europe/Moscow')da untimestampnaive; aplicaAT TIME ZONEde nuevo y recuperas untimestamptz, util a veces para "recambiar de zona", pero mas a menudo un error.'Europe/Moscow'sobreviven a los cambios de reglas de DST; desplazamientos como'+03'no.MySQL y ClickHouse
Ningun motor tiene la sintaxis
AT TIME ZONE. En MySQL usaCONVERT_TZ(ts, 'UTC', 'Europe/Moscow')(las tablas de zonas con nombre deben estar cargadas). En ClickHouse usatoTimeZone(ts, 'Europe/Moscow')para unDateTime, recordando que alli la zona es solo un atributo de visualizacion sobre un timestamp basado en UTC.