sqlpostgresqlforeign-keycascade

Foreign Key ON DELETE: CASCADE, SET NULL and RESTRICT in Practice

What happens to child rows when you delete a parent, and how to choose between CASCADE, SET NULL, RESTRICT and NO ACTION without losing data.

10 min lukuaikaReferencesql · postgresql · foreign-key · cascade · constraints · referential-integrity
Tämä artikkeli on tällä hetkellä venäjäksi — englanninkielinen käännös on työn alla.

Когда мы связываем таблицы через внешний ключ, база данных не просто «запоминает», что одна таблица зависит от другой. Она ещё должна понимать, что делать, если родительскую запись удалят.

Например, у нас есть пользователь и его заказы. Пользователь хранится в таблице users, а заказы — в таблице orders. Каждый заказ ссылается на конкретного пользователя через user_id.

И вот вопрос: что должно произойти с заказами, если удалить пользователя?

Вариантов несколько:

  • удалить заказы вместе с пользователем;
  • оставить заказы, но убрать из них ссылку на пользователя;
  • запретить удаление пользователя, пока у него есть заказы;
  • отложить проверку до конца транзакции.

За это поведение отвечает часть внешнего ключа ON DELETE.


Базовый пример: пользователи и заказы

Представим простой интернет-магазин. У нас есть пользователи:

CREATE TABLE users (
    id         bigint PRIMARY KEY,
    email      text UNIQUE NOT NULL,
    name       text,
    country    text,
    created_at timestamptz DEFAULT now()
);

И есть заказы:

CREATE TABLE orders (
    id         bigint PRIMARY KEY,
    user_id    bigint NOT NULL,
    amount     numeric(12,2),
    status     text,
    created_at timestamptz DEFAULT now(),

    CONSTRAINT fk_orders_user
        FOREIGN KEY (user_id)
        REFERENCES users(id)
        ON DELETE CASCADE
);

Здесь поле orders.user_id ссылается на users.id.

Это значит: в таблице orders нельзя создать заказ с user_id, которого нет в таблице users.

Например, если пользователя с id = 7 не существует, такой запрос упадёт с ошибкой:

INSERT INTO orders (id, user_id, amount, status)
VALUES (1001, 7, 2500, 'new');

База скажет: «Нельзя создать заказ для пользователя, которого нет».

И это хорошо. Так мы защищаем данные от мусора и случайных ошибок.

Но внешний ключ проверяет не только вставку. Он ещё участвует в удалении.


Что произойдёт при удалении родителя

Допустим, у пользователя есть заказы:

SELECT *
FROM orders
WHERE user_id = 7;

А потом мы удаляем самого пользователя:

DELETE FROM users
WHERE id = 7;

Что теперь делать с его заказами?

Они ведь больше не смогут ссылаться на пользователя 7, потому что такого пользователя уже нет. Получается «висящая ссылка»: заказ указывает туда, где записи больше не существует.

Чтобы этого не было, база использует правило ON DELETE.

В нашем примере указано:

ON DELETE CASCADE

Это значит: если удалить пользователя, база автоматически удалит и все его заказы.

То есть один запрос:

DELETE FROM users
WHERE id = 7;

на самом деле приведёт к двум последствиям:

  1. пользователь 7 будет удалён из users;
  2. все его заказы будут удалены из orders.

Именно это называется каскадным удалением.


ON DELETE CASCADE: удалить дочерние строки вместе с родителем

CASCADE означает: «если удаляем родителя, удаляем и всё, что от него зависит».

Пример:

CREATE TABLE orders (
    id         bigint PRIMARY KEY,
    user_id    bigint NOT NULL,
    amount     numeric(12,2),
    status     text,

    CONSTRAINT fk_orders_user
        FOREIGN KEY (user_id)
        REFERENCES users(id)
        ON DELETE CASCADE
);

Теперь удаление пользователя автоматически удалит его заказы:

DELETE FROM users
WHERE id = 7;

Это удобно, когда дочерние данные не имеют смысла без родительской записи.

Хороший пример — позиции заказа.

Допустим, есть таблица orders и таблица order_items:

CREATE TABLE order_items (
    id         bigint PRIMARY KEY,
    order_id   bigint NOT NULL,
    product_id bigint NOT NULL,
    quantity   int NOT NULL,

    CONSTRAINT fk_order_items_order
        FOREIGN KEY (order_id)
        REFERENCES orders(id)
        ON DELETE CASCADE
);

Позиция заказа без самого заказа обычно не нужна. Если мы удаляем заказ, логично удалить и его позиции.

DELETE FROM orders
WHERE id = 1001;

После этого все строки из order_items, связанные с заказом 1001, тоже удалятся.

Вот здесь CASCADE выглядит естественно.


Где CASCADE может быть опасен

ON DELETE CASCADE выглядит очень соблазнительно: база сама всё подчистит, не надо писать дополнительные запросы, не надо думать о зависимых строках.

Но именно поэтому он опасен.

Представьте такой запрос:

DELETE FROM users
WHERE country = 'RU';

На первый взгляд мы просто удаляем пользователей из России. Но если у таблицы orders стоит ON DELETE CASCADE, вместе с пользователями удалятся и все их заказы.

А если у заказов есть позиции заказа с ON DELETE CASCADE, то удалятся ещё и они.

Получается цепочка:

users
  -> orders
      -> order_items

Один запрос может снести сразу несколько уровней данных.

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

Поэтому важное правило:

CASCADE подходит для технически зависимых данных, но плохо подходит для ценных бизнес-сущностей.

Например:

  • позиции заказа можно удалить вместе с заказом;
  • временные токены можно удалить вместе с пользователем;
  • черновики или настройки можно удалить вместе с аккаунтом.

Но вот заказы, платежи, документы, транзакции и история действий часто должны жить дольше пользователя. Даже если пользователь удалил аккаунт, бизнесу может быть нужно хранить историю покупок, возвратов, оплат и налоговых документов.

Для таких случаев лучше использовать RESTRICT, SET NULL или мягкое удаление через поле deleted_at.


ON DELETE SET NULL: оставить строку, но убрать ссылку

Иногда дочерняя запись должна остаться, даже если родитель удалён.

Например, у нас есть заказы. Пользователь удалил аккаунт, но сам заказ всё ещё важен: по нему была оплата, доставка, возврат, отчётность.

В такой ситуации можно сделать так, чтобы при удалении пользователя заказ остался, но ссылка на пользователя стала NULL.

Для этого используется:

ON DELETE SET NULL

Пример:

CREATE TABLE orders (
    id         bigint PRIMARY KEY,
    user_id    bigint,
    amount     numeric(12,2),
    status     text,
    created_at timestamptz DEFAULT now(),

    CONSTRAINT fk_orders_user
        FOREIGN KEY (user_id)
        REFERENCES users(id)
        ON DELETE SET NULL
);

Обратите внимание: здесь user_id уже не NOT NULL.

Это важно.

Если мы хотим, чтобы база могла записать в user_id значение NULL, колонка должна разрешать NULL.

Теперь при удалении пользователя:

DELETE FROM users
WHERE id = 7;

заказы не удалятся. Они останутся в таблице, но поле user_id станет NULL.

Условно было так:

id user_id amount status
1001 7 2500.00 paid
1002 7 1100.00 shipped

Станет так:

id user_id amount status
1001 NULL 2500.00 paid
1002 NULL 1100.00 shipped

Смысл такой: «заказы существуют, но пользователь, к которому они были привязаны, удалён».


Как изменить CASCADE на SET NULL

Допустим, сначала мы создали внешний ключ с CASCADE, а потом поняли, что заказы удалять нельзя. Их нужно оставлять, просто очищая ссылку на пользователя.

Тогда можно изменить ограничение.

Сначала нужно разрешить NULL в колонке user_id:

ALTER TABLE orders
    ALTER COLUMN user_id DROP NOT NULL;

Потом удалить старый внешний ключ и создать новый:

ALTER TABLE orders
    DROP CONSTRAINT fk_orders_user;

ALTER TABLE orders
    ADD CONSTRAINT fk_orders_user
        FOREIGN KEY (user_id)
        REFERENCES users(id)
        ON DELETE SET NULL;

Теперь удаление пользователя не будет удалять заказы.


ON DELETE RESTRICT: запретить удаление, если есть зависимые строки

RESTRICT работает строже.

Он говорит базе: «нельзя удалить родительскую запись, если на неё кто-то ссылается».

Пример:

CREATE TABLE orders (
    id         bigint PRIMARY KEY,
    user_id    bigint NOT NULL,
    amount     numeric(12,2),
    status     text,
    created_at timestamptz DEFAULT now(),

    CONSTRAINT fk_orders_user
        FOREIGN KEY (user_id)
        REFERENCES users(id)
        ON DELETE RESTRICT
);

Теперь, если у пользователя есть хотя бы один заказ, такой запрос упадёт:

DELETE FROM users
WHERE id = 7;

База не даст удалить пользователя, потому что в orders есть строки, которые на него ссылаются.

Это хороший вариант для данных, которые нельзя случайно потерять.

Например:

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

RESTRICT заставляет сначала разобраться с зависимыми данными. Например, перенести заказы, архивировать пользователя, закрыть документы или явно удалить связанные строки.

Это менее удобно, чем CASCADE, зато намного безопаснее.


А что будет, если ON DELETE вообще не указать?

Если не написать ON DELETE, поведение по умолчанию обычно будет таким: база не позволит удалить родительскую запись, пока на неё есть ссылки.

Например:

CREATE TABLE orders (
    id         bigint PRIMARY KEY,
    user_id    bigint NOT NULL,

    CONSTRAINT fk_orders_user
        FOREIGN KEY (user_id)
        REFERENCES users(id)
);

Если у пользователя есть заказы, удаление пользователя завершится ошибкой:

DELETE FROM users
WHERE id = 7;

То есть база защитит нас от появления «висящих» ссылок.

В PostgreSQL действие по умолчанию — NO ACTION.

На практике для обычного разработчика NO ACTION часто выглядит почти как RESTRICT: удалить родителя нельзя, если есть дочерние строки.

Разница проявляется в более продвинутых сценариях с отложенными проверками внутри транзакций. Для новичка главное запомнить так:

если ON DELETE не указан, база не будет автоматически удалять или обнулять дочерние строки.

Она скорее запретит удаление, если есть зависимые данные.


RESTRICT и NO ACTION: в чём разница

RESTRICT и NO ACTION действительно похожи. Оба варианта защищают данные и не дают удалить родителя, если на него есть ссылки.

Но есть тонкое отличие.

RESTRICT проверяется сразу.

NO ACTION в PostgreSQL может быть отложен до конца транзакции, если внешний ключ объявлен как DEFERRABLE.

Проще говоря:

  • RESTRICT говорит: «проверяем прямо сейчас»;
  • NO ACTION может сказать: «проверим позже, перед COMMIT».

В обычных таблицах без DEFERRABLE вы почти не заметите разницы.


DEFERRABLE: когда проверку можно отложить

Иногда нужно внутри одной транзакции временно нарушить порядок ссылок, а к концу транзакции привести всё в нормальное состояние.

Например, мы загружаем данные пачкой. В середине загрузки часть строк уже вставлена, часть ещё нет. На короткий момент ссылка может выглядеть неправильной, но к концу транзакции всё станет корректно.

Для таких случаев в PostgreSQL есть отложенные ограничения:

CREATE TABLE employees (
    id         bigint PRIMARY KEY,
    name       text,
    manager_id bigint,
    dept       text,
    salary     numeric(12,2),

    CONSTRAINT fk_emp_manager
        FOREIGN KEY (manager_id)
        REFERENCES employees(id)
        ON DELETE SET NULL
        DEFERRABLE INITIALLY DEFERRED
);

Здесь таблица employees ссылается сама на себя: у сотрудника есть руководитель, который тоже является сотрудником.

employees.manager_id -> employees.id

Если удалить руководителя, у его подчинённых manager_id станет NULL.

DELETE FROM employees
WHERE id = 10;

После этого сотрудники, у которых был manager_id = 10, останутся в таблице, но будут уже без руководителя.

DEFERRABLE INITIALLY DEFERRED означает, что проверка ограничения будет выполнена не сразу после каждого отдельного запроса, а ближе к концу транзакции — перед COMMIT.

Это нужно не каждый день, но полезно знать, что такая возможность существует.


Самоссылки: когда таблица ссылается сама на себя

Самоссылка — это внешний ключ, который ведёт не в другую таблицу, а в эту же самую таблицу.

Классический пример — сотрудники и руководители:

CREATE TABLE employees (
    id         bigint PRIMARY KEY,
    name       text NOT NULL,
    manager_id bigint,

    CONSTRAINT fk_emp_manager
        FOREIGN KEY (manager_id)
        REFERENCES employees(id)
        ON DELETE SET NULL
);

Здесь manager_id хранит id руководителя.

Например:

id name manager_id
1 Анна NULL
2 Иван 1
3 Мария 1
4 Олег 2

Анна — руководитель Ивана и Марии. Иван — руководитель Олега.

Если удалить Анну:

DELETE FROM employees
WHERE id = 1;

у Ивана и Марии manager_id станет NULL.

Они не исчезнут из компании, просто останутся без указанного руководителя.

Это как раз хороший пример для ON DELETE SET NULL.


Индекс на колонке внешнего ключа

В PostgreSQL есть важный момент, о котором часто забывают начинающие.

Когда вы создаёте внешний ключ, PostgreSQL автоматически проверяет, что родительская колонка уникальна или является первичным ключом. Например, users.id уже индексируется, потому что это PRIMARY KEY.

Но колонку orders.user_id PostgreSQL автоматически не индексирует.

То есть вот здесь:

FOREIGN KEY (user_id)
REFERENCES users(id)

индекс на users.id уже есть, а на orders.user_id — нет.

Почему это важно?

Когда вы удаляете пользователя, базе нужно быстро понять, есть ли в таблице orders строки с этим user_id.

Например:

DELETE FROM users
WHERE id = 7;

База должна проверить:

SELECT *
FROM orders
WHERE user_id = 7;

Если таблица orders маленькая, проблемы не будет. Но если там миллионы строк, а индекса по user_id нет, база может просматривать большую часть таблицы.

Поэтому для внешних ключей почти всегда стоит создавать индекс на дочерней колонке:

CREATE INDEX idx_orders_user_id
ON orders (user_id);

Для самоссылки правило такое же:

CREATE INDEX idx_employees_manager_id
ON employees (manager_id);

Индекс особенно важен при CASCADE, SET NULL, RESTRICT и любых частых удалениях или обновлениях родительских строк.


А что насчёт ON UPDATE?

Кроме ON DELETE, у внешнего ключа есть похожее правило ON UPDATE.

Оно отвечает за ситуацию, когда меняется значение родительского ключа.

Например, если бы мы изменили users.id, база должна была бы решить, что делать с orders.user_id.

ON UPDATE CASCADE

означает: если изменился users.id, автоматически обновить orders.user_id.

Но на практике первичные ключи почти никогда не меняют. Особенно если это технический id.

Поэтому ON UPDATE встречается гораздо реже, чем ON DELETE.

Главное для начала — хорошо разобраться именно с удалением.


Как выбрать правильный ON DELETE

Можно думать так.

Используйте CASCADE, если дочерняя строка не имеет смысла без родителя

Например:

  • позиции заказа без заказа;
  • комментарии к удалённому черновику;
  • временные токены пользователя;
  • настройки, которые существуют только внутри аккаунта.

Пример:

FOREIGN KEY (order_id)
REFERENCES orders(id)
ON DELETE CASCADE

Используйте SET NULL, если дочерняя строка должна остаться, но связь можно потерять

Например:

  • заказ остаётся, но пользователь удалён;
  • сотрудник остаётся, но его руководитель уволен;
  • задача остаётся, но исполнитель удалён из системы.

Пример:

FOREIGN KEY (manager_id)
REFERENCES employees(id)
ON DELETE SET NULL

Важно: колонка должна разрешать NULL.

Используйте RESTRICT или NO ACTION, если удаление должно быть запрещено

Например:

  • нельзя удалить клиента с финансовыми документами;
  • нельзя удалить товар, который есть в заказах;
  • нельзя удалить категорию, пока в ней есть товары;
  • нельзя удалить склад, пока на нём есть остатки.

Пример:

FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE RESTRICT

Так база не даст случайно уничтожить важные данные.


Короткая шпаргалка

Действие Что делает Когда использовать
CASCADE Удаляет дочерние строки вместе с родителем Когда дочерние данные не нужны без родителя
SET NULL Оставляет дочерние строки, но очищает ссылку Когда запись должна жить дальше без родителя
RESTRICT Запрещает удаление родителя Когда зависимые данные важны и удаление должно быть явным
NO ACTION По умолчанию запрещает нарушение ссылки Похоже на RESTRICT, но может работать с отложенной проверкой

Особенности разных СУБД

В PostgreSQL доступны CASCADE, SET NULL, RESTRICT, NO ACTION и отложенные ограничения через DEFERRABLE.

В MySQL с движком InnoDB тоже есть основные действия для внешних ключей: CASCADE, SET NULL, RESTRICT, NO ACTION. Но отложенные ограничения DEFERRABLE в MySQL не поддерживаются: проверки выполняются сразу.

В ClickHouse внешних ключей в привычном смысле нет. Он не будет сам проверять ссылочную целостность между таблицами. Такие правила обычно контролируются на уровне приложения, пайплайна загрузки данных или архитектуры хранения.


Главное, что нужно запомнить

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

ON DELETE отвечает на простой, но очень важный вопрос:

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

Для начала держите в голове три основных сценария:

  • CASCADE — удалить всё зависимое вместе с родителем;
  • SET NULL — оставить дочернюю строку, но убрать ссылку;
  • RESTRICT — запретить удаление, пока есть зависимые данные.

И ещё одно практическое правило: почти всегда создавайте индекс на колонке внешнего ключа.

CREATE INDEX idx_orders_user_id
ON orders (user_id);

Так база будет быстрее находить зависимые строки, а удаления и проверки внешних ключей не превратятся в тяжёлые операции на больших таблицах.

Хорошо выбранный ON DELETE делает схему не просто правильной, а предсказуемой. А предсказуемость — одна из главных вещей, за которые мы любим хорошие базы данных.

Harjoittele oikeilla tehtävillä

Ratkaise tehtäviä SQL-harjoittelussa välittömällä arvioinnilla ja vihjeillä.

Avaa harjoittelu