PostgreSQL предлагает много числовых типов, и выбор не очевидный. На практике почти все ошибки сводятся к трём сценариям: переполнился счётчик id, потерялись копейки из-за округления, сломался флаг потому что хранился как число. Разберём каждый по очереди.
Проблема с id: почему integer однажды закончится
Представьте: вы создаёте таблицу заказов и выбираете integer для id — кажется, 2 миллиарда строк хватит на сто лет. Через несколько лет таблица разрастается, id исчерпывается, и вы обнаруживаете это в прод-инциденте в пятницу вечером.
Миграция integer → bigint на живой большой таблице — это дни работы:
ALTER TYPE ... bigintтребует эксклюзивной блокировки и переписывает всю таблицу;- обходные пути сложны: новая колонка, копирование данных, переключение.
Поэтому правило простое: id таблицы — всегда bigint.
CREATE TABLE order_item (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL,
quantity integer NOT NULL CHECK (quantity > 0),
weight_g integer NOT NULL
);
Три целочисленных типа и их диапазоны:
| Тип | Размер | Диапазон |
|---|---|---|
smallint | 2 байта | −32 768 … 32 767 |
integer | 4 байта | ±2.1 миллиарда |
bigint | 8 байт | ±9.2 квинтиллиона |
Разница в размере строки — 4 байта. Стоимость этих 4 байт несравнима со стоимостью инцидента.
Когда smallint всё же уместен
smallint имеет смысл только для фиксированных шкал, где переполнение физически невозможно:
day_of_week smallint NOT NULL CHECK (day_of_week BETWEEN 1 AND 7),
timezone_offset smallint NOT NULL -- смещение в минутах от UTC
Для счётчиков, лимитов, остатков, любых «бизнесовых» чисел — только integer или bigint. smallint экономит 2 байта, но не оправдывает риск переполнения.
GENERATED ALWAYS AS IDENTITY вместо serial
Раньше для автоинкрементного id писали serial или bigserial:
-- старый способ — не рекомендуется
CREATE TABLE foo (
id bigserial PRIMARY KEY
);
serial — это не настоящий тип данных, а сокращение, которое PostgreSQL разворачивает в integer с последовательностью и значением по умолчанию. Проблемы:
- последовательность живёт отдельно и не удаляется при
DROP TABLE; - нет способа запретить явную вставку произвольного id;
- есть угловые случаи в
pg_dump.
Начиная с PostgreSQL 10 стандартный способ — GENERATED ALWAYS AS IDENTITY:
-- современный способ
CREATE TABLE foo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);
ALWAYS означает, что PostgreSQL сам генерирует id и не позволяет вставить произвольное значение. Это защита от случайных конфликтов.
Если нужно явно вставить id при переносе данных — есть мягкая версия BY DEFAULT AS IDENTITY. Она разрешает явный insert, но в обычной работе всё равно генерирует значение автоматически.
Когда GENERATED ALWAYS AS IDENTITY не подходит: если вам нужен глобально уникальный id без обращения к базе — например, для распределённых сервисов. В таком случае используют UUID v7.
Деньги и числа с плавающей точкой: классическая ловушка
Самая распространённая ошибка с числами в PostgreSQL — хранить деньги в float или double precision. Посмотрим, почему это опасно.
Компьютер хранит float в двоичном виде. Проблема в том, что большинство десятичных дробей нельзя точно представить в двоичном. Например, 0.1 в двоичном — бесконечная дробь. Поэтому:
0.1 + 0.2 = 0.30000000000000004 (в float)
Для отдельной транзакции погрешность незаметна. Но за год миллионы операций накапливают расхождение, которое обнаружится при сверке с банком — и это будет юридически неверный расчёт.
Правильный тип для денег — numeric(p, s), где p — общее количество значимых цифр, s — знаков после запятой. numeric хранит число точно, как десятичные цифры:
amount_total numeric(15, 2) NOT NULL, -- до 13 цифр до запятой, 2 после
exchange_rate numeric(20, 8) NOT NULL, -- курсы: 8 знаков после запятой
discount_percent numeric(5, 2) NOT NULL CHECK (discount_percent BETWEEN 0 AND 100)
Альтернатива: копейки в bigint
Ещё один рабочий подход — хранить денежные суммы в целых единицах наименьшего номинала:
amount_cents bigint NOT NULL CHECK (amount_cents >= 0)
Плюсы: быстрее numeric, целочисленные операции не дают погрешности. Минусы: неудобно для систем с разной точностью (криптовалюта — 8 знаков, рубли — 2, некоторые fiat-валюты — 3). Один пропущенный делитель в коде превращается в баг.
Для типичного маркетплейса или SaaS-биллинга — numeric(p, s).
Тип money: почему его не используют
В PostgreSQL есть встроенный тип money. Он выглядит удобно, но на практике не используется:
- привязан к глобальной локали сервера (формат вывода меняется при смене локали);
- не хранит код валюты — нельзя различить рубли и доллары;
- неудобно конвертировать в другие типы.
Для любой задачи с деньгами — numeric(p, s) лучше.
Когда float всё же уместен
real и double precision не запрещены — они нужны там, где небольшая погрешность допустима по природе данных:
- метрики мониторинга: CPU usage, задержка p95;
- научные расчёты: вес, температура, расстояние (входные данные уже приближённые);
- машинное обучение: эмбеддинги, числовые признаки.
Везде, где числа должны точно сходиться — деньги, баллы лояльности, учётные количества — float не подходит.
Boolean — это boolean, не число
Ещё одна частая ошибка — хранить булев флаг как число или строку:
-- частая ошибка
is_active smallint NOT NULL DEFAULT 1,
is_active varchar(1) NOT NULL DEFAULT 'Y' CHECK (is_active IN ('Y','N')),
is_active char(1) NOT NULL DEFAULT 'Y'
Это создаёт проблемы: запросы становятся неочевидными, можно случайно записать 2 вместо 1, различные части кода начинают использовать разные соглашения.
PostgreSQL имеет встроенный тип boolean — используйте его:
is_active boolean NOT NULL DEFAULT true,
is_deleted boolean NOT NULL DEFAULT false
boolean занимает 1 байт, точно отражает семантику и работает с операторами AND, OR, NOT без преобразований.
Частые ошибки
id как integer — однажды кончится. Используйте bigint.
serial / bigserial — устаревший способ. Используйте GENERATED ALWAYS AS IDENTITY.
Деньги в float или double precision — накапливает погрешность. Используйте numeric(p, s).
Тип money — привязан к локали, без кода валюты. Используйте numeric(p, s).
smallint для счётчиков — переполнится. Используйте integer или bigint.
Boolean как smallint или varchar — неочевидно и ненадёжно. Используйте boolean.
Коротко
- Id таблицы — всегда
bigint. Миграцияint → bigintна большой таблице стоит дорого, 4 лишних байта — нет. - Автоинкремент —
GENERATED ALWAYS AS IDENTITYвместо устаревшихserial/bigserial. smallint— только для фиксированных шкал (день недели, смещение UTC). Для счётчиков —integer/bigint.- Деньги —
numeric(p, s).floatнакапливает погрешность,moneyпривязан к локали. - Альтернатива для денег — копейки в
bigint, но ограничена при разной точности валют. real/double precision— допустимы для метрик, научных данных, ML. Не для финансов.- Флаги —
boolean, неsmallint, неvarchar('Y'/'N').
Что почитать дальше
- Строковые типы — text против varchar, когда что выбирать.
- Время и таймзоны — timestamptz и почему timezone имеет значение.
- UUID и идентификаторы — когда нужен UUID вместо bigint.
- Enum и перечисления — как хранить статусы и категории.