sqlpostgresqltimezonetimestamptz

AT TIME ZONE в PostgreSQL: как переводить время между часовыми поясами

AT TIME ZONE либо назначает часовой пояс наивному timestamp, либо показывает момент времени в локальной зоне пользователя.

11 мин чтенияСправочникsql · postgresql · timezone · timestamptz · timestamp · clickhouse

Время в SQL кажется простой вещью ровно до первого отчёта по часовым поясам.

Пользователь в Москве оформил заказ вечером. Сервер хранит время в UTC. Аналитик строит отчёт по дням. Поддержка смотрит на время операции в карточке клиента. Финансы считают выручку за локальные сутки.

И вот тут внезапно оказывается, что «17 июня в 23:30» — это не просто дата и время. Это дата и время где именно? В Москве? В Лондоне? В Нью-Йорке? В UTC?

В PostgreSQL для таких задач есть оператор AT TIME ZONE. Он переводит время между часовыми поясами, но у него есть важная особенность: он работает в двух разных режимах.

И режим выбирается не словами в запросе, а типом значения слева от оператора.

Зачем нужен AT TIME ZONE

Оператор AT TIME ZONE используют, когда нужно:

  • показать пользователю время в его часовом поясе;
  • сгруппировать события по локальному дню пользователя;
  • понять, как UTC-время выглядит в конкретной зоне;
  • наоборот, взять локальное время без зоны и превратить его в точный момент времени;
  • аккуратно обработать переходы на летнее и зимнее время.

Самый частый сценарий такой: приложение хранит время события как timestamptz, а потом показывает его в зоне пользователя.

Например, заказ был создан в один абсолютный момент времени. Для пользователя из Москвы это будет одно время на часах, для пользователя из Нью-Йорка — другое, но сам момент останется тем же.

Два типа времени: timestamp и timestamptz

Чтобы понять AT TIME ZONE, сначала нужно различать два типа.

timestamp — это дата и время без часового пояса.

Например:

SELECT TIMESTAMP '2026-06-17 15:00:00';

Такое значение говорит только:

2026-06-17 15:00:00

Но оно не говорит, где это было 15:00. В Москве? В Берлине? В UTC? В базе хранится просто «15:00», без привязки к реальному моменту на временной шкале.

timestamptz — это дата и время с часовым поясом.

Например:

SELECT TIMESTAMPTZ '2026-06-17 12:00:00+00';

Такое значение уже описывает конкретный абсолютный момент. Его можно показать в разных зонах, но сам момент не меняется.

Проще говоря:

  • timestamp — это надпись на часах без информации о городе;
  • timestamptz — это точный момент времени, который можно показать на часах любого города.

Главная развилка: тип значения слева

У AT TIME ZONE один и тот же синтаксис, но два разных смысла.

Если слева стоит timestamptz, оператор показывает, какое локальное время было в выбранной зоне.

SELECT TIMESTAMPTZ '2026-06-17 12:00:00+00' AT TIME ZONE 'Europe/Moscow';

Результат:

2026-06-17 15:00:00

Смысл такой:

Какое время показывали часы в Москве в момент 2026-06-17 12:00:00+00?

А если слева стоит timestamp, оператор делает обратную операцию: он считает, что это локальное время уже было записано в указанной зоне, и превращает его в абсолютный момент.

SELECT TIMESTAMP '2026-06-17 15:00:00' AT TIME ZONE 'Europe/Moscow';

Результат при отображении в UTC будет таким:

2026-06-17 12:00:00+00

Смысл другой:

Если в Москве было 2026-06-17 15:00:00, какой это был абсолютный момент?

Это ключевая идея всей статьи.

AT TIME ZONE не всегда «переводит время» в бытовом смысле. Иногда он показывает момент в зоне, а иногда назначает зону наивному времени.

Запомнить проще так

Оператор меняет тип на противоположный.

SELECT
    TIMESTAMPTZ '2026-06-17 12:00:00+00' AT TIME ZONE 'Europe/Moscow';

Здесь было timestamptz, стало timestamp.

То есть был абсолютный момент, а получили локальное время на часах.

SELECT
    TIMESTAMP '2026-06-17 15:00:00' AT TIME ZONE 'Europe/Moscow';

Здесь был timestamp, стало timestamptz.

То есть было наивное локальное время, а получили абсолютный момент.

Короткая шпаргалка:

Выражение Результат Что означает
timestamptz AT TIME ZONE 'Europe/Moscow' timestamp показать момент как московское время
timestamp AT TIME ZONE 'Europe/Moscow' timestamptz считать, что это московское время, и получить момент

Перед тем как писать запрос, всегда задавайте себе вопрос:

Что у меня слева: точный момент или просто дата-время без зоны?

Пример: показать UTC-время в зоне пользователя

Допустим, у нас есть таблица заказов.

В orders.created_at хранится момент создания заказа. Хорошая практика — хранить такие значения в типе timestamptz.

А в таблице пользователей есть поле tz с часовым поясом пользователя.

Например:

Europe/Moscow
Asia/Ho_Chi_Minh
America/Sao_Paulo

Теперь покажем заказ в локальном времени пользователя.

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;

Что происходит в этом запросе:

  • o.created_at — абсолютный момент;
  • u.tz — часовой пояс пользователя;
  • local_created_at — время на часах пользователя в этот момент.

Например, один и тот же заказ может быть сохранён как 2026-06-17 12:00:00+00.

Для пользователя из Москвы он отобразится как 15:00.

Для пользователя из Вьетнама — как 19:00.

Для пользователя из Нью-Йорка — как утреннее время.

Момент один, отображения разные.

Почему лучше хранить timestamptz

Для событий почти всегда лучше использовать timestamptz.

Событие — это конкретная точка на временной шкале. Заказ создан. Платёж прошёл. Сообщение отправлено. Пользователь вошёл в аккаунт.

Все эти вещи происходят в абсолютный момент времени.

Поэтому хороший подход такой:

  1. В базе хранить момент как timestamptz.
  2. В интерфейсе показывать его через AT TIME ZONE.
  3. Для отчётов переводить в нужную локальную зону перед группировкой.

Так вы не теряете смысл данных. Один и тот же момент можно корректно показать в любой зоне.

А вот если хранить события в timestamp без зоны, потом легко попасть в ловушку: значение вроде 2026-06-17 15:00:00 есть, а где оно произошло — уже непонятно.

Пример: группировка по локальному дню пользователя

Одна из самых частых задач — отчёт по дням.

Но «день» зависит от часового пояса.

Если пользователь живёт во Вьетнаме, его локальный день начинается раньше, чем день в UTC. Если вы просто сгруппируете заказы по UTC-дате, часть вечерних или ночных событий попадёт не в тот день с точки зрения пользователя.

Правильный порядок такой:

  1. Сначала перевести момент в локальное время пользователя.
  2. Потом обрезать до дня через date_trunc.
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;

Здесь local_day — это не день по серверу и не день по UTC. Это день в часовом поясе пользователя.

Для продуктовой аналитики это важно. Пользователь мыслит локальными днями: сегодня, вчера, завтра. Если отчёт показывает день по UTC, цифры могут выглядеть странно: покупка была вечером в понедельник, а в отчёте попала во вторник.

Используйте имена зон, а не голые сдвиги

В AT TIME ZONE лучше передавать имена часовых поясов из базы IANA:

SELECT TIMESTAMPTZ '2026-06-17 12:00:00+00' AT TIME ZONE 'Europe/Moscow';

Хорошие примеры:

Europe/Moscow
Asia/Ho_Chi_Minh
America/New_York
America/Sao_Paulo
Europe/Berlin

Плохая идея — хранить только сдвиг вроде +03 или -05.

Почему?

Потому что часовой пояс — это не просто сдвиг от UTC. У зоны есть история правил: переходы на летнее время, отмены переходов, политические изменения, разные правила в разные годы.

Имя America/New_York знает, когда в Нью-Йорке был сдвиг -05, а когда -04.

А строка -05 ничего этого не знает. Это просто фиксированная разница в пять часов.

Для обычного летнего дня она может дать неверный результат.

Пример с переходом на летнее время

Посмотрим на Нью-Йорк в день весеннего перехода на летнее время.

В 2026 году переход происходит 8 марта. Ночью часы перескакивают с 02:00 на 03:00. Это значит, что локального времени 02:30 в эту ночь просто не существует.

Сравним два абсолютных момента с разницей ровно в один час.

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
after_dst  = 2026-03-08 03:30:00

По UTC прошёл один час.

Но на локальных часах Нью-Йорка время прыгнуло с 01:30 сразу на 03:30.

Часа 02:30 в эту ночь не было.

Если бы вы просто вычитали фиксированные пять часов, получили бы неправильную картину для второй строки. Именно поэтому лучше доверить расчёт PostgreSQL и именованным зонам.

Обратный сценарий: назначить зону локальному времени

Иногда у нас есть время без зоны, но мы знаем, в какой зоне оно было указано.

Например, пользователь создаёт встречу:

2026-06-17 15:00:00

И отдельно выбирает часовой пояс:

Europe/Moscow

Значит, это не просто абстрактные 15:00. Это 15:00 по Москве.

Чтобы превратить такое значение в абсолютный момент, используем AT TIME ZONE для timestamp.

SELECT TIMESTAMP '2026-06-17 15:00:00' AT TIME ZONE 'Europe/Moscow';

Смысл:

Это время было московским локальным временем. Какой это момент в UTC?

Такой подход полезен при сохранении пользовательских дат из формы.

Например:

INSERT INTO meetings (user_id, starts_at)
VALUES (
    101,
    TIMESTAMP '2026-06-17 15:00:00' AT TIME ZONE 'Europe/Moscow'
);

Если starts_at имеет тип timestamptz, в базе сохранится абсолютный момент встречи. Потом его можно будет показать любому участнику в его часовом поясе.

Частая ошибка: думать, что timestamp будет переведён

Представьте, что в таблице есть колонка created_at типа timestamp.

Вы пишете:

SELECT created_at AT TIME ZONE 'Europe/Moscow'
FROM orders;

Может показаться, что PostgreSQL «переведёт время в Москву».

Но нет.

Если created_at — это timestamp без зоны, PostgreSQL сделает другое: он решит, что это значение уже было московским временем, и превратит его в timestamptz.

То есть он не переводит наивное время из одной зоны в другую. Он назначает ему зону.

Поэтому перед использованием AT TIME ZONE нужно проверять тип колонки.

SELECT
    column_name,
    data_type
FROM information_schema.columns
WHERE table_name = 'orders'
  AND column_name = 'created_at';

Если там timestamp with time zone, вы работаете с абсолютным моментом.

Если там timestamp without time zone, у вас наивное время, и сначала нужно понять, в какой зоне оно было записано.

Двойное применение AT TIME ZONE

Иногда в запросах можно увидеть двойное применение AT TIME ZONE.

SELECT
    created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Moscow'
FROM orders;

Такой код может быть правильным, но часто он появляется из-за путаницы.

Вспомним правило: оператор меняет тип на противоположный.

Если created_at был timestamptz, то первое применение:

created_at AT TIME ZONE 'UTC'

превращает его в timestamp.

А второе применение:

AT TIME ZONE 'Europe/Moscow'

снова превращает значение в timestamptz, но уже с трактовкой, что получившийся timestamp был локальным временем в Москве.

Это не то же самое, что просто показать время в Москве.

Для обычного отображения пользователю чаще всего достаточно одного применения:

SELECT created_at AT TIME ZONE 'Europe/Moscow'
FROM orders;

Двойной вариант нужен в специальных случаях, когда вы осознанно меняете трактовку наивного времени. Если вы не можете объяснить словами, что делает каждое применение, лучше остановиться и перепроверить типы.

Как не запутаться в запросах

Хорошая привычка — давать результатам понятные имена.

Плохо:

SELECT
    created_at AT TIME ZONE u.tz AS created_at
FROM orders o
JOIN users u ON u.id = o.user_id;

Лучше:

SELECT
    o.created_at AS created_at_utc,
    o.created_at AT TIME ZONE u.tz AS created_at_local
FROM orders o
JOIN users u ON u.id = o.user_id;

Так сразу видно:

  • created_at_utc — исходный момент;
  • created_at_local — локальное отображение для пользователя.

Ещё лучше, если в коде приложения и в базе названия помогают не путать смысл:

  • created_at;
  • paid_at;
  • starts_at;
  • local_date;
  • user_tz;
  • timezone_name.

Когда речь идёт о времени, хорошие названия спасают от дорогих ошибок.

AT TIME ZONE в отчётах и биллинге

Ошибки с часовыми поясами особенно больно бьют по отчётам.

Например, бизнес просит:

Покажи выручку за 17 июня по Москве.

Это не то же самое, что:

Покажи выручку за 17 июня по UTC.

Для московского дня нужно взять московские границы дня и применить их к моментам заказов.

Один вариант — переводить created_at в московское локальное время и фильтровать по локальной дате.

SELECT
    sum(amount) AS revenue
FROM orders
WHERE status = 'paid'
  AND created_at AT TIME ZONE 'Europe/Moscow' >= TIMESTAMP '2026-06-17 00:00:00'
  AND created_at AT TIME ZONE 'Europe/Moscow' <  TIMESTAMP '2026-06-18 00:00:00';

Такой запрос понятный: мы явно говорим, что сравнение идёт по московскому локальному времени.

Но для больших таблиц такой фильтр может быть неудобен для индекса, потому что функция применяется к колонке. В реальных системах часто делают иначе: заранее переводят локальные границы дня в timestamptz, а потом сравнивают исходную колонку.

SELECT
    sum(amount) AS revenue
FROM orders
WHERE status = 'paid'
  AND created_at >= TIMESTAMP '2026-06-17 00:00:00' AT TIME ZONE 'Europe/Moscow'
  AND created_at <  TIMESTAMP '2026-06-18 00:00:00' AT TIME ZONE 'Europe/Moscow';

Здесь границы 2026-06-17 00:00:00 и 2026-06-18 00:00:00 трактуются как московское локальное время, превращаются в абсолютные моменты, и уже потом сравниваются с created_at.

Для индексов это обычно лучше: колонка остаётся слева без преобразования.

Что будет с несуществующим локальным временем

Самая неприятная часть часовых поясов — локальные времена, которых не существовало.

Например, в зоне с переходом на летнее время часы могут перескочить с 01:59 сразу на 03:00.

Тогда время 02:30 формально попадает в дыру.

Если пользователь вводит такое время в форму, база должна как-то его интерпретировать. Разные СУБД и разные версии правил часовых поясов могут вести себя не так, как вы ожидаете.

Поэтому для важных сценариев — биллинг, SLA, бронирования, расписания, дедлайны — нужно отдельно тестировать даты переходов на летнее и зимнее время.

Обычный тест на спокойной дате вроде 2026-06-17 не покажет проблем.

Добавляйте тесты на границы:

  • ночь весеннего перехода, когда час пропадает;
  • ночь осеннего перехода, когда час повторяется;
  • локальную полночь;
  • конец месяца;
  • конец года.

И особенно проверяйте случаи, где от локального дня зависит деньги, доступ, штраф или срок выполнения.

MySQL: CONVERT_TZ вместо AT TIME ZONE

В MySQL нет синтаксиса AT TIME ZONE.

Для перевода между зонами используется функция CONVERT_TZ.

SELECT CONVERT_TZ(created_at, 'UTC', 'Europe/Moscow') AS local_created_at
FROM orders;

У неё другой стиль: нужно явно указать исходную и целевую зоны.

CONVERT_TZ(value, from_tz, to_tz)

Например:

SELECT CONVERT_TZ('2026-06-17 12:00:00', 'UTC', 'Europe/Moscow');

Главная ловушка MySQL: для именованных зон должны быть загружены таблицы часовых поясов. Если они не загружены, результат может стать NULL.

То есть запрос не обязательно упадёт с понятной ошибкой. Он может просто вернуть пустоту, и это легко пропустить в отчёте.

ClickHouse: toTimeZone вместо AT TIME ZONE

В ClickHouse тоже нет AT TIME ZONE в стиле PostgreSQL.

Для изменения зоны отображения используется toTimeZone.

SELECT toTimeZone(created_at, 'Europe/Moscow') AS local_created_at
FROM orders;

В ClickHouse важно понимать модель DateTime: значение хранит момент, а часовой пояс влияет на то, как это значение отображается и разбирается.

То есть toTimeZone не должен восприниматься как ручное прибавление часов. Он меняет зону представления времени.

Примерно так:

SELECT toTimeZone(toDateTime('2026-06-17 12:00:00', 'UTC'), 'Europe/Moscow');

Результат будет отображён как московское время для того же абсолютного момента.

При переносе логики из PostgreSQL в ClickHouse не копируйте запрос механически. Сначала отдельно проверьте:

  • какой тип у колонки;
  • какая зона у значения;
  • что именно вы хотите получить: момент или локальное отображение;
  • как ведёт себя запрос на переходах летнего времени.

Короткая памятка по AT TIME ZONE

Если слева timestamptz:

SELECT TIMESTAMPTZ '2026-06-17 12:00:00+00' AT TIME ZONE 'Europe/Moscow';

Это значит:

Show this moment in Moscow local time

Результат имеет тип timestamp.

Если слева timestamp:

SELECT TIMESTAMP '2026-06-17 15:00:00' AT TIME ZONE 'Europe/Moscow';

Это значит:

Treat this local time as Moscow time and convert it to a moment

Результат имеет тип timestamptz.

Для отображения пользователю обычно нужен первый вариант.

Для сохранения локального времени из формы в абсолютный момент — второй.

Главное из статьи

AT TIME ZONE — оператор PostgreSQL для работы с часовыми поясами.

Его главная особенность в том, что он работает в двух направлениях.

Если применить его к timestamptz, он покажет абсолютный момент как локальное время выбранной зоны.

SELECT TIMESTAMPTZ '2026-06-17 12:00:00+00' AT TIME ZONE 'Europe/Moscow';

Если применить его к timestamp, он решит, что это локальное время указанной зоны, и превратит его в абсолютный момент.

SELECT TIMESTAMP '2026-06-17 15:00:00' AT TIME ZONE 'Europe/Moscow';

Главное правило: сначала смотрите на тип значения слева.

Для событий вроде заказов, платежей и логов обычно лучше хранить timestamptz, а в пользовательскую зону переводить только при отображении или построении отчётов.

Используйте именованные зоны вроде Europe/Moscow и America/New_York, а не фиксированные сдвиги вроде +03 или -05.

И обязательно тестируйте переходы на летнее и зимнее время, если от локального дня зависят деньги, сроки, SLA или важная аналитика.

Закрепи на практике

Решай задачи в SQL-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр