Время — один из самых частых источников тихих ошибок в базах данных. Заказ от 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) {}
| Колонка PG | Java тип | Корректно |
|---|---|---|
timestamptz | Instant | да, рекомендуется |
timestamptz | OffsetDateTime | да |
timestamptz | ZonedDateTime | да, но избыточно |
timestamptz | LocalDateTime | нет — потеряется зона |
timestamp (без TZ) | LocalDateTime | да (но сам тип нежелателен) |
date | LocalDate | да |
time | LocalTime | да |
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
| Колонка PG | Go тип | Корректно |
|---|---|---|
timestamptz | time.Time | да (pgx v5 ставит UTC) |
timestamp (без TZ) | time.Time | да (без зоны в БД) |
date | pgtype.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 строка
| Колонка PG | Node тип | Корректно |
|---|---|---|
timestamptz | Date | да (pg конвертирует в UTC) |
timestamp (без TZ) | Date | осторожно: pg интерпретирует как локальную TZ |
date | string (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 — потеряется зона
| Колонка PG | Python тип | Корректно |
|---|---|---|
timestamptz | datetime(tzinfo=UTC) | да (psycopg v3) |
timestamptz | datetime без tzinfo | нет — потеряется зона |
date | date | да |
Когда 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.
- Антипаттерны типов — частые ошибки при проектировании схемы.