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

WHERE в SQL: условия, AND и OR, NULL и фильтр по дате

WHERE в SQL с примерами: несколько условий, приоритет AND и OR, IN, BETWEEN, LIKE, NULL, фильтр по дате, отличие от HAVING и способ проверить результат.

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

Менеджер спрашивает, сколько пользователей пришло не из России. Вы пишете where country <> 'RU' и получаете 1843. Потом для сверки вычитаете из всех 4613 пользователей 2715 российских, и выходит 1898. Запрос выполнился без ошибки, а 55 человек пропали: у них страна не заполнена, и WHERE молча их отбросил. Почти все неприятности с WHERE устроены так же — запрос работает, число выглядит правдоподобно, но строк не столько, сколько нужно. Ниже разобрано, как WHERE принимает решение по каждой строке и где он чаще всего возвращает не то. Все запросы выполняются на учебной базе симулятора SQL-аналитика: 4613 пользователей, 35 341 событие, 1251 платёж.

Коротко

WHERE проверяет условие для каждой строки и оставляет только те, где оно истинно. Строки, где условие ложно или неизвестно, в результат не попадают.

  • WHERE выполняется после FROM и JOIN, но до GROUP BY и SELECT. Поэтому в нём нельзя сравнивать count(*) и нельзя полагаться на алиас из SELECT.
  • Строки и даты пишутся в одинарных кавычках, числа — без кавычек. Строки сравниваются с учётом регистра: Organic и organic — разные значения.
  • AND выполняется раньше OR. Если в условии есть оба, ставьте скобки вокруг части с OR.
  • Сравнение с NULL не даёт истину: = NULL ничего не находит, а <> теряет строки с пустым значением. Нужны IS NULL и IS NOT NULL.
  • Для столбца со временем период задают полуинтервалом >= начало AND < следующий день после конца, иначе теряется последний день.
  • Перед тем как отдать число, посчитайте строки без условия и с ним и объясните разницу.

Что делает WHERE и на каком шаге запроса он выполняется

WHERE — фильтр строк. База берёт строку из таблицы, вычисляет условие и получает одно из трёх: истина, ложь или «неизвестно» (так заканчивается любое сравнение с NULL). В результат проходит только истина.

Пишется WHERE сразу после FROM, а если в запросе есть JOIN — после него. Логически запрос выполняется не в том порядке, в каком записан, и от этого зависит, что в WHERE можно использовать.

Запрос ниже отвечает на вопрос «кто из Казахстана зарегистрировался с телефона в августе». Три условия соединены через AND: строка проходит, только если истинны все три. Таких пользователей 191; сортировка и LIMIT оставляют первых пятерых.

Логический порядок выполнения запроса
ШагЧасть запросаЧто происходитЧто уже доступно
1FROM, JOINсобираются исходные строкистолбцы таблиц
2WHEREотбрасываются отдельные строкистолбцы таблиц; агрегатов и алиасов ещё нет
3GROUP BYстроки собираются в группыключи группировки
4HAVINGотбрасываются целые группыключи группировки и агрегаты
5SELECTвычисляются выражения и алиасывсё выше и алиасы
6ORDER BY, LIMITрезультат сортируется и обрезаетсяалиасы из SELECT
Три условия через AND: Казахстан, мобильное устройство, регистрация с 1 августа
select user_id, signup_date, channel, country, device
from users
where country = 'KZ'
  and device = 'mobile'
  and signup_date >= date '2026-08-01'
order by signup_date, user_id
limit 5;

-- первая строка: 2720 | 2026-08-01 | partner | KZ | mobile
-- без LIMIT таких строк 191

Почему в WHERE не работают алиас и агрегат

Оба запрета следуют из порядка выполнения. На шаге WHERE группы ещё не собраны, поэтому where count(*) > 1000 считать не из чего. PostgreSQL отвечает «aggregate functions are not allowed in WHERE», DuckDB — «WHERE clause cannot contain aggregates!». Условие на результат группировки пишут в HAVING, о нём ниже.

С алиасом похожая история. В запросе select amount * 12 as yearly from payments where yearly > 400 имя yearly появляется на шаге SELECT, то есть после WHERE. PostgreSQL в этом месте сообщает, что столбца yearly не существует. DuckDB, на котором работает песочница курса, такой запрос прощает и выполняет. Запрос, который прошёл в песочнице, на рабочей базе с PostgreSQL упадёт.

Переносимых способов два. Короткое выражение проще повторить в WHERE: where amount * 12 > 400. Длинное удобнее посчитать в CTE — тогда во внешнем запросе алиас уже обычный столбец, и WHERE видит его в любой базе.

Выражение считается в CTE, фильтр по его имени — во внешнем запросе: 142 платежа
with yearly_payments as (
  select payment_id, plan, amount * 12 as yearly
  from payments
)
select count(*) as payments_over_400
from yearly_payments
where yearly > 400;

-- 142: под условие подходит только тариф team, 39 * 12 = 468

Как сравнивать с числом, строкой и датой

Операторов сравнения шесть: =, <> (то же, что !=), >, >=, <, <=. Разница между типами — в том, как записано значение справа.

Число пишется без кавычек: where amount > 29. Следите за строгостью знака. В учебной базе три цены — 19, 29 и 39, поэтому amount > 29 находит 142 платежа, а amount >= 29 — уже 545.

Строка пишется в одинарных кавычках: where channel = 'organic'. Двойные кавычки в SQL обозначают имя столбца или таблицы, поэтому channel = "organic" база читает как сравнение двух столбцов и отвечает, что столбца organic нет. Строки сравниваются посимвольно и с учётом регистра: channel = 'Organic' на учебной базе возвращает ноль строк, хотя organic — самый большой канал. Если регистр в данных гуляет, приведите обе стороны к одному виду: lower(channel) = 'organic'.

Дата пишется строкой в формате ГГГГ-ММ-ДД, лучше с явным типом: signup_date = date '2026-07-15'. Такая запись одинаково читается любой базой и не зависит от региональных настроек, в отличие от 15.07.2026.

Результат
nfilterrow_cnt
1channel = organic1747
2channel = Organic0
3amount > 29142
4amount >= 29545
5signup_date = 15 июля99
Одно и то же сравнение при разном регистре и разной строгости знака
select 1 as n, 'channel = organic' as filter, count(*) as row_cnt
from users where channel = 'organic'
union all
select 2, 'channel = Organic', count(*)
from users where channel = 'Organic'
union all
select 3, 'amount > 29', count(*)
from payments where amount > 29
union all
select 4, 'amount >= 29', count(*)
from payments where amount >= 29
union all
select 5, 'signup_date = 15 июля', count(*)
from users where signup_date = date '2026-07-15'
order by n;

Как написать несколько условий в WHERE: AND, OR и скобки

Условия соединяются тремя словами. AND требует, чтобы были истинны обе части. OR — чтобы хотя бы одна. NOT переворачивает результат.

Ловушка в приоритете: AND выполняется раньше OR, как умножение раньше сложения. Допустим, нужны мобильные пользователи из двух каналов — organic и referral. Условие, записанное так, как оно звучит, база поймёт иначе: «все из organic, плюс те из referral, кто с телефона».

Без скобок запрос возвращает 2184 строки: 1747 пользователей organic с любым устройством и 437 мобильных из referral. Со скобками — 1231: 794 мобильных из organic и те же 437 из referral. Оба запроса выполняются без ошибок, и разница в 953 человека — это десктопные пользователи organic, которых никто не просил.

Правило простое: если в WHERE встречаются AND и OR вместе, ставьте скобки всегда, даже когда приоритет и так на вашей стороне. А несколько OR по одному столбцу лучше заменить на IN — channel in ('organic', 'referral') and device = 'mobile' даёт те же 1231 и не оставляет места для ошибки. Подробнее про логику условий и таблицы истинности — в статье про операторы сравнения, AND, OR и NOT.

Одно условие без скобок и со скобками: 2184 строки против 1231
select 1 as n, 'без скобок' as variant, count(*) as row_cnt
from users
where channel = 'organic' or channel = 'referral' and device = 'mobile'
union all
select 2, 'со скобками', count(*)
from users
where (channel = 'organic' or channel = 'referral') and device = 'mobile'
order by n;

WHERE IN и NOT IN: как сравнить со списком значений

IN проверяет, совпадает ли значение хотя бы с одним из списка. country in ('KZ', 'BY') — это то же самое, что country = 'KZ' or country = 'BY', только короче: 1326 пользователей, 912 из Казахстана и 414 из Беларуси.

NOT IN отбирает строки, значение которых не совпало ни с одним из списка. Здесь две ловушки, и обе связаны с NULL. Первая: country not in ('KZ', 'BY') возвращает 3232 строки — Россию и Армению. 55 пользователей без страны не попали ни в IN, ни в NOT IN: 1326 + 3232 = 4558, а не 4613.

Вторая ловушка опаснее. Если NULL оказался внутри самого списка, NOT IN не вернёт ни одной строки: база не может утверждать, что значение не равно неизвестному. Чаще всего NULL приносит подзапрос по столбцу с пропусками. Для таких случаев надёжнее NOT EXISTS; разбор — в статье EXISTS и IN: разница, NOT IN и ловушка с NULL.

IN, NOT IN и NOT IN со списком, в котором есть NULL
select 1 as n, 'in (KZ, BY)' as filter, count(*) as row_cnt
from users where country in ('KZ', 'BY')
union all
select 2, 'not in (KZ, BY)', count(*)
from users where country not in ('KZ', 'BY')
union all
select 3, 'not in (KZ, BY, null)', count(*)
from users where country not in ('KZ', 'BY', null)
order by n;

-- 1326, 3232 и 0

BETWEEN и LIKE: диапазон и шаблон строки

BETWEEN проверяет, лежит ли значение в диапазоне, и включает обе границы: amount between 19 and 29 — это amount >= 19 and amount <= 29. На учебной базе под него подходят 1109 платежей, то есть все по 19 и по 29. Строгий вариант amount > 19 and amount < 29 не находит ничего: между двумя ценами значений нет. Для дат BETWEEN работает так же: signup_date between date '2026-07-01' and date '2026-07-31' находит 1676 июльских регистраций. Со временем он ведёт себя коварнее, об этом ниже и в отдельной статье про BETWEEN и границы.

LIKE сравнивает строку с шаблоном. Знак % заменяет любое число символов, включая ноль, а _ — ровно один символ. event_name like '%created' находит события, название которых заканчивается на created: 4525 строк, workspace_created и report_created вместе. event_name like 'report%' — те, что начинаются с report: 1775.

LIKE учитывает регистр, как и обычное сравнение: шаблон '%Created' не находит ни одной строки. В PostgreSQL и DuckDB есть ILIKE, который регистр игнорирует, но в стандарт SQL он не входит, и в других базах вместо него пишут lower(event_name) like '%created'. Про экранирование % и _ и про то, почему шаблон с процентом в начале не использует обычный индекс, — в статье про LIKE и ILIKE.

Шаблоны LIKE на названиях событий
select 1 as n, 'like %created' as filter, count(*) as row_cnt
from events where event_name like '%created'
union all
select 2, 'like report%', count(*)
from events where event_name like 'report%'
union all
select 3, 'like %Created', count(*)
from events where event_name like '%Created'
order by n;

-- 4525, 1775 и 0

Почему WHERE с NULL ничего не находит

NULL означает «значение неизвестно». Сравнить что-то с неизвестным нельзя, поэтому любое сравнение с NULL даёт не истину и не ложь, а третий результат — «неизвестно». WHERE пропускает только истину, так что строка выпадает.

Отсюда первое следствие: where country = null не находит ничего, даже строки с пустой страной. Для проверки на NULL есть отдельные операторы IS NULL и IS NOT NULL. На учебной базе country is null возвращает 55 строк, а country = null — ноль.

Второе следствие — то, с чего началась статья. country <> 'RU' для строки без страны превращается в «неизвестное не равно RU», это не истина, и строка выпадает. В результате 1843 строки вместо 1898. Если пользователей без страны нужно считать «не из России», скажите это явно: country <> 'RU' or country is null. В PostgreSQL и DuckDB то же самое короче записывается через country is distinct from 'RU' — оператор сравнения, для которого NULL считается обычным значением.

Как заменять NULL на подпись и не испортить при этом сегменты — в статье про CASE, COALESCE и NULL.

Результат: 55 строк теряются без сообщения об ошибке
nfilterrow_cnt
1country = null0
2country is null55
3country <> RU1843
4country <> RU or country is null1898
Четыре условия про один столбец с пропусками
select 1 as n, 'country = null' as filter, count(*) as row_cnt
from users where country = null
union all
select 2, 'country is null', count(*)
from users where country is null
union all
select 3, 'country <> RU', count(*)
from users where country <> 'RU'
union all
select 4, 'country <> RU or country is null', count(*)
from users where country <> 'RU' or country is null
order by n;

Как фильтровать по дате и не потерять последний день

Пока столбец имеет тип DATE, с ним всё просто: дата без времени сравнивается как число, и BETWEEN с первым и последним днём месяца возвращает месяц целиком. Проблемы начинаются, когда в столбце дата со временем, как event_time в таблице событий.

Допустим, нужны события за июль. Условие event_time between timestamp '2026-07-01' and timestamp '2026-07-31' выглядит естественно, но правая граница — это 31 июля, 00:00:00. Всё, что случилось 31 июля после полуночи, под условие не подходит. На учебной базе такой запрос возвращает 12 174 события, а в июле их 12 752. Потерялось 578 событий — весь последний день месяца.

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

Граница 23:59:59 чинит BETWEEN только до первого события с долями секунды. Привести столбец к дате, cast(event_time as date) between …, — результат верный, но на большой таблице такой фильтр может работать медленнее: индекс по столбцу со временем к выражению над ним не применяется. Больше приёмов работы с периодами — в статье про даты и время в SQL.

События июля: BETWEEN по времени теряет 31 июля, полуинтервал — нет
select 1 as n, 'between 1 и 31 июля' as variant, count(*) as events
from events
where event_time between timestamp '2026-07-01' and timestamp '2026-07-31'
union all
select 2, 'полуинтервал', count(*)
from events
where event_time >= timestamp '2026-07-01'
  and event_time < timestamp '2026-08-01'
order by n;

-- 12174 и 12752

Чем WHERE отличается от HAVING

WHERE фильтрует строки до группировки, HAVING — готовые группы после неё. Условие про отдельную строку (страна, устройство, дата) пишут в WHERE, условие про группу целиком (сколько в ней строк, какая сумма) — в HAVING.

В примере WHERE сначала оставляет только мобильных пользователей, GROUP BY считает их по каналам, а HAVING убирает каналы, где таких меньше 500. Остаются organic с 794 и paid_search с 670; referral (437) и partner (328) отброшены. Важно, что WHERE при этом меняет сами числа: без него count(*) считал бы всех пользователей канала, а не мобильных. HAVING на значения агрегатов не влияет, он только решает, показывать ли группу. Подробный разбор — в статье HAVING в SQL: фильтр после GROUP BY и отличие от WHERE.

WHERE отбирает строки, HAVING — группы
select channel, count(*) as mobile_users
from users
where device = 'mobile'
group by channel
having count(*) >= 500
order by mobile_users desc;

-- organic 794, paid_search 670

Почему WHERE после LEFT JOIN убирает строки без пары

LEFT JOIN сохраняет все строки левой таблицы: если пары справа нет, столбцы правой таблицы заполняются NULL. Дальше срабатывает то же правило про NULL. Условие в WHERE на столбец правой таблицы для строк без пары даёт «неизвестно», и они выпадают — LEFT JOIN тихо превращается в обычный JOIN.

Пример: к пользователям присоединяем платежи и хотим видеть только платежи по тарифу team, но пользователей — всех. С условием p.plan = 'team' в WHERE остаётся 142 строки и 102 пользователя из 4613. Если перенести условие в ON, оно будет ограничивать только то, какие платежи присоединяются: 4653 строки, все 4613 пользователей и те же 142 платежа.

Условия на левую таблицу в WHERE безопасны. А условие на правую стоит перечитать: если вам нужны все строки слева, его место в ON. Подробнее — в статье про LEFT JOIN.

Одно условие на правую таблицу в WHERE и в ON
select 1 as n, 'условие в WHERE' as variant,
       count(*) as row_cnt, count(distinct u.user_id) as user_cnt
from users as u
left join payments as p on p.user_id = u.user_id
where p.plan = 'team'
union all
select 2, 'условие в ON',
       count(*), count(distinct u.user_id)
from users as u
left join payments as p on p.user_id = u.user_id and p.plan = 'team'
order by n;

-- 142 строки и 102 пользователя против 4653 строк и 4613 пользователей

Как проверить, что фильтр вернул то, что нужно

У запроса с WHERE нет сообщения «вы потеряли строки». Проверка — это сравнение чисел, и на неё уходит минута.

Сначала посчитайте строки без условия: select count(*) from users. Это точка отсчёта — 4613. Потом посчитайте строки с условием и с его отрицанием. Если сумма двух частей меньше целого, разницу забрали NULL. В запросе ниже три счётчика собраны в одной строке через count(*) filter (where …) — это агрегат с собственным условием, он работает в PostgreSQL и DuckDB.

Результат: 4613 всего, 2715 из России, 1843 не из России, 55 без страны. 2715 + 1843 = 4558, не хватает ровно 55. Теперь понятно, что именно спрашивать у менеджера: считать ли пользователей без страны «не из России».

Целое и части: куда делись 55 строк
select
  count(*) as all_users,
  count(*) filter (where country = 'RU') as ru,
  count(*) filter (where country <> 'RU') as not_ru,
  count(*) filter (where country is null) as no_country
from users;

-- 4613 | 2715 | 1843 | 55
  • Посмотрите на сами значения перед фильтром: select country, count(*) from users group by country покажет регистр, пробелы, опечатки и пустые значения.
  • Для периода выведите min и max даты в отфильтрованных строках: так видно потерянный последний день.
  • После JOIN сравните число строк и число уникальных пользователей — фильтр мог быть верным, а строки размножило соединение.
  • Пустой результат — тоже результат, но сначала убедитесь, что он не из-за регистра, кавычек или = NULL.

Частые вопросы

Можно ли написать в запросе два WHERE?

Нет, WHERE в одном SELECT один. Несколько условий соединяют через AND и OR. Второй WHERE появляется только в подзапросе или CTE — у каждого SELECT он свой.

Что выполняется раньше — WHERE или JOIN?

Логически сначала FROM и JOIN собирают строки, затем WHERE их фильтрует. Реальный план база строит сама и часто применяет фильтр до соединения, если результат от этого не меняется. Для обычного JOIN итог одинаковый, для LEFT JOIN условие в WHERE и в ON даёт разные результаты.

Как в WHERE указать «не равно»?

Оператором <>; запись != означает то же и работает в PostgreSQL, DuckDB и большинстве других баз. Помните, что строки с NULL под «не равно» не попадают.

Можно ли использовать в WHERE столбец, которого нет в SELECT?

Да. WHERE работает со столбцами таблиц из FROM, а не со списком SELECT, так что фильтровать можно по столбцу, который в результат не выводится.

Почему WHERE с датой ничего не возвращает?

Чаще всего причина в формате или типе: дата записана не как ГГГГ-ММ-ДД, перепутаны границы BETWEEN или столбец со временем сравнивается через = с датой — равенство выполнится только для событий ровно в полночь.

Что попробовать на учебной базе

Все запросы статьи выполняются в песочнице симулятора SQL-аналитика как есть. Три упражнения, после которых поведение WHERE перестаёт удивлять. Первое: посчитайте пользователей с device = 'mobile' or device = 'desktop' и сравните с общим числом строк — есть ли в этом столбце пропуски. Второе: напишите условие «organic или paid_search, зарегистрировались в июле» без скобок и со скобками и объясните разницу в числах, не глядя в ответ. Третье: посчитайте события за 30 августа через = с датой и через полуинтервал, а потом проверьте max(event_time) — выгрузка в базе обрывается посреди дня.

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