Процент и доля в SQL: от общего, в группе и прирост к прошлому месяцу
Как посчитать процент в SQL: доля от общего через SUM() OVER (), доля внутри группы, конверсия, прирост к прошлому месяцу через LAG, процентные пункты и округление.
Содержание статьи
Процент в SQL — это деление, и вся сложность в том, на что делить. Доля канала в выручке, доля мобильных внутри канала, конверсия, прирост к прошлому месяцу отличаются только знаменателем. Ошибка в знаменателе не даёт сообщения об ошибке: запрос возвращает правдоподобные 100% или 48,6% вместо 37,9%. Ниже — готовые запросы на каждый случай и ловушки, которые в них встречаются. Все примеры посчитаны на учебной базе SQL-курса и работают в PostgreSQL и DuckDB; их можно повторить в песочнице курса.
Как посчитать процент от общего в SQL: короткий ответ
Процент от общего — значение строки, делённое на сумму по всем строкам и умноженное на 100. Сумму по всем строкам удобно взять оконной функцией sum(…) OVER (): она считается по всему результату GROUP BY и не схлопывает строки. Второй запрос или подзапрос не нужен.
Вот доля каждого канала в выручке учебной базы. Всего платежей на $30 639, из них organic принёс $14 114, то есть 46,1%.
| channel | revenue | share_pct |
|---|---|---|
| organic | 14 114 | 46,1 |
| referral | 9 326 | 30,4 |
| partner | 4 732 | 15,4 |
| paid_search | 2 467 | 8,1 |
SELECT
u.channel,
sum(p.amount) AS revenue,
round(100 * sum(p.amount)::numeric
/ sum(sum(p.amount)::numeric) OVER (), 1) AS share_pct
FROM payments p
JOIN users u ON u.user_id = p.user_id
GROUP BY u.channel
ORDER BY revenue DESC;sum(sum(p.amount)) OVER ()— сумма сумм: внутреннийsumработает вGROUP BY, внешний — по всем получившимся строкам.::numericнужен, потому чтоamountв учебной базе имеет тип DOUBLE, аround(x, 1)в PostgreSQL определён только для numeric. Для целых счётчиков хватает100.0 * count(*).- Умножайте на
100.0до деления, а не результат после: так ответ не зависит от того, целые ли операнды.
Почему процент в SQL получился нулём
Самая частая жалоба: «доля считается, но везде 0». Причина — целочисленное деление. count(*) возвращает целое, и в PostgreSQL деление целого на целое отбрасывает дробную часть: 376 активированных из 1 015 пользователей paid_search дают 376 / 1015 = 0. DuckDB с версии 0.8 делит дробно и вернёт 0,370, поэтому запрос, проверенный в ноутбуке, на рабочей базе молча превращается в нули.
Лечится приведением одного из операндов до деления: 376 * 1.0 / 1015, 376::numeric / 1015 или 100.0 * count(…) / count(*). Приводить готовый результат поздно: (376 / 1015)::numeric — это уже приведённый ноль. Подробный разбор типов — в статье про CAST.
С делением на ноль базы тоже расходятся. PostgreSQL останавливает запрос ошибкой division by zero, DuckDB 1.5 возвращает бесконечность. Для процента годится только третий вариант — NULL, и он получается через NULLIF(знаменатель, 0): пустая ячейка честно говорит «не из чего считать».
| Выражение | PostgreSQL 16 | DuckDB |
|---|---|---|
| 376 / 1015 | 0 | 0.3704… |
| 376 * 1.0 / 1015 | 0.3704… | 0.3704… |
| 376::numeric / 1015 | 0.3704… | 0.3704… |
| 1 / 0 | ошибка: division by zero | Infinity |
| 1 / NULLIF(0, 0) | NULL | NULL |
Доля от общего, когда в запросе есть WHERE
sum(…) OVER () считает сумму по строкам результата, а результат уже прошёл через WHERE. Если исключить канал, доли остальных пересчитаются так, будто его нет. Organic — 37,9% всех пользователей, но в запросе с WHERE channel <> 'paid_search' он получает 48,6%.
Обе цифры верны, это ответы на разные вопросы: «доля среди трёх каналов» и «доля среди всех». Ошибка начинается, когда вопрос был про всех, а в запросе остался фильтр — например, из соседнего отчёта. Если нужна доля от полного итога, знаменатель берут отдельно: скалярным подзапросом или через FILTER в агрегате, не трогая WHERE.
SELECT
channel,
count(*) AS users,
round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS share_of_filtered,
round(100.0 * count(*) / (SELECT count(*) FROM users), 1) AS share_of_all
FROM users
WHERE channel <> 'paid_search'
GROUP BY channel
ORDER BY users DESC;
-- organic | 1747 | 48.6 | 37.9
-- referral | 1037 | 28.8 | 22.5
-- partner | 814 | 22.6 | 17.6Доля внутри группы: SUM() OVER (PARTITION BY)
Доля внутри группы отвечает на вопрос «какая часть канала пришла с телефонов». Знаменатель здесь — не весь итог, а итог своей группы, и его задаёт PARTITION BY channel. Сумма долей внутри каждого канала равна 100%.
Рядом полезно поставить долю от общего итога: так видно, что две метрики рассказывают разное. В paid_search 66,0% пользователей пришли с мобильных — почти вдвое больше, чем с десктопа. Но от всей базы это 14,5%, меньше, чем десктопная часть organic. Первое число — про характер канала, второе — про вклад в продукт.
| channel | device | users | share_in_channel, % | share_of_all, % |
|---|---|---|---|---|
| organic | desktop | 953 | 54,6 | 20,7 |
| organic | mobile | 794 | 45,4 | 17,2 |
| paid_search | desktop | 345 | 34,0 | 7,5 |
| paid_search | mobile | 670 | 66,0 | 14,5 |
| partner | desktop | 486 | 59,7 | 10,5 |
| partner | mobile | 328 | 40,3 | 7,1 |
| referral | desktop | 600 | 57,9 | 13,0 |
| referral | mobile | 437 | 42,1 | 9,5 |
SELECT
channel,
device,
count(*) AS users,
round(100.0 * count(*) / sum(count(*)) OVER (PARTITION BY channel), 1) AS share_in_channel,
round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS share_of_all
FROM users
GROUP BY channel, device
ORDER BY channel, device;Конверсия как доля: числитель — подмножество знаменателя
Конверсия — тоже доля, но с правилом, которое легко нарушить: каждый, кто попал в числитель, обязан быть в знаменателе. Активация — доля пользователей, создавших workspace, среди всех зарегистрированных. Пользователи присоединяются к событиям через LEFT JOIN, а условие на тип события стоит в ON, чтобы не активированные остались в знаменателе.
Перенесите то же условие в WHERE, и LEFT JOIN превратится во внутреннее соединение: строки без события отфильтруются, знаменатель станет равен числителю, и все четыре канала покажут 100%. Запрос выполнится без единой ошибки.
Вторая версия той же ловушки — числитель из другой популяции. «Доля плательщиков среди активированных» в organic, посчитанная как все плательщики канала, делённые на активированных, даёт 410 / 1 166 = 35,2%. Но 132 плательщика из 410 workspace не создавали, и в знаменателе их нет. Правильный числитель — активированные, которые платили: 278 / 1 166 = 23,8%.
| Канал | Активация | Активация с условием в WHERE | Платящие среди активированных: неверно / верно |
|---|---|---|---|
| referral | 74,7% (775 из 1 037) | 100% | 35,9% / 25,9% |
| organic | 66,7% (1 166 из 1 747) | 100% | 35,2% / 23,8% |
| partner | 53,2% (433 из 814) | 100% | 31,9% / 18,7% |
| paid_search | 37,0% (376 из 1 015) | 100% | 22,9% / 10,4% |
SELECT
u.channel,
count(DISTINCT u.user_id) AS users,
count(DISTINCT e.user_id) AS activated,
round(100.0 * count(DISTINCT e.user_id)
/ count(DISTINCT u.user_id), 1) AS activation_pct
FROM users u
LEFT JOIN events e
ON e.user_id = u.user_id
AND e.event_name = 'workspace_created'
GROUP BY u.channel
ORDER BY activation_pct DESC;
-- если перенести e.event_name = 'workspace_created' в WHERE,
-- все четыре канала покажут 100.0Процент или процентный пункт
Когда меняется сама доля, у изменения две записи. В эксперименте с чек-листом онбординга конверсия выросла с 20,0% до 30,3%. Разница — +10,3 процентного пункта: вычитание одной доли из другой. Относительный прирост — +51,2%: разница, делённая на исходную долю. Фраза «конверсия выросла на 10%» неоднозначна, и в отчёте её лучше не писать.
Есть и вторая тонкость. Если посчитать относительный прирост из уже округлённых процентов, 30,3 / 20,0 − 1, получится +51,5%, а не +51,2%. Точные доли — 0,20039 и 0,30305. На трёх знаках разница незаметна, на маленьких базовых долях она становится заметной. Приросты считают из неокруглённых значений, а округляют только то, что показывают.
Δ п.п. = 100 · (p₂ − p₁); Δ % = 100 · (p₂ / p₁ − 1)20,04% → 30,30%: +10,27 п.п. и +51,2%. Для долей в отчёт идут процентные пункты, относительный прирост — рядом и с пометкой.
Прирост к прошлому месяцу: LAG и процентные пункты
Прирост к прошлому периоду требует значения из соседней строки, и его даёт lag(). Сначала запрос сворачивает данные до строки на месяц, потом lag(signups) OVER (ORDER BY month) подставляет значение предыдущего месяца. У первого месяца предыдущего нет, и прирост остаётся NULL — так и должно быть.
Для количества — регистраций — прирост считают в процентах: 1 042 → 1 676 → 1 895, то есть +60,8% и +13,1%. Для доли — активации — в процентных пунктах: 68,1% → 56,7% → 57,5%, то есть −11,4 п.п. и +0,7 п.п. Относительное изменение доли, −16,7%, в одном отчёте с процентами роста регистраций только запутает читателя.
WITH monthly AS (
SELECT date_trunc('month', u.signup_date) AS month,
count(DISTINCT u.user_id) AS signups,
count(DISTINCT e.user_id) * 1.0 / count(DISTINCT u.user_id) AS activation
FROM users u
LEFT JOIN events e
ON e.user_id = u.user_id
AND e.event_name = 'workspace_created'
GROUP BY 1
)
SELECT
CAST(month AS DATE) AS month,
signups,
round(100.0 * (signups - lag(signups) OVER (ORDER BY month))
/ lag(signups) OVER (ORDER BY month), 1) AS signups_growth_pct,
round(100 * activation, 1) AS activation_pct,
round(100 * (activation - lag(activation) OVER (ORDER BY month)), 1) AS activation_change_pp
FROM monthly
ORDER BY month;
-- 2026-06-01 | 1042 | NULL | 68.1 | NULL
-- 2026-07-01 | 1676 | 60.8 | 56.7 | -11.4
-- 2026-08-01 | 1895 | 13.1 | 57.5 | 0.7Почему активация упала на 11 пунктов, хотя ни один канал не ухудшился
Падение на 11,4 п.п. за месяц выглядит как поломка онбординга. Разбивка по каналам показывает другое. 1 июля запустился paid_search, и в июле он дал 482 из 1 676 регистраций с активацией 35,9%. Без него июльская активация — 65,2%, августовская — 65,1%, почти как июньские 68,1%.
Процент от общего — это среднее, взвешенное составом. Когда меняется состав, среднее меняется, даже если каждая часть стоит на месте. Поэтому месячный прирост доли стоит сразу показывать и в разбивке по сегменту, который мог поменять состав: каналу, стране, устройству.
У paid_search есть и техническая деталь. В июне его нет вовсе, и строки «июнь, paid_search» в результате не будет. lag() для июля вернёт NULL, и прирост останется пустым. Если же достроить календарь с нулём в июне, PostgreSQL на делении упадёт с division by zero. Знаменатель прироста стоит обернуть в NULLIF(…, 0): рост «с нуля» в процентах не выражается.
paid_search запущен 1 июля. Без него активация почти не меняется. Учебная база SQL-курса.
Округление: ROUND, тип аргумента и сумма долей не 100%
round(x, 1) в PostgreSQL принимает только numeric. Для double precision запрос падает с ошибкой function round(double precision, integer) does not exist. Поэтому выручку, которая хранится как float, приводят к numeric до округления. В DuckDB round работает с любым числовым типом, и запрос, написанный там, в PostgreSQL может не запуститься.
Вторая неприятность видна в отчёте. Доли выручки, округлённые до целых, — 46, 30, 15 и 8%. В сумме 99%. Каждое число округлено правильно, просто четыре дробных хвоста, 0,07, 0,44, 0,44 и 0,05, в сумме дают ровно единицу, а округление каждый раз их отбрасывает.
Обычно достаточно подписи «сумма может не равняться 100% из-за округления». Если нужна ровно сотня, например для круговой диаграммы, используют метод наибольших остатков. Все доли округляют вниз, недостающие пункты раздают тем, у кого самый большой дробный остаток. Здесь недостающий пункт получает partner: 15,44 → 16. Показывать такие числа можно, считать от них дальше — нельзя.
WITH shares AS (
SELECT u.channel,
100 * sum(p.amount)::numeric / sum(sum(p.amount)::numeric) OVER () AS pct
FROM payments p
JOIN users u ON u.user_id = p.user_id
GROUP BY u.channel
),
floored AS (
SELECT channel, pct,
floor(pct) AS base,
100 - sum(floor(pct)) OVER () AS points_left,
row_number() OVER (ORDER BY pct - floor(pct) DESC) AS by_remainder
FROM shares
)
SELECT channel,
round(pct, 2) AS exact_pct,
base + CASE WHEN by_remainder <= points_left THEN 1 ELSE 0 END AS shown_pct
FROM floored
ORDER BY pct DESC;
-- organic | 46.07 | 46
-- referral | 30.44 | 30
-- partner | 15.44 | 16
-- paid_search | 8.05 | 8Частые ошибки при расчёте процентов
Все ошибки ниже дают правдоподобное число. Проверка у них общая: посчитайте числитель и знаменатель отдельными колонками рядом с долей и посмотрите, те ли это люди и те ли суммы.
- Целочисленное деление в PostgreSQL: доля равна 0.
- Условие правой таблицы в
WHEREпосле LEFT JOIN: знаменатель сжимается до числителя, конверсия 100%. - Числитель не из знаменателя: плательщики, которые не активировались, делятся на активированных.
sum() OVER ()в запросе с фильтром: доля от отфильтрованного, а не от всего.- NULL в знаменателе: доля RU — 58,9% всех пользователей и 59,6% тех, у кого страна указана.
count(country)иcount(*)— разные знаменатели, выбирайте осознанно. - JOIN, размножающий строки:
count(*)после соединения с событиями считает события, а не людей. Для людей —count(DISTINCT user_id). - Прирост доли в процентах вместо пунктов и прирост из округлённых значений.
- Деление на ноль: ошибка в PostgreSQL, бесконечность в DuckDB.
NULLIFдаёт NULL в обеих.
Вопросы о процентах в SQL
Как посчитать процент от числа в SQL? Умножьте на долю: 20% от выручки — sum(amount) * 0.2. Если нужна доля одного числа от другого — 100.0 * a / b.
Как посчитать процент от общего без оконных функций? Разделите на скалярный подзапрос: 100.0 * count(*) / (SELECT count(*) FROM users). Он работает везде, но при сложном фильтре его приходится повторять. Окно берёт фильтр из основного запроса само.
Как округлить процент до сотых? round(x, 2), где x — numeric. В PostgreSQL для float нужно round(x::numeric, 2). Для форматирования со знаком процента — round(x, 1) || '%', но это уже текст: сортировать и суммировать его нельзя, поэтому форматируют в последнюю очередь или в BI.
Чем доля отличается от процента? Ничем, кроме множителя: доля 0,303 — это 30,3%. В хранилище и промежуточных расчётах удобнее доли, в отчёте — проценты.
Почему в BI доля выходит не такой, как в SQL? Чаще всего BI суммирует уже посчитанные проценты или усредняет их по строкам. Среднее из процентов каналов — не процент всей базы. Отдавайте в BI числитель и знаменатель, а долю пусть он считает как их отношение.
Итог
Любой процент в SQL — числитель, знаменатель и умножение на 100. Формула не меняется, меняется знаменатель: весь итог, итог группы, отфильтрованный итог, прошлый месяц. Выберите его по вопросу, поставьте числитель и знаменатель колонками рядом с долей, делите не целые, округляйте в конце и пишите изменения долей в процентных пунктах.
Агрегаты и оконные функции, на которых держатся эти запросы, разобраны в SQL-курсе; задания там проверяются на той же учебной базе. Песочница открыта без регистрации — все запросы статьи можно запустить в ней и сверить с результатами выше.
Материалы по теме
A/B-тест в SQL: конверсия, проверка сплита и разбор по сегментам
Как посчитать A/B-тест запросом: конверсия по группам, проверка SRM, разница, z и доверительный интервал в SQL и срез по устройствам, после которого меняется решение.

CTE в SQL: WITH, несколько шагов, рекурсивный запрос и проверка каждого слоя
CTE и оператор WITH на задачах аналитика: как разложить расчёт конверсии на именованные шаги, проверить каждый контрольным числом, собрать календарь без пропусков через WITH RECURSIVE и понять, когда CTE материализуется, а когда его пора превратить во view.

Оконные функции SQL: OVER и PARTITION BY, ROW_NUMBER, LAG и скользящее среднее
Оконные функции SQL на задачах аналитика: первое событие пользователя, сравнение с предыдущим днём, накопительный итог, скользящее среднее и скользящий WAU, NTILE, сессии и перцентили.