Logo
Overview

SQL для системного аналитика с нуля: SELECT, JOIN, агрегаты

October 5, 2026
8 min read

SQL для системного аналитика с нуля: SELECT, JOIN, агрегаты

Меня ни разу не спрашивали на собеседовании: «Расскажите про третью нормальную форму». Но «напишите запрос, который достанет список клиентов с суммой их заказов за последний месяц» — спрашивали всегда. SQL для аналитика — не про администрирование баз и не про настройку репликации. Это инструмент добычи данных: вы пишете запрос, база отдаёт таблицу, вы делаете выводы.

Если вы начинающий аналитик — стартовый roadmap поможет понять общую картину профессии. Здесь же — только SQL. Ровно тот минимум, который нужен, чтобы пройти техническую часть собеса и начать работать с реальными данными.

Что такое SELECT и почему вы будете писать его каждый день

SELECT — ключевое слово SQL, которое говорит базе: «верни мне данные». Это не просто «покажи таблицу», а полноценный конвейер: источник → фильтр → группировка → сортировка → порция.

Самый простой запрос выглядит так:

SELECT name, email
FROM clients;

Перевод: «из таблицы clients покажи колонки name и email». Звёздочка * вместо списка колонок означает «все колонки». На проде так лучше не делать — вытянете гигабайт blob’ов и положите базу.

Первое, с чем сталкивается новичок: SQL-запросы пишутся не в том порядке, в котором выполняются. Вы пишете SELECT ... FROM ... WHERE ..., а база выполняет: сначала FROM, потом WHERE, потом GROUP BY, потом HAVING, потом SELECT, потом ORDER BY, потом LIMIT. Это важно — иначе непонятно, почему WHERE не видит алиасы из SELECT.

WHERE: фильтруем до того, как получили результат

WHERE отбрасывает строки, которые не проходят условие. Пишется до группировки.

SELECT name, created_at
FROM orders
WHERE status = 'paid'
AND created_at >= '2026-01-01';

Этот запрос достанет оплаченные заказы с начала года. Условия соединяются через AND, OR, NOT. Скобки работают как в математике:

WHERE (status = 'paid' OR status = 'shipped')
AND amount > 1000

Пара нюансов, на которых сыплются:

  • = а не == — SQL использует одиночное равно для сравнения.
  • Строки в одинарных кавычках — 'paid', не "paid". Двойные кавычки в SQL — для имён таблиц и колонок.
  • NULL проверяется через IS NULL, а не = NULL — NULL в SQL означает «неизвестно», и любое сравнение с NULL даёт NULL, а не TRUE или FALSE. Поэтому WHERE column = NULL не найдёт ни одной строки. Правильно: WHERE column IS NULL или WHERE column IS NOT NULL.

JOIN: зачем аналитику соединять таблицы

Данные в нормальной базе разнесены по таблицам. Заказы — в orders, клиенты — в clients, товары — в products. Чтобы ответить на вопрос «клиенты из Москвы, потратившие больше 100 000 за квартал», нужно соединить минимум две таблицы. Для этого — JOIN.

SELECT c.name, o.amount, o.created_at
FROM orders o
JOIN clients c ON o.client_id = c.id
WHERE c.city = 'Москва';

Что здесь происходит: для каждой строки из orders база ищет строку в clients с таким же id. Алиасы o и c — короткие псевдонимы таблиц, чтобы не писать полные имена каждый раз. ON задаёт условие соединения — какой столбец с каким сравнивать.

Типов JOIN четыре, но на практике хватает трёх:

100%
graph TD
  subgraph INNER["INNER JOIN — только совпадения"]
      direction LR
      A1["Orders: 1,A 2,B 3,C"] --> M["Результат: 1,A,X 2,B,Y"]
      B1["Clients: 1,X 2,Y 4,Z"] --> M
  end
  subgraph LEFT_J["LEFT JOIN — всё из левой таблицы"]
      direction LR
      A2["Orders: 1,A 2,B 3,C"] --> M2["Результат: 1,A,X 2,B,Y 3,C,NULL"]
      B2["Clients: 1,X 2,Y 4,Z"] -.-> M2
  end
  subgraph FULL_J["FULL JOIN — всё из обеих"]
      direction LR
      A3["Orders: 1,A 2,B 3,C"] --> M3["Результат: 1,A,X 2,B,Y 3,C,NULL NULL,4,Z"]
      B3["Clients: 1,X 2,Y 4,Z"] --> M3
  end

  style INNER fill:#e8f4e8,stroke:#3a9a5c
  style LEFT_J fill:#e8eaf4,stroke:#5a4db2
  style FULL_J fill:#fef8e8,stroke:#c88400
  style A1 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style B1 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style M fill:#50c878,stroke:#3a9a5c,color:#fff
  style A2 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style B2 fill:#e0e0e0,stroke:#999,color:#000
  style M2 fill:#7b68ee,stroke:#5a4db2,color:#fff
  style A3 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style B3 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style M3 fill:#f0a500,stroke:#c88400,color:#fff

На диаграмме три JOIN-а на одних и тех же данных. В orders три заказа (id клиентов 1, 2, 3), в clients три клиента (id 1, 2, 4). INNER JOIN вернул только строки, где id совпали в обеих таблицах — заказы клиентов 1 и 2. Заказ клиента 3 (его нет в clients) и клиент 4 (без заказов) выпали. LEFT JOIN сохранил все строки из левой таблицы orders — для клиента 3 подставил NULL в колонках из clients. FULL JOIN собрал вообще всё, заполнив несовпадения NULL-ами с обеих сторон.

Правило простое: если вам нужны все заказы, даже те, у которых нет клиента (например, при миграции данных) — LEFT JOIN. Если только заказы существующих клиентов — INNER JOIN. RIGHT JOIN — по сути LEFT JOIN наоборот, я за всю практику использовал его раза три.

GROUP BY и агрегатные функции: от строк к цифрам

Аналитику редко нужны сырые строки. Обычно нужно «средний чек по месяцам», «количество заказов на клиента», «сумма продаж по категориям». Для этого — GROUP BY и агрегатные функции.

Основные агрегаты:

ФункцияЧто делаетПример
COUNT(*)Количество строкCOUNT(*) → 142
SUM(column)Сумма значенийSUM(amount) → 847 200
AVG(column)СреднееAVG(amount) → 5 966
MIN(column)МинимумMIN(created_at) → самая ранняя дата
MAX(column)МаксимумMAX(amount) → самый дорогой заказ

GROUP BY собирает строки в группы по значению одной или нескольких колонок, и агрегаты считаются внутри каждой группы:

SELECT client_id, COUNT(*) AS order_count, SUM(amount) AS total
FROM orders
WHERE status = 'paid'
GROUP BY client_id
ORDER BY total DESC;

Этот запрос для каждого клиента считает количество оплаченных заказов и общую сумму, сортирует по убыванию суммы. AS задаёт алиас для колонки в результате — в коде приложения вы получите поле total, а не sum(amount).

Важно: в SELECT можно класть только те колонки, которые есть в GROUP BY, плюс агрегаты. Запрос SELECT client_id, name, COUNT(*) ... GROUP BY client_id упадёт, если name не в GROUP BY. Логика простая: в группе из десяти строк у client_id одно значение, а у name их может быть десять — что показывать?

HAVING: фильтр после группировки

WHERE фильтрует строки до группировки. А если нужно отбросить группы, где сумма меньше порога? Для этого HAVING:

SELECT client_id, COUNT(*) AS orders, SUM(amount) AS total
FROM orders
GROUP BY client_id
HAVING SUM(amount) > 100000
ORDER BY total DESC;

Здесь база сначала группирует все заказы по клиентам, потом HAVING отсекает группы с суммой меньше 100 000, и только потом сортирует. WHERE такое не сможет — на этапе WHERE агрегатов ещё не существует.

Частая ошибка: писать WHERE SUM(amount) > 100000. Не работает. WHERE — до агрегации, HAVING — после. Мысленно проговорите порядок выполнения, и ошибка станет очевидной.

Анатомия SQL-запроса: что, зачем и в каком порядке

Одна диаграмма вместо тысячи слов. Порядок, в котором база обрабатывает запрос:

100%
flowchart TD
  Q["SQL-запрос: SELECT ... FROM ... WHERE ..."] --> F["FROM таблицы — источник данных"]
  F --> J{"JOIN? Есть связи между таблицами?"}
  J -->|"Да"| JN["JOIN — соединяем строки по ключу"]
  J -->|"Нет"| W["WHERE — отбрасываем ненужные строки"]
  JN --> W
  W --> G{"GROUP BY? Нужна агрегация?"}
  G -->|"Да"| GB["GROUP BY — группируем строки"]
  G -->|"Нет"| S["SELECT — выбираем колонки и агрегаты"]
  GB --> H{"HAVING? Фильтр по агрегату?"}
  H -->|"Да"| HV["HAVING — отбрасываем группы"]
  H -->|"Нет"| S
  HV --> S
  S --> O{"ORDER BY? Нужна сортировка?"}
  O -->|"Да"| OB["ORDER BY — сортируем"]
  O -->|"Нет"| L["LIMIT — режем результат"]
  OB --> L
  L --> R["Результат: таблица"]

  style Q fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style F fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style J fill:#f0a500,stroke:#c88400,color:#fff
  style JN fill:#7b68ee,stroke:#5a4db2,color:#fff
  style W fill:#7b68ee,stroke:#5a4db2,color:#fff
  style G fill:#f0a500,stroke:#c88400,color:#fff
  style GB fill:#7b68ee,stroke:#5a4db2,color:#fff
  style H fill:#f0a500,stroke:#c88400,color:#fff
  style HV fill:#7b68ee,stroke:#5a4db2,color:#fff
  style S fill:#50c878,stroke:#3a9a5c,color:#fff
  style O fill:#f0a500,stroke:#c88400,color:#fff
  style OB fill:#7b68ee,stroke:#5a4db2,color:#fff
  style L fill:#7b68ee,stroke:#5a4db2,color:#fff
  style R fill:#50c878,stroke:#3a9a5c,color:#fff

На схеме видно главное: запрос идёт не сверху вниз по тексту, а по конвейеру. Сначала база берёт таблицы (FROM), соединяет (JOIN), фильтрует строки (WHERE), группирует (GROUP BY), фильтрует группы (HAVING), выбирает колонки (SELECT), сортирует (ORDER BY) и отрезает порцию (LIMIT). Ромбы — это необязательные шаги: если нет JOIN — пропускаем, нет GROUP BY — идём сразу к SELECT.

Держите эту схему перед глазами, когда пишете запрос сложнее чем SELECT * FROM. Она спасает от 90% ошибок с алиасами и фильтрацией.

Как это выглядит на реальной задаче

Представьте: продакт просит «дай топ-10 клиентов по сумме заказов за последний квартал, только оплаченные». Вы открываете DBeaver и пишете:

SELECT c.name, COUNT(o.id) AS order_cnt, SUM(o.amount) AS total
FROM orders o
JOIN clients c ON o.client_id = c.id
WHERE o.status = 'paid'
AND o.created_at >= '2026-07-01'
AND o.created_at < '2026-10-01'
GROUP BY c.name
ORDER BY total DESC
LIMIT 10;

Разбор по шагам: FROM orders o — основная таблица, JOIN clients c — подтягиваем имена клиентов, WHERE — только оплаченные и только за Q3, GROUP BY c.name — группируем по клиенту, SELECT — считаем количество заказов и сумму, ORDER BY total DESC — сортируем от больших к малым, LIMIT 10 — берём десятку.

Результат — таблица из трёх колонок. В Excel или Python-ноутбуке дальше можно строить график. Но основа — вот этот запрос.

Типичные ошибки начинающего аналитика

Три ошибки, которые я вижу на каждом втором код-ревью.

Ошибка 1: COUNT(column) вместо COUNT(*). COUNT(column) считает только NOT NULL значения. Если в колонке email у кого-то NULL, вы получите число меньше реального. COUNT(*) считает строки — для аналитики обычно нужно именно оно.

Ошибка 2: фильтрация по агрегату через WHERE. Уже упоминал, но повторю — это настолько частая ошибка, что стоит отдельного напоминания. WHERE SUM(amount) > 1000 не сработает. Нужен HAVING.

Ошибка 3: JOIN без условия или с неправильным условием. Забыли ON — получили декартово произведение: каждая строка левой таблицы соединилась с каждой строкой правой. Там, где ждали 200 строк, прилетело 40 000 и база упала по таймауту.

Отдельная история — подзапросы. Их часто пишут там, где достаточно JOIN или GROUP BY. Прежде чем писать SELECT ... FROM (SELECT ...), спросите себя: можно ли решить задачу одним запросом с GROUP BY? В восьми случаях из десяти — можно.

Что дальше

SQL — не про заучивание синтаксиса. Он про понимание того, как данные лежат в таблицах и как их преобразовывать. Если вы усвоили SELECT, JOIN, GROUP BY и HAVING — техническая часть собеса на позицию джуна-аналитика у вас в кармане.

База для тренировки: возьмите открытый датасет (продажи, фильмы, авиаперевозки — что угодно), загрузите в SQLite и пробуйте. Не смотрите на чужие запросы первые полчаса. Сами. Это единственный способ набить руку.

Когда базовый SQL перестанет быть узким местом — возвращайтесь к теме с другой стороны: как AI генерирует SQL-запросы и помогает проектировать схемы. LLM уже неплохо пишут SELECT и JOIN, но понимать, что именно они сгенерировали, всё равно придётся вам.

Чек-лист для аналитика

  • Пишете SELECT с явным списком колонок, а не *
  • Понимаете разницу между INNER JOIN, LEFT JOIN и когда что применять
  • Используете WHERE для фильтрации строк и HAVING для фильтрации групп
  • Не путаете порядок выполнения (FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT)
  • Умеете написать запрос с GROUP BY, COUNT, SUM, AVG и объяснить результат
  • Знаете, что COUNT(column) не равен COUNT(*), и почему
  • Проверяете план запроса, если он выполняется дольше пары секунд