Когда в команде нет единого соглашения по именованию, база данных постепенно превращается в хаос: половина таблиц с заглавной буквы, половина со строчной, индексы без имён, is_deleted рядом с deletedAt. Разобраться в такой схеме через полгода — отдельная задача.
Здесь разберём правила, которые делают схему читаемой и предсказуемой с первого взгляда.
Регистр: 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.
Колонки: суффиксы говорят о типе
Хорошее имя колонки сразу понятно без 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 время удаления уже не восстановить.
Индексы и 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)
Явное имя у CHECK constraint сразу объясняет ошибку: CHECK constraint violated: ck_order_total_positive — понятно, какое правило нарушено.
Зарезервированные слова
PostgreSQL держит список слов, которые нельзя использовать как идентификаторы без кавычек: user, order, group, type, position, value, name, default, desc, asc, start, end, class.
Если назвать таблицу user — каждый запрос придётся писать с кавычками: SELECT * FROM "user". Это неудобно и легко сломать.
Хорошие альтернативы:
- «Пользователь» →
customer,account,person - «Заказ» →
order_doc,purchase,shipment
Полный список зарезервированных слов:
SELECT * FROM pg_get_keywords() WHERE catcode IN ('R', 'T');
Длина имени
PostgreSQL молча обрезает имена длиннее 63 символов (это ограничение NAMEDATALEN - 1). Обрезание не вызывает ошибки — имя просто станет другим, и возможна коллизия с уже существующим объектом.
Практическое правило: держать имена до 30 символов. Если используете сокращения — придерживайтесь одного варианта по всему проекту: если usr — то везде usr, не мешайте с user и account.
Sequences и IDENTITY
Если использовать GENERATED ALWAYS AS IDENTITY, PostgreSQL автоматически создаст sequence с именем <таблица>_<колонка>_seq. Это имя трогать не нужно.
Обращаться к 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 от таблицы при чтении запроса.
Частые ошибки
CamelCase или кавычки — вместо "OrderDoc" пишите order_doc. Без кавычек — case-insensitive, с кавычками — придётся везде их писать.
customer_id как первичный ключ в таблице customer — правило: внутри своей таблицы ключ всегда id.
is_deleted boolean для мягкого удаления — используйте deleted_at timestamptz, момент удаления будет сохранён.
Число без единицы — delivery_time integer не говорит ничего. Пишите delivery_days или delivery_seconds.
Зарезервированные слова без кавычек — CREATE TABLE user не заработает. Переименуйте в account или customer.
Служебный префикс tbl_ — tbl_orders — PostgreSQL и так знает, что это таблица. Префикс ничего не добавляет.
Тип данных в имени — created_timestamp вместо created_at. Тип видно из DDL, имя должно говорить о смысле.
data jsonb — слишком абстрактно. Назовите по смыслу: attributes, payload, metadata, config.
FK и CHECK без явных имён — автоматические имена нечитаемые, явные имена дают понятные сообщения об ошибках.
Коротко
- Всё в snake_case, никаких кавычек — иначе PG начнёт различать регистр.
- Таблицы — существительные в единственном числе:
order_doc,customer. - Первичный ключ —
id; внешний ключ —<родительская_таблица>_id. - Boolean с
is_/has_/can_, временны́е метки с_at, деньги с_amount/_price. - Длительности — с единицей:
ttl_seconds,delivery_days. - Мягкое удаление —
deleted_at timestamptz, неis_deleted boolean. - Индексы и constraints — с префиксом
ix_/uk_/fk_/ck_и явным именем. - Зарезервированные слова (
user,order,group) — не использовать как имена. - Имена длиннее 63 символов PostgreSQL обрежет молча.
- View — суффикс
_v, materialized view —_mv.
Что почитать дальше
- Типы индексов в PostgreSQL — какой индекс выбрать для задачи.
- Composite-индексы и левый префикс — порядок колонок в индексе.
- Миграции без даунтайма — как безопасно переименовать колонку.
- Время и таймзоны в PostgreSQL — почему всегда
timestamptz.