CAST и типы данных в SQL: почему конверсия равна нулю
Целочисленное деление, numeric против float, текст в число и дату, приведение типов в WHERE и индексы: как готовить данные так, чтобы расчёт не врал молча.
Содержание статьи
В отчёте конверсия из визита в заказ равна нулю. Не 2%, не 0,7% — ровно ноль по всем каналам. Данные на месте, заказы есть, формула правильная: заказы поделить на визиты. Дело в типах: оба счётчика целые, а целочисленное деление отбрасывает дробную часть. Такие ошибки опаснее падающих запросов — запрос отработал, число получено, и заметит подвох только тот, кто усомнится в результате. Разберём места, где тип данных меняет смысл расчёта, и как приводить типы так, чтобы это было видно.
Целочисленное деление съедает дробную часть
Если оба операнда целые, результат деления тоже целый: 1 / 4 даёт 0, а не 0,25. Это не особенность PostgreSQL, а поведение большинства СУБД, и оно ровно объясняет нулевую конверсию: заказов меньше, чем визитов, значит частное округляется вниз до нуля.
Достаточно привести к дробному типу один операнд — дальше второй приведётся автоматически. Обычно приводят числитель и сразу умножают на 100, если нужен процент. Порядок операций тут тоже имеет значение: умножение на 100 после целочисленного деления уже ничего не спасёт.
Вторая половина той же проблемы — деление на ноль. В PostgreSQL это ошибка, а не NULL, и отчёт просто упадёт, как только в каком-то канале не окажется визитов. Стандартная защита — nullif(знаменатель, 0): тогда вместо ошибки получится NULL, который честно означает «посчитать нельзя».
SELECT channel,
count(DISTINCT order_id) / count(DISTINCT session_id) AS wrong,
round(100.0 * count(DISTINCT order_id) / count(DISTINCT session_id), 2) AS ok_but_fragile,
round(
100.0 * count(DISTINCT order_id)
/ nullif(count(DISTINCT session_id), 0), 2
) AS safe
FROM sessions
WHERE started_at >= date '2026-07-01'
GROUP BY channel;Деньги считаются в numeric, а не в float
Типы real и double precision хранят числа в двоичной плавающей точке. Из-за этого десятичные дроби представимы не точно: классическое 0.1 + 0.2 не равно 0.3, и суммы расходятся в последних знаках. На одной строке это невидимо, на миллионе заказов превращается в расхождение с бухгалтерией.
Для денег и любых значений, где важна точная десятичная арифметика, используется numeric (он же decimal). Он медленнее, зато складывает и делит именно так, как ожидает финансовый отдел. Плавающая точка уместна там, где точность не критична: координаты, доли, промежуточные научные расчёты.
Отдельно стоит договориться о единице хранения. Многие продукты хранят суммы в копейках или центах целым числом — тогда арифметика точна по определению, но при выводе нужно не забыть разделить на 100, причём с приведением к numeric, иначе снова получится целочисленное деление.
| Данные | Тип | Почему |
|---|---|---|
| Суммы, цены, выручка | numeric | точная десятичная арифметика |
| Копейки/центы | bigint | целое хранение, деление только при выводе |
| Доли, коэффициенты | numeric или double precision | зависит от требований к точности |
| Момент времени с зоной | timestamptz | хранится в UTC, показывается по зоне сессии |
| Календарный день | date | нет времени — нет сюрпризов с полуночью |
| Флаг | boolean | 0/1 в integer провоцирует sum вместо count |
Текст в число: сначала очистка, потом CAST
Из выгрузок и внешних систем суммы часто приезжают строками: "1 200", "1,50", "1200 ₽", пустая строка вместо пропуска. Прямое приведение такой колонки к числу падает на первом же нестандартном значении, а обёртка всего запроса в надежду «а вдруг данные чистые» работает ровно до следующей выгрузки.
В PostgreSQL нет безопасного варианта вроде TRY_CAST из SQL Server или SAFE_CAST из BigQuery, поэтому проверку пишут руками: сначала отфильтровать формат регулярным выражением, потом приводить. Заодно это даёт бесплатную метрику качества — сколько строк не прошло проверку.
Ключевой принцип: невалидные значения не должны исчезать молча. Если из 10 000 строк 160 не сконвертировались, это число должно попасть в отчёт рядом с выручкой, а не раствориться в фильтре.
WITH clean AS (
SELECT order_id,
nullif(trim(amount_text), '') AS amount_raw,
CASE
WHEN trim(amount_text) ~ '^-?[0-9]+([.,][0-9]+)?$'
THEN replace(trim(amount_text), ',', '.')::numeric
END AS amount
FROM raw_orders
)
SELECT
count(*) AS rows_total,
count(amount) AS parsed_ok,
count(*) FILTER (WHERE amount IS NULL AND amount_raw IS NOT NULL) AS parse_failed,
sum(amount) AS revenue
FROM clean;Даты: формат, зона и граница суток
Со строковыми датами та же история, но с дополнительным слоем. Формат 2026-07-01 приведётся сам, а 01.07.2026 требует явного разбора через to_date с указанием шаблона — иначе база либо ошибётся, либо перепутает день с месяцем на числах меньше 13.
Дальше начинается вопрос зоны. timestamptz хранит момент в UTC и показывает его в часовом поясе сессии, timestamp — просто набор цифр без привязки. Пока все смотрят отчёт из одного города, разницы не видно. Как только появляется дашборд для команды в другой зоне, границы суток разъезжаются, и дневные метрики перестают сходиться.
Практическое правило: хранить моменты в timestamptz, а к бизнес-дню приводить явно и одинаково во всех отчётах — через AT TIME ZONE с зоной продукта. И не смешивать в одном расчёте дни, посчитанные по разным зонам.
SELECT
date_trunc('day', occurred_at) AS day_utc,
date_trunc('day', occurred_at AT TIME ZONE 'Europe/Moscow') AS day_msk,
count(*) AS events
FROM events
WHERE occurred_at >= timestamptz '2026-07-01 00:00+03'
AND occurred_at < timestamptz '2026-07-08 00:00+03'
GROUP BY 1, 2
ORDER BY 1;Флаги: 0/1, «true» и настоящий boolean
Признак «оплачено» приезжает в трёх видах: числом 0/1, строкой "true"/"false" и нормальным булевым значением. Пока флаг просто выводят, разница незаметна. Она вылезает в агрегатах: по числовому флагу естественно написать sum(is_paid) и получить количество оплаченных, а по булевому — уже count(*) FILTER (WHERE is_paid).
Опасность в том, что первая форма молча работает и на данных, где 1 означает не «оплачено», а код статуса. Сумма кодов — число бессмысленное, но выглядит как обычная метрика. Строковый вариант ещё коварнее: в PostgreSQL непустая строка 'false' при явном приведении к boolean станет FALSE, а вот произвольное значение вроде 'no' вызовет ошибку — и снова после очередной выгрузки.
Правильный порядок — привести флаг к boolean на слое очистки и дальше считать через FILTER или count(*) FILTER (WHERE ...). И помнить про третье состояние: у булевой колонки, кроме TRUE и FALSE, бывает NULL, поэтому WHERE NOT is_paid не то же самое, что WHERE is_paid IS NOT TRUE.
Приведение типа в WHERE выключает индекс
Условие created_at::date = '2026-07-01' выглядит естественно и работает правильно. Проблема в другом: колонка обёрнута в выражение, поэтому обычный индекс по created_at использовать нельзя — базе придётся вычислить выражение для каждой строки.
Тот же запрос, записанный полуинтервалом >= начало AND < конец, оставляет колонку нетронутой и попадает в индекс. Это не микрооптимизация: на таблице событий разница между сканированием и диапазоном по индексу измеряется минутами.
Если преобразование действительно нужно в фильтре — например, всегда сравнивается lower(email), — создайте индекс по выражению. Тогда база сможет использовать его напрямую.
-- индекс по created_at не поможет
SELECT count(*) FROM orders WHERE created_at::date = date '2026-07-01';
-- полуинтервал: колонка чистая, индекс работает
SELECT count(*) FROM orders
WHERE created_at >= date '2026-07-01'
AND created_at < date '2026-07-02';
-- если выражение неизбежно — индекс по выражению
CREATE INDEX orders_email_lower_idx ON orders (lower(email));Одна граница приведения вместо десяти
Самая устойчивая практика — приводить типы один раз, на понятной границе, а не в каждом выражении отчёта. Обычно это слой staging или первый CTE запроса: там текст превращается в числа и даты, там же считаются флаги качества, и дальше весь запрос работает с нормальными типами.
Это снимает целый класс расхождений. Пока приведение размазано по отчётам, два аналитика легко получают разные числа: один отфильтровал невалидные строки, другой заменил их нулём, третий вообще не заметил. Один слой с явными правилами делает эти решения видимыми и одинаковыми для всех.
И последнее: приведение типа не проверяет бизнес-смысл. Строка "-500" отлично конвертируется в число, но отрицательная выручка — скорее всего, возврат, попавший в ту же колонку. Тип — это только формат; смысловые проверки нужны отдельно.
Данные иллюстративные. Отброшенные строки должны быть видимой цифрой в отчёте, а не разницей, которую замечают через квартал.
Чеклист
Ошибки типов не выглядят как ошибки — именно поэтому их стоит проверять до того, как число попадёт в презентацию.
- В каждом делении хотя бы один операнд дробного типа, а знаменатель защищён через
nullif? - Денежные значения хранятся и считаются в
numeric, а не в плавающей точке? - Текстовые числа и даты проходят проверку формата, а количество непрошедших строк видно в отчёте?
- Для моментов используется
timestamptz, а бизнес-день считается в одной зоне во всех отчётах? - В
WHEREколонка не обёрнута в приведение там, где можно написать диапазон? - Приведение типов собрано в одном слое, а не повторяется в каждом выражении?
Материалы по теме
BETWEEN в SQL и фильтр по датам: как не потерять последний день
Как правильно фильтровать даты в SQL: BETWEEN, полуоткрытые интервалы, timestamp, часовые пояса и полные календарные периоды.

JSONB в PostgreSQL: операторы, типы и запросы к свойствам событий
Как читать JSONB в аналитике: операторы ->, ->>, @> и ?, приведение типов, три вида пропущенного значения, разворачивание массивов и индексы под реальные запросы.
Как убрать дубли событий в SQL и не завысить продуктовую метрику
Практический разбор дедупликации событий в SQL: повторная отправка, idempotency key, ROW_NUMBER и контроль числа пользователей после очистки.