WHERE в SQL: условия, AND и OR, NULL и фильтр по дате
WHERE в SQL с примерами: несколько условий, приоритет AND и OR, IN, BETWEEN, LIKE, NULL, фильтр по дате, отличие от HAVING и способ проверить результат.
Содержание статьи
Менеджер спрашивает, сколько пользователей пришло не из России. Вы пишете 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 оставляют первых пятерых.
| Шаг | Часть запроса | Что происходит | Что уже доступно |
|---|---|---|---|
| 1 | FROM, JOIN | собираются исходные строки | столбцы таблиц |
| 2 | WHERE | отбрасываются отдельные строки | столбцы таблиц; агрегатов и алиасов ещё нет |
| 3 | GROUP BY | строки собираются в группы | ключи группировки |
| 4 | HAVING | отбрасываются целые группы | ключи группировки и агрегаты |
| 5 | SELECT | вычисляются выражения и алиасы | всё выше и алиасы |
| 6 | ORDER BY, LIMIT | результат сортируется и обрезается | алиасы из SELECT |
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 видит его в любой базе.
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.
| n | filter | row_cnt |
|---|---|---|
| 1 | channel = organic | 1747 |
| 2 | channel = Organic | 0 |
| 3 | amount > 29 | 142 |
| 4 | amount >= 29 | 545 |
| 5 | signup_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.
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.
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 и 0BETWEEN и 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.
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.
| n | filter | row_cnt |
|---|---|---|
| 1 | country = null | 0 |
| 2 | country is null | 55 |
| 3 | country <> RU | 1843 |
| 4 | country <> RU or country is null | 1898 |
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.
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.
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.
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. Теперь понятно, что именно спрашивать у менеджера: считать ли пользователей без страны «не из России».
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) — выгрузка в базе обрывается посреди дня.
Материалы по теме

HAVING в SQL: фильтр после GROUP BY и отличие от WHERE
HAVING в SQL: фильтр после GROUP BY, отличие от WHERE, порядок выполнения запроса, HAVING COUNT и алиасы в PostgreSQL и DuckDB.

EXISTS и IN в SQL: разница, NOT IN и ловушка с NULL
EXISTS и IN в SQL: разница, сравнение с JOIN, почему NOT IN с NULL возвращает ноль строк и чем его заменить.

Операторы сравнения и логика в SQL: AND, OR, NOT и NULL
Трёхзначная логика SQL на практике: почему NOT IN возвращает пустоту, где AND незаметно съедает OR и как собрать длинный фильтр так, чтобы его можно было проверить.