Все материалы
Продуктовая аналитикагайдсредний

TIMESTAMP в PostgreSQL: timestamptz, часовые пояса и перевод в дату

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

КейсПрактика31 октября 2026 г.9 мин

Дата и время в PostgreSQL кажутся простыми, пока один и тот же DAU не начинает расходиться между дашбордом и запросом коллеги. Почти всегда причина — тип timestamp против timestamptz и часовой пояс, в котором режут сутки. Ниже — чем отличаются типы времени, что на самом деле хранит timestamptz, как работает AT TIME ZONE, как перевести timestamp в дату, отфильтровать день без потерь и посчитать разницу во времени. Запросы на учебной базе выполняются в песочнице, примеры с настройкой сессии помечены как запросы для своего PostgreSQL.

Коротко

  • timestamp (без пояса) хранит показания часов: «30 августа, 12:00» — неизвестно где.
  • timestamptz хранит момент во времени. Внутри это UTC, а показывается он в часовом поясе текущей сессии.
  • Для событий и логов выбирайте timestamptz: момент не зависит от того, где стоит сервер и кто читает данные.
  • Сутки, неделя и месяц существуют только в каком-то поясе. Перед ::date и date_trunc переведите время в пояс отчёта через AT TIME ZONE.
  • Фильтр за день — полуинтервал: >= начала дня и < начала следующего. BETWEEN до 23:59 теряет последние секунды.

Типы даты и времени в PostgreSQL

Типов пять, но аналитику почти всегда нужны три: date, timestamp и timestamptz. Оба типа timestamp занимают 8 байт и хранят время с точностью до микросекунды. Разница не в размере, а в смысле значения.

Типы даты и времени
ТипПолное имяПримерЧто хранит
datedate2026-08-30календарный день без времени
timetime without time zone14:05:00время суток без даты
timestamptimestamp without time zone2026-08-30 14:05:00дату и время «на стене», без пояса
timestamptztimestamp with time zone2026-08-30 14:05:00+03момент во времени, внутри — UTC
intervalinterval3 days 04:00:00длительность

timestamp и timestamptz: в чём разница

Название timestamp with time zone сбивает с толку: часовой пояс в нём не хранится. PostgreSQL при записи переводит значение в UTC, а при чтении показывает его в поясе сессии (настройка TimeZone). Один и тот же момент москвич увидит как 12:00+03, а сервер с настройкой UTC — как 09:00+00. Это одно значение, а не два разных.

timestamp without time zone ничего не переводит: что записали, то и лежит. Если в литерал попало смещение +03, PostgreSQL его молча отбросит. Поэтому timestamp годится, когда пояс известен из договорённости — «всё время в базе московское», как в учебной базе, где event_time описан как UTC+3. Как только данные приходят из разных стран или сервисов, договорённость ломается, и безопаснее timestamptz.

PostgreSQL only: один литерал в двух типах при сессии в UTC
set timezone = 'UTC';

select
  timestamptz '2026-08-30 12:00:00+03' as tz_value,
  timestamp   '2026-08-30 12:00:00+03' as plain_value;
-- tz_value:    2026-08-30 09:00:00+00 — тот же момент, показанный в UTC
-- plain_value: 2026-08-30 12:00:00    — смещение +03 отброшено

Текущие дата и время

now() и current_timestamp возвращают время начала транзакции, а не момент выполнения строки. Внутри одной транзакции они одинаковы, и это удобно: все строки отчёта получают одну метку «посчитано в». Реальное время на момент вызова даёт clock_timestamp() — его используют, чтобы замерить длительность шагов.

Функции текущего времени
ФункцияТип результатаЧто возвращает
current_datedateсегодняшнюю дату в поясе сессии
now(), current_timestamptimestamptzначало текущей транзакции
localtimestamptimestampто же, но без пояса — показания часов сессии
clock_timestamp()timestamptzфактическое время в момент вызова

AT TIME ZONE: перевод между поясами

Оператор AT TIME ZONE работает в обе стороны, и это главный источник путаницы. Применённый к timestamptz, он отвечает на вопрос «сколько показывали часы в этом поясе» и возвращает timestamp без пояса. Применённый к timestamp, он говорит «эти показания часов были в таком-то поясе» и возвращает момент — timestamptz.

Отсюда приём в два шага для данных, которые лежат в timestamp в известном поясе. Первый AT TIME ZONE объявляет, где снимали показания, второй — в каком поясе их показать. Результат не зависит от настройки сервера, поэтому запрос даёт один ответ в песочнице, в PostgreSQL и в DuckDB.

Направление AT TIME ZONE
Что на входеВыражениеЧто получается
timestamptz (момент)ts at time zone 'Europe/Moscow'timestamp: показания часов в Москве
timestamp (показания)ts at time zone 'Europe/Moscow'timestamptz: момент, когда в Москве было столько
timestamp в известном поясе(ts at time zone 'UTC') at time zone 'Europe/Moscow'timestamp: те же события по московским часам
Московское время событий в UTC: два шага AT TIME ZONE
select
  event_time                                                    as msk_local,
  (event_time at time zone 'Europe/Moscow') at time zone 'UTC'  as utc_local
from events
order by event_id
limit 3;
Пишите названия поясов, а не смещения

Europe/Moscow и Asia/Vladivostok понятны однозначно. Строка вида UTC+3 читается по правилам POSIX, где знак перевёрнут: PostgreSQL поймёт её как UTC−3, и полдень превратится в 18:00 по Москве.

Как перевести timestamp в дату

Самый короткий путь — приведение ::date или cast(ts as date): время отбрасывается, остаётся календарный день. Для timestamp это безопасно, если пояс данных совпадает с поясом отчёта. Для timestamptz дата получится в поясе сессии — у вас и у коллеги в другом городе она может различаться.

Масштаб ошибки легко недооценить. В учебной базе время московское, а пользователи активны с 6 до 23 часов. Если считать дни по владивостокскому времени (+7 часов к московскому), каждое пятое событие окажется в других сутках: всё, что случилось в Москве после 17:00, во Владивостоке уже завтра.

Результат на учебной базе
eventsother_dayother_day_pct
35 3417 27620.6
Сколько событий сменят дату, если резать сутки по Владивостоку
with local_time as (
  select
    event_time                                                                as msk_time,
    (event_time at time zone 'Europe/Moscow') at time zone 'Asia/Vladivostok' as vvo_time
  from events
)
select
  count(*)                                                 as events,
  count(*) filter (where msk_time::date <> vvo_time::date) as other_day,
  round(100.0 * count(*) filter (where msk_time::date <> vvo_time::date)
        / count(*), 1)                                     as other_day_pct
from local_time;

Фильтр за день без потерь

Условие «заказы за 1 августа» пишут тремя способами, и только один из них верен для любых данных. BETWEEN '2026-08-01 00:00' AND '2026-08-01 23:59' теряет всё, что случилось в последнюю минуту суток. BETWEEN с датами '2026-08-01' и '2026-08-02' захватывает полночь следующего дня: обе границы у BETWEEN включительные.

Полуинтервал — от начала дня включительно до начала следующего не включительно — не зависит от точности времени и работает с индексом по столбцу. Проверим на трёх учебных заказах: один утром, один за 20 секунд до полуночи и один ровно в полночь 2 августа.

Результат
between_2359between_dateshalf_open
132
Три способа отфильтровать день: правильный ответ — 2 заказа
with orders(order_id, created_at) as (
  values
    (1, timestamp '2026-08-01 09:15:00'),
    (2, timestamp '2026-08-01 23:59:40'),
    (3, timestamp '2026-08-02 00:00:00')
)
select
  count(*) filter (where created_at between timestamp '2026-08-01 00:00'
                                        and timestamp '2026-08-01 23:59') as between_2359,
  count(*) filter (where created_at between date '2026-08-01'
                                        and date '2026-08-02')            as between_dates,
  count(*) filter (where created_at >= date '2026-08-01'
                     and created_at <  date '2026-08-02')                 as half_open
from orders;

Разница между двумя timestamp

Вычитание двух timestamp даёт interval, например 51 days 23:00:00. Читать его удобно, но для расчётов нужно число. extract(epoch from …) превращает интервал в секунды, дальше делите на 60, 3 600 или 86 400 — получите минуты, часы или дни с дробной частью.

Запрос ниже считает, сколько дней прошло между первым и последним событием пользователя. Это основа для метрик «время жизни в продукте» и «время до первой покупки».

Дней между первым и последним событием пользователя
select
  user_id,
  min(event_time)                                                          as first_seen,
  max(event_time)                                                          as last_seen,
  round(extract(epoch from max(event_time) - min(event_time)) / 86400, 1)  as days_between
from events
group by user_id
order by user_id
limit 3;
  • Разница в целых днях между датами — просто date2 - date1, результат — целое число.
  • Разница в календарных месяцах — age(ts2, ts1) и затем extract(year …) * 12 + extract(month …).
  • Если события могут быть в разных поясах, вычитайте timestamptz: разница моментов не зависит от поясов.

Строка, Unix-время и формат вывода

Строку в формате ISO (2026-08-30 14:05:00) PostgreSQL приводит к timestamp сам. Для других форматов есть to_timestamp(text, формат), для Unix-времени в секундах — to_timestamp(число). Обе функции возвращают timestamptz. Обратно в строку переводит to_char.

PostgreSQL only: разбор строки, Unix-время и формат
select
  to_timestamp('30.08.2026 14:05', 'DD.MM.YYYY HH24:MI')    as from_text,
  to_timestamp(1788080400)                                 as from_epoch,
  extract(epoch from timestamptz '2026-08-30 12:00:00+03') as to_epoch,
  to_char(timestamp '2026-08-30 14:05:00', 'DD.MM.YYYY HH24:MI') as formatted;
-- при сессии Europe/Moscow:
-- from_text  2026-08-30 14:05:00+03
-- from_epoch 2026-08-30 12:00:00+03
-- to_epoch   1788080400
-- formatted  30.08.2026 14:05

Точность: секунды и микросекунды

По умолчанию timestamp хранит микросекунды. В выгрузке это выглядит как 14:05:12.483920 и мешает сравнивать значения глазами. Тип с точностью timestamp(0) округляет до секунд — округляет, а не отбрасывает: 12:34:56.789 станет 12:34:57. Если нужно именно отбросить доли, используйте date_trunc('second', ts).

Как выбрать тип для нового столбца

  • Событие, заказ, лог, всё, что «случилось в момент», — timestamptz.
  • День рождения, дата договора, плановая дата без времени — date.
  • Расписание «открываемся в 9:00 по местному времени» в разных городах — timestamp или time плюс отдельный столбец с поясом.
  • Если таблица уже в timestamp, запишите в описании столбца, в каком поясе время. Без этого через год никто не вспомнит.

Время в других СУБД

Понятия те же, названия разные. При переносе запроса проверьте, что тип означает момент, а не показания часов.

Аналоги timestamp и timestamptz
СУБДПоказания часовМомент во времени
PostgreSQLtimestamptimestamptz
DuckDBtimestamptimestamptz (часовые пояса — через расширение ICU)
MySQLdatetimetimestamp — хранится в UTC, диапазон до 2038 года
ClickHouseDateTime без поясаDateTime('Europe/Moscow') — пояс задан в типе
SQL Serverdatetime2datetimeoffset

Частые ошибки

  • Сравнили DAU из BI и из psql — не сошлось: у сервера и клиента разный часовой пояс сессии, а столбец timestamptz.
  • Записали '2026-08-30 12:00+03' в timestamp и решили, что пояс сохранился. Смещение отброшено.
  • Написали пояс как UTC+3 и получили сдвиг на шесть часов: в POSIX-записи знак обратный.
  • Фильтр BETWEEN … 23:59 за день: потеряна последняя минута суток.
  • Применили функцию к столбцу в WHERE (event_time::date = …): результат верный, но индекс по event_time не используется.

Частые вопросы

Как перевести timestamp в date в PostgreSQL?

ts::date или cast(ts as date). Для timestamptz сначала переведите в нужный пояс: (ts at time zone 'Europe/Moscow')::date.

Что хранит timestamp with time zone?

Момент во времени в UTC. Сам пояс не сохраняется — при чтении значение показывается в поясе сессии.

Как узнать часовой пояс сессии?

Командой show timezone;. Поменять на время сессии — set timezone = 'Europe/Moscow';.

Чем now() отличается от clock_timestamp()?

now() — время начала транзакции, оно одинаково для всех строк запроса. clock_timestamp() — реальное время в момент вызова.

Как получить время без миллисекунд?

date_trunc('second', ts) отбрасывает доли секунды, ts::timestamp(0) округляет до ближайшей секунды.

Запросы с датами на учебной базе можно выполнить в песочнице, а группировки по дням и неделям отработать в курсе «SQL с нуля».

Продолжить чтение
Вся библиотека