Данные приезжают в хранилище из файлов, API и чужих баз, и почти всегда сначала как текст. Тип колонки решает, как значения сравниваются, сортируются, складываются и соединяются. Ошибка типа редко роняет запрос. Гораздо чаще она тихо меняет цифру.
Деление целых даёт целое
Доля отменённых заказов по месяцам:
SELECT date_trunc('month', created_at)::date AS month,
count(*) FILTER (WHERE lower(status) = 'cancelled') / count(*) * 100 AS pct
FROM orders
GROUP BY 1
ORDER BY 1;
-- 2025-01-01 | 0
-- 2025-02-01 | 0
-- 2025-03-01 | 0Везде ноль. count возвращает целое, а деление целого на целое в PostgreSQL отбрасывает дробную часть: 1 / 2 равно 0, 7 / 2 равно 3. Семь отмен из ста заказов дают 0, и умножение на 100 уже ничего не спасает.
Достаточно сделать дробным один операнд, причём до деления:
round(100.0 * count(*) FILTER (WHERE lower(status) = 'cancelled') / count(*), 1) AS cancel_pct
-- 2025-01-01 | 6.9
-- 2025-02-01 | 7.3
-- 2025-03-01 | 8.2Порядок важен: 100 * 3 / 8 даёт 37, а 100.0 * 3 / 8 даёт 37.5. Явный вариант: count(*)::numeric.
float для измерений, numeric для денег
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8 AS float_eq, -- false
0.1 + 0.2 = 0.3 AS numeric_eq, -- true
0.1::float8 + 0.2::float8 AS float_sum; -- 0.30000000000000004float8 хранит двоичное приближение числа. Суммы по миллионам строк накапливают погрешность, результат зависит от порядка сложения, а сравнение на равенство ненадёжно. Константа 0.1 без приведения в PostgreSQL имеет тип numeric, он считает в десятичной системе точно.
Деньги, количества и всё, что потом сверяется до копейки, хранится в numeric. float8 уместен для измерений, где погрешность есть по природе: время выполнения, координаты, вероятности.
Округляют эти типы по-разному:
SELECT round(2.5::numeric) AS n, -- 3, от нуля
round(2.5::float8) AS f; -- 2, к чётномуА round(x, 2) для float8 в PostgreSQL не существует вовсе, запрос упадёт с function round(double precision, integer) does not exist. Приводите к numeric до округления.
Строка, которая выглядит как число
SELECT string_agg(v, ', ' ORDER BY v) AS as_text, -- 10, 100, 25, 9
string_agg(v, ', ' ORDER BY v::int) AS as_int -- 9, 10, 25, 100
FROM (VALUES ('9'), ('10'), ('100'), ('25')) t(v);Текст сортируется и сравнивается посимвольно: '10' < '9', потому что '1' < '9'. Если номер версии, недели или партии хранится строкой, max() вернёт не то, что вы ждёте.
Обратная ловушка: идентификаторы с ведущими нулями. '00017'::int превращается в 17, и обратно без знания исходной длины его не восстановить. Код товара, почтовый индекс, ИНН, номер договора выглядят как числа, но это строки. Правило: если со значением не делают арифметику, это текст.
PostgreSQL строг к типам в сравнениях:
SELECT count(*) FROM orders WHERE order_id = '42'; -- работает: литерал без типа приводится к integer
SELECT count(*) FROM orders WHERE order_id = '42abc'; -- ERROR: invalid input syntax for type integer
SELECT count(*) FROM orders WHERE order_id = '42'::text; -- ERROR: operator does not exist: integer = textСтрогость полезна: соединить integer с text без явного приведения не получится, и ошибка видна сразу. MySQL в той же ситуации молча приведёт '42abc' к 42 и вернёт строку. Если ключи в двух таблицах разных типов, приводите один из них явно и помните, что функция над колонкой в условии мешает использовать индекс. К этому вернёмся в блоке про производительность.
Границы дат: BETWEEN теряет последний день
Сумма успешных платежей за май 2026:
SELECT round(sum(amount), 2) FROM payments
WHERE status = 'success'
AND paid_at BETWEEN '2026-05-01' AND '2026-05-31'; -- 13 682 526.34paid_at имеет тип timestamptz, поэтому '2026-05-31' превращается в полночь начала 31 мая. Все платежи за 31-е после 00:00:00 в интервал не попали: 12 платежей на 166 460.19.
WHERE status = 'success'
AND paid_at >= '2026-05-01' AND paid_at < '2026-06-01'; -- 13 848 986.53Полуинтервал «от включительно, до не включая» работает одинаково для дат, времени с секундами и с микросекундами. Вариант BETWEEN ... AND '2026-05-31 23:59:59' хуже: он теряет события последней секунды. Для инкрементальных загрузок это правило обязательное: окна [from, to) стыкуются без дыр и без дублей на границах.
Часовые пояса: какому дню принадлежит заказ
timestamptz хранит момент времени, а не «дату и часы на стене». При выводе и приведении к дате он пересчитывается в часовой пояс сессии. В нашей базе сессия работает в UTC.
SELECT count(*) FROM orders WHERE created_at::date = '2026-06-15'; -- 17
SELECT count(*) FROM orders WHERE (created_at AT TIME ZONE 'Europe/Moscow')::date = '2026-06-15'; -- 14
SELECT count(*) FROM orders WHERE (created_at AT TIME ZONE 'Asia/Vladivostok')::date = '2026-06-15'; -- 16Три разных ответа на вопрос «сколько заказов 15 июня». Заказ, созданный в 22:00 UTC, в Москве уже относится к 16-му числу, а во Владивостоке к 16-му относится всё, что создано после 14:00 UTC. Ни один ответ не ошибочен сам по себе. Ошибка в том, чтобы не знать, в каком поясе посчитан отчёт.
Что из этого следует на практике:
- Храните моменты времени в
timestamptz. Типtimestampбез зоны не знает, в каком поясе записано значение, и после переезда сервера или смены настроек данные «съезжают». - Результат
created_at::dateзависит от настройкиTimeZoneу сессии. Один и тот же запрос из Airflow и из клиента аналитика может дать разные цифры. Пишите пояс явно:AT TIME ZONE 'Europe/Moscow'. - Бизнес-дата отчёта определяется договорённостью: по Москве, по UTC или по поясу клиента. В нашей базе пояс города лежит в
cities.timezone, локальную дату заказа можно получить через соединение. - Дневные партиции и инкрементальные окна считайте в одном поясе, обычно в UTC, а в бизнес-пояс переводите на уровне витрины.
Одинаковые на вид строки
Сравнение текста учитывает регистр и пробелы. В orders.status лежит 12 разных значений вместо шести:
SELECT status, count(*) FROM orders GROUP BY status ORDER BY 1;
-- CANCELLED | 4
-- DELIVERED | 34
-- ...
-- delivered | 4489Фильтр status = 'delivered' пропустит 34 заказа, а GROUP BY status разобьёт одну категорию на две строки отчёта. В customers.email похожая история: у клиента 143 адрес user143@example.com, а у клиента 180 он же записан как USER143@EXAMPLE.COM с пробелом в конце. Для базы это разные строки, для бизнеса один человек. Таких пар 22.
Нормализуйте значения при сравнении и группировке: lower(trim(email)), lower(status). Лучше делать это один раз при загрузке в хранилище, а не в каждом запросе. Тогда и индексы работают, и никто не забудет про lower в очередном отчёте.
Практика
Заготовки снова рабочие на вид: выполняются без ошибок и возвращают неверные цифры.
Посчитайте долю отменённых заказов по месяцам создания, в процентах с одним знаком после запятой. Колонки: month типа date с первым числом месяца, cancel_pct. Сортировка по месяцу.
Статус отменённого заказа: cancelled, регистр в данных встречается разный. Заготовка возвращает нули.