SQL для аналитика с нуля: JOIN, GROUP BY и HAVING на одном примере
Аналитику SQL нужен не для того, чтобы писать код продукта. Он нужен, чтобы не зависеть от чужого календаря: продакт спрашивает «сколько клиентов ушло после первого списания», и ты либо отвечаешь через двадцать минут сам, либо ставишь задачу разработчику и ждёшь три дня.
Ещё SQL — это способ проверить собственные требования. Ты нарисовал модель данных, договорился, что у платежа есть статус, а потом смотришь в базу и видишь, что статусов там семь, три из них никто не может объяснить, а в половине строк дата оплаты пустая. Такие открытия лучше делать до релиза.
Эта статья — не справочник по синтаксису. Мы возьмём один маленький набор таблиц и пройдём путь от «достать строки» до «посчитать метрику и отфильтровать результат». Разберём ровно те три конструкции, которые закрывают большинство рабочих задач аналитика: JOIN, GROUP BY и HAVING. И отдельно — ошибки, из-за которых запрос выполняется без единой красной строчки и отдаёт неверные цифры.
Что должен уметь аналитик, а что не обязан
Разделим сразу, чтобы ты не тратил месяцы не на то.
Нужно почти каждый день:
- достать строки с фильтром —
SELECT … WHERE; - соединить несколько таблиц —
JOIN; - посчитать итоги в разрезе —
GROUP BY+COUNT/SUM/AVG; - отфильтровать сами итоги —
HAVING; - отсортировать и ограничить вывод —
ORDER BY,LIMIT.
Полезно, но позже: подзапросы, CTE (WITH), оконные функции (ROW_NUMBER, LAG) — они начинают выручать, когда задачи становятся такими, что в один GROUP BY не влезают.
Обычно не нужно: оптимизация планов выполнения, написание хранимых процедур, проектирование индексов под нагрузку. Это зона разработчика и DBA. Знать, что индекс существует и зачем он, — да. Уметь его подобрать — нет.
💡 Практическое следствие: не начинай с толстого учебника по СУБД. Начни с четырёх таблиц и десяти вопросов к ним.
Наш пример: сервис подписки
Дальше всё — на одном наборе таблиц. Это сервис с платной подпиской и программой лояльности, тот же сквозной кейс, что мы используем в курсе.
clients — клиенты
id, name, created_at
subscriptions — подписки клиентов
id, client_id, plan, status, started_at, price
payments — попытки списания по подписке
id, subscription_id, amount, status, paid_at
loyalty_points — начисления баллов
id, client_id, points, reason, created_at
Связи простые: у клиента может быть несколько подписок, у подписки — несколько платежей (каждое ежемесячное списание, включая неудачные). client_id в subscriptions и subscription_id в payments — внешние ключи, они и будут местом стыковки таблиц.
Если структура «сущности → связи → ключи» тебе пока не очевидна, это нормально: SQL становится понятным ровно в тот момент, когда ты видишь модель данных, а не набор таблиц. Порядок освоения тут жёсткий — сначала модель, потом запросы.
Шаг 1. SELECT и WHERE — фундамент за две минуты
SELECT id, name, created_at
FROM clients
WHERE created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
Читается ровно как звучит: взять три столбца из таблицы клиентов, оставить зарегистрированных с начала года, отсортировать свежими вперёд, показать двадцать.
Три вещи, которые экономят время с самого начала:
- ⚠️
SELECT *— только чтобы «посмотреть, что там». В рабочем запросе перечисляй столбцы: так видно, что ты действительно используешь, и запрос не сломается от новой колонки. NULL— это не ноль и не пустая строка, это «значение неизвестно». Сравнивать его через=бесполезно:WHERE paid_at = NULLне вернёт ничего никогда. Правильно —WHERE paid_at IS NULL.- Условие
IN ('paid', 'refunded')читается легче, чем цепочкаOR, и реже содержит ошибку в скобках.
Шаг 2. JOIN — собираем правду из разных таблиц
Первый настоящий рабочий вопрос: «покажи клиентов и их подписки».
Имени клиента нет в таблице подписок — там только client_id. Значит, таблицы нужно соединить по ключу:
SELECT c.name, s.plan, s.status
FROM clients c
JOIN subscriptions s ON s.client_id = c.id;
c и s — алиасы, короткие имена таблиц. С ними сразу видно, из какой таблицы каждый столбец, и это не косметика: когда таблиц три, name без префикса становится загадкой.
INNER JOIN и LEFT JOIN — единственное различие, которое важно помнить
| Вид | Что оставляет | Типичный вопрос |
|---|---|---|
INNER JOIN (просто JOIN) |
только строки, у которых нашлась пара в обеих таблицах | «клиенты, у которых есть подписка» |
LEFT JOIN |
все строки левой таблицы; если пары нет — справа NULL |
«все клиенты, и сколько у кого подписок, включая нулевые» |
Разница выглядит технической, а на деле это разные ответы на вопрос бизнеса. Запрос выше с INNER JOIN просто не покажет клиентов без подписки — и если продакт спросил «сколько у нас клиентов», ты назовёшь заниженное число и даже не заметишь этого. Ошибка молчаливая: базе всё равно, запрос корректный, цифра неверная.
Правило для себя: INNER JOIN по умолчанию, LEFT JOIN — когда левая таблица это и есть ответ. «Отчёт по всем клиентам» → клиенты слева, LEFT JOIN вправо.
Соединяем три таблицы
Вопрос сложнее: «сколько каждый клиент заплатил за всё время?». Имя — в clients, деньги — в payments, а связаны они только через subscriptions:
SELECT c.name, p.amount, p.status
FROM clients c
JOIN subscriptions s ON s.client_id = c.id
JOIN payments p ON p.subscription_id = s.id
WHERE p.status = 'paid';
Цепочка читается слева направо: клиенты → их подписки → платежи по этим подпискам. Пока это ещё не ответ — мы получили список отдельных платежей. Сложить их — работа следующего шага.
Шаг 3. GROUP BY — от строк к метрикам
Агрегатная функция сворачивает много строк в одно число: COUNT(*) — сколько строк, SUM(x) — сумма, AVG(x) — среднее, MIN/MAX — границы.
Без группировки агрегат считает по всей выборке:
SELECT COUNT(*) AS payments_count, SUM(amount) AS revenue
FROM payments
WHERE status = 'paid';
GROUP BY разбивает выборку на группы и считает агрегат в каждой отдельно:
SELECT c.name,
COUNT(*) AS payments_count,
SUM(p.amount) AS total_paid
FROM clients c
JOIN subscriptions s ON s.client_id = c.id
JOIN payments p ON p.subscription_id = s.id
WHERE p.status = 'paid'
GROUP BY c.id, c.name
ORDER BY total_paid DESC;
Вот это уже ответ на вопрос продакта: строка на клиента, в ней — количество списаний и сумма.
🔤 Правило GROUP BY: каждый столбец из SELECT, который не обёрнут в агрегатную функцию, обязан быть в GROUP BY. Иначе база не знает, какое из значений группы показать.
Группируем по c.id, c.name, а не по одному имени — потому что тёзки существуют. Группировка по неуникальному полю склеит двух разных Ивановых в одну строку, и это снова молчаливая ошибка: цифра будет, она будет неверной.
Шаг 4. HAVING — фильтр для результатов, а не для строк
Вопрос: «покажи клиентов, которые принесли больше 10 000 ₽».
Попытка написать это через WHERE не сработает:
-- ❌ так нельзя
WHERE SUM(p.amount) > 10000
Причина не в прихоти синтаксиса. WHERE отрабатывает до группировки — в тот момент суммы ещё не существует, есть только отдельные строки платежей. Фильтр по уже посчитанному итогу — это HAVING:
SELECT c.name, SUM(p.amount) AS total_paid
FROM clients c
JOIN subscriptions s ON s.client_id = c.id
JOIN payments p ON p.subscription_id = s.id
WHERE p.status = 'paid' -- отсекаем строки: неуспешные платежи
GROUP BY c.id, c.name
HAVING SUM(p.amount) > 10000 -- отсекаем группы: клиенты с малой суммой
ORDER BY total_paid DESC;
В одном запросе оба фильтра, и они делают разное: WHERE решает, какие платежи попадут в подсчёт, HAVING — какие клиенты попадут в отчёт.
Проверка на понимание: если перенести p.status = 'paid' из WHERE в HAVING, запрос либо не выполнится, либо посчитает сумму по всем платежам подряд — включая отклонённые. Разница между «выручка» и «сумма всех попыток списания» может быть кратной.
Порядок выполнения запроса — то, что объясняет всё остальное
Мы пишем запрос в одном порядке, а база выполняет его в другом. Как только это укладывается в голове, WHERE против HAVING перестаёт быть темой для запоминания — становится очевидным.
1. FROM + JOIN — собрали общую таблицу из нескольких
2. WHERE — выбросили ненужные строки
3. GROUP BY — разложили оставшиеся по группам
4. HAVING — выбросили ненужные группы
5. SELECT — посчитали и назвали столбцы (алиасы появляются здесь)
6. ORDER BY — отсортировали
7. LIMIT — обрезали вывод
Отсюда следуют вещи, которые иначе выглядят капризами:
- в
WHEREнельзя использовать агрегаты — на втором шаге их ещё нет; - в
WHEREиGROUP BYобычно нельзя использовать алиас изSELECT— он появляется только на пятом шаге (вORDER BYможно, он идёт после); LIMIT 10не ускоряет подсчёт: база сначала честно всё сгруппировала и отсортировала, и только потом отдала десять строк.
💡 Если запрос выдаёт непонятное — пройдись по этим семи шагам и спроси на каждом: сколько строк здесь осталось? Девять отладок из десяти заканчиваются на шаге 1 или 2.
Шесть ошибок, из-за которых цифры врут молча
Синтаксическую ошибку найдёт база. Эти — только ты сам.
1. Дубли после JOIN. Соединили клиентов с платежами, чтобы посчитать средний чек, — и каждый клиент размножился по числу платежей. Если после этого сложить, например, s.price, цена подписки просуммируется столько раз, сколько было списаний. Симптом: сумма подозрительно круглая и большая. Привычка: после JOIN сравни COUNT(*) до и после — выросло ли число строк неожиданно.
2. LEFT JOIN, убитый фильтром в WHERE. Классика:
-- хотели: все клиенты, в том числе без активной подписки
FROM clients c
LEFT JOIN subscriptions s ON s.client_id = c.id
WHERE s.status = 'active' -- ❌ и LEFT JOIN превратился в INNER
У клиента без подписок s.status равен NULL, условие NULL = 'active' не выполняется — строка выбрасывается. Лечится переносом условия в ON:
LEFT JOIN subscriptions s ON s.client_id = c.id AND s.status = 'active'
3. COUNT(*) вместо COUNT(столбец). COUNT(*) считает строки, COUNT(s.id) — строки, где значение не NULL. После LEFT JOIN у клиента без подписок будет одна строка с пустотой: COUNT(*) даст 1, COUNT(s.id) — честный 0.
4. Среднее от среднего. AVG по уже усреднённым значениям не равен общему среднему, если размеры групп разные. Средний чек по компании — это SUM(amount) / COUNT(*) по всем платежам, а не AVG от средних чеков по клиентам.
5. Деньги в дробных типах. Если суммы лежат во FLOAT/REAL, итог поедет в копейках из-за округления. В нормально спроектированной базе деньги хранят в минорных единицах целым числом (копейки) или в DECIMAL. Увидел FLOAT у суммы — это находка для отчёта о качестве данных, а не мелочь.
6. Период по дате без учёта времени и границ. WHERE paid_at >= '2026-09-01' AND paid_at < '2026-10-01' — надёжно. BETWEEN '2026-09-01' AND '2026-09-30' потеряет всё, что произошло 30 сентября после полуночи, потому что дата без времени — это полночь. За месяц теряется день.
Общее у всех шести: запрос отработал успешно. Никто не узнает о проблеме, кроме того, кто перепроверил. Поэтому первое правило работы с данными — не доверяй первому результату запроса, объясни его порядок величины. Если цифра похожа на правду, но ты не можешь сказать почему — ты ещё не закончил.
Как учиться: план на две недели
Ускоряет не количество прочитанной теории, а количество написанных запросов к данным, которые тебе интересны.
Неделя 1 — одиночные таблицы.
Возьми любой открытый датасет или базу учебного проекта. Каждый день — три вопроса к данным и три запроса: SELECT, WHERE, ORDER BY, LIMIT, потом COUNT/SUM/AVG без группировки. Задавай вопросы словами сначала: «сколько записей за август», «какая самая большая сумма».
Неделя 2 — связи и группировки.
JOIN двух, затем трёх таблиц. GROUP BY с одним и двумя столбцами. HAVING. Каждый запрос заканчивай вопросом «а это точно верное число?» и проверяй его вторым способом — например, сумму по группам сверь с общей суммой без группировки.
Чего не делать: не проходить курс по СУБД целиком, не учить синтаксис оконных функций, пока не освоена группировка, и не писать запросы «в блокнот» — только туда, где они выполняются и показывают результат. SQL учится руками; читая про JOIN, его не понять, а написав пять штук и увидев дубли — понять за вечер.
Чек-лист перед тем, как отдать цифру
- Знаю, сколько строк возвращает запрос, и почему столько.
- Проверил, не размножились ли строки после
JOIN. - Все неагрегатные столбцы
SELECTесть вGROUP BY, группировка — по уникальному ключу. - Фильтры разведены: строки — в
WHERE, итоги — вHAVING. -
LEFT JOINне превращён вINNERусловием вWHERE. -
NULLобработан сознательно:IS NULL, а не= NULL. - Границы периода заданы как
>= начало AND < следующее начало. - Порядок величины результата объясним вслух.
Что дальше
SQL — второй шаг, а не первый. Сначала ты понимаешь, какие сущности есть в предметной области и как они связаны, и только потом достаёшь из них данные: запрос к модели, которую ты не понимаешь, даёт синтаксически верный и бессмысленный ответ. Поэтому если ты только определяешься с направлением, начни с честного пошагового плана входа в профессию, а если разбираешься, кому из двух ролей SQL нужен чаще, — посмотри «Бизнес-аналитик vs системный аналитик». SQL, к слову, спрашивают почти на каждом собеседовании джуна — типовые вопросы с разбором собраны в отдельной статье.
Дальше по самой теме данных порядок такой: ER-диаграмма → нормализация → SELECT → JOIN и агрегаты → подзапросы, CTE и оконные функции → конкурентный доступ и блокировки. Последний пункт кажется лишним для аналитика ровно до первого спора «почему баллы начислились дважды».
Именно в этом порядке устроен блок данных в курсе «Аналитик.Про»: запросы пишутся прямо в браузере, в SQL-песочнице на том же датасете подписки — clients, subscriptions, payments, loyalty_points. Ты меняешь запрос и сразу видишь результат, а к каждому заданию есть эталонный разбор, с которым можно сверить свой вариант. Ничего устанавливать не нужно, и первые блоки курса открыты без оплаты — можно дойти до практики и решить, твоё ли это.