Tento článok je momentálne v ruštine — anglický preklad sa pripravuje.
AT TIME ZONE — это оператор PostgreSQL, который переводит время между часовыми поясами, и работает он сразу в двух направлениях. Для timestamptz он показывает, что было на стенных часах выбранной зоны в этот момент; для наивного timestamp — наоборот, объявляет, что значение уже было снято по этой зоне, и возвращает абсолютный момент. Применяют его, когда нужно показать пользователю хранимое UTC-время в его поясе или, реже, дописать пояс к времени без зоны.
Какое из двух направлений сработает, определяет не синтаксис, а тип аргумента — и это главная развилка при работе с AT TIME ZONE. Если колонка объявлена как timestamptz, оператор переводит абсолютный момент в локальные часы; если как timestamp без зоны, он делает обратное. Поэтому первое, что стоит проверить перед написанием выражения, — каким типом объявлена колонка времени. Запишите выбранную трактовку рядом с SQL: тогда видно, показываете ли вы момент в зоне пользователя или назначаете зону наивному значению, и читатель не спутает эти два режима.
Два режима, одна команда
Ключ к пониманию AT TIME ZONE — смотреть на тип аргумента, а не на сам синтаксис. Запомнить просто: оператор всегда меняет тип на противоположный, и вместе с типом меняется смысл операции.
timestamptz AT TIME ZONE 'zone' -> timestamp (стенное время в этой зоне).
timestamp AT TIME ZONE 'zone' -> timestamptz (интерпретация наивного значения как времени этой зоны).
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';
Обратите внимание: оба выражения связаны и обратимы. Первое отвечает на вопрос «который час в Москве в этот момент», второе — «какому абсолютному моменту соответствуют 15:00 по Москве». Именно поэтому колонку времени важно объявлять как timestamptz: тогда база хранит абсолютный момент, а перевод в любую зону — это уже задача отображения, а не пересчёта данных.
Перевод хранимого UTC в зону пользователя
Канонический паттерн: вы храните created_at как timestamptz (внутри это всегда UTC) и хотите показать его в зоне конкретного пользователя.
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;
Здесь u.tz — текстовое поле с именем зоны вроде 'America/Sao_Paulo'. Всегда используйте именованные зоны из базы IANA, а не сдвиги '+03': имя зоны знает историю переходов, а голый сдвиг — нет.
Сгруппировать заказы по «локальному дню» пользователя тоже легко — сначала переводим в зону, потом усекаем:
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;
Переход на летнее время — где это реально важно
Главная причина не складывать сдвиги руками: DST. Возьмём зону Нью-Йорка вокруг весеннего перехода 2026 года, когда часы прыгают с 02:00 на 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;
Между двумя моментами прошёл ровно час абсолютного времени, но стенное время скакнуло с 01:30 сразу на 03:30 — час 02:xx этой ночью не существует. Жёсткий сдвиг -05 дал бы неверный ответ для второй строки.
Подводные камни
- Gotcha: путаница режимов. Применение
AT TIME ZONE к уже наивному значению из колонки timestamp (без зоны) не «переведёт» его, а объявит, что оно было в этой зоне. Сначала проверьте тип колонки.
- Двойное применение возвращает к моменту.
(ts AT TIME ZONE 'Europe/Moscow') даёт наивный timestamp; если применить AT TIME ZONE ещё раз, вы получите timestamptz — иногда это нужно для «сдвига» зоны, но чаще это баг.
- Используйте имена IANA. Зоны вроде
'Europe/Moscow' устойчивы к смене правил DST; сдвиги вроде '+03' — нет.
MySQL и ClickHouse
Синтаксиса AT TIME ZONE нет ни в MySQL, ни в ClickHouse — перевод между зонами там делают отдельные функции. В MySQL используйте CONVERT_TZ(ts, 'UTC', 'Europe/Moscow'); функция принимает исходную и целевую зоны явными аргументами, но требует загруженных таблиц часовых поясов (mysql_tzinfo_to_sql), иначе вернёт NULL. Это её главная ловушка при переносе с PostgreSQL: незагруженные зоны не дают ошибку, а молча превращают результат в NULL.
В ClickHouse для DateTime применяйте toTimeZone(ts, 'Europe/Moscow'). Здесь принципиальное отличие от PostgreSQL: значение DateTime всегда хранится как UTC-таймстамп, а зона — лишь свойство отображения, привязанное к типу колонки. Поэтому toTimeZone не сдвигает сам момент, а только меняет, в какой зоне он будет показан и из какой разобран при выводе; абсолютная точка во времени остаётся прежней.
Самые коварные расхождения между этими тремя движками возникают не на «счастливом пути», а на переходе DST и на несуществующих локальных часах вроде ночи перевода стрелок. Если AT TIME ZONE или его аналог влияет на локальный день в отчёте, на границу биллингового периода или на SLA, добавьте отдельный тест именно на эти даты-границы, а не только на обычный сценарий.
AT TIME ZONE— это оператор PostgreSQL, который переводит время между часовыми поясами, и работает он сразу в двух направлениях. Дляtimestamptzон показывает, что было на стенных часах выбранной зоны в этот момент; для наивногоtimestamp— наоборот, объявляет, что значение уже было снято по этой зоне, и возвращает абсолютный момент. Применяют его, когда нужно показать пользователю хранимое UTC-время в его поясе или, реже, дописать пояс к времени без зоны.Какое из двух направлений сработает, определяет не синтаксис, а тип аргумента — и это главная развилка при работе с
AT TIME ZONE. Если колонка объявлена какtimestamptz, оператор переводит абсолютный момент в локальные часы; если какtimestampбез зоны, он делает обратное. Поэтому первое, что стоит проверить перед написанием выражения, — каким типом объявлена колонка времени. Запишите выбранную трактовку рядом с SQL: тогда видно, показываете ли вы момент в зоне пользователя или назначаете зону наивному значению, и читатель не спутает эти два режима.Два режима, одна команда
Ключ к пониманию
AT TIME ZONE— смотреть на тип аргумента, а не на сам синтаксис. Запомнить просто: оператор всегда меняет тип на противоположный, и вместе с типом меняется смысл операции.timestamptz AT TIME ZONE 'zone'->timestamp(стенное время в этой зоне).timestamp AT TIME ZONE 'zone'->timestamptz(интерпретация наивного значения как времени этой зоны).-- 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+00Обратите внимание: оба выражения связаны и обратимы. Первое отвечает на вопрос «который час в Москве в этот момент», второе — «какому абсолютному моменту соответствуют 15:00 по Москве». Именно поэтому колонку времени важно объявлять как
timestamptz: тогда база хранит абсолютный момент, а перевод в любую зону — это уже задача отображения, а не пересчёта данных.Перевод хранимого UTC в зону пользователя
Канонический паттерн: вы храните
created_atкакtimestamptz(внутри это всегда UTC) и хотите показать его в зоне конкретного пользователя.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;Здесь
u.tz— текстовое поле с именем зоны вроде'America/Sao_Paulo'. Всегда используйте именованные зоны из базы IANA, а не сдвиги'+03': имя зоны знает историю переходов, а голый сдвиг — нет.Сгруппировать заказы по «локальному дню» пользователя тоже легко — сначала переводим в зону, потом усекаем:
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;Переход на летнее время — где это реально важно
Главная причина не складывать сдвиги руками: DST. Возьмём зону Нью-Йорка вокруг весеннего перехода 2026 года, когда часы прыгают с 02:00 на 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, hour 02:00 does not exist)Между двумя моментами прошёл ровно час абсолютного времени, но стенное время скакнуло с 01:30 сразу на 03:30 — час 02:xx этой ночью не существует. Жёсткий сдвиг
-05дал бы неверный ответ для второй строки.Подводные камни
AT TIME ZONEк уже наивному значению из колонкиtimestamp(без зоны) не «переведёт» его, а объявит, что оно было в этой зоне. Сначала проверьте тип колонки.(ts AT TIME ZONE 'Europe/Moscow')даёт наивныйtimestamp; если применитьAT TIME ZONEещё раз, вы получитеtimestamptz— иногда это нужно для «сдвига» зоны, но чаще это баг.'Europe/Moscow'устойчивы к смене правил DST; сдвиги вроде'+03'— нет.MySQL и ClickHouse
Синтаксиса
AT TIME ZONEнет ни в MySQL, ни в ClickHouse — перевод между зонами там делают отдельные функции. В MySQL используйтеCONVERT_TZ(ts, 'UTC', 'Europe/Moscow'); функция принимает исходную и целевую зоны явными аргументами, но требует загруженных таблиц часовых поясов (mysql_tzinfo_to_sql), иначе вернётNULL. Это её главная ловушка при переносе с PostgreSQL: незагруженные зоны не дают ошибку, а молча превращают результат вNULL.В ClickHouse для
DateTimeприменяйтеtoTimeZone(ts, 'Europe/Moscow'). Здесь принципиальное отличие от PostgreSQL: значениеDateTimeвсегда хранится как UTC-таймстамп, а зона — лишь свойство отображения, привязанное к типу колонки. ПоэтомуtoTimeZoneне сдвигает сам момент, а только меняет, в какой зоне он будет показан и из какой разобран при выводе; абсолютная точка во времени остаётся прежней.Самые коварные расхождения между этими тремя движками возникают не на «счастливом пути», а на переходе DST и на несуществующих локальных часах вроде ночи перевода стрелок. Если
AT TIME ZONEили его аналог влияет на локальный день в отчёте, на границу биллингового периода или на SLA, добавьте отдельный тест именно на эти даты-границы, а не только на обычный сценарий.