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

Почти любой вопрос к базе рано или поздно упирается в дату. Заказы за первую неделю августа. Выручка по дням. Оплата, которая висит вторые сутки. Сколько минут проходит от заказа до оплаты. Всё это выглядит просто — пока первый же запрос «покажи заказы за пятое августа» не возвращает ноль строк, хотя глазами в таблице эти заказы видно.

Ошибки здесь тихие: не падает запрос, не ругается база, просто отчёт немного врёт. Поэтому в этой статье сначала про то, что вообще лежит в колонке времени, потом про фильтр по дню и по неделе, а дальше — группировка по дням, разница между моментами и грабля, из-за которой быстрый запрос вдруг становится медленным.

Что лежит в колонке: дата или момент

DATE хранит только дату — «5 августа 2026», без часов. TIMESTAMP хранит дату вместе со временем: «5 августа 2026, 10:30:00». Разница кажется мелкой, но именно из-за неё ломаются фильтры.

В нашей песочнице маркетплейса время хранится вместе с датой: created_at у заказа, paid_at у оплаты, occurred_at у события. И вот что происходит:

живой пример

SELECT id, created_at FROM orders WHERE created_at = '2026-08-05';
-- 0 строк
Запустить

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

Запрос верен синтаксически и бесполезен по сути. Строка '2026-08-05' для колонки со временем означает момент 2026-08-05 00:00:00 — ровно полночь. Ни один заказ не был создан ровно в полночь: они созданы в 09:00 и в 10:30. Равенство с датой сработало бы только для колонки типа DATE.

Есть ещё TIMESTAMPTZ — момент, который знает про часовой пояс. Для отчётов по одному городу разница незаметна, а как только появляются продавцы из разных часовых поясов, она становится главной. Подробности — в статье Время и таймзоны в PostgreSQL; здесь достаточно помнить, что в колонке лежит момент, а не «дата».

День — это не точка, а отрезок

Раз в колонке момент, то «за пятое августа» — это все моменты от полуночи пятого до полуночи шестого. В SQL такой отрезок записывают двумя границами:

живой пример

SELECT id, customer_id, status, created_at
FROM orders
WHERE created_at >= '2026-08-05'
  AND created_at <  '2026-08-06'
ORDER BY created_at;
Запустить

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

Начало включаем, конец — нет. Это называется полуоткрытый интервал, и он главный приём в работе с датами: у соседних дней нет ни щели, ни нахлёста.

05.08 00:00 06.08 00:00 09:00 10:30 06.08 08:15 created_at = '2026-08-05'попали в одну точку — полночь. Ноль строк created_at BETWEEN '2026-08-05' AND '2026-08-06'захватили и полночь шестого — чужой заказ в отчёте за пятое created_at >= '2026-08-05' AND created_at < '2026-08-06'ровно сутки: начало включено, конец нет

Один день — отрезок между двумя полуночами. Равенство берёт точку, BETWEEN прихватывает чужую полночь, полуоткрытый интервал берёт ровно сутки.

Неделя пишется так же — двумя границами, а не семью условиями:

WHERE created_at >= '2026-08-01' AND created_at < '2026-08-08'

Почему не BETWEEN. BETWEEN '2026-08-01' AND '2026-08-07' включает обе границы, то есть заканчивается в полночь седьмого — и весь седьмой день, кроме первой секунды, теряется. Попытка починить это концом дня (AND '2026-08-07 23:59:59') выбрасывает последнюю секунду суток: заказ, созданный в 23:59:59.4, не попадёт никуда. BETWEEN хорош для дат без времени; для моментов надёжнее два знака сравнения.

Сегодня, вчера и «за последние семь дней»

Отчёты редко пишут с фиксированными датами: обычно нужно «от текущего момента назад». Для этого есть CURRENT_DATE — сегодняшняя дата без времени — и now() — текущий момент. К ним прибавляют и от них отнимают интервал:

-- заказы за последние 7 суток
WHERE created_at >= now() - interval '7 days'

-- заказы за сегодня
WHERE created_at >= CURRENT_DATE AND created_at < CURRENT_DATE + 1

-- оплаты, которых нет дольше двух суток
WHERE paid_at IS NULL AND created_at < now() - interval '2 days'

interval '7 days' читается как есть и понимает всё то же самое в других единицах: '12 hours', '30 minutes', '1 mon'. Прибавление целого числа к дате — это прибавление дней: CURRENT_DATE + 1 — завтрашняя полночь.

Группировка по дням: отрезать время

«Сколько заказов создаётся в день» — вопрос про группировку, а GROUP BY группирует по точному значению. Моменты 10:30 и 10:31 — разные значения, и группировка по created_at даст почти столько же строк, сколько заказов. Значит, время нужно отрезать:

живой пример

SELECT CAST(created_at AS DATE) AS day,
       COUNT(*) AS orders
FROM orders
GROUP BY CAST(created_at AS DATE)
ORDER BY day;
Запустить

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

CAST(created_at AS DATE) выбрасывает часы и оставляет дату — все заказы одного дня схлопываются в одну группу. Тот же приём короче пишется как created_at::date.

Когда нужен не день, а неделя или месяц, применяют date_trunc — он «усекает» момент до начала периода:

живой пример

SELECT date_trunc('month', created_at) AS month, SUM(total_amount) AS revenue
FROM orders
WHERE paid_at IS NOT NULL
GROUP BY date_trunc('month', created_at)
ORDER BY month;
Запустить

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

date_trunc('month', '2026-08-14 10:30') даёт 2026-08-01 00:00:00: не число месяца, а момент его начала. Это удобно — по нему можно сортировать, и соседние месяцы разных лет не перемешаются, как перемешались бы номера месяцев.

Типичная ошибка — группировать по EXTRACT(MONTH FROM created_at). Функция возвращает просто число 8, и август 2025 года сольётся с августом 2026-го в одну строку. EXTRACT хорош, когда номер и нужен: «в какие часы суток чаще заказывают» — это EXTRACT(HOUR FROM created_at).

Сколько времени прошло

Две даты можно просто вычесть друг из друга — в PostgreSQL получится целое число дней:

живой пример

SELECT id,
       status,
       DATE '2026-08-25' - CAST(created_at AS DATE) AS days_waiting
FROM orders
WHERE status IN ('PENDING_PAYMENT', 'EXPIRED')
ORDER BY days_waiting DESC;
Запустить

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

Здесь считаются пересечённые границы суток, а не полные сутки: от 14 августа 09:00 до полуночи 25-го прошло десять суток с хвостиком, но границ дня пересечено одиннадцать.

С моментами арифметика другая: разность двух timestamp — это интервал (00:10:00), а не число. Чтобы получить минуты или секунды, из интервала достают секунды и делят:

живой пример

SELECT id,
       created_at,
       paid_at,
       (EXTRACT(EPOCH FROM (paid_at - created_at)) / 60)::int AS minutes_to_pay
FROM orders
WHERE paid_at IS NOT NULL
ORDER BY minutes_to_pay DESC;
Запустить

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

EXTRACT(EPOCH FROM интервал) — это «сколько всего секунд в этом интервале». Делим на 60 — минуты, на 3600 — часы, на 86400 — сутки.

Два правила, на которых спотыкаются чаще всего. Первое: вычитаемое и уменьшаемое местами не меняют — created_at - paid_at даст отрицательные минуты. Второе: любое действие с NULL даёт NULL, поэтому неоплаченные заказы нужно отсечь условием paid_at IS NOT NULL, иначе они приедут в отчёт пустыми строками и испортят среднее (про это подробно — в статье NULL и типы данных).

Первый и последний заказ покупателя — это обычные агрегаты по времени:

живой пример

SELECT customer_id,
       MIN(created_at) AS first_order,
       MAX(created_at) AS last_order,
       CAST(MAX(created_at) AS DATE) - CAST(MIN(created_at) AS DATE) AS days_between
FROM orders
GROUP BY customer_id;
Запустить

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

Почему быстрый запрос вдруг стал медленным

Условие с функцией на колонке база не может проверить по индексу. Такой запрос формально верен и работает — но читает всю таблицу:

-- индекс по created_at не поможет
WHERE CAST(created_at AS DATE) = '2026-08-05'
WHERE EXTRACT(YEAR FROM created_at) = 2026

База умеет искать по индексу, только если слева от знака сравнения стоит сама колонка. Поэтому фильтр записывают отрезком — тем самым полуоткрытым интервалом, с которого статья началась:

WHERE created_at >= '2026-08-05' AND created_at < '2026-08-06'

В SELECT и в GROUP BY функции по датам применять можно и нужно — там нет поиска. Опасно именно в WHERE. Подробнее про то, когда индекс работает, а когда нет — в статье Почему запрос медленный.

Коротко

  • В колонке TIMESTAMP лежит момент, а не дата: равенство с '2026-08-05' означает полночь и почти всегда даёт ноль строк.
  • День и неделя — это отрезок: >= начало и < следующая граница. BETWEEN для моментов прихватывает чужую полночь.
  • «За последние N дней» — now() - interval '7 days'; сегодня — CURRENT_DATE.
  • Группировка по дню — CAST(created_at AS DATE), по месяцу — date_trunc. EXTRACT(MONTH …) склеит августы разных лет.
  • Разность дат — число дней; разность моментов — интервал, секунды из него достаёт EXTRACT(EPOCH FROM …).
  • Функция на колонке в WHERE выключает индекс: фильтруйте отрезком.

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