sqlpostgresqltimezonetimestamptz

SQL AT TIME ZONE: Converting Stored UTC to a User's Local Time

The dual behavior of AT TIME ZONE: on timestamptz it returns local wall time, on a naive timestamp it interprets the value as being in that zone and returns timestamptz.

3 min czytaniaReferencesql · postgresql · timezone · timestamptz · timestamp · clickhouse
Ten artykuł jest obecnie po rosyjsku — trwa tłumaczenie na angielski.

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 дал бы неверный ответ для второй строки.

Подводные камни

  • 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, добавьте отдельный тест именно на эти даты-границы, а не только на обычный сценарий.

Ćwicz na prawdziwych zadaniach

Rozwiązuj zadania w trenerze SQL z natychmiastową oceną i podpowiedziami.

Otwórz trener