Чтобы выбрать записи за день в 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;
| id | local_time | amount |
|---|---|---|
| 1 | 2026-08-31 23:59:59 | 100 |
| 2 | 2026-09-01 00:00:00 | 200 |
| 3 | 2026-09-30 00:00:00 | 300 |
| 4 | 2026-09-30 12:00:00 | 400 |
| 5 | 2026-09-30 23:59:59.999999 | 500 |
| 6 | 2026-10-01 00:00:00 | 600 |
Здесь ::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;
| id | amount |
|---|---|
| 2 | 200 |
| 3 | 300 |
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;
| id | amount |
|---|---|
| 3 | 300 |
| 4 | 400 |
| 5 | 500 |
У такого диапазона три полезных свойства:
>=включает заказ3, созданный ровно в начале дня.<включает заказ5за микросекунду до конца и исключает заказ6ровно в начале следующего дня.- Соседние дни не пересекаются: заказ
6попадёт в выборку за 1 октября один раз.
Начало входит в день, следующая полночь — уже нет
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;
| id | session_day | business_day |
|---|---|---|
| 2 | 2026-08-31 | 2026-09-01 |
Приведение created_at::date использует пояс сессии. В business_day мы сначала получаем московское местное время, затем берём дату — поэтому настройка сессии на результат этого выражения не влияет. Здесь AT TIME ZONE работает в другую сторону: превращает timestamptz в timestamp выбранного пояса.
Один заказ попадает в разные месяцы при разных поясах
После 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;
| month | orders_count | total |
|---|---|---|
| 2026-08-01 | 2 | 300 |
| 2026-09-01 | 4 | 1800 |
Для отчёта по 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;
| month | orders_count | total |
|---|---|---|
| 2026-08-01 | 1 | 100 |
| 2026-09-01 | 4 | 1400 |
| 2026-10-01 | 1 | 600 |
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 для аналитиков». Теперь при работе со временем можно отдельно проверить границы периода и пояс, по которому строится отчёт.