Опирается на правила:
PG-T-030…PG-T-035из PostgreSQL Style Guide → раздел Время и таймзоны.
Важно знать
- Бизнес-время — всегда
timestamptz, никогдаtimestamp without time zone.timestamptzне хранит зону — хранит UTC, конвертирует на границе I/O.timestampбез зоны — мусор когда данные приходят с разных серверов/клиентов.- Java для
timestamptz—InstantилиOffsetDateTime.LocalDateTime— никогда.timestampбез TZ допустим только для «локального времени без привязки» (расписание).now()= время транзакции;clock_timestamp()= момент вызова.DateTimeServiceв коде, неInstant.now()напрямую — тестируемость.INTERVALдля смещений в SQL (now() - interval '15 minutes').
Время — самый частый источник тихих багов в проде. Заказ от 23:30 не попадает в дневной отчёт, события приходят «в будущем», cron срабатывает дважды. В 90% случаев виноват не код приложения, а тип колонки в БД. UCP формулирует: timestamptz для всего бизнес-времени, Instant на Java-стороне.
timestamptz всегда
PG-T-030:
-- ✓
CREATE TABLE order_event (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ✗
CREATE TABLE bad (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_at timestamp NOT NULL
);
Что хранится на самом деле
Главное недопонимание: timestamptz не хранит зону. Он хранит UTC.
- При записи: PG берёт значение, конвертирует в UTC по
TimeZoneсессии, сохраняет как микросекунды от эпохи. - При чтении: PG берёт UTC, конвертирует обратно в
TimeZoneсессии, возвращает.
timestamptz = «timestamp в UTC + автоматическая конвертация на границе I/O».
SET TIME ZONE 'Europe/Moscow';
INSERT INTO order_event (occurred_at) VALUES ('2026-05-07 14:00:00');
-- хранится как 2026-05-07 11:00:00+00 (UTC)
SET TIME ZONE 'UTC';
SELECT occurred_at FROM order_event;
-- 2026-05-07 11:00:00+00
SET TIME ZONE 'America/New_York';
SELECT occurred_at FROM order_event;
-- 2026-05-07 07:00:00-04
Один момент, разные представления — это правильно.
С timestamp (без TZ):
INSERT INTO bad (created_at) VALUES ('2026-05-07 14:00:00');
-- хранится буквально '2026-05-07 14:00:00' без зоны
-- Что это значит? UTC? MSK? Зона приложения? Зона клиента?
-- Ответа нет, через год никто не помнит.
Если с разных серверов / клиентов приедут значения — мусор: '2026-05-07 12:00:00' от UTC-сервера и от MSK-клиента это разные моменты, но в БД они одинаковые.
Java-сторона
PG-T-031: Instant/OffsetDateTime, никогда LocalDateTime.
| Колонка PG | Java тип | Корректно |
|---|---|---|
timestamptz | Instant | ✓ рекомендуется |
timestamptz | OffsetDateTime | ✓ |
timestamptz | ZonedDateTime | ✓ но избыточно |
timestamptz | LocalDateTime | ✗ — потеряется зона |
timestamp (без TZ) | LocalDateTime | ✓ (но сам тип нежелателен) |
date | LocalDate | ✓ |
time | LocalTime | ✓ |
Instant — это «момент времени в UTC». Идеально для timestamptz. jOOQ при правильной конфигурации генерирует Instant.
Типичный баг: jOOQ сгенерил LocalDateTime для timestamptz → при чтении PG отдаёт UTC, JDBC-драйвер конвертирует в локальную зону JVM → получаем LocalDateTime без указания, в какой зоне он. На сервере с TZ=UTC и на ноутбуке разработчика с TZ=Europe/Moscow — разные значения.
// ✓
record OrderEventRow(long id, Instant occurredAt) {}
// ✗
record OrderEventRow(long id, LocalDateTime occurredAt) {}
Когда timestamp (без TZ) нужен
PG-T-032: «локальное время без привязки к моменту».
-- ✓ — расписание магазина (без зоны: «открывается в 9 утра локально»)
shop_opens_at time NOT NULL, -- 09:00
shop_closes_at time NOT NULL, -- 18:00
holiday_date date NOT NULL, -- 2026-01-01 в локальной зоне магазина
flight_local_departure timestamp NOT NULL -- 14:00 по локальному времени рейса
Зона хранится отдельно (timezone text NOT NULL), приложение конвертирует при необходимости.
Это редкие случаи. Дефолт — timestamptz.
now() vs clock_timestamp()
PG-T-033:
| Функция | Что возвращает |
|---|---|
now() / transaction_timestamp() | начало транзакции. Одинаковое внутри tx. |
statement_timestamp() | начало текущего statement. |
clock_timestamp() | момент вызова. Каждый вызов — новое значение. |
Для created_at/updated_at — обычно now(). Все строки, вставленные в одной транзакции, имеют одну отметку — удобно для дебага и аудита.
created_at timestamptz NOT NULL DEFAULT now()
Если замер «сколько шёл цикл вставки 10000 строк» — clock_timestamp().
DateTimeService — тестируемость
PG-T-034: не Instant.now() напрямую.
public interface DateTimeService {
Instant now();
}
@Component
@Profile("!integration-test")
public class SystemDateTimeService implements DateTimeService {
@Override
public Instant now() {
return Instant.now();
}
}
В тесте — мокаем (см. Test Strategy → BaseIntegrationTest):
@MockitoBean
DateTimeService dateTimeService;
@BeforeEach
void freezeTime() {
when(dateTimeService.now()).thenReturn(Instant.parse("2026-05-07T12:00:00Z"));
}
Без этого тесты, где сравнивается время в БД с временем расчёта, flaky: assertEquals(expected, actual) по Instant может отличаться на 100 микросекунд.
Аналогично — для проверяемых сценариев лучше передавать значение из приложения, чем полагаться на DEFAULT now() в SQL.
INTERVAL
PG-T-035: для смещений в SQL.
-- ✓ — читаемо, учитывает високосные секунды
SELECT * FROM session WHERE last_seen_at < now() - interval '15 minutes';
-- ✗ — нечитаемо
SELECT * FROM session WHERE last_seen_at < now() - 900 * interval '1 second';
INTERVAL корректно учитывает летнее время, високосные секунды, разницу длительности месяцев.
+infinity и -infinity
PG поддерживает специальные значения:
INSERT INTO subscription (expires_at) VALUES ('infinity');
SELECT * FROM subscription WHERE expires_at > now(); -- найдёт
Полезно для «бессрочных» подписок, политик, ролей. Лучше, чем NULL или 9999-12-31:
- Явное намерение.
- Поддерживается арифметикой.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
timestamp без TZ для бизнес-времени | PG-T-030 | timestamptz |
LocalDateTime в Java для timestamptz | PG-T-031 | Instant |
created_at timestamp без зоны | PG-T-030 | timestamptz |
Instant.now() в коде сервиса | PG-T-034 | DateTimeService |
now() - 86400 * interval '1 second' | PG-T-035 | now() - interval '1 day' |
9999-12-31 для бессрочного | PG-T-035 | 'infinity'::timestamptz |
Колонка с зоной как text | PG-T-030 | timestamptz + timezone отдельно |
clock_timestamp() для audit created_at | PG-T-033 | now() (стабильный в tx) |
Куда дальше
- PG → Время и таймзоны — нормативные формулировки.
- Числа и точность — bigint, numeric.
- Строковые типы — text по умолчанию.
- UUID и идентификаторы — UUID v7 time-sortable.
- Антипаттерны типов — сводка.
- Test Strategy → BaseIntegrationTest —
@MockitoBean DateTimeService. - TestObjectGenerator —
withNano(0)для сравнения времени.