Первичный и внешний ключ в SQL: что это и как связаны таблицы
Первичный и внешний ключ простыми словами: PRIMARY KEY и FOREIGN KEY в CREATE TABLE, составной ключ, типы связей, ON DELETE и проверки ключей, когда ограничений в базе нет.
Содержание статьи
Аналитик собирает отчёт по выручке и хочет рядом с каждым платежом видеть, заходил ли человек в продукт. Он соединяет платежи с событиями по user_id, и выручка вырастает с $30 639 до $284 913. Запрос выполнился без ошибок. Чтобы предсказать такое заранее, нужно понимать ключи: какая колонка делает строку единственной, а какая ссылается на строку другой таблицы. Первичный ключ отвечает на первый вопрос, внешний — на второй. Ниже — определения, синтаксис PRIMARY KEY и FOREIGN KEY, проверенный на PostgreSQL 16, типы связей между таблицами и главное для аналитика: как по ключам заранее понять, размножит ли JOIN строки. Числа посчитаны на учебной базе SQL-курса, запросы можно повторить в его песочнице.
Коротко
Первичный ключ (PRIMARY KEY) — колонка или набор колонок, значение которых уникально и не пусто в каждой строке. Внешний ключ (FOREIGN KEY) — колонка, значение которой должно совпадать с первичным или уникальным ключом другой таблицы.
- Первичный ключ в таблице один. Он запрещает повторы и NULL, а PostgreSQL сам строит под него уникальный индекс.
- Внешних ключей может быть сколько угодно. Значения в них повторяются: у одного пользователя много платежей.
- Внешний ключ не даёт сослаться на несуществующую строку и решает, что будет со ссылками при удалении: запретить, удалить каскадом или обнулить.
- Для аналитика ключи — это прогноз JOIN. Соединение «от многих к одному» сохраняет число строк, «от одного к многим» — умножает.
- В аналитических хранилищах ключи часто не проверяются. Тогда уникальность и «сирот» проверяют запросом до того, как строить на таблице отчёт.
Первичный ключ: что делает строку единственной
Первичный ключ — это ответ на вопрос «как отличить эту строку от всех остальных». Для таблицы пользователей это номер пользователя, для таблицы платежей — номер платежа. Два условия обязательны: значение не повторяется и не бывает пустым. По сути PRIMARY KEY — это UNIQUE и NOT NULL вместе, плюс пометка «это главный идентификатор таблицы».
В учебной базе SQL-курса у каждой из пяти таблиц есть такая колонка. Проверка простая: число строк должно совпасть с числом различных значений, а NULL не должно быть ни одного. Для всех пяти таблиц так и есть.
Первичный ключ в таблице может быть только один. Если объявить два, PostgreSQL ответит multiple primary keys for table "…" are not allowed. Другие колонки, которые тоже должны быть уникальными, например email, объявляют через UNIQUE.
| Таблица | Первичный ключ | Строк | Различных значений | NULL |
|---|---|---|---|---|
| users | user_id | 4 613 | 4 613 | 0 |
| events | event_id | 35 341 | 35 341 | 0 |
| payments | payment_id | 1 251 | 1 251 | 0 |
| subscriptions | subscription_id | 912 | 912 | 0 |
| experiment_exposures | exposure_id | 3 068 | 3 068 | 0 |
Внешний ключ: как платёж знает, чей он
В таблице платежей нет канала, страны и даты регистрации. Там есть user_id — ссылка на строку в users. Это и есть внешний ключ: колонка в одной таблице, значения которой берутся из первичного ключа другой. Таблицу со ссылкой называют дочерней, таблицу, на которую ссылаются, — родительской.
В отличие от первичного ключа, внешний повторяется. В payments 1 251 строка, а различных user_id — 912: кто-то платил дважды или трижды. В events 35 341 строка и 4 613 различных user_id — у каждого пользователя есть хотя бы одно событие.
Ссылаться внешний ключ может только на колонку, уникальность которой объявлена: первичный ключ или UNIQUE. Попытка сослаться на обычную колонку, например на users.channel, в PostgreSQL заканчивается ошибкой there is no unique constraint matching given keys for referenced table "users".
NULL во внешнем ключе разрешён, если колонку не объявили NOT NULL. Такая строка просто ни на что не ссылается. Для платежей это обычно ошибка данных, поэтому внешний ключ часто объявляют вместе с NOT NULL.
| Таблица | Строк | Различных user_id | Строк на пользователя |
|---|---|---|---|
| events | 35 341 | 4 613 | от 1 до 22, медиана 7 |
| payments | 1 251 | 912 | от 1 до 3 |
| subscriptions | 912 | 912 | ровно 1 |
| experiment_exposures | 3 068 | 3 068 | ровно 1 |
Чем первичный ключ отличается от внешнего
Короткий ответ: первичный ключ идентифицирует строку своей таблицы, внешний указывает на строку чужой. Остальные различия следуют из этого.
| Первичный ключ | Внешний ключ | |
|---|---|---|
| Что делает | отличает строку от остальных | ссылается на строку другой таблицы |
| Повторы | запрещены | разрешены |
| NULL | запрещён | разрешён, если не объявлен NOT NULL |
| Сколько в таблице | один | сколько угодно |
| Индекс в PostgreSQL | создаётся автоматически | не создаётся, его добавляют вручную |
| Что проверяет база | уникальность при вставке и изменении | существование родителя; удаление родителя |
Составной, естественный и суррогатный ключ
Ключ из одной колонки называют простым, из нескольких — составным. Составной ключ нужен, когда уникальна только комбинация значений. В таблице участников эксперимента один человек может попасть в несколько разных экспериментов, но в один и тот же — только однажды. Значит, уникальна пара (experiment_name, user_id). В учебной базе эксперимент один, и пар 3 068 — столько же, сколько строк.
Второе деление — по происхождению значения. Естественный ключ взят из самих данных: email, номер паспорта, код страны. Суррогатный придуман базой: порядковый номер или UUID, который ничего не значит, кроме «эта строка». Все пять ключей учебной базы суррогатные.
Суррогатный ключ обычно удобнее. Email меняется, у двух систем он может быть записан в разном регистре, а номер строки остаётся прежним. В PostgreSQL его генерируют через GENERATED ALWAYS AS IDENTITY или старый serial, а UUID — встроенной функцией gen_random_uuid(). Естественный ключ при этом полезно всё равно защитить ограничением UNIQUE, иначе один человек зарегистрируется дважды под двумя номерами.
Будьте осторожны с «ключами по совпадению». В учебной базе пара (user_id, event_time) в событиях тоже уникальна: 35 341 пара на 35 341 строку. Но это свойство конкретной выгрузки, а не правило. В живом потоке событий повторная доставка даёт две одинаковые строки, и строить на такой паре дедупликацию без проверки нельзя.
| Тип | Пример | Плюс | Риск |
|---|---|---|---|
| простой суррогатный | user_id, payment_id | не меняется, короткий | не защищает от дублей по смыслу |
| простой естественный | email, код страны | понятен без справочника | значение меняется или пишется по-разному |
| составной | (experiment_name, user_id) | точно описывает правило уникальности | длинные условия JOIN |
| UUID | gen_random_uuid() | генерируется где угодно без обращения к базе | длиннее числа, неудобно читать |
Связи один-к-одному, один-ко-многим и многие-ко-многим
Связь между таблицами описывают тем, сколько строк с каждой стороны может соответствовать одной строке с другой. Все три типа есть в учебной базе.
Один-ко-многим — самая частая связь. Один пользователь, много платежей: 622 плательщика заплатили один раз, 241 — дважды, 49 — трижды. Один пользователь, много событий: от 1 до 22, медиана 7. Внешний ключ стоит на стороне «многих».
Один-к-одному — внешний ключ, который ещё и уникален. У плательщика одна подписка: 912 строк в subscriptions и 912 различных user_id. Строго говоря, связь «один к нулю или одному»: из 4 613 пользователей подписка есть у 912. В базе такое правило закрепляют ограничением UNIQUE на subscriptions.user_id, и вторая подписка того же человека не запишется.
Многие-ко-многим напрямую не хранится. Её раскладывают на две связи один-ко-многим через промежуточную таблицу. experiment_exposures — такая таблица между пользователями и экспериментами: один человек может участвовать во многих экспериментах, в одном эксперименте много людей. Сейчас эксперимент один, в нём 3 068 человек, а 1 545 пользователей в тест не попали.
| Связь | Тип | Как устроена |
|---|---|---|
| users → events | один-ко-многим | events.user_id повторяется |
| users → payments | один-ко-многим | payments.user_id повторяется |
| users → subscriptions | один к нулю или одному | subscriptions.user_id уникален |
| users ↔ эксперименты | многие-ко-многим | через experiment_exposures с ключом (experiment_name, user_id) |
912 плательщиков, 1 251 платёж. Связь users → payments — один-ко-многим: внешний ключ payments.user_id повторяется. Учебная база SQL-курса.
SELECT n_payments, count(*) AS payers
FROM (
SELECT user_id, count(*) AS n_payments
FROM payments
GROUP BY user_id
) AS per_user
GROUP BY n_payments
ORDER BY n_payments;
-- 1 → 622, 2 → 241, 3 → 49PRIMARY KEY и FOREIGN KEY в CREATE TABLE
Ключи объявляют при создании таблицы. Ниже часть учебной схемы, записанная с ограничениями, как её создали бы в PostgreSQL. В песочнице курса этот код не выполнится: она работает только на чтение. Проверить его можно в любом PostgreSQL, например на временных таблицах.
Простой ключ пишут прямо у колонки. Составной — отдельной строкой после колонок: PRIMARY KEY (experiment_name, user_id). Внешний ключ у колонки записывается как REFERENCES users (user_id); если имя колонки в родительской таблице опустить, возьмётся её первичный ключ.
Если таблица уже существует, ключ добавляют через ALTER TABLE … ADD CONSTRAINT. PostgreSQL при этом проверит все старые строки и откажется создавать ограничение, если хотя бы одна строка ссылается в пустоту. Опция NOT VALID пропускает проверку старых строк, но новые проверяются сразу.
| Действие | Сообщение |
|---|---|
| вставить второго пользователя с user_id = 1 | duplicate key value violates unique constraint "users_pkey" |
| вставить пользователя с user_id = NULL | null value in column "user_id" of relation "users" violates not-null constraint |
| вставить платёж пользователя 99, которого нет | insert or update on table "payments" violates foreign key constraint "payments_user_id_fkey" |
| удалить пользователя, у которого есть платежи | update or delete on table "users" violates foreign key constraint "payments_user_id_fkey" on table "payments" |
| вторая подписка того же пользователя | duplicate key value violates unique constraint "subscriptions_user_id_key" |
CREATE TABLE users (
user_id integer PRIMARY KEY,
signup_date date NOT NULL,
channel text
);
CREATE TABLE payments (
payment_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES users (user_id),
paid_at date NOT NULL,
amount numeric(10, 2) NOT NULL
);
CREATE TABLE subscriptions (
subscription_id integer PRIMARY KEY,
user_id integer NOT NULL UNIQUE REFERENCES users (user_id),
plan text NOT NULL
);
CREATE TABLE experiment_exposures (
experiment_name text NOT NULL,
user_id integer NOT NULL REFERENCES users (user_id),
variant text NOT NULL,
PRIMARY KEY (experiment_name, user_id)
);
-- если таблицу создали без ключа, его добавляют позже
ALTER TABLE payments
ADD CONSTRAINT payments_user_fk
FOREIGN KEY (user_id) REFERENCES users (user_id);Ограничения внешнего ключа: что происходит при удалении
Внешний ключ проверяет не только вставку, но и удаление родителя. Что делать с платежами, если удалили пользователя, задаёт ON DELETE. По умолчанию действует NO ACTION: удаление запрещено, пока на строку кто-то ссылается. RESTRICT запрещает то же самое, но его проверку нельзя отложить до конца транзакции, даже если ограничение объявлено как DEFERRABLE.
Два других варианта меняют данные молча, и аналитику важно о них знать. CASCADE удаляет дочерние строки вместе с родителем: удалили пользователя — пропали его платежи, и выручка прошлых месяцев в отчёте изменилась задним числом. SET NULL оставляет платежи, но обнуляет в них user_id. Итог по таблице платежей сохраняется, а в разбивке по каналам через JOIN с users эти платежи выпадают, и сумма по каналам перестаёт сходиться с общей.
Если в отчёте выручка за закрытый месяц изменилась, а новых платежей за этот месяц не было, первое, что стоит проверить, — правила удаления у внешних ключей и журнал удалений в источнике.
| Правило | Что происходит с платежами при удалении пользователя | Что видит аналитик |
|---|---|---|
| NO ACTION (по умолчанию) | удаление запрещено, пока есть платежи | история не меняется |
| RESTRICT | то же, но проверку нельзя отложить | история не меняется |
| CASCADE | платежи удаляются вместе с пользователем | выручка прошлых периодов уменьшается |
| SET NULL | в платежах user_id становится NULL | сумма по каналам меньше общей суммы |
| SET DEFAULT | в платежах ставится значение по умолчанию | платежи уходят на «технического» пользователя |
Что ключи значат для аналитика: зерно и JOIN без дублей
Каждый JOIN соединяет таблицы по какой-то паре колонок, и ключи заранее говорят, сколько строк получится. Правило одно. Если идти от внешнего ключа к первичному — от платежа к его пользователю, — у каждой строки слева ровно одна пара справа, и строк не прибавляется. Если идти в обратную сторону — от пользователя к его платежам, — строки умножаются на число платежей.
Хуже всего соединять две «многие» таблицы через общего родителя. Платежи и события обе ссылаются на users, но друг на друга — нет. JOIN по user_id сопоставляет каждый платёж человека с каждым его событием. В учебной базе это 11 667 строк вместо 1 251, и sum(amount) даёт $284 913 вместо $30 639.
Соединение платежей с подписками по тому же user_id безопасно: subscriptions.user_id уникален, у каждого платежа одна пара. Строк остаётся 1 251, сумма — $30 639. Разница между этим JOIN и предыдущим видна только по ключам, синтаксис у них одинаковый.
Отсюда рабочая привычка: перед соединением назвать зерно обеих таблиц и тип связи. Если связь один-ко-многим в сторону «многих», сначала агрегируйте многую сторону до одной строки на ключ, а уже потом соединяйте.
| Соединение | Тип связи | Строк | sum(amount) |
|---|---|---|---|
| payments без соединения | — | 1 251 | $30 639 |
| payments → users | многие к одному | 1 251 | $30 639 |
| payments → subscriptions | многие к одному (user_id уникален) | 1 251 | $30 639 |
| users → payments, LEFT JOIN | один ко многим | 4 952 | $30 639 |
| payments ↔ events | многие ко многим | 11 667 | $284 913 |
SELECT
(SELECT count(*) FROM payments) AS payment_rows, -- 1 251
(SELECT sum(amount) FROM payments) AS revenue, -- 30 639
(SELECT count(*) FROM payments p
JOIN events e ON e.user_id = p.user_id) AS rows_with_events, -- 11 667
(SELECT sum(p.amount) FROM payments p
JOIN events e ON e.user_id = p.user_id) AS inflated_revenue, -- 284 913
(SELECT sum(p.amount) FROM payments p
JOIN subscriptions s ON s.user_id = p.user_id) AS revenue_with_subs;-- 30 639Если ограничений в базе нет: проверка уникальности и «сирот»
Ключ, объявленный в базе, проверяется при каждой записи. Но в аналитике таблицы часто собирают без ограничений. BigQuery принимает PRIMARY KEY и FOREIGN KEY только с пометкой NOT ENFORCED и в документации прямо предупреждает, что запросы к таблицам с нарушенными ключами могут вернуть неверный результат. В Snowflake на стандартных таблицах проверяется только NOT NULL, а первичный, внешний и уникальный ключи остаются описанием. В ClickHouse PRIMARY KEY задаёт разреженный индекс для чтения и допускает строки с одинаковым значением ключа.
Учебная база устроена так же. Таблицы созданы через CREATE TABLE AS, и запрос SELECT count(*) FROM duckdb_constraints() в песочнице вернёт 0: ни одного объявленного ограничения. Колонки user_id, payment_id и остальные — ключи по смыслу и по данным, но не по схеме.
Поэтому ключи проверяют запросом. Три вопроса: уникален ли первичный ключ, нет ли в нём NULL и у всех ли ссылок есть родитель. Строки дочерней таблицы без родителя называют «сиротами». В учебной базе проверки проходят чисто: дублей нет, NULL нет, сирот ноль во всех четырёх дочерних таблицах. В рабочих данных сироты появляются, когда события приходят раньше, чем пользователь попал в таблицу, или когда пользователя удалили, а события оставили.
SELECT
-- 1. первичный ключ уникален: разница должна быть 0
(SELECT count(*) - count(DISTINCT user_id) FROM users) AS duplicate_ids, -- 0
-- 2. в первичном ключе нет NULL
(SELECT count(*) - count(user_id) FROM users) AS null_ids, -- 0
-- 3. у каждого платежа и события есть пользователь
(SELECT count(*) FROM payments p
WHERE NOT EXISTS (SELECT 1 FROM users u
WHERE u.user_id = p.user_id)) AS orphan_payments, -- 0
(SELECT count(*) FROM events e
WHERE NOT EXISTS (SELECT 1 FROM users u
WHERE u.user_id = e.user_id)) AS orphan_events; -- 0Перед первым отчётом на новой таблице, после смены источника и каждый раз, когда JOIN даёт больше строк, чем ожидалось. Если нашлись дубли, покажите их списком: GROUP BY user_id HAVING count(*) > 1. Это быстрее объяснить инженеру, чем разницу двух чисел.
Частые вопросы
Может ли первичный ключ быть NULL? Нет. PRIMARY KEY включает NOT NULL, и PostgreSQL отвергнет такую строку с ошибкой violates not-null constraint.
Может ли внешний ключ быть NULL? Да, если колонку не объявили NOT NULL. Строка с NULL во внешнем ключе ни на что не ссылается и проверку проходит.
Может ли внешний ключ ссылаться не на первичный ключ? Да, на любую колонку или набор колонок с ограничением UNIQUE. На обычную колонку без уникальности — нет.
Может ли таблица ссылаться сама на себя? Да. Классический пример — manager_id в таблице сотрудников, который ссылается на employee_id той же таблицы.
Создаёт ли PostgreSQL индекс на внешний ключ? Нет, только на первичный ключ и UNIQUE. Индекс на колонке внешнего ключа обычно добавляют вручную: он ускоряет JOIN и удаление родителя.
Поддерживает ли ключи DuckDB? Да, PRIMARY KEY и FOREIGN KEY в DuckDB проверяются. Правила CASCADE и SET NULL для внешних ключей DuckDB 1.5 не поддерживает и отвечает ошибкой при создании таблицы.
Итог
Первичный ключ делает строку единственной: он уникален, не пуст и в таблице один. Внешний ключ связывает строку с родителем в другой таблице: он повторяется, может быть пустым и не даёт сослаться в пустоту. Вместе они задают тип связи — один-к-одному, один-ко-многим или многие-ко-многим через промежуточную таблицу.
Для аналитика из этого следует одно практическое правило: прежде чем соединять таблицы, назовите их ключи и направление связи. Тогда 11 667 строк вместо 1 251 не станут сюрпризом. А если ограничений в базе нет, уникальность и сирот проверяют запросом — три строки SQL перед отчётом. Ключи разбираются в нулевой главе SQL-курса, JOIN без дублей — в четвёртой. Главы 0–2 открыты бесплатно.
Материалы по теме
Что такое SQL простыми словами: реляционная база, таблицы и запросы
Что такое SQL и реляционная база данных: таблица, строка и колонка, первый запрос с результатом, JOIN, команды языка, СУБД и диалекты, что SQL умеет и чего не умеет.
SQL-запросы: 40 примеров для аналитика с результатами
Сорок SQL-запросов на одной учебной базе: SELECT и WHERE, COUNT и GROUP BY, JOIN, даты, оконные функции, DAU, retention и A/B-тест. У каждого запроса показан результат.
SQL-задачи с решениями: 30 задач для аналитика на данных продукта
Тридцать SQL-задач с ответами и решениями на одной учебной базе: WHERE, GROUP BY, JOIN, даты, когорты и оконные функции. Каждый ответ можно проверить в песочнице.