Время — самый частый источник тихих багов в продакшене. Заказ от 23:30 не попадает в дневной отчёт, события приходят «в будущем», cron срабатывает дважды. В 90% случаев виноват не код приложения, а тип колонки в БД.

1. Главное правило

PG-T-030 Для бизнес-времени — всегда timestamptz. Никогда timestamp (он же timestamp without time zone).

CREATE TABLE order_event (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    occurred_at timestamptz NOT NULL,    -- правильно
    created_at  timestamp   NOT NULL     -- неправильно
);

2. Что хранится на самом деле

Главное недопонимание про PG: timestamptz не хранит зону. Он хранит UTC.

  • При записи: PG берёт значение, конвертирует в UTC по текущему TimeZone сессии, сохраняет как количество микросекунд от эпохи.
  • При чтении: PG берёт UTC, конвертирует обратно в TimeZone сессии, возвращает.

То есть timestamptz — это «timestamp в UTC + автоматическая конвертация на границе I/O». Зона нужна, чтобы PG знал, в какой зоне трактовать ваш ввод и в какой отдавать вывод.

timestamp — это просто число «год-месяц-день-час-минута-секунда» без привязки к зоне. PG считает его «локальным временем непонятно где». Если в одну колонку с разных серверов / клиентов / контейнеров приедут значения, получится мусор: 2026-05-07 12:00:00 от UTC-сервера и от MSK-клиента — это разные моменты, но в БД они одинаковые.

3. Откуда берутся проблемы

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

Один и тот же момент времени, отображается по-разному в зависимости от зоны сессии. Это правильно — это и есть смысл timestamptz.

С timestamp без зоны:

INSERT INTO bad (created_at) VALUES ('2026-05-07 14:00:00');
-- хранится буквально '2026-05-07 14:00:00' — без зоны

-- что это значит? UTC? MSK? Зона приложения? Зона клиента?
-- ответа нет, и через год никто уже не помнит, какая была договорённость.

4. JDBC и Java-сторона

PG-T-031 На Java-стороне используем Instant или OffsetDateTime для timestamptz. LocalDateTime — никогда.

Колонка PGJava типКорректно
timestamptzInstantда, рекомендуется
timestamptzOffsetDateTimeда
timestamptzZonedDateTimeда, но избыточно
timestamptzLocalDateTimeнет — потеряется зона на конверсии
timestamp (без TZ)LocalDateTimeда (но сам тип нежелателен)
dateLocalDateда
timeLocalTimeда

Instant — это «момент времени в UTC». Идеально ложится на timestamptz. jOOQ при включённой конфигурации генерирует Instant, JdbcTemplate тоже умеет.

Типичный баг: 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) {}

5. Когда timestamp without time zone всё-таки нужен

PG-T-032 timestamp (без TZ) допустим только для «локального времени без привязки к моменту» — например, расписание открытия магазина, рабочее время, время вылета по локальному рейсу.

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.

6. now(), clock_timestamp(), statement_timestamp()

PG-T-033 Знайте разницу:

ФункцияЧто возвращает
now() / transaction_timestamp()время начала транзакции. Внутри одной транзакции возвращает то же самое.
statement_timestamp()время начала текущего statement (одного INSERT/UPDATE/...).
clock_timestamp()время в момент вызова. Каждое обращение даёт новое значение.

Для created_at / updated_at обычно нужен now() — тогда у всех строк, вставленных в одной транзакции, одна и та же отметка. Это полезно для отладки и аудита.

created_at timestamptz NOT NULL DEFAULT now()

Если делаете замер «сколько шёл цикл вставки 10 000 строк» внутри транзакции — берёте clock_timestamp().

7. Тестируемое время

PG-T-034 В приложении не вызывайте Instant.now() / LocalDateTime.now() напрямую. Заведите DateTimeService и моките его в тестах.

Тривиальный сервис:

public interface DateTimeService {
    Instant now();
}

@Component
@Profile("!integration-test")
public class SystemDateTimeService implements DateTimeService {
    @Override public Instant now() { return Instant.now(); }
}

В тесте:

@MockitoBean DateTimeService dateTimeService;

@BeforeEach
void freezeTime() {
    when(dateTimeService.now()).thenReturn(Instant.parse("2026-05-07T12:00:00Z"));
}

Без этого тесты, где сравнивается время в БД с временем расчёта, становятся flaky на ровном месте: сравнение assertEquals(expected, actual) по Instant иногда отличается на 100 микросекунд.

То же самое применимо к now() в SQL-DEFAULT: для проверяемых сценариев лучше передавать значение из приложения, а не полагаться на DEFAULT now().

8. INTERVAL и арифметика

PG-T-035 Используйте INTERVAL для смещений в 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 корректно учитывает летнее время, високосные секунды, разницу длительности месяцев.

9. Важная мелочь: +infinity и -infinity

PG поддерживает специальные значения:

INSERT INTO subscription (expires_at) VALUES ('infinity');
SELECT * FROM subscription WHERE expires_at > now();   -- найдёт

Полезно для «бессрочных» подписок, политик, ролей. Лучше, чем NULL или 9999-12-31 — выражает намерение явно и поддерживается арифметикой.


Чек-лист на ревью

  • [ ] Все «когда что-то произошло» поля — timestamptz. timestamp без TZ — только для локального времени с явной отдельной зоной.
  • [ ] На Java-стороне timestamptz мапится на Instant или OffsetDateTime, не на LocalDateTime.
  • [ ] Время не берётся через Instant.now() напрямую — есть DateTimeService и он мокается в тестах.
  • [ ] В SQL для смещений — INTERVAL, не магические числа секунд.
  • [ ] created_at / updated_at имеют DEFAULT now() (или, если важна детерминированность — пишутся явно из приложения).

Связанные

  • UUID и идентификаторы — UUID v7 включает временную метку, тоже завязан на время.
  • Антипаттерны.
  • Стратегия тестов — про детерминированное время через @MockitoBean.