← назад к разделу

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

Разберём, как PostgreSQL работает со временем и почему правильный выбор типа предотвращает целый класс проблем.

Проблема: timestamp без таймзоны теряет смысл данных

Представьте: вы сохраняете в базу строку '2026-05-07 14:00:00'. Что это? 14:00 в UTC? В московском времени? В зоне приложения? В зоне сервера? PostgreSQL не знает — он сохранит буквально эти цифры, без какого-либо контекста.

-- Тип timestamp (без таймзоны)
INSERT INTO orders (created_at) VALUES ('2026-05-07 14:00:00');
-- Хранится буквально '2026-05-07 14:00:00'
-- Что это значит через год — никто не знает

Когда данные приходят с разных серверов или клиентов с разными временными зонами, значения перемешиваются: '2026-05-07 12:00:00' от UTC-сервера и от московского клиента — это разные моменты времени, но в базе они выглядят одинаково. Сравнивать их бессмысленно.

Правило простое: для всего бизнес-времени используй timestamptz.

timestamptz — что это такое

timestamptz (полное название — timestamp with time zone) работает иначе:

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

Важное следствие: timestamptz не хранит зону — он хранит UTC. Зона используется только при вводе и выводе.

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

Три разных представления — один и тот же момент. Это и есть правильная работа со временем.

На практике схема выглядит так:

CREATE TABLE order_event (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    occurred_at timestamptz NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
);

Как читать timestamptz в приложении

Когда драйвер отдаёт timestamptz приложению, он возвращает значение в UTC. Задача приложения — сохранить его в типе, который понимает временную зону, а не как «локальное» время без контекста.

Типичная ошибка: драйвер возвращает UTC, но код кладёт его в тип без зоны. На сервере с TZ=UTC всё работает, на машине разработчика с TZ=Europe/Moscow — нет.

Java

// Правильно: Instant — это UTC-момент
record OrderEventRow(long id, Instant occurredAt) {}

// Неправильно: LocalDateTime — без зоны, потеряется при конвертации
record OrderEventRow(long id, LocalDateTime occurredAt) {}
Колонка PGJava типКорректно
timestamptzInstantда, рекомендуется
timestamptzOffsetDateTimeда
timestamptzZonedDateTimeда, но избыточно
timestamptzLocalDateTimeнет — потеряется зона
timestamp (без TZ)LocalDateTimeда (но сам тип нежелателен)
dateLocalDateда
timeLocalTimeда

jOOQ при правильной конфигурации генерирует Instant для timestamptz.

Go

// pgx v5: timestamptz → time.Time (всегда UTC)
type OrderEventRow struct {
    ID         int64
    OccurredAt time.Time // timestamptz → time.Time{UTC}
}

var row OrderEventRow
err := pool.QueryRow(ctx,
    "SELECT id, occurred_at FROM order_event WHERE id = $1", id,
).Scan(&row.ID, &row.OccurredAt)

// row.OccurredAt.UTC() — гарантированно UTC
Колонка PGGo типКорректно
timestamptztime.Timeда (pgx v5 ставит UTC)
timestamp (без TZ)time.Timeда (без зоны в БД)
datepgtype.Date / time.Timeда

Node.js

// node-postgres (pg): timestamptz → Date (UTC внутри)
interface OrderEventRow {
  id: number;
  occurred_at: Date;
}

const result = await pool.query<OrderEventRow>(
  'SELECT id, occurred_at FROM order_event WHERE id = $1',
  [id]
);
// result.rows[0].occurred_at.toISOString() — UTC строка
Колонка PGNode типКорректно
timestamptzDateда (pg конвертирует в UTC)
timestamp (без TZ)Dateосторожно: pg интерпретирует как локальную TZ
datestring (ISO)да

Python

# psycopg v3: timestamptz → datetime(tzinfo=UTC)
from datetime import datetime
import psycopg

async with await psycopg.AsyncConnection.connect(dsn) as conn:
    async with conn.cursor() as cur:
        await cur.execute(
            "SELECT id, occurred_at FROM order_event WHERE id = %s", (row_id,)
        )
        row = await cur.fetchone()
        occurred_at: datetime = row[1]  # datetime(tzinfo=timezone.utc)

# Неправильно: naive datetime без tzinfo — потеряется зона
Колонка PGPython типКорректно
timestamptzdatetime(tzinfo=UTC)да (psycopg v3)
timestamptzdatetime без tzinfoнет — потеряется зона
datedateда

Когда timestamp без зоны всё же нужен

Есть редкий случай, когда timestamp (без зоны) оправдан: «локальное время без привязки к конкретному моменту».

Примеры:

  • расписание магазина — «открывается в 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
timezone        text NOT NULL            -- 'Europe/Moscow'

Зона хранится отдельной колонкой, приложение конвертирует при необходимости. Для всего остального — timestamptz.

now() и clock_timestamp() — в чём разница

PostgreSQL предлагает несколько функций для получения текущего времени, и они работают по-разному:

ФункцияЧто возвращает
now() / transaction_timestamp()Начало текущей транзакции. Одинаковое внутри всей транзакции.
statement_timestamp()Начало текущего SQL-выражения.
clock_timestamp()Фактический момент вызова. Каждый вызов — новое значение.

Для created_at и updated_at используй now(). Все строки, вставленные в одной транзакции, получат одну отметку — удобно для отладки и аудита: по одному значению видно, что строки появились вместе.

created_at timestamptz NOT NULL DEFAULT now()

clock_timestamp() нужен для замеров производительности внутри транзакции — например, чтобы понять, сколько занял цикл вставки 10 000 строк.

INTERVAL для смещений во времени

Когда нужно выбрать записи за последние N минут, дней или месяцев, используй INTERVAL:

-- Правильно: читаемо, учитывает особенности календаря
SELECT * FROM session WHERE last_seen_at < now() - interval '15 minutes';
SELECT * FROM report  WHERE period_start > now() - interval '1 month';

-- Неправильно: нечитаемо
SELECT * FROM session WHERE last_seen_at < now() - 900 * interval '1 second';

INTERVAL корректно обрабатывает летнее время, високосные секунды и разную длину месяцев.

+infinity для бессрочных записей

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

-- Бессрочная подписка
INSERT INTO subscription (expires_at) VALUES ('infinity');

-- Найдёт активные подписки, включая бессрочные
SELECT * FROM subscription WHERE expires_at > now();

Это лучше, чем NULL или 9999-12-31:

  • NULL неоднозначен: «неизвестно» или «никогда»?
  • 9999-12-31 — магическая константа, которую придётся обрабатывать отдельно.
  • infinity явно выражает намерение и поддерживается арифметикой и индексами.

Тестируемость: не вызывай время напрямую

Если сервис вызывает Instant.now() (или time.Now(), new Date(), datetime.now()) прямо в коде, тесты становятся нестабильными: значения в базе и в вычислениях расходятся на микросекунды, сравнивать их сложно.

Паттерн решения — абстракция ClockService, которую можно подменить в тестах:

Java

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"));
}

Go

type ClockService interface {
    Now() time.Time
}

type SystemClock struct{}
func (SystemClock) Now() time.Time { return time.Now().UTC() }

// В тесте:
type FixedClock struct{ t time.Time }
func (f FixedClock) Now() time.Time { return f.t }

fixed := FixedClock{t: time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)}
svc := NewOrderService(pool, fixed)

Node.js

interface ClockService {
  now(): Date;
}

class SystemClock implements ClockService {
  now(): Date { return new Date(); }
}

// В тесте (Jest):
const mockClock: ClockService = {
  now: jest.fn().mockReturnValue(new Date('2026-05-07T12:00:00Z')),
};
const service = new OrderService(pool, mockClock);

Python

from typing import Protocol
from datetime import datetime, timezone

class ClockService(Protocol):
    def now(self) -> datetime: ...

class SystemClock:
    def now(self) -> datetime:
        return datetime.now(tz=timezone.utc)

# В тесте (pytest):
class FixedClock:
    def now(self) -> datetime:
        return datetime(2026, 5, 7, 12, 0, 0, tzinfo=timezone.utc)

service = OrderService(pool, FixedClock())

Коротко

  • Для бизнес-времени — всегда timestamptz, никогда timestamp без зоны.
  • timestamptz не хранит зону — хранит UTC; зона используется только при вводе и выводе.
  • timestamp без зоны допустим только для «локального времени без привязки» (расписание), и тогда зона хранится отдельной колонкой.
  • В приложении читай timestamptz в UTC-тип: Instant (Java), time.Time (Go), Date (Node), datetime(tzinfo=UTC) (Python).
  • now() возвращает время начала транзакции — используй для created_at; clock_timestamp() — для замеров.
  • INTERVAL для смещений: now() - interval '15 minutes', не now() - 900 * interval '1 second'.
  • Для бессрочных записей — 'infinity'::timestamptz, не NULL и не 9999-12-31.
  • Текущее время в коде сервиса — через абстракцию ClockService, не напрямую.

Что почитать дальше

  • Числа и точность в PostgreSQL — bigint, numeric, деньги.
  • Строковые типы — text по умолчанию вместо varchar.
  • UUID и идентификаторы — UUID v7 time-sortable.
  • Антипаттерны типов — частые ошибки при проектировании схемы.