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.

3 min readReferencesql · postgresql · foreign-key · cascade · constraints · referential-integrity

A foreign key does more than mark a relationship between tables: it dictates what the database does to child rows when you delete the parent. That reaction is called a referential action, and you pick it with the ON DELETE clause. Choose wrong and you either get an error on every delete or silently lose data.

Take three familiar tables. orders has a foreign key to users, and employees references itself through manager_id.

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
);

Now DELETE FROM users WHERE id = 7 automatically removes every order of that user. Without an ON DELETE clause the default action is NO ACTION: deleting a parent that still has children just fails with an error.

The four delete actions

  • CASCADE deletes the child rows along with the parent. Great for strictly dependent data (line items inside an order), dangerous for anything that has value on its own.
  • SET NULL clears the reference in the child row. The FK column must be nullable. Useful when the child outlives the parent: drop the user but keep the orders with user_id = NULL.
  • RESTRICT blocks deleting the parent while any child exists. The check fires immediately.
  • NO ACTION is the same default behaviour, but the check can be deferred to the end of the transaction in a deferrable constraint.
-- orders keep living, the link is cleared
ALTER TABLE orders
    DROP CONSTRAINT fk_orders_user,
    ADD CONSTRAINT fk_orders_user
        FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;

There is also ON UPDATE: the same actions fire when the parent's primary key itself changes. In practice you almost never mutate a PK, so ON UPDATE CASCADE is rarely needed.

When cascade is dangerous

ON DELETE CASCADE is seductive because "everything cleans itself up". But it also erases data with no confirmation and no trace in the application.

-- one innocent-looking statement
DELETE FROM users WHERE country = 'RU';
-- and every order of every matching user is gone too

Gotcha: cascades chain. If orders has a child order_items also set to CASCADE, deleting one user wipes three levels deep. At scale that is one long, blocking transaction and a bloated WAL. For financial or audit data prefer RESTRICT or a soft delete (deleted_at), and leave real DELETE to cleanup jobs.

Indexing the foreign key column

PostgreSQL automatically indexes the primary key a FK points to, but it does NOT create an index on the referencing column itself. Without one, every delete or update of a parent triggers a sequential scan of the child table to find dependent rows.

CREATE INDEX idx_orders_user_id ON orders (user_id);

This matters most with CASCADE and SET NULL, because the child check runs on every parent delete. The same rule applies to the self-referencing table:

CREATE INDEX idx_employees_manager ON employees (manager_id);

Deferrable constraints and self-references

Sometimes you need to break integrity temporarily inside a transaction, for example to swap rows around or load data with circular references. For that you declare the constraint DEFERRABLE:

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
);

With INITIALLY DEFERRED the check is postponed until COMMIT, so you can hold dangling references inside the transaction. Remember the engine differences:

  • MySQL (InnoDB) supports CASCADE, SET NULL, RESTRICT and NO ACTION, but has no DEFERRABLE: every check is immediate.
  • ClickHouse has no foreign keys at all: integrity is the application's job and relationships are modelled with JOIN.

The baseline rule: cascade for strictly dependent data, SET NULL for children that outlive the parent, RESTRICT for anything valuable, and always index the FK column.

Practice on real tasks

Solve tasks in the SQL trainer with instant grading and hints.

Open trainer