Как посчитать процент в SQL

Проверь себя · 1/3разбор после ответа
В таблице payments нужно выбрать платежи из России (country = 'RU') со статусом либо 'failed', либо 'chargeback'. Какой WHERE корректный?

Зачем это нужно аналитику

Проценты — рабочая валюта аналитики. «Конверсия выросла на 2 п.п.», «доля категории — 30% от выручки», «MoM +15%». Посчитать процент в SQL кажется тривиальным, но тут три устойчивые грабли, на которых регулярно спотыкаются даже middle-аналитики.

Первая — целочисленное деление. В Postgres (и во многих других СУБД) integer / integer возвращает integer. То есть 5 / 20 даёт 0, и последующее умножение на 100 уже ничего не спасёт. Лечится приведением к NUMERIC / FLOAT — например, умножением на 100.0 или через CAST.

Вторая — деление на ноль. Если знаменатель оказался пустым (нет показов, нет пользователей в сегменте), запрос упадёт. Решается обёрткой NULLIF(y, 0): если y = 0, вернётся NULL, и результат будет NULL, а не ошибка.

Третья — COUNT(*) вместо COUNT(DISTINCT user_id) при подсчёте доли пользователей. Если у одного и того же человека пять событий, в COUNT(*) он учитывается пять раз.

Дальше — 12 готовых шаблонов под частые задачи: доля от общего, доля в группе, конверсия, WoW / MoM / YoY, накопительный процент, Pareto, форматирование.

Базовая формула

percent = часть × 100 / целое

Если хочется обойтись без умножения на 100.0, можно привести один из операндов к NUMERIC через CAST — эффект тот же.

1. Доля от общего количества

Сколько % заказов оплачены:

SELECT
    100.0 * COUNT(*) FILTER (WHERE status = 'paid') / COUNT(*) AS paid_pct
FROM orders;

Через AVG и булев индикатор — ещё короче:

SELECT
    100 * AVG(CASE WHEN status = 'paid' THEN 1.0 ELSE 0 END) AS paid_pct
FROM orders;

Важно: 1.0 (numeric) — не 1 (int), иначе деление внутри AVG окажется целочисленным.

2. Доля каждой категории

Процент заказов по категориям, без лишних CTE:

SELECT
    category,
    COUNT(*)                                        AS orders,
    100.0 * COUNT(*) / SUM(COUNT(*)) OVER ()        AS share_pct
FROM orders
GROUP BY category
ORDER BY share_pct DESC;

Оконная функция SUM(COUNT(*)) OVER () считает общее количество строк после группировки, поэтому отдельный подзапрос не нужен.

3. Конверсия воронки

Идём по пользователям — отдельные шаги воронки и попарные конверсии:

WITH funnel AS (
    SELECT
        COUNT(DISTINCT user_id) FILTER (WHERE event_name = 'signup')     AS signups,
        COUNT(DISTINCT user_id) FILTER (WHERE event_name = 'activation') AS activated,
        COUNT(DISTINCT user_id) FILTER (WHERE event_name = 'purchase')   AS paid
    FROM events
)
SELECT
    signups,
    activated,
    paid,
    100.0 * activated / NULLIF(signups, 0)   AS cr_signup_to_activation,
    100.0 * paid      / NULLIF(signups, 0)   AS cr_signup_to_paid,
    100.0 * paid      / NULLIF(activated, 0) AS cr_activation_to_paid
FROM funnel;

Три тонких момента:

  • COUNT(DISTINCT user_id) — чтобы не задвоить пользователей с несколькими событиями.
  • NULLIF(x, 0) — страховка от пустой когорты.
  • 100.0 × … — гарантия того, что деление будет дробным.

4. Week-over-week (WoW) изменение

Классический отчёт «что поменялось за неделю»:

WITH weekly AS (
    SELECT
        DATE_TRUNC('week', created_at) AS week,
        COUNT(*)                       AS orders
    FROM orders
    GROUP BY 1
)
SELECT
    week,
    orders,
    LAG(orders) OVER (ORDER BY week) AS prev_week,
    100.0 * (orders - LAG(orders) OVER (ORDER BY week))
          / NULLIF(LAG(orders) OVER (ORDER BY week), 0) AS wow_change_pct
FROM weekly
ORDER BY week;

5. Month-over-month (MoM)

Тот же трюк, только на уровне месяцев и на выручке:

WITH monthly AS (
    SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue
    FROM orders GROUP BY 1
)
SELECT
    month,
    revenue,
    LAG(revenue) OVER (ORDER BY month) AS prev_month,
    100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
          / NULLIF(LAG(revenue) OVER (ORDER BY month), 0) AS mom_growth_pct
FROM monthly
ORDER BY month;

6. Year-over-year (YoY)

Сравниваем с тем же месяцем год назад через LAG(revenue, 12):

WITH monthly AS (
    SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue
    FROM orders GROUP BY 1
)
SELECT
    month,
    revenue,
    LAG(revenue, 12) OVER (ORDER BY month) AS prev_year_same_month,
    100.0 * (revenue - LAG(revenue, 12) OVER (ORDER BY month))
          / NULLIF(LAG(revenue, 12) OVER (ORDER BY month), 0) AS yoy_pct
FROM monthly
ORDER BY month;

YoY хорош против сезонности: MoM в декабре/январе часто врёт из-за праздников, а YoY сравнивает сопоставимые периоды.

Закрепи формулу procent в Карьернике
Запомнить надолго — 5 коротких сессий с задачами на эту тему. Бесплатно
Тренировать procent в Telegram

7. Процент в разрезе когорты

SELECT
    DATE_TRUNC('month', signup_at)                          AS cohort,
    COUNT(*)                                                AS signups,
    COUNT(*) FILTER (WHERE has_paid)                        AS buyers,
    100.0 * COUNT(*) FILTER (WHERE has_paid) / COUNT(*)     AS paid_conversion_pct
FROM users
GROUP BY 1
ORDER BY 1;

Такая таблица хорошо ложится в дашборд: видна доля платящих по каждой месячной когорте — сразу бросается в глаза, если после какого-то релиза конверсия просела.

8. Накопительный процент

Сколько процентов от года мы уже собрали к текущему дню:

WITH daily AS (
    SELECT DATE(created_at) AS day, COUNT(*) AS orders
    FROM orders
    WHERE created_at >= DATE_TRUNC('year', CURRENT_DATE)
    GROUP BY 1
),
cumulative AS (
    SELECT
        day,
        orders,
        SUM(orders) OVER (ORDER BY day) AS running_total
    FROM daily
)
SELECT
    day,
    orders,
    running_total,
    100.0 * running_total / SUM(orders) OVER () AS pct_of_total
FROM cumulative
ORDER BY day;

Удобно для runrate-аналитики: «к 15 апреля у нас 28% годового плана — значит, идём с отставанием».

9. Доля внутри группы

Например, доля продукта в категории:

SELECT
    category,
    product_id,
    revenue,
    100.0 * revenue / SUM(revenue) OVER (PARTITION BY category) AS share_in_category_pct
FROM products;

PARTITION BY category ограничивает «целое» пределами категории — удобно, когда нужно сравнивать доли внутри групп, а не по всему датасету.

10. Pareto 80/20

Сколько продуктов генерируют 80% выручки:

WITH ranked AS (
    SELECT
        product_id,
        revenue,
        SUM(revenue) OVER ()                                            AS total_revenue,
        SUM(revenue) OVER (ORDER BY revenue DESC
                           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_revenue
    FROM products
)
SELECT
    COUNT(*) FILTER (WHERE running_revenue / total_revenue <= 0.8) AS products_to_80pct,
    COUNT(*)                                                       AS products_total,
    100.0
    * COUNT(*) FILTER (WHERE running_revenue / total_revenue <= 0.8)
    / COUNT(*)                                                     AS pareto_share_pct
FROM ranked;

Результат — «ядро» ассортимента. Чаще всего цифра близка к 20%, но на зрелых маркетплейсах может быть и меньше: топ-5% товаров держат половину спроса.

11. Форматирование процента

Postgres:

SELECT ROUND(100.0 * x / NULLIF(y, 0), 2) AS pct;

-- со знаком «%»
SELECT ROUND(100.0 * x / NULLIF(y, 0), 1)::text || '%' AS pct_label;

MySQL:

SELECT CONCAT(ROUND(100.0 * x / NULLIF(y, 0), 2), '%') AS pct_label;

Если результат уходит в BI-инструмент (Tableau / Metabase / DataLens / Superset), форматирование лучше делать в самом инструменте, а в SQL оставлять голое число — так проще переиспользовать запрос.

12. Условный процент

Доля повторных покупателей — у кого более одного заказа:

SELECT
    100.0 * COUNT(*) FILTER (WHERE orders_count > 1) / COUNT(*) AS repeat_buyer_pct
FROM (
    SELECT user_id, COUNT(*) AS orders_count
    FROM orders
    WHERE status = 'paid'
    GROUP BY user_id
) t;

Ключевой момент — фильтр status = 'paid' внутри подзапроса, иначе знаменатель раздуется отменёнными заказами.

Частые ошибки

Ошибка 1. Целочисленное деление

-- 0: 5 / 20 в integer-арифметике = 0, и умножение на 100 уже не спасёт
SELECT 5 / 20 * 100;

-- 25: выражение считается слева направо — 100 * 5 = 500, 500 / 20 = 25.
-- Целочисленная арифметика не мешает, потому что 500 делится на 20 нацело.
SELECT 100 * 5 / 20;

-- 33.3333... — дробное деление благодаря литералу 100.0
SELECT 100.0 * 1 / 3;

-- то же самое через CAST
SELECT 100 * 1 / CAST(3 AS NUMERIC);

Правило: если числитель или знаменатель могут дать дробь, приводите хотя бы один из операндов к NUMERIC / FLOAT. Самый простой способ — умножение на 100.0.

Ошибка 2. Деление на ноль

x / y                 -- упадёт с ошибкой при y = 0
x / NULLIF(y, 0)      -- безопасно: при y = 0 результат NULL

NULLIF короткий, читаемый и переносим между Postgres, MySQL, SQL Server, Snowflake и BigQuery.

Ошибка 3. COUNT(*) вместо COUNT(DISTINCT)

-- «Процент пользователей, совершивших покупку»
-- Неверно: считает события, а не пользователей.
100.0 * COUNT(*) FILTER (WHERE event_name = 'purchase') / COUNT(*)

-- Верно: считает уникальных пользователей на каждом шаге.
100.0
* COUNT(DISTINCT user_id) FILTER (WHERE event_name = 'purchase')
/ COUNT(DISTINCT user_id)

Ошибка 4. Неправильная группировка

Самая тихая из ошибок. Если вы считаете долю когорты, а GROUP BY сделан по дню — получите «долю дня», а не «долю когорты». Всегда проверяйте, совпадает ли гранулярность агрегации с тем, что вы подписываете в заголовок.

Ошибка 5. Смешение единиц

-- В числителе — пользователи, в знаменателе — события. Цифра бессмысленна.
COUNT(DISTINCT user_id) / COUNT(*)

Одно из двух должно быть переведено в общую единицу измерения: либо «пользователь / пользователь», либо «событие / событие».

Связанные темы

FAQ

Почему 100 / 3 возвращает 33, а не 33.33?

В Postgres integer / integer возвращает integer — дробная часть отбрасывается. Используйте 100.0 / 3 либо CAST(100 AS NUMERIC) / 3. В ClickHouse поведение похожее, в SQLite и MySQL правила отличаются — лучше всегда приводить к дробному типу.

Как показать 2 знака после запятой?

ROUND(value, 2) в Postgres, MySQL, BigQuery и Snowflake.

NULLIF или CASE для защиты от деления на 0?

NULLIF короче и читается лучше: x / NULLIF(y, 0). Работает во всех популярных СУБД.

Как получить отрицательный процент при падении метрики?

(new - old) / old × 100 — положительный при росте и отрицательный при падении. Одна формула для обоих направлений, дополнительные проверки не нужны.

Как аккуратно показывать проценты на маленькой выборке?

Всегда выводите рядом сам знаменатель — «CR 12% (n = 25)». Это снимает вопросы про разброс и не даёт стейкхолдерам принимать решения по двум наблюдениям.