Когда я решила стать аналитиком, я не знала про SQL вообще ничего, совсем, образование у меня экономическое и в университете SQL нам никто не преподавал.
В моем представлении было, что аналитика – это красивые графики в Excel, умные выводы и встречи с руководством, а то, что за всем этим стоят запросы к базам данных – как-то ускользало от внимания, до тех пор пока я не начала искать работу.
Первая же вакансия, которая мне понравилась, была в ритейле и была связана с аналитиой ассортимента, требования: SQL, SQL и еще раз SQL и тут пришлось учиться.
Но знаете что? SQL оказался не страшным, а невероятно логичным и красивым инструментом, особенно когда задачи реальные, из жизни большой компании.
В этой статье приведу пример 5 задач, которые меня научили SQL по-настоящему, все они построены на том, с чем работает аналитик ассортимента: товары, категории, продажи и поставки.
Задачи:
ABC-анализ товарного ассортимента – ранжирование товаров по вкладу в общую выручку.
Анализ воронки продаж по категориям – доля каждой категории с накоплением.
Динамика продаж год к году – сравнение с прошлым периодом.
Анализ покупательской корзины – товары, которые часто берут вместе.
RFM-сегментация покупателей – кто приносит деньги, а кто уйдет.
Спойлер: запросы не самые базовые, все запросы используют оконные функции, подзапросы и CTE.
Исходные данные
Для примеров я использую структуру, приближенную к реальному ритейлу:
sales – продажи (date, product_id, store_id, quantity, amount)
products – товары (product_id, product_name, category, price, cost)
transactions – чеки (transaction_id, date, customer_id, product_id, quantity)
customers – покупатели (customer_id, card_number, first_purchase_date)
Задача 1. ABC-анализ товарного ассортимента
Категорийному менеджеру нужно понять, какие товары приносят основную выручку, а какие самые не выгодные, тут нужно понимать, что ABC-анализ делит товары на три группы:
A — 80% выручки (золотой фонд)
B — 15% выручки (средний класс)
C — 5% выручки (по нашему ТЗ это первые кандидаты на вывод)
Решение:
WITH product_revenue AS ( -- Считаем выручку по каждому товару SELECT p.product_id, p.product_name, p.category, SUM(s.amount) AS revenue, SUM(SUM(s.amount)) OVER() AS total_revenue FROM sales s JOIN products p ON s.product_id = p.product_id WHERE s.date BETWEEN '2026-01-01' AND '2026-03-31' GROUP BY p.product_id, p.product_name, p.category ), product_share AS ( -- Считаем долю каждого товара и накопленную долю SELECT product_id, product_name, category, revenue, ROUND(100 * revenue / total_revenue, 2) AS share_percent, ROUND(100 * SUM(revenue) OVER(ORDER BY revenue DESC) / total_revenue, 2) AS cumulative_share, ROW_NUMBER() OVER(ORDER BY revenue DESC) AS rank FROM product_revenue ) SELECT product_id, product_name, category, revenue, share_percent, cumulative_share, CASE WHEN cumulative_share <= 80 THEN 'A' WHEN cumulative_share <= 95 THEN 'B' ELSE 'C' END AS abc_class FROM product_share ORDER BY revenue DESC;
Что мы здесь используем:
Оконные функции с агрегацией – SUM(SUM(amount)) OVER() позволяет посчитать общую выручку и одновременно сохранить детализацию по товарам.
Накопительная сумма – SUM(revenue) OVER(ORDER BY revenue DESC) считает кумулятивную долю, что критически важно для ABC-анализа.
Условия CASE WHEN - для разметки данных, в нашем случае для присвоения группы.
Задача 2. Анализ воронки продаж по категориям с накоплением
Руководство хочет видеть не просто сколько продала каждая категория, а как распределяются продажи внутри категорий – какие подкатегории растят выручку, а какие отстают.
Решение:
WITH category_stats AS ( SELECT p.category, p.product_name, SUM(s.amount) AS product_revenue, SUM(SUM(s.amount)) OVER(PARTITION BY p.category) AS category_revenue, SUM(SUM(s.amount)) OVER() AS total_revenue FROM sales s JOIN products p ON s.product_id = p.product_id WHERE s.date BETWEEN '2026-01-01' AND '2026-03-31' GROUP BY p.category, p.product_name ), category_ranking AS ( SELECT category, product_name, product_revenue, category_revenue, ROUND(100 * product_revenue / category_revenue, 2) AS share_in_category, ROUND(100 * category_revenue / total_revenue, 2) AS category_share_of_total, RANK() OVER(PARTITION BY category ORDER BY product_revenue DESC) AS rank_in_category FROM category_stats ) SELECT category, product_name, product_revenue, share_in_category, category_share_of_total, rank_in_category FROM category_ranking WHERE rank_in_category <= 3 -- топ-3 товара в каждой категории ORDER BY category, rank_in_category;
Что мы здесь используем:
Многоуровневая агрегация – сначала считаем выручку по товарам, потом по категориям (через PARTITION BY), потом общую, удобно что все в одном запросе.
Ранжирование внутри групп – RANK() OVER(PARTITION BY category) показывает лидеров внутри каждой категории отдельно.
Задача 3. Динамика пр��даж год к году с скользящим средним
Аналитику нужно понять сезонность и тренды: как продажи текущего месяца соотносятся с прошлым годом, и есть ли устойчивый рост.
Решение:
WITH monthly_sales AS ( -- Помесячная выручка SELECT DATE_TRUNC('month', s.date) AS month, SUM(s.amount) AS revenue, EXTRACT(YEAR FROM DATE_TRUNC('month', s.date)) AS year, EXTRACT(MONTH FROM DATE_TRUNC('month', s.date)) AS month_num FROM sales s WHERE s.date >= '2024-01-01' GROUP BY DATE_TRUNC('month', s.date) ), sales_with_lag AS ( SELECT month, revenue, year, month_num, -- Выручка за тот же месяц прошлого года LAG(revenue, 12) OVER(ORDER BY month) AS revenue_same_month_last_year, -- Скользящее среднее за 3 месяца AVG(revenue) OVER(ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3m, -- Скользящее среднее за 12 месяцев (годовой тренд) AVG(revenue) OVER(ORDER BY month ROWS BETWEEN 11 PRECEDING AND CURRENT ROW) AS moving_avg_12m FROM monthly_sales ) SELECT month, revenue, revenue_same_month_last_year, ROUND(100 * (revenue - revenue_same_month_last_year) / revenue_same_month_last_year, 2) AS yoy_growth_percent, ROUND(moving_avg_3m, 2) AS moving_avg_3m, ROUND(moving_avg_12m, 2) AS moving_avg_12m, -- Сравнение с трендом CASE WHEN revenue > moving_avg_12m * 1.1 THEN 'Выше тренда' WHEN revenue < moving_avg_12m * 0.9 THEN 'Ниже тренда' ELSE 'В рамках тренда' END AS trend_deviation FROM sales_with_lag WHERE year = 2026 ORDER BY month;
Что мы здесь используем:
LAG с большим смещением – LAG(revenue, 12) достает значение 12 строк назад (ровно год) для сравнения год к году.
Скользящее среднее – ROWS BETWEEN 2 PRECEDING AND CURRENT ROW задает рамку окна для расчета, это сглаживает случайные скачки и показывает реальный тренд.
Задача 4. Анализ покупательской корзины (часто вместе)
Ритейлу важно понимать, какие товары люди кладут в корзину одновременно. это нужно для: планирования выкладки (рядом ставят то, что часто берут вместе), для формирования акций (купи сыр — получи колбасу со скидкой) и для рекомендаций в мобильном приложении.
Решение:
WITH basket_items AS ( -- Каждый товар в каждом чеке SELECT t.transaction_id, t.customer_id, t.product_id, p.category FROM transactions t JOIN products p ON t.product_id = p.product_id WHERE t.date BETWEEN '2026-01-01' AND '2026-03-31' ), product_pairs AS ( -- Связываем товары внутри одного чека SELECT b1.category AS category_a, b1.product_id AS product_a, b2.category AS category_b, b2.product_id AS product_b, COUNT(*) AS times_together FROM basket_items b1 JOIN basket_items b2 ON b1.transaction_id = b2.transaction_id AND b1.product_id < b2.product_id -- исключаем дубли и самосоединения GROUP BY b1.category, b1.product_id, b2.category, b2.product_id ), ranked_pairs AS ( SELECT category_a, category_b, product_a, product_b, times_together, -- Ранжируем пары внутри каждой комбинации категорий ROW_NUMBER() OVER(PARTITION BY category_a, category_b ORDER BY times_together DESC) AS rank_in_category_pair, -- Доля от максимальной популярности ROUND(100 * times_together / MAX(times_together) OVER(PARTITION BY category_a, category_b), 2) AS percent_of_top_pair FROM product_pairs WHERE category_a <= category_b -- упорядочиваем категории ) SELECT category_a, category_b, product_a, product_b, times_together, percent_of_top_pair FROM ranked_pairs WHERE rank_in_category_pair <= 5 -- топ-5 пар в каждой связке категорий AND times_together > 10 -- фильтруем шум ORDER BY category_a, category_b, rank_in_category_pair;
Что мы здесь используем:
Self-join с условием – соединение таблицы с самой собой, чтобы найти пары товаров в одном чеке. Условие b1.product_id < b2.product_id убирает дубли (молоко+хлеб и хлеб+молоко считаются один раз) .
Двойное ранжирование – сначала считаем популярность пар, потом ранжируем внутри каждой группы категорий, это позволяет увидеть топовые сочетания в разрезе Молочка + Бакалея, Мясо + Овощи и т.д.
Задача 5. RFM-сегментация покупателей
Бизнес-контекст
RFM (Recency, Frequency, Monetary) – стандартный способ сегментировать покупателей, чтобы понять: кто приносит деньги и их надо холить, кто спит и их надо будить и кто ушел и его уже не вернуть.
Решение:
WITH customer_metrics AS ( -- Считаем RFM-метрики по каждому покупателю SELECT c.customer_id, -- Recency: дней с последней покупки (чем меньше, тем лучше) EXTRACT(DAY FROM AGE(CURRENT_DATE, MAX(t.date))) AS recency, -- Frequency: количество покупок COUNT(DISTINCT t.transaction_id) AS frequency, -- Monetary: общая сумма SUM(t.quantity * p.price) AS monetary, -- Средний чек AVG(t.quantity * p.price) AS avg_check FROM customers c LEFT JOIN transactions t ON c.customer_id = t.customer_id LEFT JOIN products p ON t.product_id = p.product_id WHERE t.date >= '2025-01-01' -- последний год GROUP BY c.customer_id HAVING COUNT(DISTINCT t.transaction_id) > 0 -- только покупавшие ), rfm_scores AS ( SELECT customer_id, recency, frequency, monetary, avg_check, -- NTILE(5) делит на 5 равных групп -- Recency: чем меньше дней, тем выше балл (инвертируем) NTILE(5) OVER(ORDER BY recency DESC) AS r_score, -- тут хитро: DESC, потому что меньший recency = лучше NTILE(5) OVER(ORDER BY frequency DESC) AS f_score, NTILE(5) OVER(ORDER BY monetary DESC) AS m_score FROM customer_metrics ), rfm_segments AS ( SELECT customer_id, recency, frequency, monetary, avg_check, r_score, f_score, m_score, CASE WHEN r_score >= 4 AND f_score >= 4 AND m_score >= 4 THEN 'Чемпионы' WHEN r_score >= 3 AND f_score >= 3 AND m_score >= 3 THEN 'Лояльные' WHEN r_score >= 4 AND f_score <= 2 THEN 'Новые' -- недавно, но мало WHEN r_score <= 2 AND f_score >= 4 AND m_score >= 4 THEN 'На грани' -- давно не были, но раньше много WHEN r_score <= 2 AND f_score <= 2 AND m_score <= 2 THEN 'Умершие' ELSE 'Спящие' END AS segment, -- Для каждого клиента находим его любимый товар FIRST_VALUE(p.product_name) OVER(PARTITION BY c.customer_id ORDER BY t.quantity DESC) AS favorite_product FROM customer_metrics c LEFT JOIN transactions t ON c.customer_id = t.customer_id LEFT JOIN products p ON t.product_id = p.product_id ) SELECT segment, COUNT(*) AS customers_count, ROUND(100 * COUNT(*) / SUM(COUNT(*)) OVER(), 2) AS segment_share, ROUND(AVG(recency), 1) AS avg_recency_days, ROUND(AVG(frequency), 1) AS avg_frequency, ROUND(AVG(monetary), 2) AS avg_monetary, ROUND(AVG(avg_check), 2) AS avg_avg_check, -- Пример списка любимых товаров сегмента (для презентаций) STRING_AGG(DISTINCT favorite_product, ', ' ORDER BY favorite_product LIMIT 5) AS top_favorite_products FROM rfm_segments GROUP BY segment ORDER BY avg_monetary DESC;
Что мы здесь используем:
NTILE(5) – магическая функция, которая делит покупателей на 5 равных групп по любому показателю, это основа RFM: первые 20% по частоте получают балл 5, последние 20% балл 1.
FIRST_VALUE OVER(PARTITION BY) – находит любимый товар каждого клиента (тот, который он покупал чаще всего), нужно для персонализации.
Многоступенчатая логика — сегментация строится на комбинации трех баллов. Здесь: Чемпионы – это те, у кого все тройки высокие, На грани – раньше были чемпионами, но давно не заходили.
Что мне помогло понять и начать использовать SQL по-настоящему
Понимание бизнес-смысла – когда знаешь, что твой запрос решит, ставить ли майонез рядом с пельменями, учишься быстрее.
Оконные функции – это суперсила – они позволяют делать в одном запросе то, на что раньше уходили десятки строк кода и Excel-таблицы, к ним нужно просто привыкнуть, это придет с опытом использования.
Постепенное усложнение задач – сначала простые SUM OVER, потом PARTITION BY, потом ROWS BETWEEN, потом комбинации с подзапросами.
Ручная проверка – берем маленький кусочек данных и считаем на нем (например по одному товару/дню), чтобы убедиться, что оконные функции считают правильно, а затем уже мастабируем на все данные.
⁉️А какие задачи встречались вам на тестовых? Делитесь в комментариях
✔️Больше про будни и задачи аналитика данных в моем тг 🌸Таня и Данные📊
