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

Схема базы — это фундамент. Когда в ней выбраны не те типы, проблемы копятся незаметно: данные теряют смысл после смены сервера, деньги расходятся на копейки, индексы перестают работать, добавить значение в перечисление становится задачей на несколько релизов.

Ниже — ошибки, которые встречаются в реальных продовых схемах чаще всего.

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
  • Миграции без даунтайма