TIMESTAMP в PostgreSQL: timestamptz, часовые пояса и перевод в дату
Чем timestamp отличается от timestamptz, как работает AT TIME ZONE, как перевести timestamp в дату, отфильтровать день и не потерять сутки на часовом поясе.
Содержание статьи
Дата и время в 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 байт и хранят время с точностью до микросекунды. Разница не в размере, а в смысле значения.
| Тип | Полное имя | Пример | Что хранит |
|---|---|---|---|
date | date | 2026-08-30 | календарный день без времени |
time | time without time zone | 14:05:00 | время суток без даты |
timestamp | timestamp without time zone | 2026-08-30 14:05:00 | дату и время «на стене», без пояса |
timestamptz | timestamp with time zone | 2026-08-30 14:05:00+03 | момент во времени, внутри — UTC |
interval | interval | 3 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.
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_date | date | сегодняшнюю дату в поясе сессии |
now(), current_timestamp | timestamptz | начало текущей транзакции |
localtimestamp | timestamp | то же, но без пояса — показания часов сессии |
clock_timestamp() | timestamptz | фактическое время в момент вызова |
AT TIME ZONE: перевод между поясами
Оператор AT TIME ZONE работает в обе стороны, и это главный источник путаницы. Применённый к timestamptz, он отвечает на вопрос «сколько показывали часы в этом поясе» и возвращает timestamp без пояса. Применённый к timestamp, он говорит «эти показания часов были в таком-то поясе» и возвращает момент — timestamptz.
Отсюда приём в два шага для данных, которые лежат в timestamp в известном поясе. Первый AT TIME ZONE объявляет, где снимали показания, второй — в каком поясе их показать. Результат не зависит от настройки сервера, поэтому запрос даёт один ответ в песочнице, в PostgreSQL и в DuckDB.
| Что на входе | Выражение | Что получается |
|---|---|---|
| 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: те же события по московским часам |
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, во Владивостоке уже завтра.
| events | other_day | other_day_pct |
|---|---|---|
| 35 341 | 7 276 | 20.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_2359 | between_dates | half_open |
|---|---|---|
| 1 | 3 | 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.
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, запишите в описании столбца, в каком поясе время. Без этого через год никто не вспомнит.
Время в других СУБД
Понятия те же, названия разные. При переносе запроса проверьте, что тип означает момент, а не показания часов.
| СУБД | Показания часов | Момент во времени |
|---|---|---|
| PostgreSQL | timestamp | timestamptz |
| DuckDB | timestamp | timestamptz (часовые пояса — через расширение ICU) |
| MySQL | datetime | timestamp — хранится в UTC, диапазон до 2038 года |
| ClickHouse | DateTime без пояса | DateTime('Europe/Moscow') — пояс задан в типе |
| SQL Server | datetime2 | datetimeoffset |
Частые ошибки
- Сравнили 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 с нуля».
Материалы по теме

DATE_TRUNC в SQL: группировка по неделям, месяцам и кварталам
Как работает date_trunc в PostgreSQL: выручка по месяцам, недели с понедельника и с воскресенья, неполный период, часовой пояс и аналоги в MySQL, ClickHouse и SQL Server.
SQL-запросы: 40 примеров для аналитика с результатами
Сорок SQL-запросов на одной учебной базе: SELECT и WHERE, COUNT и GROUP BY, JOIN, даты, оконные функции, DAU, retention и A/B-тест. У каждого запроса показан результат.

BETWEEN в SQL: включает ли границы и как не потерять последний день
BETWEEN в SQL включает обе границы: x >= a and x <= b. Почему с timestamp он теряет последний день периода и чем заменить BETWEEN для дат.