Как посчитать процент в SQL
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 сравнивает сопоставимые периоды.
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 результат NULLNULLIF короткий, читаемый и переносим между 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(*)Одно из двух должно быть переведено в общую единицу измерения: либо «пользователь / пользователь», либо «событие / событие».
Связанные темы
- Как посчитать конверсию в SQL
- Как посчитать воронку в SQL
- Как сравнить два периода в SQL
- NULL в SQL — шпаргалка
- COALESCE — шпаргалка
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)». Это снимает вопросы про разброс и не даёт стейкхолдерам принимать решения по двум наблюдениям.