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

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

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

одно и то же значение, записанное в разные типы тип по привычке тип по смыслу 23:30 пятницы timestamp timestamptz 0.1 + 0.2 float8 numeric(19,4) 0f9a… и 0F9A… varchar(36) uuid timestampзона неизвестнаtimestamptzодин момент float80.30000000000000004numeric(19,4)ровно 0.3 varchar(36)две разные строкиuuidодно значение

Тип колонки — это не формальность, а обещание базы: что она проверит при записи и что вернёт при чтении. timestamp вернёт то же настенное время, но не скажет, в какой зоне оно снято. float8 вернёт близкое число, а не то же самое. varchar(36) вернёт ровно ту строку, что положили, — вместе с регистром букв, поэтому один и тот же идентификатор в двух написаниях останется двумя разными значениями.

Обязательно

Самое дорогое следствие: неявное приведение убивает индекс

Прежде чем разбирать типы по одному, стоит назвать то, из-за чего неверный тип бьёт по эксплуатации сильнее всего. Когда сравниваются значения разных типов, база приводит их к общему — и если приведение оказалось на стороне колонки, индекс по ней перестаёт работать. Запрос при этом остаётся правильным, просто читает всю таблицу.

Три случая, которые встречаются постоянно.

Число как строка. WHERE id = '123' по колонке bigint обычно приводит строку к числу и работает нормально. А вот наоборот — колонка varchar и параметр-число — приводит колонку: WHERE order_number = 123 при order_number varchar заставит базу вычислить приведение для каждой строки. Это прямое следствие «идентификатор в строковой колонке».

Соединение по колонкам разных типов. orders.customer_id объявлен varchar(36), а customer.id — uuid. Соединение формально работает, но индекс по одной из сторон не используется, и соединение больших таблиц превращается в перебор. Это и есть самая дорогая часть решения «UUID в varchar»: не лишние байты, а сломанные планы.

Дата против момента. WHERE created_at = current_date при created_at timestamptz приводит типы так, что индекс остаётся, но условие означает «ровно полночь»; а WHERE created_at::date = current_date приводит саму колонку — и индекс отключается. Правильная форма всегда одна: диапазон по колонке без обёрток.

Как это увидеть: в EXPLAIN условие по индексированной колонке должно стоять в Index Cond. Если оно уехало в Filter, а рядом виден Seq Scan — ищите приведение. Признак в самом тексте плана — явное ::text или ::numeric вокруг имени колонки.

varchar(255) по привычке

В MySQL и Oracle длина строки влияет на хранение. Разработчики, которые пришли из этих баз, несут varchar(255) с собой в PostgreSQL.

В PostgreSQL это ничего не даёт. text и varchar(n) хранятся одинаково — длина в varchar(n) — это просто проверка CHECK, не оптимизация.

Как правильно: используйте text для строк без бизнес-ограничения на длину. Если ограничение есть — напишите его явно и осмысленно: varchar(20) для кода страны, а не магические 255.

timestamp без таймзоны для бизнес-времени

timestamp хранит «голое» время без привязки к часовому поясу. Через год никто не помнит, в какой зоне работал сервер в момент записи. Заказ от 23:30 пятницы после переезда сервиса на другой хост окажется субботним.

Как правильно: timestamptz. Название обманывает: сам часовой пояс он не хранит. Внутри лежит только момент времени в UTC, а при чтении база показывает его в зоне текущей сессии. Момент зафиксирован однозначно, и переезд сервера ничего не испортит. Если же по задаче важно запомнить исходную зону — например, во сколько по местному времени клиент оформил заказ, — её хранят отдельной колонкой.

varchar(36) для UUID

UUID как строка занимает 36 символов с дефисами плюс служебный байт, проверки формата нет, а сравнение идёт побуквенно. Один и тот же идентификатор, записанный в разном регистре, для базы будет двумя разными значениями:

живой пример

SELECT '0f9a5b2c-3d4e-4f5a-8b9c-0d1e2f3a4b5c'
           = '0F9A5B2C-3D4E-4F5A-8B9C-0D1E2F3A4B5C' AS as_text,
       '0f9a5b2c-3d4e-4f5a-8b9c-0d1e2f3a4b5c'::uuid
           = '0F9A5B2C-3D4E-4F5A-8B9C-0D1E2F3A4B5C'::uuid AS as_uuid;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Слева false, справа true. То есть в колонке varchar(36) запрос WHERE id = ? с идентификатором в «неправильном» регистре просто ничего не найдёт, хотя строка в таблице есть. У типа uuid так не бывает: оба написания — одно значение.

Как правильно: тип uuid в PostgreSQL — это 16 байт, без дефисов во внутреннем представлении, с автоматической валидацией при записи.

float / real / double для денег

Числа с плавающей точкой не умеют точно представлять большинство десятичных дробей. На одной операции это незаметно, но на длинной цепочке расчётов погрешность накапливается — и проявляется на сверке с банком. Короткая проверка:

живой пример

SELECT 0.1::float8 + 0.2::float8               AS float_sum,
       0.1::numeric(19,4) + 0.2::numeric(19,4) AS numeric_sum,
       0.1::float8 + 0.2::float8 = 0.3::float8 AS float_eq
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Слева — 0.30000000000000004, справа — ровно 0.3000, а сравнение с 0.3 во float даст false.

Как правильно: numeric(precision, scale) — точная десятичная арифметика без погрешностей. Например, numeric(19, 4) для суммы в рублях.

serial / bigserial в новой схеме

serial и bigserial — это синтаксический сахар из ранних версий PostgreSQL. Они создают последовательность и привязывают её к колонке неочевидным образом: права на неё приходится раздавать отдельно от таблицы, а запретить приложению вставить свой id в обход последовательности нельзя — и потом она начнёт выдавать уже занятые номера.

Как правильно: с PostgreSQL 10+ используйте GENERATED ALWAYS AS IDENTITY. Поведение то же, но связь с колонкой явная и корректно переносится при дампе.

Только «нельзя вставить свой id» — это не замок, а дверь, которую надо открыть намеренно. Обычный INSERT со своим значением такая колонка отвергнет и подскажет, что делать; кто очень хочет, напишет INSERT ... OVERRIDING SYSTEM VALUE и всё-таки вставит. Разница с serial в том, что случайно так не получится: в миграции или в скрипте импорта эта конструкция видна сразу и проходит ревью.

-- устаревший вариант
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 нельзя — только добавить (переименовать, впрочем, можно: ALTER TYPE ... RENAME VALUE). Добавить атрибуты (например, отображаемое название или порядок сортировки) — невозможно.

Как правильно: если перечисление может расти, переименовываться или требовать атрибутов — используйте справочную таблицу с FK. Это чуть больше кода, но гибкость окупается.

JSONB как «гибкая схема» для основных полей

JSONB удобен, поэтому его порой используют там, где нужны обычные колонки: metadata->>'email' вместо просто email. В итоге теряется типобезопасность, усложняются индексы, обязательность полей не контролируется.

Как правильно: если по полю фильтруют или сортируют — это колонка. JSONB оправдан для полиморфных данных (разные наборы атрибутов у разных записей), необязательных полей или редко используемых JSON-документов.

Что делать, когда уже поздно и по полю внутри документа фильтруют миллион строк. Переносить его в колонку сразу нельзя — код читает из документа. Промежуточный шаг — генерируемая колонка поверх того же документа:

ALTER TABLE products
    ADD COLUMN brand text GENERATED ALWAYS AS (attributes->>'brand') STORED;
CREATE INDEX ix_products_brand ON products (brand);

Документ остаётся источником правды, колонка обновляется сама, индекс работает как по обычной колонке. Дальше запросы переводят на неё по одному, и только потом, когда никто не читает поле из документа, поле переносят в колонку по-настоящему — обычным расширением и сжатием схемы.

Массив там, где должна быть таблица

Хранить позиции заказа в jsonb[] или int[] кажется удобным: всё в одной строке. Но это таблица, перевёрнутая на бок. По элементам массива нельзя сделать FK, нельзя добавить атрибуты к элементу, сложно обновить один элемент.

Как правильно: объекты с идентичностью (позиции, участники, вложения) — это отдельная таблица с FK. Массив уместен только для скалярных значений без идентичности: теги, коды локалей, списки строк.

Колонка без NOT NULL по умолчанию

Самый массовый дефект реальных схем, и он не про экзотику: колонка, которая на самом деле обязательна, объявлена без NOT NULL, потому что «так быстрее начать». Дальше это расходится по всему коду: каждый запрос по такой колонке обязан думать про пустоту, WHERE status <> 'PAID' молча теряет строки, UNIQUE пропускает дубли из пустых значений, а в приложении поле становится необязательным и обрастает проверками.

Правило простое и дешёвое: NOT NULL ставят по умолчанию, а разрешают пустоту осознанно, когда «неизвестно» — законное состояние предметной области (дата оплаты у неоплаченного заказа). Тогда по схеме видно, где пустота действительно возможна, и проверять её приходится только там. Добавить NOT NULL на живой таблице можно без долгой блокировки, через CHECK … NOT VALID и VALIDATE, — как именно, в статье про миграции.

char(n), numeric без точности и специальные типы

Три мелких выбора, которые всплывают позже.

char(n) добивает значение пробелами до заданной длины: 'RU' в char(3) хранится как 'RU ', и в выгрузке появляются хвосты пробелов, а сравнение с 'RU' в разных базах ведёт себя по-разному. Никакой экономии по сравнению с varchar он не даёт. Берут его только под строго фиксированные коды — валюта, страна, — а по умолчанию text или varchar.

numeric без указания точности хранит столько знаков, сколько дали. Для промежуточных расчётов это удобно, для денег — нет: шкала должна быть частью контракта, иначе одна и та же сумма приезжает то как 199.00, то как 199, и сравнение в коде через equals начинает падать. Деньги объявляют с точностью и масштабом: numeric(19,4).

inet и interval — типы, о которых часто не знают. IP-адрес держат в inet, а не в varchar: он занимает меньше, проверяется при вставке, умеет сравниваться с сетью (WHERE ip << '10.0.0.0/8') и правильно сортируется. Длительность держат в interval, а не в int минутами: единица измерения записана в самом значении, и его не надо умножать на шестьдесят при каждом чтении.

Один аргумент в пользу массива всё же есть, и он встречается в жизни: массив как денормализация под индекс. Метки товара, хранящиеся отдельной таблицей, для фильтра «покажи всё, где есть хоть одна из этих меток» требуют соединения и группировки; тот же список, продублированный в колонке-массиве с GIN-индексом, отвечает на это одним условием tags && ARRAY['новинка','распродажа'] и по индексу. Условия, при которых это оправдано: список короткий, меняется редко, отдельными элементами не управляют (нет «кто и когда поставил эту метку»), и таблица-справочник всё равно остаётся источником правды. То есть массив здесь — производные данные для быстрого фильтра, а не замена таблице.

Дополнительно: при первом чтении можно пропустить

Глубже: valid_from / valid_to вместо range-типарасширенное

Хранить интервал двумя отдельными колонками кажется очевидным, но это ловушка: без сложных триггеров нельзя запретить пересечение интервалов на уровне БД, а две параллельные вставки всё равно проскочат мимо проверки в коде.

Как правильно: 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 &&);

Одна деталь: сначала нужен CREATE EXTENSION btree_gist — без него база не умеет проверять обычное равенство по product_id внутри пространственного индекса и откажется создавать ограничение.

Глубже: Тип moneyрасширенное

money в PostgreSQL привязан к локали сессии — одно и то же значение читается по-разному в зависимости от настроек. Кода валюты в нём нет.

Как правильно: numeric(p, s) для суммы плюс отдельная колонка currency char(3) (код ISO 4217, например RUB, USD).

Глубже: Тип без таймзоны на стороне приложениярасширенное

Даже если в схеме стоит timestamptz, драйвер может передать значение без привязки к часовому поясу — и PostgreSQL интерпретирует его в зоне текущей сессии. На UTC-сервере и машине разработчика с локальной зоной одна и та же строка кода даст разные значения в БД. Та же подстановка без базы, на трёх зонах:

живой пример

import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;

public class ZoneDemo {
    public static void main(String[] args) {
        LocalDateTime naive = LocalDateTime.parse("2026-01-31T23:30:00");
        for (String zone : new String[] {"UTC", "Europe/Moscow", "Asia/Novosibirsk"}) {
            System.out.println(zone + " -> " + naive.atZone(ZoneId.of(zone)).toInstant());
        }
        OffsetDateTime exact = OffsetDateTime.parse("2026-01-31T23:30:00+03:00");
        System.out.println("с зоной -> " + exact.toInstant());
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Одна и та же строка даёт три разных момента: 23:30, 20:30 и 16:30 по UTC. Значение с зоной везде одно.

Как правильно: используйте тип с таймзоной на стороне приложения:

  • Java: Instant или OffsetDateTime (не LocalDateTime)
  • Go: time.Time (он всегда несёт зону)
  • Node.js: pg превращает объект Date в строку со смещением, но берёт смещение локальной зоны процесса — в timestamptz момент запишется верно, а вот timestamp без зоны он и прочитает как локальное время процесса. Держите в схеме timestamptz и задавайте зону процесса явно (TZ=UTC), иначе ловушка вернётся со стороны чтения
  • Python: datetime с tzinfo (не «наивный» datetime)

Глубже: Прямые вызовы системного времени и генератора UUID в кодерасширенное

Если производственный код напрямую вызывает time.Now(), Instant.now(), uuid.New() и подобное, тесты становятся недетерминированными: зафиксировать время или идентификатор без моков на уровне платформы невозможно.

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

Глубже: UUID v4 для первичного ключарасширенное

UUID v4 полностью случаен. При вставке строки PostgreSQL вынужден найти нужную страницу B-tree, которая уже могла быть вытеснена из кэша. На больших таблицах это превращается в постоянный случайный ввод-вывод и плохую упаковку страниц.

Как правильно: UUID v7 — монотонный, начинается с временно́й метки. Вставки идут последовательно, страницы упаковываются плотно, кэш работает эффективнее. В PostgreSQL 18 появилась встроенная uuidv7(); на более ранних версиях v7 генерируют на стороне приложения — gen_random_uuid() умеет только v4.

Коротко

  • Тип выбирают по смыслу значения, а не по привычке: text вместо varchar(255), uuid вместо varchar(36), boolean вместо smallint и char(1).
  • timestamptz для любого бизнес-времени — и тип с зоной на стороне приложения.
  • Деньги — numeric(p, s): у float копится погрешность, у money вместо валюты локаль сессии.
  • В новых схемах GENERATED ALWAYS AS IDENTITY, а не serial: случайно вставить свой id в обход последовательности не выйдет, а намеренно — только через заметный в ревью OVERRIDING SYSTEM VALUE.
  • Значение из PG ENUM нельзя удалить — растущее перечисление держат справочной таблицей с FK.
  • JSONB — для полиморфных и редких данных; поле, по которому фильтруют, — колонка (а если уже поздно, сначала генерируемая колонка с индексом поверх документа), объект с идентичностью — таблица, а массив оправдан только как производная денормализация под GIN.
  • Интервал — range-тип с EXCLUDE USING gist и btree_gist, а не пара valid_from / valid_to.
  • Первичный ключ — UUID v7, не v4; время и генератор идентификаторов — через подменяемый в тестах сервисный слой.
  • Дороже всего в эксплуатации не сами байты, а неявные приведения: приведение на стороне колонки отключает индекс — проверяется по Index Cond против Filter в плане.
  • NOT NULL ставят по умолчанию, а пустоту разрешают осознанно; char(n) добивает пробелами, numeric без точности теряет шкалу, для IP есть inet, для длительности — interval.

Что почитать дальше