Зачем учить DDL отдельно
До сих пор мы писали почти исключительно SELECT — читали готовую e-commerce-вселенную. На реальной работе схема не падает с неба: её кто-то проектирует и каждое решение в CREATE TABLE влияет на запросы следующие пять лет. Неправильный тип у колонки — медленный индекс. Пропущенный NOT NULL — WHERE x = 1 молча отсекает строки с NULL. SERIAL вместо IDENTITY — головная боль при pg_dump и миграциях между мажорными версиями.
В этом модуле собираемся прыгнуть на другую сторону баррикады и научиться проектировать таблицы. Начнём с самого базового — синтаксиса CREATE TABLE и того, что значит каждая его часть.
Анатомия CREATE TABLE
CREATE TABLE объявляет новое отношение: имя, список атрибутов с типами и ограничениями. Минимальная форма выглядит так:
CREATE TABLE table_name (
column_name data_type [column_constraints],
...
[table_constraints]
);
Три уровня деталей:
- Тип колонки — что вообще туда можно положить (
INT,TEXT,TIMESTAMPTZ,JSONB…). - Column constraints — правила на одну колонку (
NOT NULL,DEFAULT,UNIQUE,CHECK). - Table constraints — правила на несколько колонок сразу (
PRIMARY KEY (a, b),FOREIGN KEY,CHECKчерез несколько полей).
Каждый CREATE TABLE — это имя, набор колонок с типами и набор ограничений. Ограничения бывают на уровне колонки и на уровне таблицы.
Создадим простую таблицу подписок и посмотрим, как Postgres её принимает:
Минимальная таблица subscriptions: четыре колонки, два ограничения
Что произошло: для первой подписки мы не указали plan — Postgres подставил 'free' из DEFAULT. Для обеих не указали created_at — Postgres подставил текущее время. Колонка id — PRIMARY KEY, значит автоматически и NOT NULL, и UNIQUE.
Базовые типы PostgreSQL
Тип колонки — это контракт «что туда можно положить и сколько места это займёт». Полная палитра огромна; вот восемь, которые покрывают 90% случаев:
| Тип | Когда брать | Размер |
|---|---|---|
INT / BIGINT | целые числа: счётчики, FK, цены в копейках | 4 / 8 байт |
NUMERIC(p, s) | деньги, точные дроби | переменный |
TEXT | любые строки — заголовки, имена, описания | переменный |
BOOLEAN | флаги | 1 байт |
DATE | дни без времени | 4 байта |
TIMESTAMPTZ | момент времени с зоной | 8 байт |
JSONB | полуструктурированные данные | переменный |
UUID | глобально уникальные ID | 16 байт |
Две тонкости:
VARCHAR(n)vsTEXT— в Postgres они хранятся одинаково и работают одинаково быстро. Лимит длиныVARCHAR(n)— этоCHECK (length(x) <= n)и больше ничего. Если лимит реально нужен — пишите явныйCHECK, иначе беритеTEXT. Это противоположно интуиции из MySQL.TIMESTAMPvsTIMESTAMPTZ— первый хранит «дату и время как есть, без часового пояса», второй конвертит в UTC при записи и обратно в зону клиента при чтении. Для приложений с пользователями из разных стран почти всегда нуженTIMESTAMPTZ.
NOT NULL и DEFAULT — два разных инструмента
Эти два часто путают, хотя они решают разные задачи.
NOT NULL — это
NULL». Если попытаться вставить NULL или не указать значение и нет DEFAULT — Postgres откажет с ошибкой.
DEFAULT — это значение, которое подставится, если в INSERT колонка пропущена. Это не ограничение, а «удобство». Само по себе DEFAULT ничего не запрещает: можно явно вставить NULL в created_at DEFAULT now() — если на колонке нет NOT NULL, всё пройдёт.
Часто эти два используются вместе: created_at TIMESTAMPTZ NOT NULL DEFAULT now() означает «обязательное поле, но если ты не дал значение — я подставлю текущее время».
Проверь, как ведёт себя NOT NULL без DEFAULT и DEFAULT без NOT NULL
SERIAL vs IDENTITY — что выбрать в 2026
До Postgres 10 единственный способ автоматически генерировать id был SERIAL. Это псевдотип — Postgres под капотом разворачивал его в три вещи: обычная колонка INT, отдельная SEQUENCE, и DEFAULT nextval(sequence).
В Postgres 10 появился стандарт SQL — GENERATED ... AS IDENTITY. Он делает то же самое, но синтаксис ближе к стандарту и поведение более предсказуемое:
SERIAL | IDENTITY | |
|---|---|---|
| Возраст | старый, до Postgres 10 | Postgres 10+ |
| Стандарт SQL | нет | да (SQL:2003) |
INSERT ... VALUES (DEFAULT, ...) | работает | работает |
Явная вставка значения в id | разрешена тихо, ломает sequence | блокируется ALWAYS, разрешается BY DEFAULT |
| Принадлежит таблице | SEQUENCE отдельный объект | SEQUENCE принадлежит колонке |
Поведение при DROP COLUMN | sequence остаётся «висеть» | sequence удаляется вместе с колонкой |
Главная польза IDENTITY — режим ALWAYS. Он запрещает приложению вставлять явное значение в id, что страхует от классического бага: «вставили строку с id=100, sequence не сдвинулся, потом через nextval пришёл id=100, упало по уникальности».
IDENTITY ALWAYS защищает от случайной вставки явного id и ломки sequence. SERIAL такой защиты не даёт.
Рекомендация: для новых таблиц — IDENTITY (обычно BY DEFAULT; ALWAYS если вы хотите параноидальной защиты).
Сравни два способа генерировать id
PRIMARY KEY — что под капотом
PRIMARY KEY выглядит как магическое слово, но никакой особой структуры за ним нет. Это синтаксический сахар над двумя более простыми вещами:
NOT NULL— в первичный ключ не должен попастьNULL.UNIQUE— все значения должны быть разными.
Плюс одна бонусная штука: Postgres автоматически создаёт уникальный B-tree индекс, чтобы и NOT NULL, и UNIQUE, и быстрый поиск по id обеспечивались одной структурой.
То есть это эквивалентно:
CREATE TABLE t (
id INT NOT NULL UNIQUE,
...
);
CREATE UNIQUE INDEX ON t (id);
…только записанное в одну строку и помеченное как «вот этот ключ — главный». Можно иметь только один PRIMARY KEY на таблицу, но сколько угодно UNIQUE ограничений.
Маленький нюанс: UNIQUE в стандарте SQL допускает несколько NULL (потому что NULL = NULL это NULL, а не TRUE). Это часто удивляет. В Postgres 15+ есть UNIQUE NULLS NOT DISTINCT, чтобы трактовать NULL-ы как равные и не пускать больше одного.
Чек-лист
CREATE TABLEобъявляет имя, типы колонок и ограничения. Ограничения бывают на колонку (NOT NULL,DEFAULT,UNIQUE,CHECK) и на таблицу (PRIMARY KEY (a,b),FOREIGN KEY).- Базовый набор типов в Postgres:
INT/BIGINT,NUMERIC,TEXT,BOOLEAN,DATE,TIMESTAMPTZ,JSONB,UUID.VARCHAR(n)в Postgres не быстрееTEXT— беритеTEXT+CHECKесли нужен лимит. NOT NULL— это запрет наNULL.DEFAULT— это значение «если не указано». Это разные инструменты, часто используются вместе.- В 2026 году для автогенерируемых
idберитеGENERATED ... AS IDENTITY, а неSERIAL.IDENTITY ALWAYSзащищает от случайной ломки sequence. PRIMARY KEYпод капотом — этоNOT NULL UNIQUEплюс автоматический B-tree индекс. Один на таблицу.