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

CAST и типы данных в SQL: почему конверсия равна нулю

Целочисленное деление, numeric против float, текст в число и дату, приведение типов в WHERE и индексы: как готовить данные так, чтобы расчёт не врал молча.

КПКейсПрактика3 августа 2026 г.13 мин

В отчёте конверсия из визита в заказ равна нулю. Не 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нет времени — нет сюрпризов с полуночью
Флагboolean0/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 колонка не обёрнута в приведение там, где можно написать диапазон?
  • Приведение типов собрано в одном слое, а не повторяется в каждом выражении?
Продолжить чтение
Вся библиотека