BETWEEN в SQL: включает ли границы и как не потерять последний день
BETWEEN в SQL включает обе границы: это x >= a and x <= b. Почему с timestamp он теряет последний день периода, как работают NOT BETWEEN, NULL и строки и чем заменить BETWEEN для дат.
Содержание статьи
В понедельник уходит недельный отчёт по событиям за 1–7 июня. В фильтре стоит where event_time between '2026-06-01' and '2026-06-07', запрос отрабатывает без ошибок и возвращает 654 события. На графике по дням шесть столбиков, а на месте воскресенья пусто. Воскресенье не было тихим: в тот день пользователи сделали 94 действия. Их отрезал не сбой загрузки, а то, как BETWEEN читает дату без времени.
Коротко
Да, BETWEEN включает обе границы. Проблемы начинаются не с самим оператором, а с тем, какое значение оказывается на границе.
x between a and b— это ровноx >= a and x <= b: обе границы входят в диапазон.- Для колонки типа
timestampстрока'2026-06-07'превращается в2026-06-07 00:00:00, поэтому весь день 7 июня кроме первой секунды в диапазон не попадает. - Для периода по времени пишите полуоткрытый интервал:
event_time >= '2026-06-01' and event_time < '2026-06-08'. - Для колонки типа
dateBETWEEN безопасен: там нет времени суток, которое можно потерять. - Порядок границ важен:
between 10 and 1не вернёт ничего. В PostgreSQL для этого естьbetween symmetric. - Если значение или граница — NULL, результат не true, и строка не попадёт ни в BETWEEN, ни в NOT BETWEEN.
Включает ли BETWEEN границы?
Включает, и это определено стандартом SQL, а не особенностью конкретной базы. PostgreSQL, MySQL, SQL Server, ClickHouse и DuckDB трактуют between a and b одинаково: значение подходит, если оно не меньше нижней границы и не больше верхней.
На числах это легко проверить. В учебной базе оплаты бывают трёх сумм: 19, 29 и 39. Фильтр amount between 19 and 29 возвращает 1109 оплат — все по 19 и все по 29. Обе границы вошли, выпала только сумма 39.
Отсюда полезная привычка: читая чужой запрос, мысленно разворачивайте BETWEEN в два сравнения. Почти все ошибки с ним становятся видны именно на этом шаге.
select count(*) from payments
where amount between 19 and 29;
select count(*) from payments
where amount >= 19 and amount <= 29;Почему BETWEEN с датами теряет последний день
Колонка event_time в таблице events имеет тип timestamp: в ней дата и время. Когда вы сравниваете её со строкой '2026-06-07', база приводит строку к тому же типу и получает момент 2026-06-07 00:00:00. Верхняя граница диапазона — полночь в начале воскресенья, а не его конец.
Событие в 14:30 7 июня больше этой полночи, значит условие <= для него ложно. DuckDB подтверждает: timestamp '2026-06-07 14:30' between '2026-06-01' and '2026-06-07' возвращает false. PostgreSQL показывает то же самое прямо в плане запроса: BETWEEN там разворачивается в event_time <= '2026-06-07 00:00:00'.
В учебной базе события идут с 06:00 до 22:59, поэтому в полночь нет ни одного и воскресенье выпадает целиком. Отчёт недосчитывает 94 события из 748, то есть 12,6% недели. Недельная аудитория падает со 196 до 182 пользователей: четырнадцать человек заходили только в воскресенье.
На реальных данных, где события идут круглые сутки, из последнего дня останутся только записи ровно в 00:00:00, остальное уйдёт. Ошибку трудно заметить: запрос не падает и возвращает правдоподобное число.
Учебная база SQL-курса, DuckDB. С BETWEEN первые шесть дней совпадают с полуоткрытым интервалом, а 7 июня вместо 94 событий получается 0.
select
count(*) filter (
where event_time between '2026-06-01' and '2026-06-07'
) as with_between,
count(*) filter (
where event_time >= '2026-06-01' and event_time < '2026-06-08'
) as half_open
from events;Как правильно фильтровать период по timestamp
Надёжный способ — полуоткрытый интервал: нижняя граница включается, верхняя нет, и верхней границей служит начало следующего дня. event_time >= '2026-06-01' and event_time < '2026-06-08' берёт всё, что случилось с полуночи 1 июня до полуночи 8 июня, какой бы ни была точность времени.
Популярный обходной путь — between '2026-06-01' and '2026-06-07 23:59:59'. В учебной базе он даёт те же 748 событий, но только потому, что время там хранится с точностью до минуты. Если колонка хранит миллисекунды, событие в 23:59:59.5 не пройдёт: DuckDB для него возвращает false, а условие < '2026-06-08' — true.
Второй обходной путь — поднять верхнюю границу до '2026-06-08'. Тогда в период попадает полночь следующего дня, и событие ровно в 00:00:00 8 июня посчитается и в этой неделе, и в следующей. В учебной базе таких событий нет, поэтому число совпало, но на потоке с миллионами строк двойной учёт на стыке периодов почти гарантирован.
У полуоткрытых интервалов есть ещё одно свойство: соседние периоды стыкуются без зазоров и без пересечений. Неделя [1 июня; 8 июня) и неделя [8 июня; 15 июня) вместе дают ровно две недели. Поэтому этот вид удобен и в where, и в генерации календаря, и в оконных расчётах.
| Условие | Строк | Что происходит |
|---|---|---|
| event_time between '2026-06-01' and '2026-06-07' | 654 | выпадает 7 июня, кроме полуночи |
| event_time >= '2026-06-01' and event_time < '2026-06-08' | 748 | ровно семь суток |
| event_time between '2026-06-01' and '2026-06-07 23:59:59' | 748 | совпало из-за минутной точности; дробные секунды теряются |
| event_time between '2026-06-01' and '2026-06-08' | 748 | захватывает полночь 8 июня: двойной учёт на стыке недель |
| event_time::date between '2026-06-01' and '2026-06-07' | 748 | верно, но индекс по event_time не используется |
| event_time between '2026-06-07' and '2026-06-01' | 0 | границы перепутаны |
Когда BETWEEN с датами безопасен
Если колонка имеет тип date, у значения нет времени суток и потерять хвост дня нечем. В учебной базе так устроены payments.paid_at, subscriptions.started_at и users.signup_date. Запрос paid_at between '2026-07-01' and '2026-07-07' возвращает 73 оплаты на сумму 1817 — столько же, сколько полуоткрытый интервал до 8 июля.
Прежде чем писать BETWEEN по дате, проверьте тип колонки в схеме. Название обманывает: created_at бывает и date, и timestamp, а поле с суффиксом _date иногда хранит время. Если в команде принято писать все периоды полуоткрытыми интервалами, проверять тип вообще не нужно — вариант работает для обоих.
BETWEEN в JOIN: лишний день в окне «7 дней после»
BETWEEN часто встречается в диапазонных соединениях: найти события пользователя в окне после покупки, подписки или показа эксперимента. Здесь включённая верхняя граница ошибается в другую сторону — добавляет день.
Возьмём первые семь дней после старта подписки. Условие e.event_time::date between s.started_at and s.started_at + 7 охватывает дни с нулевого по седьмой, то есть восемь календарных дней. В учебной базе оно находит 1088 событий у 632 подписок. Полуоткрытое окно >= started_at and < started_at + interval 7 day находит 980 событий у 601 подписки. Сравнивать такую «неделю» с недельными метриками в других отчётах уже нельзя.
Когда окно зависит от строк обеих таблиц, ошибка на границе размножается по всем парам. Проверяйте окно на одной подписке вручную: выпишите первый и последний день, которые должны попасть в расчёт, и сравните с тем, что вернул запрос.
select
count(distinct s.subscription_id) as subs_with_events,
count(*) as events
from subscriptions as s
join events as e
on e.user_id = s.user_id
and e.event_time >= s.started_at
and e.event_time < s.started_at + interval 7 day;Почему не привести event_time к дате
Условие event_time::date between '2026-06-01' and '2026-06-07' считает правильно: 748 событий. Логика понятна, и на маленьких таблицах это рабочий вариант. Цена проявляется на больших.
Индекс по event_time хранит значения колонки, а не результат выражения над ней. Когда колонка обёрнута в приведение типа или функцию, планировщик не может искать по индексу диапазон и читает таблицу целиком. На PostgreSQL 16 на таблице в 200 000 строк с индексом по event_time полуоткрытый интервал и обычный BETWEEN дают Bitmap Index Scan, а фильтр по event_time::date — Seq Scan. Заодно планировщик ошибается в оценке числа строк: для выражения у него нет статистики.
Условия, которые могут пользоваться индексом, называют sargable: колонка стоит в сравнении как есть, а все вычисления перенесены на сторону констант. Полуоткрытый интервал именно такой. Если отчёт действительно группирует по дню, приводите к дате в select и group by, а фильтр оставляйте по исходной колонке.
select
event_time::date as day,
count(*) as events
from events
where event_time >= '2026-06-01'
and event_time < '2026-06-08'
group by day
order by day;Важен ли порядок границ и что такое BETWEEN SYMMETRIC
Порядок важен. x between 10 and 1 разворачивается в x >= 10 and x <= 1, а таких чисел не бывает. База не переставит границы за вас и не выдаст ошибку: запрос просто вернёт ноль строк. На учебной базе event_time between '2026-06-07' and '2026-06-01' возвращает 0 событий, а amount between 29 and 19 — 0 оплат.
Так чаще всего ломаются отчёты, где границы подставляются из параметров дашборда: пользователь выбрал даты в обратном порядке, и график опустел. В PostgreSQL есть between symmetric: он сначала упорядочивает границы, поэтому 5 between symmetric 10 and 1 возвращает true. Это расширение PostgreSQL. DuckDB, на котором работает песочница курса, на такой запрос отвечает ошибкой «Not implemented», так что в переносимом коде надёжнее упорядочить параметры на стороне дашборда или через least и greatest.
Как работает NOT BETWEEN
x not between a and b — это x < a or x > b. Границы, которые BETWEEN включал, NOT BETWEEN исключает: amount not between 19 and 29 возвращает 142 оплаты, и все они по 39.
Ошибка с полуночью переходит сюда зеркально. event_time not between '2026-06-01' and '2026-06-07' считает события воскресенья 7 июня лежащими вне недели. Если вы так отбираете «всё, кроме первой недели», те самые 94 события окажутся в чужом периоде. Противоположностью полуоткрытого интервала служит event_time < '2026-06-01' or event_time >= '2026-06-08'.
Что BETWEEN делает с NULL
Если проверяемое значение — NULL, оба сравнения дают «неизвестно», и строка не проходит ни BETWEEN, ни NOT BETWEEN. В таблице users у 55 пользователей не указана страна. country between 'A' and 'K' находит 931 пользователя, country not between 'A' and 'K' — 3627. Вместе 4558, а в таблице 4613: пятьдесят пять строк не попали ни в одну из групп.
С NULL на границе результат зависит от второго сравнения. 0 between 1 and null возвращает false, потому что 0 >= 1 уже ложно. 5 between 1 and null возвращает NULL: первое сравнение истинно, второе неизвестно. В where оба варианта отбрасывают строку, но при сборке условий через case или флаги разница становится заметной. Если граница приходит из пустого параметра, подставляйте её через coalesce.
BETWEEN для строк: алфавит вместо чисел
Строки сравниваются посимвольно, как слова в словаре. Поэтому country between 'A' and 'K' возвращает AM и BY, но не KZ: строка «KZ» длиннее «K» и в словаре стоит после неё. Казахстан с 912 пользователями выпадает, хотя интуитивно буква K «входит» в диапазон. Если нужны все коды на A–K, пишите country >= 'A' and country < 'L' — это 1843 пользователя.
Вторая ловушка — числа, сохранённые как текст. '10' between '1' and '9' возвращает true и в DuckDB, и в PostgreSQL: строка «10» начинается с единицы и стоит перед «9». Для числового 10 between 1 and 9 ответ false. Если идентификаторы, суммы или версии приложения лежат в текстовой колонке, приведите их к числу до сравнения. В PostgreSQL на порядок строк ещё влияет collation, поэтому диапазоны по кириллице и регистру стоит проверять на своих данных.
select count(*) from users
where country between 'A' and 'K'; -- AM, BY
select count(*) from users
where country >= 'A' and country < 'L'; -- AM, BY, KZПроверьте на учебной базе
Откройте песочницу SQL-курса и посчитайте события и уникальных пользователей по неделям июня двумя способами: через BETWEEN с датами без времени и через полуоткрытый интервал. Найдите неделю, где разница больше всего, и объясните её одним предложением. Затем сделайте то же для payments.paid_at и убедитесь, что на колонке типа date расхождения нет.
Если в вашей команде BETWEEN уже стоит в сохранённых запросах дашбордов, начните с поиска по тексту запросов: between рядом с колонками _at и _time. Каждое такое место стоит сравнить с полуоткрытым интервалом на последнем полном периоде.
Материалы по теме
SQL-запросы: 40 примеров для аналитика с результатами
Сорок SQL-запросов на одной учебной базе: SELECT и WHERE, COUNT и GROUP BY, JOIN, даты, оконные функции, DAU, retention и A/B-тест. У каждого запроса показан результат.

EXPLAIN ANALYZE в PostgreSQL: как читать план запроса аналитику
Как читать EXPLAIN и EXPLAIN ANALYZE в PostgreSQL: дерево плана, cost и actual time, оценка строк и статистика, Seq Scan и Bitmap, Nested Loop и Hash Join, BUFFERS, work_mem и count(distinct) на реальных планах.
Вопросы по SQL на собеседовании: 35 вопросов с короткими ответами
Теоретические вопросы по SQL для аналитика с ответами в два-пять предложений: ключи, NULL, JOIN, GROUP BY, окна, индексы, CTE, DAU и retention — с примерами на данных.