В PostgreSQL есть два очень похожих типа:
timestamptz — дата и время с часовым поясом;
timestamp — дата и время без часового пояса.
На вид разница маленькая. Всего несколько букв. Но по смыслу это два разных мира.
timestamptz хранит абсолютный момент времени. Например: «заказ был создан именно в эту секунду». Пользователь может быть в Берлине, Москве, Нью-Йорке или Бангкоке — событие одно и то же, просто на часах у разных людей будет разное локальное время.
timestamp хранит показание часов без привязки к зоне. Например: 2026-03-15 10:00. Но это «10 утра» где? В Москве? В Лондоне? В Нью-Йорке? Сам тип этого не знает.
Из-за этой разницы одни и те же данные могут либо спокойно работать по всему миру, либо тихо разъехаться на несколько часов. Особенно больно это проявляется в заказах, оплатах, логах, аудитах и отчётах по дням.
Разберёмся спокойно и практично: когда брать timestamptz, когда можно брать timestamp, как работает AT TIME ZONE и какие ошибки чаще всего ломают данные.
Главное различие: момент времени или часы на стене
Представьте два предложения.
Первое:
Пользователь оплатил заказ 15 марта 2026 года в 10:00 по UTC.
Это конкретный момент на мировой временной шкале. Его можно сравнивать с другими событиями, сортировать, искать в диапазоне, показывать в разных часовых поясах.
Для этого нужен timestamptz.
Второе:
Магазин открывается каждый день в 09:00.
Это не конкретный момент истории. Это локальное «время на стене». Если магазин в Москве, это 09:00 по Москве. Если магазин в Берлине, это 09:00 по Берлину.
Для таких случаев иногда подходит timestamp или даже отдельный тип time.
Короткое правило:
- событие уже случилось или случится в конкретный момент — берите
timestamptz;
- нужно хранить локальное время без привязки к мировому моменту — можно рассмотреть
timestamp.
Что на самом деле хранит timestamptz
Название timestamp with time zone часто сбивает с толку. Кажется, будто PostgreSQL хранит и время, и название часового пояса.
Но это не так.
timestamptz не хранит строку вроде Europe/Berlin или Asia/Tokyo. Он хранит абсолютный момент времени. Внутри PostgreSQL приводит его к единой шкале времени, а при выводе показывает значение в часовом поясе текущей сессии.
Посмотрим на пример:
SET timezone = 'UTC';
SELECT '2026-03-15 10:00+00'::timestamptz AS created_at;
Результат:
created_at
------------------------
2026-03-15 10:00:00+00
Теперь поменяем часовой пояс сессии:
SET timezone = 'Europe/Moscow';
SELECT '2026-03-15 10:00+00'::timestamptz AS created_at;
Результат будет уже другим на вид:
created_at
------------------------
2026-03-15 13:00:00+03
Но событие не изменилось. Это всё тот же момент времени. Просто в Москве в этот момент на часах было 13:00.
Теперь Нью-Йорк:
SET timezone = 'America/New_York';
SELECT '2026-03-15 10:00+00'::timestamptz AS created_at;
Результат:
created_at
------------------------
2026-03-15 06:00:00-04
Опять другое отображение, но тот же самый момент.
Вот почему timestamptz хорошо подходит для событий. Он хранит не «красивую строку для пользователя», а реальную точку на оси времени.
Что хранит timestamp без зоны
timestamp без зоны хранит просто дату и время.
SELECT '2026-03-15 10:00'::timestamp AS wall_time;
Результат:
wall_time
---------------------
2026-03-15 10:00:00
Здесь нет ответа на вопрос: «10 утра где именно?»
Если вы сохраните такое значение для оплаты, регистрации или события в логе, то потом можете не понять, какой это был реальный момент.
Для локальных расписаний это нормально:
store opens at 09:00
lesson starts at 18:30
birthday is 1991-03-26
Но для событий вроде created_at, paid_at, sent_at, logged_at, deleted_at это обычно плохой выбор.
Почему события почти всегда нужно хранить в timestamptz
Любое поле вида «когда это случилось» — это момент истории.
Пользователь зарегистрировался. Заказ был оплачен. Письмо отправлено. Ошибка записалась в лог. Администратор изменил статус.
Все эти события нужно сравнивать между собой. Нужно понимать, что было раньше, что позже, какие события попали в диапазон, сколько заказов было за день.
Для этого лучше использовать timestamptz.
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE,
name text,
country text,
created_at timestamptz NOT NULL DEFAULT now()
);
И для заказов:
CREATE TABLE orders (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
amount numeric(12, 2) NOT NULL,
status text NOT NULL DEFAULT 'new',
created_at timestamptz NOT NULL DEFAULT now()
);
Теперь created_at — это настоящий момент времени. Его можно безопасно сравнивать, сортировать и фильтровать.
Например, посчитать заказы за март по UTC:
SELECT count(*) AS order_count
FROM orders
WHERE created_at >= '2026-03-01 00:00+00'::timestamptz
AND created_at < '2026-04-01 00:00+00'::timestamptz;
Здесь границы диапазона заданы явно: +00 означает UTC. PostgreSQL не будет гадать, какую зону вы имели в виду.
Главная ловушка: строка без часового пояса
Самая опасная ошибка — вставить в timestamptz значение без зоны.
Например:
INSERT INTO orders (user_id, amount, created_at)
VALUES (1, 50.00, '2026-03-15 10:00');
Выглядит безобидно. Но что значит 10:00?
PostgreSQL возьмёт часовой пояс текущей сессии и решит, что это 10:00 именно в нём.
Посмотрим:
SET timezone = 'America/New_York';
INSERT INTO orders (user_id, amount, created_at)
VALUES (1, 50.00, '2026-03-15 10:00');
PostgreSQL поймёт это как 10:00 в Нью-Йорке.
А теперь:
SET timezone = 'UTC';
INSERT INTO orders (user_id, amount, created_at)
VALUES (1, 50.00, '2026-03-15 10:00');
Теперь PostgreSQL поймёт это как 10:00 по UTC.
В запросе текст выглядит одинаково, но реальные моменты времени разные.
Для событий лучше писать зону явно:
INSERT INTO orders (user_id, amount, created_at)
VALUES (1, 50.00, '2026-03-15 10:00+00'::timestamptz);
Или вообще не передавать created_at, если подходит текущее время:
INSERT INTO orders (user_id, amount)
VALUES (1, 50.00);
Тогда сработает DEFAULT now().
Почему лучше хранить в UTC, а показывать в зоне пользователя
Рабочее правило для большинства приложений:
События храним как timestamptz, а в нужный часовой пояс переводим только при выводе.
Например, в таблице заказов хранится created_at.
Покажем его как берлинское локальное время:
SELECT
id,
created_at,
created_at AT TIME ZONE 'Europe/Berlin' AS berlin_time
FROM orders
ORDER BY created_at DESC
LIMIT 5;
created_at остаётся абсолютным моментом.
А выражение:
created_at AT TIME ZONE 'Europe/Berlin'
говорит: «Покажи, какие часы были в Берлине в этот момент».
Результат может выглядеть так:
id | created_at | berlin_time
---+-------------------------+---------------------
1 | 2026-03-15 10:00:00+00 | 2026-03-15 11:00:00
Обратите внимание: berlin_time уже без зоны. Это локальное время на стене в Берлине.
Как работает AT TIME ZONE
У AT TIME ZONE есть два разных поведения. Это сначала кажется странным, но логика есть.
Когда слева timestamptz, результатом будет timestamp.
SELECT '2026-03-15 10:00+00'::timestamptz
AT TIME ZONE 'Europe/Berlin' AS berlin_time;
Смысл:
Возьми абсолютный момент и покажи локальные часы в указанной зоне.
А когда слева timestamp, результатом будет timestamptz.
SELECT '2026-03-15 10:00'::timestamp
AT TIME ZONE 'Europe/Berlin' AS instant;
Смысл другой:
Возьми наивное локальное время и считай, что оно было временем в указанной зоне. Преврати его в абсолютный момент.
Это две разные операции.
Пример с timestamptz:
SELECT '2026-03-15 10:00+00'::timestamptz
AT TIME ZONE 'Europe/Berlin' AS result_value;
Мы получаем берлинские часы для уже известного момента.
Пример с timestamp:
SELECT '2026-03-15 10:00'::timestamp
AT TIME ZONE 'Europe/Berlin' AS result_value;
Мы говорим PostgreSQL: «Это было 10 утра в Берлине. Теперь посчитай абсолютный момент».
Группировка по локальному дню
Одна из частых задач в аналитике: посчитать выручку по дням в часовом поясе пользователя, офиса или страны.
Здесь важно не просто взять date_trunc от timestamptz, а сначала перевести момент в нужное локальное время.
Например, посчитаем выручку по дням в Берлине:
SELECT
date_trunc('day', created_at AT TIME ZONE 'Europe/Berlin') AS local_day,
sum(amount) AS revenue
FROM orders
WHERE status = 'paid'
GROUP BY local_day
ORDER BY local_day;
Почему так?
Потому что день в UTC и день в Берлине начинаются в разные моменты. Заказ, который в UTC был поздно вечером, в Берлине мог уже попасть в следующий календарный день.
Для продуктовых отчётов это критично. Пользователь смотрит «выручку за понедельник» в своём локальном времени, а не в абстрактном UTC.
Диапазон по локальному дню
Иногда нужно выбрать все заказы за конкретный локальный день.
Например, все оплаченные заказы за 2026-03-15 по Берлину.
Можно написать так:
SELECT *
FROM orders
WHERE status = 'paid'
AND created_at >= ('2026-03-15 00:00'::timestamp AT TIME ZONE 'Europe/Berlin')
AND created_at < ('2026-03-16 00:00'::timestamp AT TIME ZONE 'Europe/Berlin');
Здесь мы берём локальную полуночь в Берлине и превращаем её в абсолютный момент через AT TIME ZONE.
Это лучше, чем пытаться вручную прибавлять или вычитать часы. В реальном мире есть переходы на летнее время, и сутки не всегда равны ровно 24 часам в локальном календаре.
Переход на летнее время: почему нельзя просто прибавлять часы
В некоторых странах есть переход на летнее и зимнее время. Из-за этого локальные часы могут перескочить вперёд или повторить один и тот же час дважды.
Если вы просто прибавляете фиксированное количество часов, можно ошибиться.
Например, плохая идея:
SELECT created_at + interval '1 hour' AS shifted_time
FROM orders;
Если ваша цель — показать время в конкретной зоне, используйте не ручной сдвиг, а AT TIME ZONE.
SELECT created_at AT TIME ZONE 'Europe/Berlin' AS berlin_time
FROM orders;
PostgreSQL знает правила часовых поясов и учитывает переходы, если вы используете нормальные имена зон вроде Europe/Berlin, а не просто фиксированный сдвиг.
То есть лучше писать:
AT TIME ZONE 'Europe/Berlin'
А не пытаться везде использовать:
interval '1 hour'
timestamp подходит не всегда, но иногда он нужен
Не нужно делать вывод, что timestamp без зоны плохой. Он просто для других задач.
Он подходит, когда вы действительно храните локальное время, которое не является абсолютным моментом.
Например:
CREATE TABLE stores (
id bigserial PRIMARY KEY,
name text NOT NULL,
opens_at time NOT NULL,
closes_at time NOT NULL
);
Для времени открытия магазина лучше подходит time, а не timestamptz.
Другой пример — расписание, которое привязано к локальному календарю:
CREATE TABLE lessons (
id bigserial PRIMARY KEY,
title text NOT NULL,
starts_at timestamp NOT NULL,
timezone text NOT NULL
);
Здесь есть нюанс. Если урок должен начаться «15 марта в 10:00 по Берлину», можно хранить локальный timestamp и отдельную колонку с зоной timezone.
Но если урок уже создан как конкретное событие в календаре, часто удобнее сразу сохранить рассчитанный timestamptz, а зону хранить отдельно только для отображения и бизнес-логики.
Для дня рождения обычно не нужен ни timestamp, ни timestamptz. Лучше использовать date:
CREATE TABLE profiles (
user_id bigint PRIMARY KEY,
birthday date
);
День рождения — это дата, а не момент времени.
Касты между timestamp и timestamptz
Касты могут быть опасны, потому что PostgreSQL снова использует часовой пояс сессии.
Например:
SET timezone = 'UTC';
SELECT '2026-03-15 10:00'::timestamp::timestamptz AS value_utc;
PostgreSQL решит, что 2026-03-15 10:00 — это время в UTC.
А теперь:
SET timezone = 'Europe/Berlin';
SELECT '2026-03-15 10:00'::timestamp::timestamptz AS value_berlin;
Теперь он решит, что это 10:00 в Берлине.
Один и тот же текст, один и тот же каст, но смысл другой.
Поэтому не стоит надеяться, что преобразование само «поймёт как надо». Если зона важна, задавайте её явно:
SELECT '2026-03-15 10:00'::timestamp
AT TIME ZONE 'Europe/Berlin' AS value_instant;
Так запрос читается честно: это было локальное время Берлина, и мы превращаем его в момент времени.
Сравнение timestamptz и timestamp
Ещё одна тихая ловушка — сравнивать timestamptz с timestamp.
Например:
SELECT *
FROM orders
WHERE created_at >= '2026-03-01 00:00'::timestamp;
Если created_at имеет тип timestamptz, PostgreSQL должен привести типы к общему виду. И при этом снова будет задействован часовой пояс сессии.
Сегодня сессия в UTC — результат один. Завтра сессия в другой зоне — граница может сдвинуться.
Лучше писать явно:
SELECT *
FROM orders
WHERE created_at >= '2026-03-01 00:00+00'::timestamptz;
Или, если граница задана в локальной зоне:
SELECT *
FROM orders
WHERE created_at >= ('2026-03-01 00:00'::timestamp AT TIME ZONE 'Europe/Berlin');
Так вы не оставляете базе места для догадок.
Практическая схема для событий
Для большинства приложений схема будет такой:
CREATE TABLE events (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL,
event_name text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
Почему created_at timestamptz?
Потому что событие произошло в конкретный момент. Его нужно корректно сравнивать с другими событиями, независимо от того, где пользователь и где сервер.
Запрос последних событий:
SELECT
id,
user_id,
event_name,
created_at
FROM events
ORDER BY created_at DESC
LIMIT 20;
Отчёт по локальным дням для Москвы:
SELECT
date_trunc('day', created_at AT TIME ZONE 'Europe/Moscow') AS local_day,
count(*) AS event_count
FROM events
GROUP BY local_day
ORDER BY local_day;
Отбор за локальный день в Москве:
SELECT *
FROM events
WHERE created_at >= ('2026-03-15 00:00'::timestamp AT TIME ZONE 'Europe/Moscow')
AND created_at < ('2026-03-16 00:00'::timestamp AT TIME ZONE 'Europe/Moscow');
Это понятнее и безопаснее, чем хранить всё в timestamp и потом гадать, в какой зоне оно было записано.
Что делать с часовым поясом пользователя
Иногда нужно знать не только момент события, но и часовой пояс пользователя.
Например, пользователь хочет видеть отчёты в своей зоне. Тогда сам момент храните в timestamptz, а зону пользователя — отдельной колонкой.
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE,
timezone text NOT NULL DEFAULT 'UTC',
created_at timestamptz NOT NULL DEFAULT now()
);
Пример значения в timezone:
Europe/Berlin
Asia/Ho_Chi_Minh
America/New_York
Потом можно строить локальные отчёты:
SELECT
u.id,
u.email,
o.created_at AT TIME ZONE u.timezone AS local_created_at
FROM users AS u
JOIN orders AS o ON o.user_id = u.id;
Здесь у каждого пользователя время будет показано в его собственной зоне.
Важно: не храните только сдвиг вроде +03. Сдвиг не знает правил перехода на летнее время. Лучше хранить именно имя зоны.
MySQL: TIMESTAMP и DATETIME
В MySQL похожая пара типов — это TIMESTAMP и DATETIME.
TIMESTAMP ближе к PostgreSQL timestamptz: MySQL хранит значение в UTC и конвертирует его с учётом @@session.time_zone при записи и чтении.
DATETIME ближе к PostgreSQL timestamp: он хранит дату и время без часового пояса. Это просто локальное значение, без ответа на вопрос, в какой зоне оно было.
При переносе логики из PostgreSQL в MySQL важно не переносить названия типов механически.
Примерно так:
- PostgreSQL
timestamptz по смыслу ближе к MySQL TIMESTAMP;
- PostgreSQL
timestamp по смыслу ближе к MySQL DATETIME.
Но у MySQL TIMESTAMP есть важное ограничение диапазона: он исторически упирается в даты примерно от 1970 до 2038 года. Поэтому для будущих дат, расписаний и бизнес-сущностей далеко за пределами этого диапазона часто используют DATETIME и отдельно продумывают работу с зоной.
ClickHouse: DateTime и DateTime64
В ClickHouse тип DateTime хранит Unix-время и может иметь часовой пояс как параметр типа.
Например:
DateTime('Europe/Berlin')
Часовой пояс влияет на парсинг и отображение, но само значение по смыслу остаётся моментом времени.
Если нужны доли секунды, используют DateTime64.
Например:
DateTime64(3, 'Europe/Berlin')
При переносе аналитики между PostgreSQL и ClickHouse важно проверять не только типы, но и то, как именно база парсит строки, показывает значения и группирует даты по локальному дню.
Особенно внимательно нужно смотреть на переходы времени, конец месяца, начало эпохи Unix и исторические даты.
Частые ошибки
Первая ошибка — хранить события в timestamp без зоны.
created_at timestamp NOT NULL DEFAULT now()
Для событий лучше:
created_at timestamptz NOT NULL DEFAULT now()
Вторая ошибка — вставлять в timestamptz строку без зоны.
Небезопасно:
INSERT INTO orders (user_id, amount, created_at)
VALUES (1, 50.00, '2026-03-15 10:00');
Лучше:
INSERT INTO orders (user_id, amount, created_at)
VALUES (1, 50.00, '2026-03-15 10:00+00'::timestamptz);
Третья ошибка — думать, что timestamptz хранит имя зоны.
Он хранит момент времени. Если вам нужна зона пользователя, храните её отдельно:
timezone text NOT NULL DEFAULT 'UTC'
Четвёртая ошибка — группировать по дню без учёта нужной зоны.
Не всегда достаточно так:
date_trunc('day', created_at)
Для локального дня лучше:
date_trunc('day', created_at AT TIME ZONE 'Europe/Berlin')
Пятая ошибка — вручную прибавлять часы вместо нормальной конвертации.
Ненадёжно:
created_at + interval '3 hours'
Лучше:
created_at AT TIME ZONE 'Europe/Moscow'
Как выбирать тип без боли
Задайте себе один вопрос:
Это конкретный момент на временной шкале или просто локальные часы?
Если это конкретный момент, берите timestamptz.
Примеры:
- пользователь зарегистрировался;
- заказ создан;
- оплата прошла;
- письмо отправлено;
- задача закрыта;
- лог записан;
- токен истекает в конкретный момент.
Если это локальное значение без универсального момента, можно брать timestamp, time или date.
Примеры:
- день рождения;
- время открытия магазина;
- шаблон расписания;
- локальное время занятия вместе с отдельной зоной;
- дата без времени.
Главное
timestamptz хранит абсолютный момент времени. При выводе PostgreSQL показывает его в часовом поясе текущей сессии.
timestamp хранит дату и время без часового пояса. Это «часы на стене», но без ответа на вопрос, в какой стране или городе они висят.
Для событий, аудита, логов, заказов, оплат и любых полей вида created_at почти всегда выбирайте timestamptz.
Не вставляйте в timestamptz строки без зоны, если вам важна точность. Пишите зону явно: например, +00.
Для вывода в зоне пользователя используйте AT TIME ZONE.
Для группировки по локальному дню сначала переведите момент в нужную зону, а потом применяйте date_trunc.
Если нужна зона пользователя, храните её отдельно как имя зоны, например Europe/Berlin, а не надейтесь, что она спрятана внутри timestamptz.
Главная мысль такая: событие храните как момент времени, а красивое локальное отображение делайте только на выходе. Так данные не разъедутся между пользователями, серверами и часовыми поясами.
В PostgreSQL есть два очень похожих типа:
timestamptz— дата и время с часовым поясом;timestamp— дата и время без часового пояса.На вид разница маленькая. Всего несколько букв. Но по смыслу это два разных мира.
timestamptzхранит абсолютный момент времени. Например: «заказ был создан именно в эту секунду». Пользователь может быть в Берлине, Москве, Нью-Йорке или Бангкоке — событие одно и то же, просто на часах у разных людей будет разное локальное время.timestampхранит показание часов без привязки к зоне. Например:2026-03-15 10:00. Но это «10 утра» где? В Москве? В Лондоне? В Нью-Йорке? Сам тип этого не знает.Из-за этой разницы одни и те же данные могут либо спокойно работать по всему миру, либо тихо разъехаться на несколько часов. Особенно больно это проявляется в заказах, оплатах, логах, аудитах и отчётах по дням.
Разберёмся спокойно и практично: когда брать
timestamptz, когда можно братьtimestamp, как работаетAT TIME ZONEи какие ошибки чаще всего ломают данные.Главное различие: момент времени или часы на стене
Представьте два предложения.
Первое:
Это конкретный момент на мировой временной шкале. Его можно сравнивать с другими событиями, сортировать, искать в диапазоне, показывать в разных часовых поясах.
Для этого нужен
timestamptz.Второе:
Это не конкретный момент истории. Это локальное «время на стене». Если магазин в Москве, это 09:00 по Москве. Если магазин в Берлине, это 09:00 по Берлину.
Для таких случаев иногда подходит
timestampили даже отдельный типtime.Короткое правило:
timestamptz;timestamp.Что на самом деле хранит
timestamptzНазвание
timestamp with time zoneчасто сбивает с толку. Кажется, будто PostgreSQL хранит и время, и название часового пояса.Но это не так.
timestamptzне хранит строку вродеEurope/BerlinилиAsia/Tokyo. Он хранит абсолютный момент времени. Внутри PostgreSQL приводит его к единой шкале времени, а при выводе показывает значение в часовом поясе текущей сессии.Посмотрим на пример:
SET timezone = 'UTC'; SELECT '2026-03-15 10:00+00'::timestamptz AS created_at;Результат:
Теперь поменяем часовой пояс сессии:
SET timezone = 'Europe/Moscow'; SELECT '2026-03-15 10:00+00'::timestamptz AS created_at;Результат будет уже другим на вид:
Но событие не изменилось. Это всё тот же момент времени. Просто в Москве в этот момент на часах было
13:00.Теперь Нью-Йорк:
SET timezone = 'America/New_York'; SELECT '2026-03-15 10:00+00'::timestamptz AS created_at;Результат:
Опять другое отображение, но тот же самый момент.
Вот почему
timestamptzхорошо подходит для событий. Он хранит не «красивую строку для пользователя», а реальную точку на оси времени.Что хранит
timestampбез зоныtimestampбез зоны хранит просто дату и время.SELECT '2026-03-15 10:00'::timestamp AS wall_time;Результат:
Здесь нет ответа на вопрос: «10 утра где именно?»
Если вы сохраните такое значение для оплаты, регистрации или события в логе, то потом можете не понять, какой это был реальный момент.
Для локальных расписаний это нормально:
Но для событий вроде
created_at,paid_at,sent_at,logged_at,deleted_atэто обычно плохой выбор.Почему события почти всегда нужно хранить в
timestamptzЛюбое поле вида «когда это случилось» — это момент истории.
Пользователь зарегистрировался. Заказ был оплачен. Письмо отправлено. Ошибка записалась в лог. Администратор изменил статус.
Все эти события нужно сравнивать между собой. Нужно понимать, что было раньше, что позже, какие события попали в диапазон, сколько заказов было за день.
Для этого лучше использовать
timestamptz.CREATE TABLE users ( id bigserial PRIMARY KEY, email text NOT NULL UNIQUE, name text, country text, created_at timestamptz NOT NULL DEFAULT now() );И для заказов:
CREATE TABLE orders ( id bigserial PRIMARY KEY, user_id bigint NOT NULL REFERENCES users(id), amount numeric(12, 2) NOT NULL, status text NOT NULL DEFAULT 'new', created_at timestamptz NOT NULL DEFAULT now() );Теперь
created_at— это настоящий момент времени. Его можно безопасно сравнивать, сортировать и фильтровать.Например, посчитать заказы за март по UTC:
SELECT count(*) AS order_count FROM orders WHERE created_at >= '2026-03-01 00:00+00'::timestamptz AND created_at < '2026-04-01 00:00+00'::timestamptz;Здесь границы диапазона заданы явно:
+00означает UTC. PostgreSQL не будет гадать, какую зону вы имели в виду.Главная ловушка: строка без часового пояса
Самая опасная ошибка — вставить в
timestamptzзначение без зоны.Например:
INSERT INTO orders (user_id, amount, created_at) VALUES (1, 50.00, '2026-03-15 10:00');Выглядит безобидно. Но что значит
10:00?PostgreSQL возьмёт часовой пояс текущей сессии и решит, что это
10:00именно в нём.Посмотрим:
SET timezone = 'America/New_York'; INSERT INTO orders (user_id, amount, created_at) VALUES (1, 50.00, '2026-03-15 10:00');PostgreSQL поймёт это как
10:00в Нью-Йорке.А теперь:
SET timezone = 'UTC'; INSERT INTO orders (user_id, amount, created_at) VALUES (1, 50.00, '2026-03-15 10:00');Теперь PostgreSQL поймёт это как
10:00по UTC.В запросе текст выглядит одинаково, но реальные моменты времени разные.
Для событий лучше писать зону явно:
INSERT INTO orders (user_id, amount, created_at) VALUES (1, 50.00, '2026-03-15 10:00+00'::timestamptz);Или вообще не передавать
created_at, если подходит текущее время:INSERT INTO orders (user_id, amount) VALUES (1, 50.00);Тогда сработает
DEFAULT now().Почему лучше хранить в UTC, а показывать в зоне пользователя
Рабочее правило для большинства приложений:
Например, в таблице заказов хранится
created_at.Покажем его как берлинское локальное время:
SELECT id, created_at, created_at AT TIME ZONE 'Europe/Berlin' AS berlin_time FROM orders ORDER BY created_at DESC LIMIT 5;created_atостаётся абсолютным моментом.А выражение:
created_at AT TIME ZONE 'Europe/Berlin'говорит: «Покажи, какие часы были в Берлине в этот момент».
Результат может выглядеть так:
Обратите внимание:
berlin_timeуже без зоны. Это локальное время на стене в Берлине.Как работает
AT TIME ZONEУ
AT TIME ZONEесть два разных поведения. Это сначала кажется странным, но логика есть.Когда слева
timestamptz, результатом будетtimestamp.SELECT '2026-03-15 10:00+00'::timestamptz AT TIME ZONE 'Europe/Berlin' AS berlin_time;Смысл:
А когда слева
timestamp, результатом будетtimestamptz.SELECT '2026-03-15 10:00'::timestamp AT TIME ZONE 'Europe/Berlin' AS instant;Смысл другой:
Это две разные операции.
Пример с
timestamptz:SELECT '2026-03-15 10:00+00'::timestamptz AT TIME ZONE 'Europe/Berlin' AS result_value;Мы получаем берлинские часы для уже известного момента.
Пример с
timestamp:SELECT '2026-03-15 10:00'::timestamp AT TIME ZONE 'Europe/Berlin' AS result_value;Мы говорим PostgreSQL: «Это было 10 утра в Берлине. Теперь посчитай абсолютный момент».
Группировка по локальному дню
Одна из частых задач в аналитике: посчитать выручку по дням в часовом поясе пользователя, офиса или страны.
Здесь важно не просто взять
date_truncотtimestamptz, а сначала перевести момент в нужное локальное время.Например, посчитаем выручку по дням в Берлине:
SELECT date_trunc('day', created_at AT TIME ZONE 'Europe/Berlin') AS local_day, sum(amount) AS revenue FROM orders WHERE status = 'paid' GROUP BY local_day ORDER BY local_day;Почему так?
Потому что день в UTC и день в Берлине начинаются в разные моменты. Заказ, который в UTC был поздно вечером, в Берлине мог уже попасть в следующий календарный день.
Для продуктовых отчётов это критично. Пользователь смотрит «выручку за понедельник» в своём локальном времени, а не в абстрактном UTC.
Диапазон по локальному дню
Иногда нужно выбрать все заказы за конкретный локальный день.
Например, все оплаченные заказы за
2026-03-15по Берлину.Можно написать так:
SELECT * FROM orders WHERE status = 'paid' AND created_at >= ('2026-03-15 00:00'::timestamp AT TIME ZONE 'Europe/Berlin') AND created_at < ('2026-03-16 00:00'::timestamp AT TIME ZONE 'Europe/Berlin');Здесь мы берём локальную полуночь в Берлине и превращаем её в абсолютный момент через
AT TIME ZONE.Это лучше, чем пытаться вручную прибавлять или вычитать часы. В реальном мире есть переходы на летнее время, и сутки не всегда равны ровно 24 часам в локальном календаре.
Переход на летнее время: почему нельзя просто прибавлять часы
В некоторых странах есть переход на летнее и зимнее время. Из-за этого локальные часы могут перескочить вперёд или повторить один и тот же час дважды.
Если вы просто прибавляете фиксированное количество часов, можно ошибиться.
Например, плохая идея:
SELECT created_at + interval '1 hour' AS shifted_time FROM orders;Если ваша цель — показать время в конкретной зоне, используйте не ручной сдвиг, а
AT TIME ZONE.SELECT created_at AT TIME ZONE 'Europe/Berlin' AS berlin_time FROM orders;PostgreSQL знает правила часовых поясов и учитывает переходы, если вы используете нормальные имена зон вроде
Europe/Berlin, а не просто фиксированный сдвиг.То есть лучше писать:
AT TIME ZONE 'Europe/Berlin'А не пытаться везде использовать:
interval '1 hour'timestampподходит не всегда, но иногда он нуженНе нужно делать вывод, что
timestampбез зоны плохой. Он просто для других задач.Он подходит, когда вы действительно храните локальное время, которое не является абсолютным моментом.
Например:
CREATE TABLE stores ( id bigserial PRIMARY KEY, name text NOT NULL, opens_at time NOT NULL, closes_at time NOT NULL );Для времени открытия магазина лучше подходит
time, а неtimestamptz.Другой пример — расписание, которое привязано к локальному календарю:
CREATE TABLE lessons ( id bigserial PRIMARY KEY, title text NOT NULL, starts_at timestamp NOT NULL, timezone text NOT NULL );Здесь есть нюанс. Если урок должен начаться «15 марта в 10:00 по Берлину», можно хранить локальный
timestampи отдельную колонку с зонойtimezone.Но если урок уже создан как конкретное событие в календаре, часто удобнее сразу сохранить рассчитанный
timestamptz, а зону хранить отдельно только для отображения и бизнес-логики.Для дня рождения обычно не нужен ни
timestamp, ниtimestamptz. Лучше использоватьdate:CREATE TABLE profiles ( user_id bigint PRIMARY KEY, birthday date );День рождения — это дата, а не момент времени.
Касты между
timestampиtimestamptzКасты могут быть опасны, потому что PostgreSQL снова использует часовой пояс сессии.
Например:
SET timezone = 'UTC'; SELECT '2026-03-15 10:00'::timestamp::timestamptz AS value_utc;PostgreSQL решит, что
2026-03-15 10:00— это время в UTC.А теперь:
SET timezone = 'Europe/Berlin'; SELECT '2026-03-15 10:00'::timestamp::timestamptz AS value_berlin;Теперь он решит, что это
10:00в Берлине.Один и тот же текст, один и тот же каст, но смысл другой.
Поэтому не стоит надеяться, что преобразование само «поймёт как надо». Если зона важна, задавайте её явно:
SELECT '2026-03-15 10:00'::timestamp AT TIME ZONE 'Europe/Berlin' AS value_instant;Так запрос читается честно: это было локальное время Берлина, и мы превращаем его в момент времени.
Сравнение
timestamptzиtimestampЕщё одна тихая ловушка — сравнивать
timestamptzсtimestamp.Например:
SELECT * FROM orders WHERE created_at >= '2026-03-01 00:00'::timestamp;Если
created_atимеет типtimestamptz, PostgreSQL должен привести типы к общему виду. И при этом снова будет задействован часовой пояс сессии.Сегодня сессия в UTC — результат один. Завтра сессия в другой зоне — граница может сдвинуться.
Лучше писать явно:
SELECT * FROM orders WHERE created_at >= '2026-03-01 00:00+00'::timestamptz;Или, если граница задана в локальной зоне:
SELECT * FROM orders WHERE created_at >= ('2026-03-01 00:00'::timestamp AT TIME ZONE 'Europe/Berlin');Так вы не оставляете базе места для догадок.
Практическая схема для событий
Для большинства приложений схема будет такой:
CREATE TABLE events ( id bigserial PRIMARY KEY, user_id bigint NOT NULL, event_name text NOT NULL, payload jsonb NOT NULL DEFAULT '{}'::jsonb, created_at timestamptz NOT NULL DEFAULT now() );Почему
created_at timestamptz?Потому что событие произошло в конкретный момент. Его нужно корректно сравнивать с другими событиями, независимо от того, где пользователь и где сервер.
Запрос последних событий:
SELECT id, user_id, event_name, created_at FROM events ORDER BY created_at DESC LIMIT 20;Отчёт по локальным дням для Москвы:
SELECT date_trunc('day', created_at AT TIME ZONE 'Europe/Moscow') AS local_day, count(*) AS event_count FROM events GROUP BY local_day ORDER BY local_day;Отбор за локальный день в Москве:
SELECT * FROM events WHERE created_at >= ('2026-03-15 00:00'::timestamp AT TIME ZONE 'Europe/Moscow') AND created_at < ('2026-03-16 00:00'::timestamp AT TIME ZONE 'Europe/Moscow');Это понятнее и безопаснее, чем хранить всё в
timestampи потом гадать, в какой зоне оно было записано.Что делать с часовым поясом пользователя
Иногда нужно знать не только момент события, но и часовой пояс пользователя.
Например, пользователь хочет видеть отчёты в своей зоне. Тогда сам момент храните в
timestamptz, а зону пользователя — отдельной колонкой.CREATE TABLE users ( id bigserial PRIMARY KEY, email text NOT NULL UNIQUE, timezone text NOT NULL DEFAULT 'UTC', created_at timestamptz NOT NULL DEFAULT now() );Пример значения в
timezone:Потом можно строить локальные отчёты:
SELECT u.id, u.email, o.created_at AT TIME ZONE u.timezone AS local_created_at FROM users AS u JOIN orders AS o ON o.user_id = u.id;Здесь у каждого пользователя время будет показано в его собственной зоне.
Важно: не храните только сдвиг вроде
+03. Сдвиг не знает правил перехода на летнее время. Лучше хранить именно имя зоны.MySQL:
TIMESTAMPиDATETIMEВ MySQL похожая пара типов — это
TIMESTAMPиDATETIME.TIMESTAMPближе к PostgreSQLtimestamptz: MySQL хранит значение в UTC и конвертирует его с учётом@@session.time_zoneпри записи и чтении.DATETIMEближе к PostgreSQLtimestamp: он хранит дату и время без часового пояса. Это просто локальное значение, без ответа на вопрос, в какой зоне оно было.При переносе логики из PostgreSQL в MySQL важно не переносить названия типов механически.
Примерно так:
timestamptzпо смыслу ближе к MySQLTIMESTAMP;timestampпо смыслу ближе к MySQLDATETIME.Но у MySQL
TIMESTAMPесть важное ограничение диапазона: он исторически упирается в даты примерно от 1970 до 2038 года. Поэтому для будущих дат, расписаний и бизнес-сущностей далеко за пределами этого диапазона часто используютDATETIMEи отдельно продумывают работу с зоной.ClickHouse:
DateTimeиDateTime64В ClickHouse тип
DateTimeхранит Unix-время и может иметь часовой пояс как параметр типа.Например:
DateTime('Europe/Berlin')Часовой пояс влияет на парсинг и отображение, но само значение по смыслу остаётся моментом времени.
Если нужны доли секунды, используют
DateTime64.Например:
DateTime64(3, 'Europe/Berlin')При переносе аналитики между PostgreSQL и ClickHouse важно проверять не только типы, но и то, как именно база парсит строки, показывает значения и группирует даты по локальному дню.
Особенно внимательно нужно смотреть на переходы времени, конец месяца, начало эпохи Unix и исторические даты.
Частые ошибки
Первая ошибка — хранить события в
timestampбез зоны.created_at timestamp NOT NULL DEFAULT now()Для событий лучше:
created_at timestamptz NOT NULL DEFAULT now()Вторая ошибка — вставлять в
timestamptzстроку без зоны.Небезопасно:
INSERT INTO orders (user_id, amount, created_at) VALUES (1, 50.00, '2026-03-15 10:00');Лучше:
INSERT INTO orders (user_id, amount, created_at) VALUES (1, 50.00, '2026-03-15 10:00+00'::timestamptz);Третья ошибка — думать, что
timestamptzхранит имя зоны.Он хранит момент времени. Если вам нужна зона пользователя, храните её отдельно:
timezone text NOT NULL DEFAULT 'UTC'Четвёртая ошибка — группировать по дню без учёта нужной зоны.
Не всегда достаточно так:
date_trunc('day', created_at)Для локального дня лучше:
date_trunc('day', created_at AT TIME ZONE 'Europe/Berlin')Пятая ошибка — вручную прибавлять часы вместо нормальной конвертации.
Ненадёжно:
created_at + interval '3 hours'Лучше:
created_at AT TIME ZONE 'Europe/Moscow'Как выбирать тип без боли
Задайте себе один вопрос:
Если это конкретный момент, берите
timestamptz.Примеры:
Если это локальное значение без универсального момента, можно брать
timestamp,timeилиdate.Примеры:
Главное
timestamptzхранит абсолютный момент времени. При выводе PostgreSQL показывает его в часовом поясе текущей сессии.timestampхранит дату и время без часового пояса. Это «часы на стене», но без ответа на вопрос, в какой стране или городе они висят.Для событий, аудита, логов, заказов, оплат и любых полей вида
created_atпочти всегда выбирайтеtimestamptz.Не вставляйте в
timestamptzстроки без зоны, если вам важна точность. Пишите зону явно: например,+00.Для вывода в зоне пользователя используйте
AT TIME ZONE.Для группировки по локальному дню сначала переведите момент в нужную зону, а потом применяйте
date_trunc.Если нужна зона пользователя, храните её отдельно как имя зоны, например
Europe/Berlin, а не надейтесь, что она спрятана внутриtimestamptz.Главная мысль такая: событие храните как момент времени, а красивое локальное отображение делайте только на выходе. Так данные не разъедутся между пользователями, серверами и часовыми поясами.