← назад к разделу

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
);

Три целочисленных типа и их диапазоны:

ТипРазмерДиапазон
smallint2 байта−32 768 … 32 767
integer4 байта±2.1 миллиарда
bigint8 байт±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 и перечисления — как хранить статусы и категории.