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

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

Здесь разберём правила, которые делают схему читаемой и предсказуемой с первого взгляда.

пишем без кавычек пишем в кавычках любое из трёх написанийOrderDocorderDocorderdoc PostgreSQL: в нижний регистр orderdoc все три — одна таблица регистр сохранён как естьCREATE TABLE "OrderDoc" SELECT * FROM OrderDoc → orderdoc нет такой таблицы relation "orderdoc" does not exist

Без кавычек PostgreSQL приводит имя к нижнему регистру, поэтому три написания попадают в одну таблицу. В кавычках регистр сохраняется — и запрос, написанный без кавычек, ищет уже другое имя и падает.

Обязательно

Регистр: snake_case без кавычек

PostgreSQL приводит все имена к нижнему регистру, если только они не взяты в двойные кавычки. Поэтому OrderDoc, orderDoc и orderdoc — это одно и то же в PostgreSQL. А вот "OrderDoc" в кавычках — совсем другое: PG сохраняет регистр и требует кавычек в каждом запросе.

Правило простое: используйте snake_case без кавычек для всех объектов — таблиц, колонок, индексов, функций, constraints.

-- правильно
CREATE TABLE order_doc (
    id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id  bigint NOT NULL,
    created_at   timestamptz NOT NULL DEFAULT now()
);

-- частая ошибка
CREATE TABLE OrderDoc (
    Id          bigint,
    customerId  bigint,
    "CreatedAt" timestamptz
);

"CreatedAt" с кавычками — это ловушка: теперь во всех запросах нужно писать кавычки, иначе PG не найдёт колонку.

Таблицы: единственное число, существительные

Таблица описывает тип объекта, а не коллекцию. Поэтому имя — существительное в единственном числе: order_doc, customer, product, payment.

Множественное число (orders, customers) тоже встречается и логически не ошибочно, но смешивать подходы в одной базе — плохо. Выберите одно соглашение и придерживайтесь его везде.

Таблицы связей (M:N) называют по обеим сущностям: order_item, customer_role, product_tag.

В крупных схемах с несколькими доменами удобно добавлять префикс домена: order_*, catalog_* — или выносить домены в отдельные схемы PostgreSQL.

Почему именно единственное число, если спорить об этом можно бесконечно: в программе на Java имя таблицы почти всегда становится именем класса записи — orders превращается в Order, а не в Orders. Строка таблицы — это один заказ, и читается SELECT * FROM order как «из заказов». Довод за множественное число ровно один и тоже честный: таблица хранит много строк. Правило здесь важнее его обоснования — расходиться внутри одной базы нельзя, потому что тогда угадывать приходится каждый раз.

Альтернатива префиксу домена — отдельная схема. Вместо billing_invoice, billing_payment, billing_account заводят схему billing и в ней invoice, payment, account. Имена становятся короче, права выдаются на схему целиком, а домены видно в дереве клиента. Плата за это тоже есть: в запросах появляется полное имя billing.invoice (или настройка search_path, которая делает имена короткими, но превращает «какую таблицу я сейчас читаю» в вопрос о настройке соединения), в миграциях схему надо создавать явно, а генераторы кода начинают раскладывать классы по пакетам иначе. Правило простое: одна схема, пока доменов два-три; отдельные схемы, когда их становится столько, что префиксы съедают половину имени.

Колонки: суффиксы говорят о типе

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

Первичный ключ — просто id, без имени таблицы. В таблице customer первичный ключ — id, а не customer_id. Имя с таблицей customer_id — это формат для внешних ключей.

Внешние ключи — <родительская_таблица>_id: customer_id, order_id.

Boolean — с префиксом is_, has_, can_: is_active, has_avatar, can_publish. Без префикса непонятно: active — это статус или действие?

Временны́е метки — суффикс _at для timestamp: created_at, updated_at, expires_at. Для дат без времени — без суффикса или _on: born_on, holiday_date.

Деньги — суффиксы _amount, _price, _rate: total_amount, discount_rate. Без суффикса price — непонятно, это сумма или процент.

Длительности — явная единица измерения: ttl_seconds, delivery_days, session_timeout_ms. Просто delivery_time integer — сколько это? Секунды? Минуты? Часы?

Счётчики — суффикс _count: view_count, items_count.

Enum-статусы — без суффикса: status, type, currency.

created_at   timestamptz NOT NULL DEFAULT now(),
updated_at   timestamptz NOT NULL DEFAULT now(),
expires_at   timestamptz,
born_on      date,
ttl_seconds  integer,
total_amount numeric(15,2),
is_active    boolean NOT NULL DEFAULT true,
view_count   integer NOT NULL DEFAULT 0

Audit-колонки и soft-delete

Стандартный набор для отслеживания истории:

created_at   timestamptz NOT NULL DEFAULT now(),
updated_at   timestamptz NOT NULL DEFAULT now(),
created_by   bigint REFERENCES customer(id),
updated_by   bigint REFERENCES customer(id),
version      bigint NOT NULL DEFAULT 0

Поле version используется для оптимистической блокировки: при обновлении строки проверяем, что версия не изменилась с момента чтения.

Мягкое удаление — частая ошибка — хранить is_deleted boolean. Лучше deleted_at timestamptz:

-- правильно: момент удаления сохранён
deleted_at   timestamptz   -- NULL = запись существует

-- частая ошибка: момент удаления потерян навсегда
is_deleted   boolean

Из deleted_at тривиально получить boolean: WHERE deleted_at IS NULL. А вот из is_deleted = true время удаления уже не восстановить.

Это работает не только с удалением: любое событие, случившееся один раз, лучше хранить моментом. В песочнице практикума у заказа есть paid_at — одна колонка отвечает и на вопрос «оплачен?», и на вопрос «когда»:

живой пример

SELECT id, customer_id, total_amount, created_at, paid_at
FROM orders
WHERE paid_at IS NOT NULL
ORDER BY paid_at
LIMIT 3;
Запустить

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

Запрос читается без DDL: customer_id — ссылка на покупателя, total_amount — деньги, _at — моменты времени. А вот с числом в этой схеме как раз промах: orders, products, payments во множественном, а customer — в единственном. Это ровно та смесь, из-за которой имя таблицы приходится каждый раз вспоминать.

Индексы и constraints: префикс по типу

Когда у индекса нет имени, PostgreSQL генерирует его сам: order_doc_pkey, order_doc_customer_id_idx. Это приемлемо для первичных ключей, но для остальных объектов лучше задавать имена явно — тогда сообщения об ошибках будут понятными.

Соглашение по префиксам:

ТипПрефиксПример
Обычный индексix_ix_order_customer_id
Уникальный индексuk_uk_customer_email
Foreign keyfk_fk_order_item_order_id
Check constraintck_ck_order_total_positive
Primary keypk_ (обычно авто)—
Триггерtr_tr_order_doc_audit
CREATE INDEX ix_order_customer_id ON order_doc (customer_id);
CREATE INDEX ix_order_status_created_at ON order_doc (status, created_at);
CREATE UNIQUE INDEX uk_customer_email ON customer (email);

-- функциональный индекс
CREATE INDEX ix_account_email_lower ON account ((lower(email)));

-- частичный индекс
CREATE INDEX ix_order_active ON order_doc (customer_id) WHERE status IN ('NEW','PAID');

ALTER TABLE order_item
    ADD CONSTRAINT fk_order_item_order_id
    FOREIGN KEY (order_id) REFERENCES order_doc(id);

CONSTRAINT ck_order_total_positive CHECK (total_amount >= 0)

Разница видна в тексте ошибки. PostgreSQL пишет new row for relation "order_doc" violates check constraint и дальше имя: с явным именем это ck_order_total_positive — правило названо, и понятно, что чинить. Без имени PG придумает своё, из таблицы и колонки: order_doc_total_amount_check. Само правило в нём не названо — придётся лезть в схему и смотреть, что там за проверка. А у табличной проверки, охватывающей несколько колонок, имя выйдет ещё более скупым: order_doc_check.

Ещё один довод за префиксы, о котором узнают, когда уже поздно: таблицы, представления, последовательности и индексы живут в одном пространстве имён схемы. Назвать индекс так же, как таблицу, нельзя — база ответит relation "orders" already exists, хотя никакой таблицы вы не создавали. Поэтому ix_, uq_, fk_ и pk_ не украшение: они разводят имена по видам и заодно делают понятным, что именно упало, когда ошибка называет только имя.

Отдельно — имена типов. Перечисление, домен и составной тип живут в том же пространстве имён, что и таблицы, и их принято называть по тому, что они описывают, в единственном числе и без префиксов: order_status, currency_code, email_address. Имя типа читается в объявлении колонки (status order_status NOT NULL), поэтому order_status_enum или t_order_status только удлиняют строку. Про сами перечисления — в статье enum в PostgreSQL.

Зарезервированные слова

PostgreSQL держит список слов, которые нельзя использовать как идентификаторы без кавычек, и держать его в голове не нужно: сомневаетесь — спросите у базы запросом ниже или подберите более конкретное имя. Чаще всего спотыкаются о те, что так и просятся в имя колонки: user, order, group, check, references, limit, offset, window, default, desc, end, table.

А вот слова, которые выглядят опасно, но на деле разрешены: type, name, value, position, start, class — колонку name или value можно завести без кавычек, и такие имена встречаются сплошь и рядом. А name и value обычно и так плохие имена колонок.

Если назвать таблицу user — каждый запрос придётся писать с кавычками: SELECT * FROM "user". Это неудобно и легко сломать.

Хорошие альтернативы:

  • «Пользователь» → customer, account, person
  • «Заказ» → order_doc, purchase, shipment

Проверить любое слово или посмотреть полный список:

живой пример

SELECT * FROM pg_get_keywords() WHERE catcode IN ('R', 'T');
Запустить

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

Длина имени

Имена длиннее 63 символов PostgreSQL обрезает (это ограничение NAMEDATALEN - 1). Ошибки не будет — только сообщение уровня NOTICE вроде identifier ... will be truncated, которое в логах миграции легко пропустить. Имя при этом станет другим, а если два длинных имени совпадают в первых 63 символах, они схлопнутся в одно, и вторая миграция упадёт на «объект уже существует».

Посмотреть, что останется от длинного имени индекса:

живой пример

SELECT length('ix_order_item_delivery_address_country_code_created_at_status_active') AS name_length,
       left('ix_order_item_delivery_address_country_code_created_at_status_active', 63) AS after_truncate;
Запустить

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

68 символов на входе, 63 на выходе — хвост ctive отрезан молча.

Практическое правило: держать имена до 30 символов. Если используете сокращения — придерживайтесь одного варианта по всему проекту: если usr — то везде usr, не мешайте с user и account.

Sequences и IDENTITY

Если использовать GENERATED ALWAYS AS IDENTITY, PostgreSQL автоматически создаст последовательность в той же схеме, где лежит таблица, с именем <таблица>_<колонка>_seq. Если такое имя уже занято, к нему допишется число — customer_id_seq1. Трогать это имя не нужно: база сама помнит, какая последовательность к какой колонке привязана.

Обращаться к nextval('customer_id_seq') напрямую имеет смысл только при ручном импорте данных — в остальных случаях IDENTITY управляет sequence самостоятельно.

View и materialized view

Представления называют с суффиксом _v, материализованные — с суффиксом _mv:

CREATE VIEW customer_active_v AS
    SELECT * FROM customer WHERE deleted_at IS NULL AND is_active = true;

CREATE MATERIALIZED VIEW order_stats_mv AS
    SELECT date_trunc('day', created_at) as day, count(*) as cnt
    FROM order_doc GROUP BY 1;

Суффикс помогает сразу отличить view от таблицы при чтении запроса.

Как имя в базе превращается в имя в коде

Соглашение в базе имеет смысл ровно потому, что по ту сторону стоит код, который эти имена читает.

Hibernate и Spring Data по умолчанию применяют CamelCaseToUnderscoresNamingStrategy: поле customerId превращается в колонку customer_id, класс OrderItem — в таблицу order_item. Пока база названа в snake_case и в единственном числе, аннотаций @Table и @Column не нужно вовсе — и это лучший признак, что соглашение выбрано правильно. Как только в базе появляется orders во множественном числе, каждому классу приходится дописывать @Table(name = "orders"), и дальше расхождение только копится.

jOOQ идёт с другой стороны: генератор читает схему и делает из order_item класс OrderItem, а из колонки total_amount — поле TOTAL_AMOUNT. Тут работает то же правило: имя в базе становится именем в коде автоматически, и опечатка в схеме приезжает в код на следующей генерации.

А вот что ломается по-настоящему — имя в кавычках. Если таблица создана как CREATE TABLE "OrderDoc" (…), её имя навсегда чувствительно к регистру: генератор jOOQ класс построит и будет цитировать имя правильно, а любой рукописный запрос SELECT * FROM OrderDoc упадёт с relation "orderdoc" does not exist, потому что без кавычек база приводит имя к нижнему регистру. Отсюда правило: кавычек в CREATE TABLE быть не должно ни одной.

И про переименование. Имя в базе — это контракт: на него смотрят приложения, отчёты, скрипты поддержки и чужие интеграции. Поэтому «просто переименовать» в проде нельзя — переименование делают в два шага, через расширение и сжатие схемы. Сначала заводят новое имя и поддерживают оба (представление со старым именем поверх новой таблицы или дублирующая колонка, которую пишут обе стороны), выпускают приложение, которое использует новое, и только потом, убедившись, что старое имя никто не читает, его убирают. Подробно этот приём — в статье про миграции.

Частые ошибки

Служебный префикс tbl_ — tbl_orders — PostgreSQL и так знает, что это таблица. Префикс ничего не добавляет.

Тип данных в имени — created_timestamp вместо created_at. Тип видно из DDL, имя должно говорить о смысле.

data jsonb — слишком абстрактно. Назовите по смыслу: attributes, payload, metadata, config.

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

Глубже: как схема ложится на код: агрегаты, справочники и jOOQрасширенное

Схема проектируется ради кода, который к ней ходит, и связь между ними стоит проговорить. Таблица не равна классу: в приложении граница проходит по агрегату, группе сущностей, которые меняются вместе и защищают общие правила. Заказ и его позиции это один агрегат и две таблицы, orders и order_items; позиции не имеют смысла без заказа, меняются в одной транзакции с ним, и внешний ключ у них с ON DELETE CASCADE. Покупатель это другой агрегат: на него ссылаются, но его не меняют вместе с заказом, и каскада между ними нет. Правило простое: одна транзакция меняет один агрегат, а агрегаты ссылаются друг на друга по идентификатору.

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

jOOQ замыкает круг: он читает схему и генерирует классы Tables.ORDERS, OrdersRecord, по полю на колонку с точным типом. Отсюда практическое следствие для схемы: типы колонок сразу становятся типами в коде, timestamptz превращается в OffsetDateTime, numeric в BigDecimal, uuid в UUID, а varchar(36) для идентификатора остаётся String, и ошибка типа из базы переезжает в код. Имена колонок из этой статьи тоже видны в коде как есть, поэтому snake_case без сокращений читается как ORDERS.CREATED_AT, а crt_dt не читается нигде. Как это устроено на стороне приложения, разбирают статьи про слой доступа к данным.

Коротко

  • Всё в snake_case без кавычек: в кавычках PG сохраняет регистр и требует их в каждом запросе.
  • Таблицы — существительные в единственном числе; первичный ключ — id, внешний — <родительская_таблица>_id.
  • Boolean с is_/has_/can_, моменты времени с _at, деньги с _amount/_price, длительности с единицей (ttl_seconds).
  • Мягкое удаление — deleted_at timestamptz, не is_deleted boolean: момент сохраняется, boolean из него получается запросом.
  • Индексы и constraints — с явным именем и префиксом ix_/uk_/fk_/ck_: имя попадает в текст ошибки.
  • Имена длиннее 63 символов PostgreSQL обрежет молча, зарезервированные слова (user, order, group) потребуют кавычек.
  • Граница в коде проходит по агрегату, а не по таблице: одна транзакция меняет один агрегат; список, который правит бизнес, это справочная таблица, список, который знает код, это CHECK; jOOQ переносит типы и имена колонок в код как есть.
  • Имя в базе становится именем в коде: CamelCaseToUnderscoresNamingStrategy у Hibernate и генератор jOOQ делают это сами, пока в схеме snake_case и никаких кавычек.
  • Таблицы, индексы, представления и последовательности делят одно пространство имён; переименование в проде делают через расширение и сжатие схемы, а не одной командой.

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