Время в 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.
Событие — это конкретная точка на временной шкале. Заказ создан. Платёж прошёл. Сообщение отправлено. Пользователь вошёл в аккаунт.
Все эти вещи происходят в абсолютный момент времени.
Поэтому хороший подход такой:
- В базе хранить момент как
timestamptz.
- В интерфейсе показывать его через
AT TIME ZONE.
- Для отчётов переводить в нужную локальную зону перед группировкой.
Так вы не теряете смысл данных. Один и тот же момент можно корректно показать в любой зоне.
А вот если хранить события в timestamp без зоны, потом легко попасть в ловушку: значение вроде 2026-06-17 15:00:00 есть, а где оно произошло — уже непонятно.
Пример: группировка по локальному дню пользователя
Одна из самых частых задач — отчёт по дням.
Но «день» зависит от часового пояса.
Если пользователь живёт во Вьетнаме, его локальный день начинается раньше, чем день в UTC. Если вы просто сгруппируете заказы по UTC-дате, часть вечерних или ночных событий попадёт не в тот день с точки зрения пользователя.
Правильный порядок такой:
- Сначала перевести момент в локальное время пользователя.
- Потом обрезать до дня через
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 кажется простой вещью ровно до первого отчёта по часовым поясам.
Пользователь в Москве оформил заказ вечером. Сервер хранит время в UTC. Аналитик строит отчёт по дням. Поддержка смотрит на время операции в карточке клиента. Финансы считают выручку за локальные сутки.
И вот тут внезапно оказывается, что «17 июня в 23:30» — это не просто дата и время. Это дата и время где именно? В Москве? В Лондоне? В Нью-Йорке? В UTC?
В PostgreSQL для таких задач есть оператор
AT TIME ZONE. Он переводит время между часовыми поясами, но у него есть важная особенность: он работает в двух разных режимах.И режим выбирается не словами в запросе, а типом значения слева от оператора.
Зачем нужен AT TIME ZONE
Оператор
AT TIME ZONEиспользуют, когда нужно:Самый частый сценарий такой: приложение хранит время события как
timestamptz, а потом показывает его в зоне пользователя.Например, заказ был создан в один абсолютный момент времени. Для пользователя из Москвы это будет одно время на часах, для пользователя из Нью-Йорка — другое, но сам момент останется тем же.
Два типа времени: timestamp и timestamptz
Чтобы понять
AT TIME ZONE, сначала нужно различать два типа.timestamp— это дата и время без часового пояса.Например:
SELECT TIMESTAMP '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';Результат:
Смысл такой:
А если слева стоит
timestamp, оператор делает обратную операцию: он считает, что это локальное время уже было записано в указанной зоне, и превращает его в абсолютный момент.SELECT TIMESTAMP '2026-06-17 15:00:00' AT TIME ZONE 'Europe/Moscow';Результат при отображении в UTC будет таким:
Смысл другой:
Это ключевая идея всей статьи.
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'timestamptimestamp AT TIME ZONE 'Europe/Moscow'timestamptzПеред тем как писать запрос, всегда задавайте себе вопрос:
Пример: показать UTC-время в зоне пользователя
Допустим, у нас есть таблица заказов.
В
orders.created_atхранится момент создания заказа. Хорошая практика — хранить такие значения в типеtimestamptz.А в таблице пользователей есть поле
tzс часовым поясом пользователя.Например:
Теперь покажем заказ в локальном времени пользователя.
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.Событие — это конкретная точка на временной шкале. Заказ создан. Платёж прошёл. Сообщение отправлено. Пользователь вошёл в аккаунт.
Все эти вещи происходят в абсолютный момент времени.
Поэтому хороший подход такой:
timestamptz.AT TIME ZONE.Так вы не теряете смысл данных. Один и тот же момент можно корректно показать в любой зоне.
А вот если хранить события в
timestampбез зоны, потом легко попасть в ловушку: значение вроде2026-06-17 15:00:00есть, а где оно произошло — уже непонятно.Пример: группировка по локальному дню пользователя
Одна из самых частых задач — отчёт по дням.
Но «день» зависит от часового пояса.
Если пользователь живёт во Вьетнаме, его локальный день начинается раньше, чем день в UTC. Если вы просто сгруппируете заказы по UTC-дате, часть вечерних или ночных событий попадёт не в тот день с точки зрения пользователя.
Правильный порядок такой:
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';Хорошие примеры:
Плохая идея — хранить только сдвиг вроде
+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;Результат будет примерно таким:
По UTC прошёл один час.
Но на локальных часах Нью-Йорка время прыгнуло с
01:30сразу на03:30.Часа
02:30в эту ночь не было.Если бы вы просто вычитали фиксированные пять часов, получили бы неправильную картину для второй строки. Именно поэтому лучше доверить расчёт PostgreSQL и именованным зонам.
Обратный сценарий: назначить зону локальному времени
Иногда у нас есть время без зоны, но мы знаем, в какой зоне оно было указано.
Например, пользователь создаёт встречу:
И отдельно выбирает часовой пояс:
Значит, это не просто абстрактные
15:00. Это15:00по Москве.Чтобы превратить такое значение в абсолютный момент, используем
AT TIME ZONEдляtimestamp.SELECT TIMESTAMP '2026-06-17 15:00:00' AT TIME ZONE 'Europe/Moscow';Смысл:
Такой подход полезен при сохранении пользовательских дат из формы.
Например:
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 в отчётах и биллинге
Ошибки с часовыми поясами особенно больно бьют по отчётам.
Например, бизнес просит:
Это не то же самое, что:
Для московского дня нужно взять московские границы дня и применить их к моментам заказов.
Один вариант — переводить
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';Это значит:
Результат имеет тип
timestamp.Если слева
timestamp:SELECT TIMESTAMP '2026-06-17 15:00:00' AT TIME ZONE 'Europe/Moscow';Это значит:
Результат имеет тип
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 или важная аналитика.