Когда в команде нет единого соглашения по именованию, база данных постепенно превращается в хаос: половина таблиц с заглавной буквы, половина со строчной, индексы без имён, is_deleted рядом с deletedAt. Разобраться в такой схеме через полгода — отдельная задача.
Здесь разберём правила, которые делают схему читаемой и предсказуемой с первого взгляда.
Без кавычек 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 key | fk_ | fk_order_item_order_id |
| Check constraint | ck_ | ck_order_total_positive |
| Primary key | pk_ (обычно авто) | — |
| Триггер | 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и никаких кавычек. - Таблицы, индексы, представления и последовательности делят одно пространство имён; переименование в проде делают через расширение и сжатие схемы, а не одной командой.
Что почитать дальше
- Типы индексов в PostgreSQL — какой индекс выбрать для задачи.
- Composite-индексы и левый префикс — порядок колонок в индексе.
- Миграции без даунтайма — как безопасно переименовать колонку.
- Время и таймзоны в PostgreSQL — почему всегда
timestamptz.