Опирается на правила: PG-T-030PG-T-035 из PostgreSQL Style Guide → раздел Время и таймзоны.

Важно знать

  • Бизнес-время — всегда timestamptz, никогда timestamp without time zone.
  • timestamptz не хранит зону — хранит UTC, конвертирует на границе I/O.
  • timestamp без зоны — мусор когда данные приходят с разных серверов/клиентов.
  • Java для timestamptzInstant или 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.

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

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-030timestamptz
LocalDateTime в Java для timestamptzPG-T-031Instant
created_at timestamp без зоныPG-T-030timestamptz
Instant.now() в коде сервисаPG-T-034DateTimeService
now() - 86400 * interval '1 second'PG-T-035now() - interval '1 day'
9999-12-31 для бессрочногоPG-T-035'infinity'::timestamptz
Колонка с зоной как textPG-T-030timestamptz + timezone отдельно
clock_timestamp() для audit created_atPG-T-033now() (стабильный в tx)

Куда дальше

  • PG → Время и таймзоны — нормативные формулировки.
  • Числа и точность — bigint, numeric.
  • Строковые типы — text по умолчанию.
  • UUID и идентификаторы — UUID v7 time-sortable.
  • Антипаттерны типов — сводка.
  • Test Strategy → BaseIntegrationTest — @MockitoBean DateTimeService.
  • TestObjectGenerator — withNano(0) для сравнения времени.