Задача знакомая, очень знакомая. Нужна выгрузка: все клиенты и их заказы за апрель. Все — значит все, включая тех, кто за месяц так ничего и не купил. Отсюда LEFT JOIN, а не INNER. Вы это прекрасно знаете, ведь не первый день в SQL.

Пишете запрос. Добавляете WHERE orders.created_at >= '2024-04-01', чтобы отрезать всё, что не апрель. Запускаете.

В таблице клиентов восемь тысяч строк. В выгрузке почему‑то шесть. Две тысячи человек куда‑то испарились. И не абы какие: пропали ровно те, ради кого вы и городили LEFT JOIN — клиенты без заказов.

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

Сейчас разберёмся, куда ушли эти две тысячи.

С LEFT JOIN всё в порядке, правда

Снимем подозрение с главного обвиняемого сразу.

LEFT JOIN делает ровно то, что обещает. Берёт каждую строку левой таблицы. Ищет ей пару справа. Нашёл — склеил. Не нашёл, и вот тут самое важное, строку всё равно оставил, просто колонки правой таблицы забил NULL‑ами.

Клиент без заказов никуда не девается. Он доезжает до результата живым, только вместо данных о заказе у него пустота.

То есть LEFT JOIN свою часть выполнил. Все клиенты были на месте. А потом пришёл WHERE.

Запрос читается не так, как выполняется

Мы читаем SQL сверху вниз: сначала SELECT, потом FROM, потом WHERE. И кажется логичным, что выполняется он в том же порядке. Не выполняется.

База сначала берётся за FROM и JOIN. Склеивает таблицы, строит промежуточный результат. И только когда он готов, по нему проходит WHERE и выкидывает ненужные строки.

Все идет к моменту, когда WHERE берётся за дело, LEFT JOIN давно закончил. В промежуточном результате уже лежат наши клиенты без заказов — с честными NULL в колонках orders.

И вот WHERE смотрит на такую строку через своё условие:

SELECT c.id, c.name, o.id AS order_id, o.created_at
FROM clients c
LEFT JOIN orders o ON o.client_id = c.id
WHERE o.created_at >= '2024-04-01';

У клиента без заказов o.created_at — это NULL. И вопрос на сообразительность: NULL >= '2024-04-01' — это правда или ложь?

Ни то ни другое. Это UNKNOWN, «неизвестно», третье значение в логике SQL. Сравнить дату с тем, чего нет, нельзя, вот база и разводит руками.

А WHERE пускает дальше только то, что строго TRUE. UNKNOWN для него — то же самое, что FALSE: на выход. И каждая строка клиента без заказов тихо вылетает.

Что остаётся? Только клиенты с заказом, да ещё подходящим по дате. То есть ровно то, что вернул бы INNER JOIN. Вы написали LEFT, получили INNER, и никто вас не предупредил.

Почему это так легко проглядеть

Синтаксис чистый, оптимизатор доволен. Результат не пустой, в нём тысячи строк, и каждая выглядит как надо. Чтобы заподозрить неладное, нужно заранее знать, сколько строк ты ждёшь, и сверить. А кто это делает каждый раз?

В код‑ревью такое часто пропускается. Коллега видит LEFT JOIN, видит понятный фильтр по дате, ставит апрув. Всё же логично написано.

А отчёт потом не падает, он просто врёт. «Клиентов, которые в апреле ничего не купили, у нас нет», красивый вывод из запроса, где этих клиентов вырезали ещё до того, как кто‑то взялся их считать.

ON и WHERE спрашивают у базы разное

Чинится всё переносом одного условия. Из WHERE — в ON:

SELECT c.id, c.name, o.id AS order_id, o.created_at
FROM clients c
LEFT JOIN orders o
  ON o.client_id = c.id
  AND o.created_at >= '2024-04-01';

С виду — косметика, условие переехало на две строчки выше. На деле поменялось всё.

Условие в ON — это часть правила, по которому ищется пара. Оно отвечает на вопрос «что вообще считать совпадением». Теперь заказ подходит клиенту, только если совпал и по client_id, и по апрелю. У клиента без апрельских заказов пары нет, и LEFT JOIN, как мы уже выяснили, в таком случае оставляет строку с NULL. А WHERE её больше не трогает: фильтра по дате в WHERE теперь просто нет. Клиент остаётся.

Условие в WHERE совсем другое. Это фильтр по уже готовому, склеенному результату. И вопрос здесь другой: «что оставить из того, что получилось». А NULL‑строкам в этом фильтре не выжить, любое сравнение с колонкой правой таблицы их срежет.

Вот и вся разница. ON и WHERE — не два кармана, куда можно сунуть одно и то же условие. Это два разных вопроса. ON спрашивает: что считать парой? WHERE спрашивает: что оставить в конце?

Иногда условие в WHERE — это правильно

Только не уносите отсюда правило «фильтр по правой таблице в WHERE — всегда плохо». Не всегда.

Нужны только клиенты с апрельскими заказами? Пишите INNER JOIN и живите спокойно. Маскировать INNER‑логику под LEFT JOIN с фильтром в WHERE плохо не потому, что не работает — работает. Плохо потому, что следующий человек прочитает LEFT JOIN и поверит ему.

А иногда NULL‑строка в WHERE нужна вам совершенно сознательно. Например, когда надо найти клиентов вообще без заказов:

SELECT c.id, c.name
FROM clients c
LEFT JOIN orders o ON o.client_id = c.id
WHERE o.id IS NULL;

LEFT JOIN притащил всех. WHERE o.id IS NULL оставил только тех, кому пара не нашлась. У всего этого есть имя — антиджойн, способ найти «то, чего нет».

И заметьте: работает он за счёт того же поведения, которое в начале статьи ломало нам выгрузку. LEFT JOIN создаёт NULL‑строки, WHERE умеет по ним фильтровать. Одно и то же свойство — то баг, то фича.

Разница в намерении. В антиджойне вы фильтруете по IS NULL, то есть прямо говорите «лови отсутствие пары». А в сломанном запросе фильтр по дате цепляет NULL‑строку случайно, как побочку, про которую автор и не думал.

Куда же делись две тысячи клиентов

Теперь понятно. LEFT JOIN их не терял — он добросовестно довёл всех до промежуточного результата, с NULL‑ами вместо заказов. Их выкинул WHERE: сравнил NULL с датой, получил UNKNOWN, а UNKNOWN для WHERE — это «не пускать».

С собой унести мысль смотреть на запрос: ON и WHERE задают базе разные вопросы. ON — «что здесь пара?». WHERE — «что оставить, когда всё склеилось?».

Как только эти вопросы перестанут сливаться в один, LEFT JOIN перестанет вас подводить.

И каждый раз, встретив в WHERE условие на колонку правой таблицы рядом с LEFT JOIN, вы поймаете себя на мысли: так, а вот тут LEFT только что превратился в INNER.

Кажется, что с SQL всё давно понятно, пока один WHERE внезапно не превращает LEFT JOIN в INNER JOIN. Если хотите проверить, насколько уверенно ориентируетесь в таких нюансах, попробуйте пройти бесплатный тест по SQL для разработчиков и аналитиков.

Если тема SQL и баз данных вам близка, приходите и на бесплатные открытые уроки:

Больше открытых уроков и материалов по backend, SQL и инфраструктуре публикуем на канале OTUS в MAX.

Комментарии (27)


  1. Granulex
    25.05.2026 19:08

    Возможно, дело не только в WHERE, а в любом условии, которое требует наличия строки из правой таблицы – включая HAVING. COUNT(right.id) > 0 работает тихим убийцей в аналитических запросах: формально это LEFT JOIN с агрегацией, по факту – INNER JOIN с дополнительным условием.


    1. 3aky
      25.05.2026 19:08

      Для меня приятным сюрпризом стало, что в таком варианте on не пропадают клиенты без заказов. Это логично, если подумать, но мне оказалось неочевидно. Поэтому общий принцип программирования - "всегда тестировать" в очередной раз празднует победу.


  1. RTFM13
    25.05.2026 19:08

    Я так и не понял, как на уровне бытовой логики вяжется "отсутствие заказов" и "свойство заказа такое-то"?


    1. xSVPx
      25.05.2026 19:08

      Да никак. Никогда бы не подумал, что кто-то решит нуллы с датами сравнивать. Ну т.е. я на sql не пишу, и да, наверное бы вместо on сходу попробовал where =null or >n, уж не знаю будет или нет это работать. (в любом случае это очевидный эдж кейс для проверки).

      Но вот чтобы так мимоходом, просто сравнить дату - это от души...


  1. Akina
    25.05.2026 19:08

    Я бы всё же явно прописал, что

    FROM clients c
    LEFT JOIN orders o ON o.client_id = c.id
                      AND o.created_at >= '2024-04-01'

    выполняет то же, что и

    FROM clients c
    LEFT JOIN orders o ON o.client_id = c.id
    WHERE o.created_at >= '2024-04-01'
       OR o.created_at IS NULL


    1. CatAssa
      25.05.2026 19:08

      Вооот, верно! Соединение делать в ON, а фильтрацию - в WHERE. Ибо и для людей читабельно и оптимизатор не станет несуществующие индексы искать.


      1. 3aky
        25.05.2026 19:08

        Вот только результат будет разный - см. мой комментарий к предыдущему сообщению.


    1. 3aky
      25.05.2026 19:08

      Они выполняют не одно и то же - клиенты, у которых есть только заказы в марте и раньше будут потеряны во втором селекте.


  1. CatAssa
    25.05.2026 19:08

    Да ладно. Не об AI?


  1. Akina
    25.05.2026 19:08

    База сначала берётся за FROM и JOIN. Склеивает таблицы, строит промежуточный результат. И только когда он готов, по нему проходит WHERE и выкидывает ненужные строки.

    Полагаю, что это на самом деле не так. Оптимизатор просто обязан - именно в порядке оптимизации,- условия из JOIN и WHERE обрабатывать одновременно, пусть и по слегка разным правилам (см. мой предыдущий коммент). Потому что, если формулировать терминами нормализации, это одна и та же сущность. А также потому, что при раздельной обработке он после обработки ON протеряет половину индексов.


    1. monco83
      25.05.2026 19:08

      В общем, без просмотра плана выполнения запроса - это гадание. Тем более без указания конкретной базы данных.


      1. 3aky
        25.05.2026 19:08

        Не все знают "как мокра вода" в наше время. Применять человеческую логику к работе оптимизатора явно не стоит, и даже в одной и той же базе данных и в соседних подверсиях он иногда совсем по разному работает (до изумления - был у меня случай с db2).


  1. Ninil
    25.05.2026 19:08

    Детский сад.


    1. cheshirskins
      25.05.2026 19:08

      Зато не об ИИ


      1. xSVPx
        25.05.2026 19:08

        Не так я себе это представлял...


  1. it1804
    25.05.2026 19:08

    SELECT 
      c.id, c.name, o.id AS order_id, o.created_at
    FROM 
      clients c
    LEFT JOIN (
      SELECT  id, client_id, created_at FROM orders WHERE o.created_at >= '2024-04-01'
    ) o ON o.client_id = c.id
    where o.created_at IS not NULL

    Вложенные подзапросы проходят на курсе?

    Для left join нужно сначала отфильтровать правую сторону через подзапрос. Если этого не сделать, а фильтровать всё в основном запросе, то получится шлак, и с этим практически невозможно бороться без каких-то дополнительных условий.

    Для right join наоборот. Заворачиваем левую сторону в отфильтрованном виде через подзапрос и прицепляем через join левую.

    Либо оборачиваем всё в подзапросы. На практике таких join-ов может быть десятки. Другими словами, видим условие фильтрации для конкретного джойна, сразу заворачиваем в подзапрос.


    1. 3aky
      25.05.2026 19:08

      В исходном сообщении непонятно - чего хотели, поэтому подсказать трудно - "как правильно", есть подозрение, что хотели список клиентов с выделением тех, кто недавно заказывал, а в этом случае имело смысл расчётную колонку к выводу добавить.


  1. Yumado
    25.05.2026 19:08

    Интересно за что заминусовали Пост. Академикам не понравилось что благодаря просветительству могут появится умнее академиков.

    А это отражает всю российскую действительность. заклювать, заминусовать.


    1. debagger
      25.05.2026 19:08

      Я плюсанул. Кажется, что вещь описана очевидная, но текст помечен как простой, для начинающих может быть полезным.


      1. YaMishar
        25.05.2026 19:08

        Я не плюсовал и не минусовал. Но кликбейт мне не нравится, ибо "никто вам об этом не скажет" - мягко говоря, преувеличение. Это в базовых книжках по SQL пояснялось в 90х годах. Недавно смотрел несколько курсов на Udemy - там тоже люди поясняли это. Судя по нашим сотрудникам, это объясняется в колледжах по всему свету. Так что да, кликбейт.


    1. artden111
      25.05.2026 19:08

      Это азы, которые не требуют отдельной статьи. База честно отработала WHERE, вернула то, что должен был вернуть запрос. Почему автор думал как-то по-другому - это его проблемы. ЗЫ: не плюсовал или минусовал если что


  1. aleksandy
    25.05.2026 19:08

    когда надо найти клиентов вообще без заказов:

    Тогда никаких внешних соединений и не надо вовсе.

    select * 
      from clients c 
      where not exists(select null from orders o where o.client_id = c.id)
    


    1. Akina
      25.05.2026 19:08

      Что забавно - этот запрос и приведённый в статье скорее всего дадут один и тот же план выполнения.


      1. monco83
        25.05.2026 19:08

        Забавно то, что скорее всего - разные.
        Есть известный тест на объединение двух таблиц 4 способами.

        select a.* from a, b where a.x = b.y
        select a.* from a inner join b on a.x = b.y
        select a.* from a where a.x in (select y from b)
        select a.* from a inner exists (select * from b where b.y = a.x)

        Запросы логически эквивалентны (в случае уникальности b.y). Но на Oracle я получал для них 3 плана выполнения запроса, а на Postgres - 4.

        С ростом версий, возможно что-то и поменялось, но в целом этот пример показывает, что rule-based в построении запросов существенную роль до сих пор играет. И это, кстати, даёт возможность оптимизировать запросы путём переписывания их в другие логически-эквивалентные формы, без прибегания к хинтам.


  1. monco83
    25.05.2026 19:08

    А почему "никто вам об этом не скажет"? Книжки с мануалами совсем перестали читать?


    1. aleksandy
      25.05.2026 19:08

      Читать книжки - это не стильно, модно, молодёжно “минус вайб”.


  1. ArtVC
    25.05.2026 19:08

    Следующая статья: «деление на ноль может привести к исключению в коде - загадка тысячелетия»