Даты в PostgreSQL: выборка за день и отчёт по месяцам

SQL Автор: Среда и версия: PostgreSQL 18.3 в PGlite 0.5.4; запросы проверены на временной таблице при трёх настройках TimeZone
содержание

Чтобы выбрать записи за день в PostgreSQL, включи его начало и исключи начало следующего дня: created_at >= начало AND created_at < следующий_день. Это полуоткрытый диапазон [начало, конец). Он захватывает и полночь, и последнюю долю секунды перед следующей датой.

Для отчёта по месяцам действует то же правило: сентябрь начинается 1 сентября и заканчивается перед 1 октября. Условие BETWEEN '2026-09-01' AND '2026-09-30' для столбца timestamp или timestamptz обрывает выборку в начале 30 сентября. Разберём эту границу на шести заказах, затем сгруппируем их по месяцам с помощью date_trunc.

Какой тип хранится в столбце: date, timestamp или timestamptz

Дата заказа и точный момент его создания — разные данные. От типа зависит, что будет означать граница выборки.

ТипЧто хранитКогда подходит
dateКалендарную дату без времениДень рождения, отчётную дату
timestampДату и время без часового поясаМестные показания часов, если пояс задаётся отдельно
timestamptzМомент времениСоздание заказа, оплату, вход пользователя

timestamp — сокращение для timestamp without time zone, а timestamptz — для timestamp with time zone. Несмотря на название, timestamptz не сохраняет исходное имя пояса. Момент хранится в UTC, а при выводе отображается в часовом поясе сессии. Это описано в документации типов PostgreSQL.

В нашем примере created_at имеет тип timestamptz. День отчёта считаем по Europe/Moscow. Для другого бизнеса пояс может быть другим; важно выбрать его явно и одинаково применять к фильтру и группировке.

Шесть заказов на границах месяца

Открой чистую сессию PostgreSQL или SQL-песочницу. Выполни этот блок один раз, а следующие запросы — в той же сессии. Временная таблица нужна только для примера.

SET TIME ZONE 'Europe/Moscow';

CREATE TEMP TABLE date_orders (
  id integer PRIMARY KEY,
  created_at timestamptz NOT NULL,
  amount integer NOT NULL
);

INSERT INTO date_orders (id, created_at, amount) VALUES
  (1, TIMESTAMPTZ '2026-08-31 20:59:59+00', 100),
  (2, TIMESTAMPTZ '2026-08-31 21:00:00+00', 200),
  (3, TIMESTAMPTZ '2026-09-29 21:00:00+00', 300),
  (4, TIMESTAMPTZ '2026-09-30 09:00:00+00', 400),
  (5, TIMESTAMPTZ '2026-09-30 20:59:59.999999+00', 500),
  (6, TIMESTAMPTZ '2026-09-30 21:00:00+00', 600);

В литералах указан сдвиг +00, поэтому исходные моменты однозначны. Посмотрим на них по московским часам:

SELECT id,
       (created_at AT TIME ZONE 'Europe/Moscow')::text AS local_time,
       amount
FROM date_orders
ORDER BY id;
idlocal_timeamount
12026-08-31 23:59:59100
22026-09-01 00:00:00200
32026-09-30 00:00:00300
42026-09-30 12:00:00400
52026-09-30 23:59:59.999999500
62026-10-01 00:00:00600

Здесь ::text нужен только для вывода: так SQL-песочница покажет все шесть знаков дробной секунды. Без него преобразование в JavaScript Date ограничит точность миллисекундами.

За сентябрь нужны заказы 2, 3, 4, 5, всего на 1400. За 30 сентября — 3, 4, 5, на 1200. Заказ 6 уже относится к октябрю, хотя в UTC его дата ещё сентябрьская.

Почему BETWEEN теряет записи последнего дня

Попробуем выбрать весь сентябрь, оставив часовой пояс сессии Europe/Moscow:

SELECT id, amount
FROM date_orders
WHERE created_at BETWEEN DATE '2026-09-01' AND DATE '2026-09-30'
ORDER BY id;
idamount
2200
3300

BETWEEN включает обе границы: это запись для >= и <=. Но дата без времени при таком сравнении означает полночь в поясе сессии. Верхняя граница здесь — 30 сентября, 00:00:00. Заказ 3 ровно на ней проходит, а заказы 4 и 5 позже неё — уже нет.

Если столбец имеет тип date, проблема другая: времени в нём нет, и BETWEEN DATE '2026-09-01' AND DATE '2026-09-30' включает весь набор сентябрьских дат. Ошибка возникает, когда границу для календарной даты принимают за конец дня со временем.

Выборка за день: включить начало, исключить следующий день

Для 30 сентября зададим две местные полуночи. AT TIME ZONE превратит каждую в точный момент в выбранном поясе:

SELECT id, amount
FROM date_orders
WHERE created_at >= (
        TIMESTAMP '2026-09-30 00:00:00' AT TIME ZONE 'Europe/Moscow'
      )
  AND created_at < (
        TIMESTAMP '2026-10-01 00:00:00' AT TIME ZONE 'Europe/Moscow'
      )
ORDER BY id;
idamount
3300
4400
5500

У такого диапазона три полезных свойства:

  • >= включает заказ 3, созданный ровно в начале дня.
  • < включает заказ 5 за микросекунду до конца и исключает заказ 6 ровно в начале следующего дня.
  • Соседние дни не пересекаются: заказ 6 попадёт в выборку за 1 октября один раз.
SQL / 01

Начало входит в день, следующая полночь — уже нет

Диапазон за 30 сентября включает заказы 3, 4 и 5 Время в Europe/Moscow. Заказ 3 в 00:00:00, заказ 4 в 12:00:00 и заказ 5 в 23:59:59.999999 входят в выборку. Заказ 6 ровно в 00:00:00 1 октября исключён. Показан порядок событий без масштаба времени. EUROPE/MOSCOW · ПОРЯДОК СОБЫТИЙ, БЕЗ МАСШТАБА 30 сентября 1 октября 00:00:00 12:00:00 23:59:59 .999999 00:00:00 id = 3 id = 4 id = 5 id = 6 включён: >= внутри дня внутри дня исключён: <
Выборка за 30 сентября возвращает id 3, 4 и 5. Заказ 6 на правой границе относится к следующему дню.

Не нужно искать «последнюю допустимую секунду»: граница < 1 октября охватывает все значения перед ней. В данных специально есть дробная секунда, которую условие <= 23:59:59 потеряло бы.

Здесь TIMESTAMP задаёт местные показания часов, а AT TIME ZONE связывает их с поясом и возвращает timestamptz. В запросе к столбцу типа timestamp, который уже хранит нужное местное время, границы тоже задают как timestamp — без такого преобразования. Две операции AT TIME ZONE для разных типов перечислены в документации PostgreSQL.

Строй границы по календарю нужного пояса: начало дня и начало следующего дня. В поясах с сезонным переводом часов между ними не всегда 24 часа.

Почему часовой пояс сессии меняет дату

У заказа 2 один момент создания, но две календарные даты: 31 августа в UTC и 1 сентября в Москве. Проверим, что произойдёт при смене настройки сессии:

SET TIME ZONE 'UTC';

SELECT id,
       created_at::date AS session_day,
       (created_at AT TIME ZONE 'Europe/Moscow')::date AS business_day
FROM date_orders
WHERE id = 2;
idsession_daybusiness_day
22026-08-312026-09-01

Приведение created_at::date использует пояс сессии. В business_day мы сначала получаем московское местное время, затем берём дату — поэтому настройка сессии на результат этого выражения не влияет. Здесь AT TIME ZONE работает в другую сторону: превращает timestamptz в timestamp выбранного пояса.

SQL / 02

Один заказ попадает в разные месяцы при разных поясах

Заказ 2: август в UTC, сентябрь в московском отчёте Момент 31 августа 2026 года, 21:00:00 UTC. В UTC это 31 августа, поэтому месяц — август. В Europe/Moscow это 1 сентября, 00:00:00, поэтому для выбранного московского отчёта месяц — сентябрь. Один момент создания · id = 2 2026-08-31 21:00:00+00 UTC EUROPE/MOSCOW 2026-08-31 21:00:00 2026-09-01 00:00:00 Месяц в UTC: август Месяц отчёта: сентябрь
В московском отчёте заказ 2 относится к сентябрю. Группировка по месяцу UTC отнесёт его к августу.

После SET TIME ZONE 'UTC' запрос за 30 сентября из предыдущего раздела по-прежнему возвращает 3, 4, 5: обе его границы содержат явный Europe/Moscow. Этот результат также проверен при настройках сессии Europe/Moscow и America/New_York.

Тот же выбор пояса нужен, когда из событий собирают непрерывные серии дней: сначала определяют календарную дату события, потом ищут соседние даты.

Отчёт по месяцам через date_trunc

Соберём количество заказов и сумму по месяцам. Сначала запрос, который зависит от сессии. Оставь TimeZone = UTC из предыдущего примера:

SELECT date_trunc('month', created_at)::date AS month,
       count(*) AS orders_count,
       sum(amount) AS total
FROM date_orders
GROUP BY month
ORDER BY month;
monthorders_counttotal
2026-08-012300
2026-09-0141800

Для отчёта по UTC это корректно. Но наш отчёт должен считать московские месяцы: заказ 2 уже сентябрьский, а заказ 6 — октябрьский.

Перед date_trunc переведём каждый момент в местное время нужного пояса:

SELECT date_trunc(
         'month', created_at AT TIME ZONE 'Europe/Moscow'
       )::date AS month,
       count(*) AS orders_count,
       sum(amount) AS total
FROM date_orders
GROUP BY month
ORDER BY month;
monthorders_counttotal
2026-08-011100
2026-09-0141400
2026-10-011600

date_trunc('month', ...) оставляет начало месяца, а ::date убирает время из этой метки. Мы передали местный timestamp, поэтому дата группы больше не зависит от TimeZone. Варианты аргументов функции разобраны в документации date_trunc.

Дальше работает обычный GROUP BY: одинаковые месяцы объединяются, count считает строки, sum складывает суммы. Этот запрос показывает только месяцы, в которых есть заказы; строки для пустого месяца сами не появятся.

Проверь себя: сумма за сентябрь

На тех же данных выбери только московский сентябрь и верни одну строку: количество заказов и их общую сумму. Запрос должен одинаково работать при любом часовом поясе сессии.

Перед запуском определи, входят ли заказы 2 и 6. Ожидаемый ответ — 4 заказа на 1400.

Решение

SELECT count(*) AS orders_count,
       sum(amount) AS total
FROM date_orders
WHERE created_at >= (
        TIMESTAMP '2026-09-01 00:00:00' AT TIME ZONE 'Europe/Moscow'
      )
  AND created_at < (
        TIMESTAMP '2026-10-01 00:00:00' AT TIME ZONE 'Europe/Moscow'
      );

Левая граница включает заказ 2 ровно в начале сентября. Правая исключает заказ 6 ровно в начале октября. Заказы 2, 3, 4, 5 дают 200 + 300 + 400 + 500 = 1400.

Для следующего шага возьми задачу на месячную выручку из подборки упражнений по SQL или продолжи путь «SQL для аналитиков». Теперь при работе со временем можно отдельно проверить границы периода и пояс, по которому строится отчёт.

Источники