SQL (PostgreSQL, MySQL, SQLite) Module 9 – Creating tables and constraints
Foreign keys and ON DELETE actions
Declare foreign keys in PostgreSQL, MySQL and SQLite, choose CASCADE, SET NULL or RESTRICT for each link, and defer checks for batch and circular inserts.
What you will learn
- Declare foreign keys and choose a referential action for each relationship
- Switch on foreign-key enforcement in SQLite and check data written without it
- Use deferrable constraints for batch loads and circular references
- Index foreign key columns and explain what a missing index costs
Before you start
What you’ll learn · Before you start
What you will learn
- Declare foreign keys and choose a referential action for each relationship
- Switch on foreign-key enforcement in SQLite and check data written without it
- Use deferrable constraints for batch loads and circular references
- Index foreign key columns and explain what a missing index costs
Before you start
On this page
A foreign key ties a child row to a parent row: orders.customer_id must name a customer who exists. Foreign keys
refuse two things, a child that points to a missing parent and a parent that disappears while children still point
to it, and the second rule is where the ON DELETE actions come in. They decide what happens to the children when
their parent is deleted: CASCADE deletes them too, SET NULL empties their link, SET DEFAULT puts the column’s default
in it, and RESTRICT or NO ACTION refuse the delete while children exist. Choosing one per relationship is real design
work: an order’s items belong to the order and can go with it, a customer who has orders must not vanish, and an
optional link, such as the agent who delivered an order, can simply be forgotten. Every example on this page runs in
sql.js 1.14.2, which packages SQLite 3.49.1, and like any SQLite connection it checks foreign keys only after
PRAGMA foreign_keys = ON.
Three links, three actions
-- One shop, three links, three ON DELETE actions.
PRAGMA foreign_keys = ON; -- SQLite checks foreign keys only after this, on every new connection
CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE agents (agent_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers (customer_id) ON DELETE RESTRICT,
agent_id INTEGER REFERENCES agents (agent_id) ON DELETE SET NULL
);
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
product TEXT NOT NULL,
qty INTEGER NOT NULL,
PRIMARY KEY (order_id, product)
);
INSERT INTO customers VALUES (1, 'Asha Kulkarni'), (2, 'Vikram Nair');
INSERT INTO agents VALUES (7, 'Ravi'), (8, 'Fatima');
INSERT INTO orders VALUES (101, 1, 7), (102, 1, 8), (103, 2, 7);
INSERT INTO order_items VALUES
(101, 'Basmati rice 1 kg', 2), (101, 'Toor dal 500 g', 1), (102, 'Masala chai 250 g', 2), (103, 'Coconut oil 1 L', 1);
DELETE FROM orders WHERE order_id = 101; -- CASCADE: the order's two items go with it
DELETE FROM agents WHERE agent_id = 7; -- SET NULL: order 103 stays, without an agent
DELETE FROM customers WHERE customer_id = 1; -- RESTRICT: refused, Asha still has order 102
SELECT order_id, customer_id, agent_id FROM orders;
SELECT order_id, product, qty FROM order_items; Output (exit status 1)
┌──────────┬─────────────┬──────────┐ │ order_id │ customer_id │ agent_id │ ├──────────┼─────────────┼──────────┤ │ 102 │ 1 │ 8 │ │ 103 │ 2 │ │ └──────────┴─────────────┴──────────┘ ┌──────────┬───────────────────┬─────┐ │ order_id │ product │ qty │ ├──────────┼───────────────────┼─────┤ │ 102 │ Masala chai 250 g │ 2 │ │ 103 │ Coconut oil 1 L │ 1 │ └──────────┴───────────────────┴─────┘
Printed as an error (standard error)
Runtime error near line 26: FOREIGN KEY constraint failed
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < actions.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Each delete did what its action promises. Deleting order 101 took its two items with it, because order_items
declares CASCADE. Deleting agent Ravi kept order 103 but set its agent_id to NULL, because the link is optional and
declares SET NULL. Deleting Asha failed, because she still has order 102 and the link from orders declares RESTRICT;
SQLite’s message, “FOREIGN KEY constraint failed”, does not say which key, while PostgreSQL names the constraint and
the key value, and MySQL prints the whole foreign key definition with error 1451.
What deleting a parent row does to its children
Text description of the diagram
The diagram shows the four tables of the lesson's shop and the action on each foreign key. Each arrow runs from a parent table to the child table that refers to it.
- customers to orders, ON DELETE RESTRICT: every order needs a customer, so deleting a customer who still has orders is refused.
- agents to orders, ON DELETE SET NULL: an order's delivery agent is optional, so deleting an agent keeps the orders and sets their agent_id to NULL.
- orders to order_items, ON DELETE CASCADE: items belong to their order, so deleting an order deletes its items as well.
Writing a foreign key
A foreign key can follow its column, as in the example, or stand among the table constraints, where it can span
several columns and take a name: CONSTRAINT items_order_fk FOREIGN KEY (order_id) REFERENCES orders (order_id) ON DELETE CASCADE. The parent’s columns must be its primary key or carry a UNIQUE constraint, so that each value points
to exactly one row. The types must match as well: PostgreSQL 18.6 refused a text column pointing to an integer
key because the types are incompatible, and MySQL 8.4.11 refused it with error 3780. A child column that allows NULL
makes the link optional, because a NULL points nowhere and is never checked. Name the constraint as you would a CHECK:
PostgreSQL and MySQL print the name in every error about it, and changing the action later means dropping the
constraint by that name and adding it again.
Choosing an action for each link
| Criterion | CASCADE | SET NULL | SET DEFAULT | RESTRICT | NO ACTION |
|---|---|---|---|---|---|
| What happens to the children | They are deleted too | Their foreign key becomes NULL | Their foreign key takes its default | The delete is refused | The delete is refused |
| What the child column needs | Nothing | It must allow NULL | A default that names an existing parent | Nothing | Nothing |
| When the check happens | At once | At once | At once | At once, never deferred | At the end of the statement, or at COMMIT when deferred |
| Typical link | Order items to their order | An optional delivery agent | Rare: an "unassigned" parent row | Orders to the customer who placed them | The same, and the default when no action is named |
| When to choose | The children are part of the parent | The link is optional and the child stands alone | Every child must point somewhere, and a fallback parent exists | The children record history that must stay | As RESTRICT, when a transaction may repair the link before it ends |
Ask one question per link: do the children mean anything without their parent? Items of a deleted order mean nothing, so CASCADE. A delivery means something without its agent, so SET NULL. An order is a financial record, so a customer who has orders must not be deleted at all, and RESTRICT makes the database refuse; such long-lived records are usually marked inactive instead of deleted. NO ACTION, the default when you name no action, refuses the same deletes as RESTRICT and differs only in when it looks, which matters inside transactions. ON UPDATE takes the same actions for a parent key that changes, which surrogate keys never do. MySQL’s InnoDB does not carry out SET DEFAULT, so keep that action for PostgreSQL and SQLite.
Turning the checks on
PostgreSQL always enforces foreign keys. SQLite enforces them only on a connection that has run
PRAGMA foreign_keys = ON, and sql.js starts with it off, as SQLite does unless it was compiled otherwise, so every
program that opens the database must switch it on, and data written by one that did not may already hold orphans. MySQL’s InnoDB enforces
foreign keys written as a FOREIGN KEY clause, but ignores a REFERENCES written after a column, as the lesson on
keys and relationships showed, and a session can switch every check off with SET FOREIGN_KEY_CHECKS = 0, which
dumps and bulk loads often do. Switching the checks back on in SQLite or MySQL does not look at the rows that are
already there.
Your turn
Exercise · Easy · SQL
Foreign keys for a college recruitment portal
A college's recruitment portal has four tables: recruiters, the drives each recruiter holds, students, and the applications students make to drives. Add the foreign keys, each with its ON DELETE action:
drives.recruiter_idpoints torecruiters. A recruiter who still has drives must never be deleted: useRESTRICT.applications.drive_idpoints todrives. When a drive is cancelled and deleted, its applications go with it.applications.student_idpoints tostudents. When a student's record is deleted, their applications go with it.
Leave the last statement as it is: it reads the foreign keys back from SQLite's catalog, one row per link, so the check compares the design you wrote, not the data.
Starter code · recruitment.sql
PRAGMA foreign_keys = ON;
-- Add the three foreign keys, each with its ON DELETE action.
CREATE TABLE recruiters (recruiter_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE drives (
drive_id INTEGER PRIMARY KEY,
recruiter_id INTEGER NOT NULL,
held_on TEXT NOT NULL
);
CREATE TABLE students (student_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE applications (
drive_id INTEGER NOT NULL,
student_id INTEGER NOT NULL,
PRIMARY KEY (drive_id, student_id)
);
-- Leave this as it is: the foreign keys SQLite now knows about, one row per link.
SELECT m.name AS child_table, f."from" AS child_column, f."table" AS parent_table, f.on_delete
FROM sqlite_schema AS m, pragma_foreign_key_list(m.name) AS f
WHERE m.type = 'table'
ORDER BY child_table, child_column; The sample tests · tests.yaml
# Sample test: your script creates the tables, and its last query lists their foreign keys from SQLite's catalog.
tests:
- name: three foreign keys, with RESTRICT for recruiters and CASCADE for applications
ordered: true
setup: ''
expected:
columns: [child_table, child_column, parent_table, on_delete]
rows:
- ['applications', 'drive_id', 'drives', 'CASCADE']
- ['applications', 'student_id', 'students', 'CASCADE']
- ['drives', 'recruiter_id', 'recruiters', 'RESTRICT'] A hint
A foreign key can follow the column's type: recruiter_id INTEGER NOT NULL REFERENCES recruiters (recruiter_id) ON DELETE RESTRICT. The action that deletes the children together with their parent is CASCADE.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Each action in depth: what it removes, when it checks, how it fails Pro: opens with a Pro, Premium or Ultimate pass
Every action has a failure that shows up only in production: a cascade that removes more than anyone expected, a check that runs earlier than the code assumed, a SET that breaks the child’s own rules. This part reproduces each one.
In this part:
- What one DELETE removes, measured
- RESTRICT or NO ACTION: the difference is timing
- SET NULL and SET DEFAULT, failure cases reproduced
- ON UPDATE, for keys that change
3 runnable examples (SQL) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Deferred checks, data loads and the index every foreign key needs, with numbers Pro: opens with a Pro, Premium or Ultimate pass
Foreign keys also decide how data can be loaded and how fast a parent can be deleted. This part covers inserts that need each other, checks within one statement, the cost of a missing index measured in comparisons, and the repair of data written while checks were off.
In this part:
- Circular references with deferred constraints
- When each engine checks within one statement
- The index every foreign key needs, measured
- Finding orphans after the checks were off
3 runnable examples (Python, SQL) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Mid to senior
A support agent deleted a duplicate customer account, and finance then found that the customer's 214 orders and their payments had disappeared too. Every foreign key in the schema says ON DELETE CASCADE. What happened, how do you recover, and how should these foreign keys be declared?
-
Mid to senior
In PostgreSQL, what is the difference between ON DELETE RESTRICT and ON DELETE NO ACTION, and when does it matter? Describe a transaction that works with one and fails with the other.
-
Mid to senior
Deleting one customer in PostgreSQL takes about 4 seconds, although the customers table has only 50,000 rows and the DELETE finds the row by its primary key. What do you look for, and how do you fix it?
Key takeaways
- Foreign keys refuse a child without a parent; the ON DELETE actions decide what deleting a parent does to its children.
- CASCADE for children that belong to the parent, SET NULL for optional links, RESTRICT or NO ACTION for history that must stay.
- NO ACTION is the default; it refuses the same deletes as RESTRICT and differs only in timing.
- SQLite checks foreign keys only after
PRAGMA foreign_keys = ON, on every connection; MySQL ignores a column-levelREFERENCES.
References
- PostgreSQL 18: Constraints (foreign keys) (The PostgreSQL Global Development Group)
- PostgreSQL 18: SET CONSTRAINTS (The PostgreSQL Global Development Group)
- PostgreSQL 18: ALTER TABLE (The PostgreSQL Global Development Group)
- SQLite: Foreign Key Support (SQLite)
- SQLite: PRAGMA statements (foreign_keys, defer_foreign_keys, foreign_key_check) (SQLite)
- MySQL 8.4 Reference Manual: FOREIGN KEY Constraints (Oracle Corporation)
- Python documentation: sqlite3 (Connection.create_collation) (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress