deшка
Урок 2.4

Типы данных и неявные приведения

Целочисленное деление, float против numeric, границы дат, часовые пояса и строки, которые выглядят одинаково.

Данные приезжают в хранилище из файлов, 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.30000000000000004

float8 хранит двоичное приближение числа. Суммы по миллионам строк накапливают погрешность, результат зависит от порядка сложения, а сравнение на равенство ненадёжно. Константа 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.34

paid_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 в очередном отчёте.

Практика

Заготовки снова рабочие на вид: выполняются без ошибок и возвращают неверные цифры.

postgresql · песочницарешено 0 / 3

Посчитайте долю отменённых заказов по месяцам создания, в процентах с одним знаком после запятой. Колонки: month типа date с первым числом месяца, cancel_pct. Сортировка по месяцу.

Статус отменённого заказа: cancelled, регистр в данных встречается разный. Заготовка возвращает нули.

Ctrl+Enter
Войти, чтобы отмечатьдальше →
2.5 →Потерянные строки: LEFT JOIN, semi и anti join