ROUND в SQL: округление, CEIL, FLOOR и ловушка целочисленного деления
Как округлять в SQL: ROUND до двух знаков и до сотен, CEIL, FLOOR и TRUNC, куда уходят половинки в PostgreSQL и DuckDB, целочисленное деление, деньги в DECIMAL и почему доли после округления дают 101%.
Содержание статьи
В отчёте по платежам две плитки. В первой доли стран: 56, 22, 12, 10 и 1%, итого 101%. Во второй конверсия A/B-теста, и в обоих вариантах она ровно 0 — отчёт собран на PostgreSQL. Финансовый директор спрашивает, что из этого правда. Правда и то и другое: запросы не упали, а числа испортило округление. В первой плитке округлили каждую долю отдельно, во второй дробную часть отбросило целочисленное деление ещё до всякого ROUND. Ниже — что делают round, ceil, floor и trunc, куда уходят половинки и где округление тихо меняет ответ. Все запросы выполнены на учебной базе SQL-курса в DuckDB; поведение PostgreSQL описано по его документации.
Коротко
Округление — последний шаг расчёта, а не промежуточный. Всё остальное следует из этого правила и из типа числа, которое вы округляете.
round(x, 2)— до двух знаков,round(x, -2)— до сотен.ceilокругляет вверх,floorвниз,truncотбрасывает хвост к нулю.- На отрицательных числах
floorиtruncрасходятся:floor(-2.5) = -3,trunc(-2.5) = -2. - DuckDB округляет половинки от нуля и для DOUBLE, и для DECIMAL. PostgreSQL так делает для
numeric; дляdouble precisionправило зависит от платформы. count(*) / count(*)в PostgreSQL даёт целое число, и конверсия превращается в 0. Приводите операнд до деления:100.0 * a / b.- Округлённые доли не обязаны давать 100%. Считайте итоги из точных значений, а округляйте то, что показываете.
- Деньги — в
DECIMAL/numeric. В DOUBLE сумма, которая должна заканчиваться на пятёрку, может округлиться не в ту сторону.
ROUND, CEIL, FLOOR и TRUNC: что делает каждая функция
Четыре функции отличаются тем, в какую сторону они сдвигают число. round берёт ближайшее значение. ceil (или ceiling, это синоним) — ближайшее не меньшее, floor — ближайшее не большее. trunc просто отрезает дробную часть, то есть двигает число к нулю.
Число знаков принимают только round и trunc. В DuckDB ceil(2.567, 1) падает с ошибкой, в PostgreSQL у ceil и floor тоже один аргумент. Если нужно «вверх до десятых», пишут ceil(x * 10) / 10.
Разница между функциями видна на отрицательных числах. Для положительных floor и trunc дают одно и то же, поэтому их легко перепутать. В разнице выручки к прошлому месяцу, в отклонении от плана, в дельте A/B-теста знак бывает любым, и там trunc(-2.5) вернёт −2, а floor(-2.5) — −3.
| Функция | 2.5 | −2.5 | 2.567 до 2 знаков | Заметки |
|---|---|---|---|---|
round(x), round(x, 2) | 3 | −3 | 2.57 | половинка уходит от нуля |
ceil(x), ceiling(x) | 3 | −2 | — (без знаков: 3) | вверх, к плюс бесконечности |
floor(x) | 2 | −3 | — (без знаков: 2) | вниз, к минус бесконечности |
trunc(x), trunc(x, 2) | 2 | −2 | 2.56 | отбрасывает хвост, к нулю |
round_even(x, n) | 2 | −2 | 2.57 | половинка к чётной цифре; функция DuckDB |
printf('%.0f', x), printf('%.2f', x) | '2' | '-2' | '2.57' | возвращает текст; 2.5 отдаёт к чётному |
select
round(2.567, 2) as round_2, -- 2.57
ceil(2.567) as ceil_, -- 3
floor(2.567) as floor_, -- 2
trunc(2.567, 2) as trunc_2, -- 2.56
round(-2.5) as round_neg, -- -3
floor(-2.5) as floor_neg, -- -3
trunc(-2.5) as trunc_neg; -- -2Как округлить до 2 знаков в SQL
Средний платёж в учебной базе — avg(amount) = 24,491606714628297. Для отчёта его округляют до центов: round(avg(amount), 2) даёт 24,49. В DuckDB это работает с колонкой любого числового типа.
В PostgreSQL round(v, s) и trunc(v, s) с числом знаков определены только для numeric. Колонка amount в учебной базе имеет тип DOUBLE, и для PostgreSQL запрос пишут с приведением: round(avg(amount)::numeric, 2). Подробнее про эту и соседние ошибки типов — в статье про CAST.
В PostgreSQL numeric без параметров хранит сколько угодно знаков. В DuckDB numeric без параметров — это DECIMAL(18,3), и приведение само округляет до трёх знаков. Получается двойное округление: round(2.4449::double, 2) = 2,44, а round(2.4449::double::numeric, 2) = 2,45, потому что по пути число стало 2,445. Если запрос должен работать в обеих базах, указывайте точность явно: ::decimal(18, 6) даёт честные 2,44.
Округление до десятков, сотен и тысяч: отрицательное число знаков
Отрицательный второй аргумент округляет слева от запятой: round(x, -1) — до десятков, round(x, -2) — до сотен, round(x, -3) — до тысяч. Так выручку готовят для слайда, где точность до доллара только мешает.
У такого округления тот же побочный эффект, что у любого другого: сумма округлённых месяцев не равна округлённой сумме. По сотням месяцы дают 4 000 + 10 900 + 15 800 = 30 700, а вся выручка $30 639 округляется до 30 600. Если на слайде есть строка «итого», её считают из точной суммы и подписывают, что значения округлены.
| month | revenue | revenue_100 | revenue_1000 |
|---|---|---|---|
| 2026-06-01 | 3 983 | 4 000 | 4 000 |
| 2026-07-01 | 10 868 | 10 900 | 11 000 |
| 2026-08-01 | 15 788 | 15 800 | 16 000 |
select
cast(date_trunc('month', paid_at) as date) as month,
sum(amount) as revenue,
round(sum(amount), -2) as revenue_100,
round(sum(amount), -3) as revenue_1000
from payments
group by 1
order by 1;Куда округляется 2.5: половинки в DuckDB и PostgreSQL
Для половинок есть два распространённых правила. «От нуля»: 2,5 → 3, −2,5 → −3. «К чётному», оно же банковское: 2,5 → 2, 3,5 → 4. Второе убирает систематический сдвиг вверх, когда половинок много.
DuckDB в round применяет правило «от нуля» и для DOUBLE, и для DECIMAL. Банковское округление там есть отдельной функцией round_even.
В PostgreSQL, по документации, round для numeric тоже округляет половинки от нуля. Для double precision поведение на половинках зависит от платформы, и чаще всего это округление к чётному. Значит, round(2.5::numeric) в PostgreSQL даёт 3, а round(2.5::float8) на типичном сервере — 2. Если результат важен до единицы, приводите к numeric и не полагайтесь на float.
Есть и третья проблема, не связанная с правилом. DOUBLE хранит число в двоичном виде, и «ровная» половинка часто хранится чуть меньше или чуть больше себя. 1,005 в DOUBLE — это 1,00499999…, поэтому round(1.005::double, 2) в DuckDB даёт 1,00, а для DECIMAL — 1,01. При этом round(2.675::double, 2) там же даёт 2,68. Предсказать заранее, в какую сторону уйдёт граница, по записи числа нельзя.
select
round(2.5::double) as dbl, -- 3
round(2.5::decimal(4, 1)) as dec, -- 3
round_even(2.5, 0) as even, -- 2
round(1.005::double, 2) as dbl_1005, -- 1.0
round(1.005::decimal(6, 3), 2) as dec_1005, -- 1.01
printf('%.0f', 2.5::double) as printf_25; -- '2'Ловушка целочисленного деления: конверсия равна нулю
Округлять бессмысленно, если дробная часть потерялась раньше. В PostgreSQL деление целого на целое отбрасывает остаток к нулю: по документации 5 / 2 = 2, (-5) / 2 = -2. count(*) возвращает целое, поэтому конверсия count(*) filter (where converted) / count(*) там равна 0 в каждой строке.
DuckDB делит оператором / всегда дробно и возвращает DOUBLE. Целочисленное деление там записывается отдельно, через //, и тоже идёт к нулю: -7 // 2 = -3. На нём удобно увидеть, что получит PostgreSQL. В эксперименте onboarding_checklist сконвертировались 467 из 1 541 участника checklist и 306 из 1 527 в control. Через // обе доли — 0, через / — 0,3030 и 0,2004.
Лечение одно: привести операнд до деления. 100.0 * a / b, a::numeric / b или a * 1.0 / b. Приведение результата, (a / b)::numeric, опоздало: к этому моменту ноль уже посчитан. Как собирать сами доли и конверсии, разобрано в статье про процент и долю в SQL.
select
variant,
count(*) filter (where converted) as converted,
count(*) as exposed,
count(*) filter (where converted) // count(*) as int_div, -- так делит PostgreSQL
count(*) filter (where converted) / count(*) as share,
round(100.0 * count(*) filter (where converted) / count(*), 1) as conversion_pct
from experiment_exposures
group by variant
order by variant;
-- checklist | 467 | 1541 | 0 | 0.3030… | 30.3
-- control | 306 | 1527 | 0 | 0.2003… | 20CEIL после деления: страница, которой не хватило
Та же ловушка ломает ceil. Нужно выгрузить 4 613 пользователей страницами по 20 строк: сколько страниц? В DuckDB ceil(count(*) / 20) — 231, потому что 4 613 / 20 = 230,65. В PostgreSQL деление целых сначала даёт 230, ceil(230) возвращает 230, и последние 13 пользователей не попадают ни на одну страницу.
Ошибка не заметна на круглых числах: 4 600 строк дали бы одинаковый ответ в обеих базах. Поэтому ceil после деления всегда пишут с дробным делимым: ceil(count(*) / 20.0).
select
count(*) as users, -- 4613
count(*) / 20 as pages_raw, -- 230.65
ceil(count(*) / 20) as pages, -- 231
ceil(count(*) // 20) as pages_pg_style -- 230
from users;ROUND только в конце: почему доли дают 101%
Вернёмся к первой плитке. Доли платежей по странам, округлённые до целых, — 56, 22, 12, 10 и 1. Каждое число округлено верно, но четыре из пяти округлились вверх: 21,82 → 22, 11,51 → 12, 9,91 → 10, 0,72 → 1. Лишние пункты сложились в 101%.
С одним знаком после запятой ошибка меньше, но не исчезает. Доли платежей по тарифу и типу платежа, округлённые до десятых, дают 100,1%. Если отчёту нужна ровно сотня, округлённые доли подгоняют методом наибольших остатков — он показан в статье про процент и долю. Для остального хватает подписи «сумма может отличаться от 100% из-за округления».
Правило здесь шире процентов. Любое значение, от которого считают дальше — сумму, разницу, прирост, среднее, — берут неокруглённым. Округлённые числа годятся только для показа.
| country | payments | pct_2 | pct_0 | total_of_rounded |
|---|---|---|---|---|
| RU | 701 | 56.04 | 56 | 101 |
| KZ | 273 | 21.82 | 22 | 101 |
| AM | 144 | 11.51 | 12 | 101 |
| BY | 124 | 9.91 | 10 | 101 |
| не указана | 9 | 0.72 | 1 | 101 |
with by_country as (
select coalesce(u.country, 'не указана') as country,
count(*) as payments,
100.0 * count(*) / sum(count(*)) over () as pct
from payments p
join users u on u.user_id = p.user_id
group by 1
)
select
country,
payments,
round(pct, 2) as pct_2,
round(pct) as pct_0,
sum(round(pct)) over () as total_of_rounded
from by_country
order by payments desc;Деньги: DOUBLE против DECIMAL и комиссия с каждого платежа
DOUBLE не хранит большинство десятичных дробей точно. Классический пример — 0.1::double + 0.2::double = 0,30000000000000004, и сравнение с 0,3 возвращает false. В DECIMAL(4, 2) та же сумма равна ровно 0,30.
На деньгах это доходит до отчёта. Пусть платёжный провайдер берёт 3,5% с каждого платежа. Точная комиссия со всей выручки — 1 072,365. В DOUBLE сумма накапливается как 1 072,3649999999886 и округляется до 1 072,36. В DECIMAL — 1 072,365 и 1 072,37. Цент разницы появился только из-за типа.
Третья колонка показывает обратную сторону правила «округлять в конце». Провайдер списывает комиссию с каждого платежа отдельно и округляет её до центов в момент списания. Все 1 251 комиссия — 0,665, 1,015 или 1,365 — стоят ровно на половинке и округляются вверх. Итог — 1 078,62, на 6,25 больше округлённой общей суммы. Для сверки с выпиской правильна именно эта цифра: когда деньги списываются построчно, округляют построчно. Правило «в конце» относится к аналитическим метрикам, а не к расчётам, которые воспроизводят биллинг.
select
sum(amount * 0.035) as fee_double, -- 1072.3649999999886
round(sum(amount * 0.035), 2) as fee_double_2, -- 1072.36
sum(cast(amount as decimal(12, 2)) * 0.035) as fee_decimal, -- 1072.36500
round(sum(cast(amount as decimal(12, 2)) * 0.035), 2) as fee_decimal_2, -- 1072.37
sum(round(cast(amount as decimal(12, 2)) * 0.035, 2)) as fee_per_payment -- 1078.62
from payments;Округление и форматирование — разные операции
round возвращает число. Число 20,0 и число 20 одинаковы, и клиент вправе показать его как 20: так учебный движок и вернул конверсию control в запросе выше. Если в отчёте нужен ровно один знак после запятой, это уже форматирование, и результат у него — текст.
В DuckDB для этого есть printf('%.1f', x) и format('{:.1f}', x): оба дают строку «20.0». printf('%.1f%%', 100.0 * 306 / 1527) сразу добавит знак процента, а format('{:,}', 30639) — разделитель тысяч: «30,639». В PostgreSQL ту же работу делает to_char с шаблоном вроде 'FM990.0', где 0 — обязательная цифра, а FM убирает ведущие пробелы.
Две оговорки. Отформатированную колонку нельзя суммировать и правильно сортировать: «9.9» окажется после «10.0». И форматирование округляет по своим правилам: в DuckDB round(2.5) = 3, а printf('%.0f', 2.5) — «2». Поэтому форматируют последним шагом, а лучше отдают в BI число и формат задают там.
FLOOR в GROUP BY: бакеты по диапазонам
Чтобы разложить значения по диапазонам, делят на ширину корзины, округляют вниз и умножают обратно: floor(x / 25) * 25 — нижняя граница корзины шириной 25. Значение 49 попадает в корзину 25–50, значение 50 — уже в 50–75. Это полуоткрытые интервалы без пересечений, и их удобно подписывать.
Вот распределение платящих пользователей по сумме их платежей. Чаще всего — 411 из 912 — платили от $25 до $50.
round(x, -1) для этой задачи хуже. Он кладёт значение в ближайший десяток, то есть корзина «40» — это диапазон от 35 до 45, а не от 40 до 50. В учебной базе туда попадают пользователи с суммами 38 и 39, а корзины 50 и 70 пустые. Подпись «40» читатель поймёт как «40 и выше», и график соврёт.
| bucket_from | bucket_to | users |
|---|---|---|
| 0 | 25 | 349 |
| 25 | 50 | 411 |
| 50 | 75 | 104 |
| 75 | 100 | 41 |
| 100 | 125 | 7 |
with per_user as (
select user_id, sum(amount) as revenue
from payments
group by user_id
)
select
floor(revenue / 25) * 25 as bucket_from,
floor(revenue / 25) * 25 + 25 as bucket_to,
count(*) as users
from per_user
group by 1, 2
order by 1;Вопросы об округлении в SQL
Как округлить вверх? ceil(x) или ceiling(x). До десятых — ceil(x * 10) / 10, потому что числа знаков ceil не принимает.
Как отбросить дробную часть без округления? trunc(x) или trunc(x, 2) до двух знаков. В PostgreSQL вариант с числом знаков работает только для numeric.
Почему round в PostgreSQL падает с ошибкой function round(double precision, integer) does not exist? Округление до знаков определено только для numeric. Пишите round(x::numeric, 2).
Как округлить процент? round(100.0 * a / b, 1): умножение на 100.0 стоит до деления и заодно спасает от целочисленного деления.
Что почитать дальше
Округление выглядит мелочью, пока не попадает в итог отчёта. Все запросы статьи можно повторить в песочнице SQL-курса: там та же учебная база и тот же DuckDB, а в заданиях курса проверка идёт на этих же таблицах.
Материалы по теме
CASE WHEN в SQL: сегменты, условные метрики и порядок условий
CASE WHEN в SQL на учебной базе продукта: синтаксис, сегменты пользователей, почему порядок WHEN меняет ответ, ELSE и NULL, CASE внутри COUNT, в GROUP BY и ORDER BY.
Функции даты в SQL: текущая дата, разница дат, EXTRACT и формат
Справочник функций даты в SQL на учебной базе: текущая дата, часть даты, date_trunc, разница дат в днях, часах и месяцах, интервалы, формат вывода и отличия СУБД.

LAG и LEAD в SQL: предыдущая и следующая строка на примерах
LAG и LEAD в SQL и PostgreSQL на учебной базе: синтаксис с offset и default, паузы между событиями, DAU к вчера и к прошлой неделе, следующий шаг после события, ничьи в ORDER BY, QUALIFY и IGNORE NULLS.