Как работать с timezone в SQL

Почему это важно

Timezone — одна из самых недооценённых тем в аналитике. Ошибки с тайм-зонами приводят к неправильным отчётам, смещённым когортам, неправильным границам дней.

Типичная ситуация: у вас есть события в таблице, вы группируете по дате, отчёт отдаёт неправильные цифры. Потому что события в UTC, а вам нужны дни по Москве. Пользователь, зашедший 23:30 UTC, для московского времени уже следующий день.

Разобраться в timezone — часовая инвестиция, которая сэкономит дни страданий.

TIMESTAMP vs TIMESTAMPTZ

В PostgreSQL есть два типа: TIMESTAMP (без timezone) и TIMESTAMPTZ (с timezone).

TIMESTAMP хранит просто дату и время, без контекста. '2026-04-15 10:00:00' — и не знаем, по какому часовому поясу.

TIMESTAMPTZ хранит абсолютный момент во времени. Внутри PostgreSQL сохраняет в UTC. При выводе конвертирует в текущую сессионную timezone.

Для production-систем используйте TIMESTAMPTZ. Она не теряет timezone информацию, корректно работает при миграциях между серверами и не требует постоянных преобразований.

Базовая работа

Создание таблицы:

CREATE TABLE events (
    event_id BIGSERIAL PRIMARY KEY,
    user_id BIGINT,
    event_time TIMESTAMPTZ,
    event_type VARCHAR(50)
);

Вставка данных:

-- С явной timezone
INSERT INTO events (user_id, event_time, event_type)
VALUES (1, '2026-04-15 10:00:00 Europe/Moscow', 'login');

-- Или через now()
INSERT INTO events (user_id, event_time, event_type)
VALUES (1, now(), 'login');

now() всегда возвращает UTC — это абсолютный момент времени. При выводе в TIMESTAMPTZ увидите в вашей сессионной tz.

Настройка сессионной timezone

Сессионная timezone влияет на отображение и интерпретацию timestamp:

-- Показать текущую
SHOW timezone;

-- Установить для сессии
SET timezone = 'Europe/Moscow';

-- Установить постоянно для пользователя
ALTER USER analyst SET timezone = 'Europe/Moscow';

После SET timezone = 'Europe/Moscow' все TIMESTAMPTZ показываются в московском времени. Но хранятся внутренне всё равно в UTC.

Попробовать силы на подобных вопросах проще всего в тренажёре Карьерник — прямо в Telegram, без регистрации через сайт.

Конвертация timezone

Самая полезная операция — AT TIME ZONE:

-- Конвертировать UTC в московское для вывода
SELECT event_time AT TIME ZONE 'Europe/Moscow' AS msk_time
FROM events;

-- Или через pretty cast
SELECT event_time::TIMESTAMP AT TIME ZONE 'UTC' AT TIME ZONE 'Europe/Moscow'
FROM events;

Первая форма — из TIMESTAMPTZ в locale TIMESTAMP. Вторая — когда данные в TIMESTAMP без timezone, но вы знаете, что они UTC, и хотите в московское время.

Группировка по дате в правильной timezone

Самая частая задача — дневная агрегация. Нужно сгруппировать events по дням в московском времени:

SELECT
    (event_time AT TIME ZONE 'Europe/Moscow')::DATE AS moscow_day,
    COUNT(*) AS events
FROM events
GROUP BY 1
ORDER BY 1;

Без AT TIME ZONE группировка будет по UTC-дням. Event пользователя в 00:30 MSK попадёт в предыдущий UTC-день.

DAU с учётом timezone

Для multi-timezone продуктов:

-- Московский DAU
SELECT
    (event_time AT TIME ZONE 'Europe/Moscow')::DATE AS day,
    COUNT(DISTINCT user_id) AS dau_moscow
FROM events
WHERE event_type = 'app_open'
GROUP BY 1
ORDER BY 1;

Для отчётов глобальных компаний (Яндекс, Авито) часто используют «условно пользовательское время» — приводят к timezone пользователя, если она известна. Но это сложнее и реже в classic reporting.

Daylight Saving Time

Россия не переводит часы с 2011 года, но многие страны переводят. Это может создавать проблемы:

  • В марте день «короче» на час (23 часа).
  • В октябре «длиннее» на час (25 часов).

Если считать DAU по этим дням без учёта DST, получите скачки. PostgreSQL корректно обрабатывает DST, если используете TIMESTAMPTZ и правильные timezone names (Europe/London, не UTC+0).

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

Первая — сохранять даты в TIMESTAMP без timezone. Потом при переходе на другой сервер с другой настройкой timezone результаты меняются.

Вторая — мешать naive и aware timestamps. Сравнение timestamp_col = '2026-04-15 10:00' работает только если оба типа согласованы.

Третья — ::date без AT TIME ZONE. Даёт UTC-дату, которую никто не ожидал.

Четвёртая — использовать UTC+3 вместо Europe/Moscow. В 2011 Россия могла перейти на UTC+4 (и потом обратно). Абстрактные offset-ы не учитывают исторические изменения.

Пройти 30–50 задач по теме за вечер можно в Telegram-тренажёре. Это то, что отличает «знаю» от «уверенно отвечу на собесе».

В ClickHouse

ClickHouse имеет тип DateTime и DateTime64 с опциональной timezone:

CREATE TABLE events (
    event_time DateTime('Europe/Moscow'),
    user_id UInt64
) ENGINE = MergeTree() ORDER BY event_time;

-- Функция для конвертации
SELECT toTimeZone(event_time, 'Asia/Tokyo') FROM events;

ClickHouse с timezone работает немного иначе, чем PostgreSQL, но общие принципы те же: храните timezone явно, конвертируйте при выводе.

В pandas

Pandas хорошо работает с timezone:

import pandas as pd

df['event_time'] = pd.to_datetime(df['event_time']).dt.tz_localize('UTC')
df['msk_time'] = df['event_time'].dt.tz_convert('Europe/Moscow')
df['msk_date'] = df['msk_time'].dt.date

Группировка по local date:

df.groupby(df['msk_date'])['user_id'].nunique()

Практика — похожа на SQL. Главное — осознанно работать с tz.

Best practices

Храните в UTC, выводите в user/business timezone. Это устраняет большинство проблем.

Используйте TIMESTAMPTZ (или аналог) в production-системах. TIMESTAMP только для локальных событий, которые не имеют смысла в других timezone (дни рождения, события по местному календарю).

Явно указывайте timezone в SQL-запросах. AT TIME ZONE 'Europe/Moscow' читаемо, не зависит от настройки сервера.

Документируйте, в какой timezone хранятся данные и отображаются отчёты. В doc каждой таблицы.

Читайте также

FAQ

UTC или Moscow time для хранения?

UTC всегда. Конвертируйте при выводе.

Как решить, какой timezone показывать в отчётах?

Обычно timezone головного офиса компании. Для global reporting — UTC.

Ошибка «cannot cast aware to naive»?

Вы смешиваете TIMESTAMPTZ и TIMESTAMP. Приводите обе стороны к одному типу.

Как проверить, какая timezone у данных?

SELECT event_time FROM events LIMIT 1; — если выводится с +03, это TIMESTAMPTZ. Без — TIMESTAMP.