Схема базы — это фундамент. Когда в ней выбраны не те типы, проблемы копятся незаметно: данные теряют смысл после смены сервера, деньги расходятся на копейки, индексы перестают работать, добавить значение в перечисление становится задачей на несколько релизов.
Ниже — ошибки, которые встречаются в реальных продовых схемах чаще всего.
varchar(255) по привычке
В MySQL и Oracle длина строки влияет на хранение. Разработчики, которые пришли из этих баз, несут varchar(255) с собой в PostgreSQL.
В PostgreSQL это ничего не даёт. text и varchar(n) хранятся одинаково — длина в varchar(n) — это просто проверка CHECK, не оптимизация.
Как правильно: используйте text для строк без бизнес-ограничения на длину. Если ограничение есть — напишите его явно и осмысленно: varchar(20) для кода страны, а не магические 255.
timestamp без таймзоны для бизнес-времени
timestamp хранит «голое» время без привязки к часовому поясу. Через год никто не помнит, в какой зоне работал сервер в момент записи. Заказ от 23:30 пятницы может превратиться в воскресенье после переезда сервиса на другой хост.
Как правильно: timestamptz — PostgreSQL переводит значение в UTC при записи и обратно при чтении. Зона фиксируется, данные остаются корректными при любом переезде.
varchar(36) для UUID
UUID как строка занимает 36 байт (с дефисами), регистронезависимо не работает, и проверки формата нет. Хранить 'ABCD-...' и 'abcd-...' можно одновременно.
Как правильно: тип uuid в PostgreSQL — это 16 байт, без дефисов во внутреннем представлении, с автоматической валидацией при записи.
float / real / double для денег
Числа с плавающей точкой не умеют точно представлять большинство десятичных дробей. 0.1 + 0.2 в них не равно 0.3. На одной операции это незаметно, но на длинной цепочке расчётов погрешность накапливается — и проявляется на сверке с банком.
Как правильно: numeric(precision, scale) — точная десятичная арифметика без погрешностей. Например, numeric(19, 4) для суммы в рублях.
serial / bigserial в новой схеме
serial и bigserial — это синтаксический сахар из ранних версий PostgreSQL. Они создают последовательность и привязывают её к колонке неочевидным образом: при pg_dump связь может теряться, права на последовательность приходится раздавать отдельно.
Как правильно: с PostgreSQL 10+ используйте GENERATED ALWAYS AS IDENTITY. Поведение то же, но связь с колонкой явная и корректно переносится при дампе.
-- устаревший вариант
id bigserial PRIMARY KEY
-- современный вариант
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
smallint 0/1 или char(1) Y/N вместо boolean
Встречается в схемах, перенесённых из баз, где boolean не поддерживался. В PostgreSQL он есть — занимает 1 байт, удобен в SQL-выражениях, читаем в запросах.
Как правильно: используйте boolean. Запросы WHERE is_active = true читаются без расшифровки.
PG ENUM для часто меняющегося списка
Перечисление через CREATE TYPE ... AS ENUM неплохо работает, пока список значений стабилен. Но удалить значение из PG ENUM нельзя — только добавить. Переименовать — сложно. Добавить атрибуты (например, отображаемое название или порядок сортировки) — невозможно.
Как правильно: если перечисление может расти, переименовываться или требовать атрибутов — используйте справочную таблицу с FK. Это чуть больше кода, но гибкость окупается.
JSONB как «гибкая схема» для основных полей
JSONB удобен, поэтому его порой используют там, где нужны обычные колонки: metadata->>'email' вместо просто email. В итоге теряется типобезопасность, усложняются индексы, обязательность полей не контролируется.
Как правильно: если по полю фильтруют или сортируют — это колонка. JSONB оправдан для полиморфных данных (разные наборы атрибутов у разных записей), необязательных полей или редко используемых JSON-документов.
Массив там, где должна быть таблица
Хранить позиции заказа в jsonb[] или int[] кажется удобным: всё в одной строке. Но это таблица, перевёрнутая на бок. По элементам массива нельзя сделать FK, нельзя добавить атрибуты к элементу, сложно обновить один элемент.
Как правильно: объекты с идентичностью (позиции, участники, вложения) — это отдельная таблица с FK. Массив уместен только для скалярных значений без идентичности: теги, коды локалей, списки строк.
valid_from / valid_to вместо range-типа
Хранить интервал двумя отдельными колонками кажется очевидным, но это ловушка: нельзя добавить ограничение непересечения интервалов на уровне БД без сложных триггеров, а race condition при вставке остаётся.
Как правильно: PostgreSQL поддерживает range-типы — tstzrange, daterange, int4range и другие. Вместе с EXCLUDE USING gist они гарантируют непересечение на уровне базы:
ALTER TABLE price_periods
ADD CONSTRAINT no_overlap
EXCLUDE USING gist (product_id WITH =, valid_period WITH &&);
Тип money
money в PostgreSQL привязан к локали сессии — одно и то же значение читается по-разному в зависимости от настроек. Кода валюты в нём нет.
Как правильно: numeric(p, s) для суммы плюс отдельная колонка currency char(3) (код ISO 4217, например RUB, USD).
Тип без таймзоны на стороне приложения
Даже если в схеме стоит timestamptz, драйвер может передать значение без привязки к часовому поясу — и PostgreSQL интерпретирует его в зоне текущей сессии. На UTC-сервере и машине разработчика с локальной зоной одна и та же строка кода даст разные значения в БД.
Как правильно: используйте тип с таймзоной на стороне приложения:
- Java:
InstantилиOffsetDateTime(неLocalDateTime) - Go:
time.Time(он всегда несёт зону) - Node.js: передавайте дату через
pgсparseInputDatesAsUTC: true - Python:
datetimeсtzinfo(не «наивный»datetime)
Прямые вызовы системного времени и генератора UUID в коде
Если производственный код напрямую вызывает time.Now(), Instant.now(), uuid.New() и подобное, тесты становятся недетерминированными: зафиксировать время или идентификатор без моков на уровне платформы невозможно.
Как правильно: оберните в сервисный слой — ClockService, UuidGenerator или аналог. В тестах подставляйте детерминированную реализацию, которая возвращает фиксированные значения.
UUID v4 для первичного ключа
UUID v4 полностью случаен. При вставке строки PostgreSQL вынужден найти нужную страницу B-tree, которая уже могла быть вытеснена из кэша. На больших таблицах это превращается в постоянный случайный ввод-вывод и плохую упаковку страниц.
Как правильно: UUID v7 — монотонный, начинается с временно́й метки. Вставки идут последовательно, страницы упаковываются плотно, кэш работает эффективнее.
Коротко
textвместоvarchar(255)— в PostgreSQL они хранятся одинаково.timestamptzдля любого бизнес-времени — неtimestamp.numeric(p, s)для денег — неfloat, неmoney.uuidкак тип — неvarchar(36).boolean— неsmallint/char(1).GENERATED ALWAYS AS IDENTITYв новых схемах — неserial.- Перечисления, которые могут расти — справочная таблица, не PG ENUM.
- JSONB — для полиморфных и необязательных данных, не для основных полей.
- Объекты с идентичностью — отдельная таблица, не массив.
- Интервалы — range-типы с
EXCLUDE, не пара колонокvalid_from/valid_to. - Тип с таймзоной на стороне приложения для
timestamptz-колонок. - Системное время и UUID — через изолируемый сервисный слой, не напрямую.
- UUID v7 для первичных ключей — не v4.
Что почитать дальше
- Строковые типы в PostgreSQL
- Время и таймзоны в PostgreSQL
- UUID и идентификаторы в PostgreSQL
- Числа и точность в PostgreSQL
- Enum, boolean и перечисления в PostgreSQL
- JSONB в PostgreSQL
- Массивы и range-типы в PostgreSQL
- Composite-индексы в PostgreSQL
- Миграции без даунтайма