Дата

Приложение Brew поехало во второй город, и сразу посыпались странности. Заказ, оформленный в 12:00 по Москве, в питерской выгрузке показывался как 12:00, а в аналитике на сервере (UTC) — как 09:00, и никто не мог понять, какое время «настоящее». Покопались — и нашли в одной таблице колонку типа timestamp (без зоны). Она хранила «настенное» время без привязки к поясу, и каждый сервис трактовал его по-своему.

Цель юнита — выбрать правильный тип для времени раз и навсегда: всегда timestamptz. Звучит парадоксально, но timestamptz не хранит часовой пояс — он хранит момент времени (инстант, по сути UTC), а пояс применяется только при отображении. Это и делает его безопасным: момент один, а как его показать — забота клиента. Это поведение уровня сессии, а не запроса, поэтому юнит — escape-hatch: ведём его psql-скриптом (demo.sql), а не через query.sql + sqlc.

timestamptz хранит момент, а не пояс

Вопреки названию, timestamptz («timestamp with time zone») не складывает зону в строку. Он нормализует значение к UTC и хранит один инстант. Когда ты его читаешь, Postgres показывает этот инстант в текущем часовом поясе сессии (SET TIME ZONE / параметр TimeZone). Поэтому один и тот же момент 2025-01-15 09:00:00+00 выглядит как 12:00+03 в Москве и как 04:00-05 в Нью-Йорке — но это одна и та же точка на оси времени.

timestamp без зоны — это ловушка

timestamp (без tz) хранит «настенные» дату-время без информации о поясе. Под SET TIME ZONE он не сдвигается — потому что не знает, в каком поясе записан. Для события (когда что-то произошло) это почти всегда ошибка: два сервиса в разных поясах прочитают одно и то же 09:00 как разные моменты. timestamp уместен лишь там, где пояс действительно не нужен (например, «время будильника 08:00» как локальное правило), и таких мест в обычном приложении мало.

Один момент — три отображения

В базе лежит одна точка на оси времени; пояс сессии лишь решает, как её показать:

plaintext
один инстант (в базе, UTC)         как его покажут разные пояса
                              ┌──►  UTC             09:00+00
 2025-01-15 09:00:00+00  ─────┼──►  Europe/Moscow   12:00+03
                              └──►  America/New_York 04:00−05

Значение не меняется — меняется только проекция. timestamp без зоны такой проекции лишён: ему нечего сдвигать, поэтому он «застывает» на записанных цифрах.

timestamptztimestamp (без зоны)
Что хранитмомент (инстант, нормализован к UTC)«настенные» дату-время без пояса
Под SET TIME ZONEсдвигается при отображениине двигается — пояс неизвестен
Для событийда, правильный выборловушка: сервисы прочтут по-разному
В Go (pgx)time.Time — инстантtime.Time без привязки к поясу

Что показывает наш код

demo.sql берёт один реальный инстант из базовой таблицы — orders.created_at заказа #1 (2025-01-15 09:00:00+00) — и читает его под тремя поясами, меняя только SET TIME ZONE:

sql
SET TIME ZONE 'UTC';            SELECT created_at FROM orders WHERE id = 1;
SET TIME ZONE 'Europe/Moscow';  SELECT created_at FROM orders WHERE id = 1;
SET TIME ZONE 'America/New_York'; SELECT created_at FROM orders WHERE id = 1;

Значение в базе не меняется — меняется только его отображение. Затем demo.sql показывает ловушку: при той же SET TIME ZONE 'Europe/Moscow' литерал timestamp (без зоны) остаётся 09:00, а timestamptz сдвигается на 12:00+03.

В Go (через pgx) timestamptz приезжает как time.Time — тоже инстант. Форматировать его в местное время — задача слоя представления (UI/отчёта), а не хранения. Поэтому здесь нам и не нужен sqlc: урок про команду сессии SET TIME ZONE, а не про типизированный запрос.

Запуск

Подними песочницу (из корня репозитория) и накати схему Brew:

sh
docker compose up -d
make lecture L=01-data-types/01-03-date-time-timestamptz T=db-reset
make lecture L=01-data-types/01-03-date-time-timestamptz

(T=run — значение по умолчанию: это psql -f demo.sql. run — алиас на основной демо, как у любого escape-hatch-юнита.)

Вывод:

plaintext
== Один инстант orders.created_at = 2025-01-15 09:00:00+00 под разными зонами ==
 
-- SET TIME ZONE 'UTC' :
 id |       created_at       
----+------------------------
  1 | 2025-01-15 09:00:00+00
 
 
-- SET TIME ZONE 'Europe/Moscow' (+03):
 id |       created_at       
----+------------------------
  1 | 2025-01-15 12:00:00+03
 
 
-- SET TIME ZONE 'America/New_York' (зимой -05):
 id |       created_at       
----+------------------------
  1 | 2025-01-15 04:00:00-05
 
 
== Ловушка: timestamp БЕЗ зоны не сдвигается, timestamptz — сдвигается ==
-- при той же SET TIME ZONE 'Europe/Moscow':
  wall_clock_no_tz   |       instant_tz       
---------------------+------------------------
 2025-01-15 09:00:00 | 2025-01-15 12:00:00+03

09:00+00, 12:00+03, 04:00-05 — это три отображения одного момента. А внизу видно различие типов: timestamp без зоны застрял на 09:00 (он не знает своего пояса), timestamptz честно сдвинулся на +03. Питерская выгрузка «12:00 везде» сломалась именно потому, что время хранили без зоны.

Заборчик

Что мы упростили:

  • Летнее время. Пояса здесь взяты с фиксированным зимним смещением (Москва +03 круглый год с 2014-го, Нью-Йорк зимой -05), чтобы вывод воспроизводился дословно. В реальности есть переход на DST: в Нью-Йорке летом было бы -04, и тот же UTC-инстант показался бы на час иначе. Postgres учитывает это по имени зоны (America/New_York) — поэтому храни имя зоны, а не числовое смещение, если важна локальная дата будущих событий.
  • Слой представления. В проде форматирование времени для пользователя живёт в UI/отчёте (его пояс берётся из профиля / Accept-Language / настроек), а БД и бэкенд оперируют инстантами в UTC.
  • SET TIME ZONE руками — только ради демо. В приложении ты так почти никогда не делаешь; здесь мы крутим пояс вручную лишь чтобы увидеть механику.

Что забрать с собой

  • Для событий храни timestamptz — он держит момент (UTC), пояс применяется при отображении.
  • timestamp без зоны хранит «настенное» время без пояса и не сдвигается под SET TIME ZONE — для событий это ловушка.
  • Один инстант выглядит по-разному под разными поясами — это нормально; значение в базе одно.
  • В Go timestamptztime.Time (инстант). Форматируй в местное время в слое представления, а не в хранилище.

Дальше — юнит 01-04 «uuid и uuidv7»: какой ключ выбрать — автоинкремент, случайный gen_random_uuid() (v4) или PG18 uuidv7() со встроенным временем, — и почему v7 годится как сортируемый по времени первичный ключ.

·Модуль 02

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Типы данных / Дата