Время — один из самых частых источников тихих ошибок в базах данных. Заказ от 23:30 не попадает в дневной отчёт. События приходят «из будущего». Cron срабатывает дважды. В большинстве таких случаев виноват не код приложения, а тип колонки в PostgreSQL.
При записи PostgreSQL переводит значение в UTC по зоне сессии и хранит момент — без всякой зоны. При чтении момент раскладывается обратно, в зону той сессии, которая спрашивает. Поэтому три разных набора цифр на экране — это одна и та же точка на оси времени.
Проблема: 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 по временной зоне текущей сессии и сохраняет как число микросекунд от фиксированной точки отсчёта (внутри PostgreSQL это полночь 1 января 2000 года, а не привычная в других системах эпоха Unix).
- При чтении: PostgreSQL берёт UTC из хранилища, конвертирует в временную зону текущей сессии, возвращает результат.
Важное следствие: timestamptz не хранит зону — он хранит UTC. Зона используется только при вводе и выводе.
И ещё одно, неочевидное: на диске timestamp и timestamptz неотличимы. Оба занимают восемь байт, оба хранят число микросекунд от той же точки отсчёта. Разница не в хранении, а в том, что база делает с этими цифрами на входе и выходе: timestamptz переводит их в UTC и обратно по зоне сессии, timestamp не трогает вовсе.
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()
);
Как перевести существующую колонку
Если колонка timestamp уже заполнена, при смене типа надо явно сказать, в какой зоне были записаны цифры, — иначе PostgreSQL возьмёт зону сессии и молча сдвинет данные:
ALTER TABLE order_event
ALTER COLUMN created_at TYPE timestamptz
USING created_at AT TIME ZONE 'Europe/Moscow';
Если сервер переезжал и часть строк записана в одной зоне, а часть в другой, их сначала разделяют по дате переезда и конвертируют с разными зонами. На большой таблице ALTER … TYPE переписывает данные под блокировкой — делают через новую колонку и перенос порциями.
Одна и та же колонка timestamp переводится в timestamptz двумя способами; смотрите на третий шаг - без USING база берёт зону сессии, и момент уезжает на три часа.
Как читать timestamptz в приложении
Из базы приходит момент — точка на оси времени. А вот в каком виде он окажется в объекте, решает тип, который запросило приложение. Попросите OffsetDateTime или Instant — получите момент как есть. Попросите Timestamp или LocalDateTime — драйвер разложит тот же момент по зоне виртуальной машины и вернёт «местные» цифры, уже без всякого признака зоны. Задача приложения — просить тип, который зону понимает.
Типичная ошибка ровно в этом: код кладёт момент в тип без зоны. На сервере с TZ=UTC всё работает, на машине разработчика с TZ=Europe/Moscow — нет.
Почему именно TZ машины, а не настройка сервера базы: при подключении драйвер сам сообщает базе зону виртуальной машины, и она становится зоной сессии. Значит, всё сказанное выше про «зону сессии» в Java-приложении означает зону JVM — в том числе и в ALTER … TYPE timestamptz без USING, который молча возьмёт её же.
Java
// Правильно: Instant — это UTC-момент
record OrderEventRow(long id, Instant occurredAt) {}
А так не надо:
// Неправильно: LocalDateTime — без зоны, потеряется при конвертации
record OrderEventRow(long id, LocalDateTime occurredAt) {}
Чем это грозит, видно и без базы. Два разных момента — заказ в 12:00 по Москве и заказ в 12:00 по UTC — в LocalDateTime выглядят одинаково, и сравнение врёт:
живой пример
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
public class InstantVsLocal {
public static void main(String[] args) {
Instant fromMoscowClient = Instant.parse("2026-05-07T09:00:00Z");
Instant fromUtcServer = Instant.parse("2026-05-07T12:00:00Z");
LocalDateTime a = LocalDateTime.ofInstant(fromMoscowClient, ZoneId.of("Europe/Moscow"));
LocalDateTime b = LocalDateTime.ofInstant(fromUtcServer, ZoneId.of("UTC"));
System.out.println("в LocalDateTime: " + a + " и " + b);
System.out.println("выглядят одинаково: " + a.isEqual(b));
System.out.println("это один момент: " + fromMoscowClient.equals(fromUtcServer));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
| Колонка PG | Java тип | Корректно |
|---|---|---|
timestamptz | Instant | да, рекомендуется |
timestamptz | OffsetDateTime | да |
timestamptz | ZonedDateTime | да, но избыточно |
timestamptz | LocalDateTime | нет — потеряется зона |
timestamp (без TZ) | LocalDateTime | да (но сам тип нежелателен) |
date | LocalDate | да |
time | LocalTime | да |
У jOOQ по умолчанию timestamptz превращается в OffsetDateTime — как и в самом JDBC. Это корректно, просто многословнее. Если в коде хочется именно Instant, его задают принудительно: forced type с конвертером в настройках генератора.
Go
// pgx v5: timestamptz → time.Time — момент из базы
type OrderEventRow struct {
ID int64
OccurredAt time.Time
}
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 | да (момент верный; печатать через .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 { rows } = await pool.query<OrderEventRow>(
'SELECT id, occurred_at FROM order_event WHERE id = $1', [id]);
// rows[0].occurred_at.toISOString() — UTC строка
| Колонка PG | Node тип | Корректно |
|---|---|---|
timestamptz | Date | да (pg конвертирует в UTC) |
timestamp (без TZ) | Date | осторожно: pg интерпретирует как локальную TZ |
date | Date | осторожно: локальная полночь, дата может съехать на день |
С date у драйвера pg своя грабля: он отдаёт объект Date на локальной полуночи, и при выводе в другой зоне календарная дата сдвигается на сутки. Если нужна именно дата — регистрируют свой разбор типа 1082 в строку.
Python
# psycopg v3: timestamptz → datetime с tzinfo
from datetime import datetime
import psycopg
with psycopg.connect(dsn) as conn:
row = conn.execute(
"SELECT id, occurred_at FROM order_event WHERE id = %s", (row_id,)
).fetchone()
occurred_at: datetime = row[1] # datetime с зоной сессии
# Неправильно: naive datetime без tzinfo — потеряется зона
| Колонка PG | Python тип | Корректно |
|---|---|---|
timestamptz | datetime с tzinfo | да (psycopg v3) |
timestamptz | datetime без tzinfo | нет — потеряется зона |
date | date | да |
AT TIME ZONE и отчёт по дням в местной зоне
Самая частая операция со временем в прикладной базе — не хранение, а отчёт: «сколько заказов за каждый день». И вот здесь выясняется, что «день» — понятие местное. Заказ, созданный в Москве 7 мая в 00:30, в базе лежит как 2026-05-06 21:30:00+00, и группировка по дате в UTC отправит его в шестое мая. Для московского отчёта это ошибка на целый день.
Разводит это оператор AT TIME ZONE, и он работает в обе стороны — именно поэтому его и путают.
Применённый к timestamptz, он снимает зону: даёт локальное время в указанном поясе, то есть timestamp без зоны. Это «покажи мне, что было на часах в Москве».
Применённый к timestamp без зоны, он, наоборот, навешивает зону: трактует значение как местное время указанного пояса и превращает в момент. Это «часы в Москве показывали столько-то, какой это момент».
Отчёт по дням строится первым способом:
живой пример
SELECT date_trunc('day', created_at AT TIME ZONE 'Europe/Moscow') AS day,
count(*) AS orders
FROM orders
GROUP BY day
ORDER BY day;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Имя зоны пишут именно так — 'Europe/Moscow', а не смещением '+03': имя знает про переводы часов и исторические изменения правил, смещение не знает ничего. Список известных базе имён лежит в pg_timezone_names.
Почему такой отчёт может читать всю таблицу
У этого запроса есть цена, и она объясняет львиную долю жалоб «отчёт за день считается минуту». Условие с функцией над колонкой индекс не использует: WHERE created_at::date = '2026-05-07', WHERE date_trunc('day', created_at) = '2026-05-07' и WHERE extract(year from created_at) = 2026 — всё это для базы выражения, которых в индексе нет, и она читает таблицу целиком.
Тот же вопрос, заданный диапазоном, индекс использует:
живой пример
SELECT count(*) FROM orders
WHERE created_at >= timestamptz '2026-05-07 00:00+03'
AND created_at < timestamptz '2026-05-08 00:00+03';
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Границы дня в нужной зоне считает приложение (или их подставляют выражением date '2026-05-07' AT TIME ZONE 'Europe/Moscow'), а колонка в условии остаётся голой — и индекс по created_at работает.
Если отчёт по дням нужен постоянно и всегда в одной зоне, есть второй путь: индекс по тому самому выражению, которым группируют, — CREATE INDEX ... ON orders ((date_trunc('day', created_at AT TIME ZONE 'Europe/Moscow'))). Он занимает место и обновляется при каждой вставке, зато делает запрос с функцией индексируемым. Выражение в индексе и в запросе должно совпадать дословно, иначе база его не узнает.
Когда 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.
Механизм, ради которого это делают, стоит назвать прямо: правила часовых поясов меняются. Страны переносят переводы часов, отменяют их и меняют смещение — и делают это с уведомлением за месяц-два, уже после того, как вы записали будущее событие.
Если встречу «13 июня в 10:00 в Москве» сохранить моментом, то после изменения правил она останется на том же моменте оси времени, но на московских часах окажется в 9:00 или 11:00 — то есть уедет. А если хранить локальное время плюс имя зоны, момент пересчитается при показе по новым правилам, и встреча останется в 10:00, как и договаривались.
Отсюда и правило: прошедшее событие — это момент (timestamptz), потому что оно уже случилось и переписать его нельзя; будущая договорённость по местным часам — это локальное время плюс зона, потому что договаривались именно о показаниях часов.
now() и clock_timestamp() — в чём разница
Все строки, вставленные в одной транзакции, должны получить одну отметку времени, иначе по created_at не видно, что они появились вместе; а замер длительности цикла внутри транзакции, наоборот, требует настоящих часов. Поэтому функций текущего времени несколько, и работают они по-разному:
| Функция | Что возвращает |
|---|---|
now() / transaction_timestamp() | Начало текущей транзакции. Одинаковое внутри всей транзакции. |
statement_timestamp() | Начало текущего SQL-выражения. |
clock_timestamp() | Фактический момент вызова. Каждый вызов — новое значение. |
Для created_at и updated_at берут now(), и по одному значению потом видно, что строки появились вместе:
created_at timestamptz NOT NULL DEFAULT now()
clock_timestamp() нужен для замеров производительности внутри транзакции — например, чтобы понять, сколько занял цикл вставки 10 000 строк.
Одна транзакция и два запроса в ней: now() оба раза отдаёт время начала транзакции, statement_timestamp() - время своего запроса, clock_timestamp() - момент вызова.
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 умеет считать в календарных единицах — и вот тут прячется главная ловушка. «День» и «двадцать четыре часа» для него не одно и то же. interval '1 day', прибавленный к timestamptz, означает «завтра в то же время по часам», и перевод часов будет учтён. interval '24 hours' — ровно двадцать четыре часа по оси времени, без оглядки на календарь. В ночь перевода часов они расходятся:
SET TIME ZONE 'America/New_York';
SELECT '2026-03-07 12:00:00-05'::timestamptz + interval '1 day' AS plus_day,
'2026-03-07 12:00:00-05'::timestamptz + interval '24 hours' AS plus_24_hours;
-- 2026-03-08 12:00:00-04 | 2026-03-08 13:00:00-04
С месяцами то же самое: interval '1 month' — это «то же число следующего месяца», а не тридцать суток. Так что выбирать единицу надо по смыслу: срок подписки — в днях и месяцах, тайм-аут — в часах и минутах. Високосных секунд PostgreSQL не знает вовсе: в его календаре в сутках всегда ровно 86 400 секунд.
interval бывает и типом колонки, не только выражением. Хранить в нём длительность честнее, чем число в колонке duration_minutes: единица измерения записана в самом значении, и его не придётся умножать на шестьдесят при каждом чтении. В Java такая колонка приезжает как PGInterval у драйвера или как Duration/Period, если преобразование настроено; с календарными единицами (месяцы) Duration работать не будет — для них нужен Period, и это ещё один довод хранить в интервале ровно то, что имелось в виду.
Рядом стоит tstzrange — диапазон моментов одним значением: [2026-01-01, 2026-04-01). Он удобнее пары колонок «с» и «по» тем, что включённость границ записана в самом значении, а база умеет проверять пересечения операторами и ограничением EXCLUDE — тем самым, которое не даёт завести две подписки на один период. Подробно диапазоны разобраны в статье про массивы и диапазоны.
+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явно выражает намерение и поддерживается арифметикой и индексами.
И оговорка про Java, без которой бесконечность приносит сюрприз в прод. Драйвер отображает infinity в крайние значения типов: OffsetDateTime.MAX и Instant.MAX — при чтении в современные типы, а при чтении в java.sql.Timestamp вместо бесконечности приезжает «значение-часовой», особая дата далеко в будущем.
Дальше начинается прикладное: такое значение уходит в JSON как +1000000000-12-31T23:59:59.999999999Z, и клиент, который разбирает дату обычным способом, на нём падает. Поэтому бесконечность либо не выпускают наружу (на границе API превращают в null или в признак «бессрочно»), либо не используют вовсе, храня NULL и записывая условие как expires_at IS NULL OR expires_at > now(). Выбор между этими двумя путями делают один раз на схему: смешивать NULL и infinity в одной колонке — гарантированный источник ошибок в условиях.
Тестируемость: не вызывай время напрямую
Если сервис вызывает Instant.now() (или time.Now(), new Date(), datetime.now()) прямо в коде, тесты становятся нестабильными: значения в базе и в вычислениях расходятся на микросекунды, сравнивать их сложно.
Паттерн решения — абстракция ClockService, которую можно подменить в тестах:
Java
живой пример
import java.time.Instant;
public class FrozenClock {
interface DateTimeService {
Instant now();
}
static boolean isActive(Instant expiresAt, DateTimeService time) {
return expiresAt.isAfter(time.now());
}
public static void main(String[] args) {
DateTimeService system = Instant::now;
DateTimeService frozen = () -> Instant.parse("2026-05-07T12:00:00Z");
Instant expiresAt = Instant.parse("2026-05-07T18:00:00Z");
System.out.println("на замороженных часах: " + isActive(expiresAt, frozen));
System.out.println("на системных часах: " + isActive(expiresAt, system));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
На замороженных часах ответ один и тот же при каждом запуске, на системных — зависит от дня, когда прогнали тест. В приложении реализация будет обычным бином, а в тесте на её место ставят фиксированные часы.
Go
живой пример
package main
import (
"fmt"
"time"
)
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 }
func main() {
expiresAt := time.Date(2026, 5, 7, 18, 0, 0, 0, time.UTC)
fixed := FixedClock{t: time.Date(2026, 5, 7, 12, 0, 0, 0, time.UTC)}
fmt.Println("на замороженных часах:", expiresAt.After(fixed.Now()))
fmt.Println("на системных часах: ", expiresAt.After(SystemClock{}.Now()))
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
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)
class FixedClock:
def now(self) -> datetime:
return datetime(2026, 5, 7, 12, 0, tzinfo=timezone.utc)
expires_at = datetime(2026, 5, 7, 18, 0, tzinfo=timezone.utc)
print("на замороженных часах:", expires_at > FixedClock().now())
print("на системных часах: ", expires_at > SystemClock().now())
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Коротко
- Для бизнес-времени — всегда
timestamptz;timestampбез зоны хранит цифры без смысла. timestamptzне хранит зону — хранит момент в UTC; зона нужна только при вводе и выводе.timestampбез зоны оправдан лишь для «локального времени без привязки» (расписание), и зона тогда лежит отдельной колонкой.- В приложении момент читают в тип с зоной:
Instant(Java),time.Time(Go),Date(Node),datetimeс зоной (Python);LocalDateTimeи naivedatetimeтеряют смысл значения. now()— время начала транзакции (дляcreated_at),clock_timestamp()— фактический момент вызова (для замеров); смещения — черезINTERVAL.- Бессрочное —
'infinity'::timestamptz, неNULLи не9999-12-31; текущее время в коде — черезClockService, иначе тест зависит от дня прогона. AT TIME ZONEработает в обе стороны: уtimestamptzснимает зону (даёт местные часы), уtimestampнавешивает; отчёт по дням в местной зоне —date_trunc('day', created_at AT TIME ZONE 'Europe/Moscow').- Функция над колонкой (
::date,date_trunc,extract) отключает индекс — день задают диапазоном>= … AND < …или строят индекс по тому же выражению. - Будущую договорённость по местным часам хранят локальным временем плюс именем зоны: правила зон меняются, и записанный момент уедет относительно часов.
infinityприезжает в Java какOffsetDateTime.MAXи ломает клиентов в JSON: наружу его не выпускают или заменяют наNULLв схеме.
Что почитать дальше
- Числа и точность в PostgreSQL — bigint, numeric, деньги.
- Строковые типы — text по умолчанию вместо varchar.
- UUID и идентификаторы — UUID v7 time-sortable.
- Антипаттерны типов — частые ошибки при проектировании схемы.