Все материалы
Продуктовая аналитикагайдстарт

BETWEEN в SQL: включает ли границы и как не потерять последний день

BETWEEN в SQL включает обе границы: это x >= a and x <= b. Почему с timestamp он теряет последний день периода, как работают NOT BETWEEN, NULL и строки и чем заменить BETWEEN для дат.

КейсПрактика24 сентября 2026 г.11 мин

В понедельник уходит недельный отчёт по событиям за 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'.
  • Для колонки типа date BETWEEN безопасен: там нет времени суток, которое можно потерять.
  • Порядок границ важен: 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 в два сравнения. Почти все ошибки с ним становятся видны именно на этом шаге.

Два запроса с одинаковым результатом: 1109 оплат
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, остальное уйдёт. Ошибку трудно заметить: запрос не падает и возвращает правдоподобное число.

События по дням, 1–7 июня 2026

Учебная база SQL-курса, DuckDB. С BETWEEN первые шесть дней совпадают с полуоткрытым интервалом, а 7 июня вместо 94 событий получается 0.

>= 1 июня и < 8 июняbetween '2026-06-01' and '2026-06-07'
Один период, два ответа: 654 и 748 событий
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, и в генерации календаря, и в оконных расчётах.

Сколько событий за 1–7 июня возвращает каждое условие (учебная база, DuckDB)
УсловиеСтрокЧто происходит
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 подписки. Сравнивать такую «неделю» с недельными метриками в других отчётах уже нельзя.

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

Ровно семь суток после старта подписки: 980 событий
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, а фильтр оставляйте по исходной колонке.

sqlФильтр по колонке как есть, дата — только в группировке
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, поэтому диапазоны по кириллице и регистру стоит проверять на своих данных.

Верхняя граница по первой букве: 931 против 1843
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. Каждое такое место стоит сравнить с полуоткрытым интервалом на последнем полном периоде.

Продолжить чтение
Вся библиотека