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

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

клиент пишет хранится клиент читает '2026-05-07 14:00'зона сессии +03 -3 ч 11:00:00+00UTC, зоны нет 11:00:00+00зона сессии: UTC 14:00:00+03Europe/Moscow 07:00:00-04America/New_York цифры разные — момент один

При записи 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 переписывает данные под блокировкой — делают через новую колонку и перенос порциями.

без USING 14:00 в колонке зона сессии UTC момент 14:00 UTC уехал на 3 часа с USING MSK 14:00 в колонке зона из USING момент 11:00 UTC момент тот же

Одна и та же колонка 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));
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

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

У 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, а не в зоне машины
Колонка PGGo типКорректно
timestamptztime.Timeда (момент верный; печатать через .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 { rows } = await pool.query<OrderEventRow>(
  'SELECT id, occurred_at FROM order_event WHERE id = $1', [id]);
// rows[0].occurred_at.toISOString() — UTC строка
Колонка PGNode типКорректно
timestamptzDateда (pg конвертирует в UTC)
timestamp (без TZ)Dateосторожно: pg интерпретирует как локальную TZ
dateDateосторожно: локальная полночь, дата может съехать на день

С 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 — потеряется зона
Колонка PGPython типКорректно
timestamptzdatetime с tzinfoда (psycopg v3)
timestamptzdatetime без tzinfoнет — потеряется зона
datedateда

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 строк.

10:00:00 BEGIN 10:00:02 now() → 10:00:00 10:00:02 statement_timestamp() → 10:00:02 10:00:02 clock_timestamp() → 10:00:02.418 10:00:09 now() → 10:00:00 10:00:09 statement_timestamp() → 10:00:09 10:00:09 clock_timestamp() → 10:00:09.731 10:00:11 COMMIT

Одна транзакция и два запроса в ней: 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 и naive datetime теряют смысл значения.
  • 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 в схеме.

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