Когда я решила стать аналитиком, я не знала про SQL вообще ничего, совсем, образование у меня экономическое и в университете SQL нам никто не преподавал.

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

Первая же вакансия, которая мне понравилась, была в ритейле и была связана с аналитиой ассортимента, требования: SQL, SQL и еще раз SQL и тут пришлось учиться.
Но знаете что? SQL оказался не страшным, а невероятно логичным и красивым инструментом, особенно когда задачи реальные, из жизни большой компании.

В этой статье приведу пример 5 задач, которые меня научили SQL по-настоящему, все они построены на том, с чем работает аналитик ассортимента: товары, категории, продажи и поставки.

Задачи:

  1. ABC-анализ товарного ассортимента – ранжирование товаров по вкладу в общую выручку.

  2. Анализ воронки продаж по категориям – доля каждой категории с накоплением.

  3. Динамика продаж год к году – сравнение с прошлым периодом.

  4. Анализ покупательской корзины – товары, которые часто берут вместе.

  5. 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 по-настоящему

  1. Понимание бизнес-смысла – когда знаешь, что твой запрос решит, ставить ли майонез рядом с пельменями, учишься быстрее.

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

  3. Постепенное усложнение задач – сначала простые SUM OVER, потом PARTITION BY, потом ROWS BETWEEN, потом комбинации с подзапросами.

  4. Ручная проверка – берем маленький кусочек данных и считаем на нем (например по одному товару/дню), чтобы убедиться, что оконные функции считают правильно, а затем уже мастабируем на все данные.

⁉️А какие задачи встречались вам на тестовых? Делитесь в комментариях

✔️Больше про будни и задачи аналитика данных в моем тг 🌸Таня и Данные📊