Все материалы
Продуктовая аналитикагайдстарт

Процент и доля в SQL: от общего, в группе и прирост к прошлому месяцу

Как посчитать процент в SQL: доля от общего через SUM() OVER (), доля внутри группы, конверсия, прирост к прошлому месяцу через LAG, процентные пункты и округление.

КПКейсПрактика23 сентября 2026 г.12 мин

Процент в SQL — это деление, и вся сложность в том, на что делить. Доля канала в выручке, доля мобильных внутри канала, конверсия, прирост к прошлому месяцу отличаются только знаменателем. Ошибка в знаменателе не даёт сообщения об ошибке: запрос возвращает правдоподобные 100% или 48,6% вместо 37,9%. Ниже — готовые запросы на каждый случай и ловушки, которые в них встречаются. Все примеры посчитаны на учебной базе SQL-курса и работают в PostgreSQL и DuckDB; их можно повторить в песочнице курса.

Как посчитать процент от общего в SQL: короткий ответ

Процент от общего — значение строки, делённое на сумму по всем строкам и умноженное на 100. Сумму по всем строкам удобно взять оконной функцией sum(…) OVER (): она считается по всему результату GROUP BY и не схлопывает строки. Второй запрос или подзапрос не нужен.

Вот доля каждого канала в выручке учебной базы. Всего платежей на $30 639, из них organic принёс $14 114, то есть 46,1%.

Результат: доли каналов в выручке
channelrevenueshare_pct
organic14 11446,1
referral9 32630,4
partner4 73215,4
paid_search2 4678,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
ВыражениеPostgreSQL 16DuckDB
376 / 101500.3704…
376 * 1.0 / 10150.3704…0.3704…
376::numeric / 10150.3704…0.3704…
1 / 0ошибка: division by zeroInfinity
1 / NULLIF(0, 0)NULLNULL

Доля от общего, когда в запросе есть 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. Первое число — про характер канала, второе — про вклад в продукт.

Доля мобильных и десктопных пользователей: внутри канала и от всех 4 613
channeldeviceusersshare_in_channel, %share_of_all, %
organicdesktop95354,620,7
organicmobile79445,417,2
paid_searchdesktop34534,07,5
paid_searchmobile67066,014,5
partnerdesktop48659,710,5
partnermobile32840,37,1
referraldesktop60057,913,0
referralmobile43742,19,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Платящие среди активированных: неверно / верно
referral74,7% (775 из 1 037)100%35,9% / 25,9%
organic66,7% (1 166 из 1 747)100%35,2% / 23,8%
partner53,2% (433 из 814)100%31,9% / 18,7%
paid_search37,0% (376 из 1 015)100%22,9% / 10,4%
Активация по каналам: условие на событие стоит в ON
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, %

paid_search запущен 1 июля. Без него активация почти не меняется. Учебная база SQL-курса.

Все каналыБез paid_search

Округление: 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. Показывать такие числа можно, считать от них дальше — нельзя.

Доли выручки, которые в сумме дают ровно 100
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-курсе; задания там проверяются на той же учебной базе. Песочница открыта без регистрации — все запросы статьи можно запустить в ней и сверить с результатами выше.

Продолжить чтение
Вся библиотека
Продуктовая аналитика23 сентября 2026 г.15 мин

A/B-тест в SQL: конверсия, проверка сплита и разбор по сегментам

Как посчитать A/B-тест запросом: конверсия по группам, проверка SRM, разница, z и доверительный интервал в SQL и срез по устройствам, после которого меняется решение.

Читать материал
Продуктовая аналитика17 июля 2026 г.16 мин
Три блока разной ширины, соединённые вертикальной линией.

CTE в SQL: WITH, несколько шагов, рекурсивный запрос и проверка каждого слоя

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

Читать материал
Продуктовая аналитика16 июля 2026 г.25 мин
Столбец одинаковых строк, рамка охватывает три соседние строки.

Оконные функции SQL: OVER и PARTITION BY, ROW_NUMBER, LAG и скользящее среднее

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

Читать материал