A foreign key does two things. It refuses a child row that points nowhere, which
Part 1 showed, and it
decides what happens to the children when the parent goes away, which is this part. Three
one-to-many links, three different ON DELETE rules, and one thing neither
engine does for you: index the foreign key column.
- Download p03-sqlserver.sql or p03-postgresql.sql and run it as in Part 1
- Section 2: find the three foreign keys and the rule each one carries
- Section 4: deleting Maria Garcia is refused by name; deleting Yuki Tanaka goes through
- Section 5: run the catalogue query that finds a foreign key from the name in an error
- Section 6: deleting appointment 3 takes its queue entry with it
- Section 7: closing Room 2 leaves two appointments with an empty
room_id - Section 8: list the indexes before and after, and notice which ones the foreign keys did not create
- Companion script p03b-sqlserver.sql or p03b-postgresql.sql: one child table per rule, SET DEFAULT, and RESTRICT against a deferred NO ACTION
Three links, three rules
Section 2 builds patients, rooms, appointments and queue entries. Each link gets the rule that fits what the rows mean:
CREATE TABLE appointments (
id bigint GENERATED ALWAYS AS IDENTITY CONSTRAINT pk_appointments PRIMARY KEY,
patient_id bigint NOT NULL
CONSTRAINT fk_appointments_patients REFERENCES patients (id),
room_id bigint
CONSTRAINT fk_appointments_rooms REFERENCES rooms (id) ON DELETE SET NULL,
starts_at timestamptz NOT NULL,
status varchar(20) NOT NULL
);
CREATE TABLE queue_entries (
id bigint GENERATED ALWAYS AS IDENTITY CONSTRAINT pk_queue_entries PRIMARY KEY,
appointment_id bigint NOT NULL
CONSTRAINT fk_queue_entries_appointments REFERENCES appointments (id) ON DELETE CASCADE,
checked_in_at timestamptz NOT NULL,
called_at timestamptz,
CONSTRAINT uq_queue_entries_appointment_id UNIQUE (appointment_id)
);
An appointment belongs to a patient, and a patient with history should not vanish by
accident, so that link keeps the default, NO ACTION: the delete is refused. An
appointment happens in a room, but the room is incidental; if Room 2 closes, the
appointment survives with no room, so room_id is nullable and the rule is
SET NULL. A queue entry is nothing without its appointment, so it follows it:
CASCADE. The SQL Server script is the same apart from the spellings from Part 1.
PostgreSQL also accepts ON DELETE RESTRICT, which behaves like NO
ACTION for everything in this part; SQL Server rejects the word with
Msg 156, Incorrect syntax near the keyword 'RESTRICT'.
Section 3 loads four patients, two rooms, four appointments and two queue entries:
id | full_name | room | starts_at | status | checked_in_at
----+--------------+--------+------------------------+-----------+------------------------
1 | Maria Garcia | Room 1 | 2026-09-28 09:00:00+00 | CheckedIn | 2026-09-28 08:52:00+00
2 | Maria Garcia | Room 2 | 2026-10-05 09:00:00+00 | Booked |
3 | David Chen | Room 1 | 2026-09-28 09:30:00+00 | CheckedIn | 2026-09-28 09:21:00+00
4 | Tomás Silva | Room 2 | 2026-09-28 10:00:00+00 | Booked |
(4 rows)
NO ACTION: the delete is refused
Section 4 deletes Maria Garcia, who has two appointments. SQL Server:
Msg 547, Level 16, State 1, Line 3
The DELETE statement conflicted with the REFERENCE constraint "fk_appointments_patients". The conflict occurred in database "dd_p03", table "dbo.appointments", column 'patient_id'.
The statement has been terminated.
PostgreSQL:
ERROR: update or delete on table "patients" violates foreign key constraint "fk_appointments_patients" on table "appointments"
DETAIL: Key (id)=(1) is still referenced from table "appointments".
Yuki Tanaka, who has no appointments, is deleted without complaint. Both messages name the constraint and the child table; PostgreSQL also names the parent key that was blocked. Section 5 shows how to go from that name to the definition, because the day you meet this error the constraint will be one of dozens. The standard view exists on both engines with the same query text:
SELECT constraint_name, delete_rule, update_rule
FROM information_schema.referential_constraints
ORDER BY constraint_name;
constraint_name | delete_rule | update_rule
-------------------------------+-------------+-------------
fk_appointments_patients | NO ACTION | NO ACTION
fk_appointments_rooms | SET NULL | NO ACTION
fk_queue_entries_appointments | CASCADE | NO ACTION
Section 5b makes the case for naming again. A foreign key created without a name gets one
from the engine, and a delete then fails against
"FK__visit_not__appoi__440B1D61" on SQL Server, a truncated table name, a
truncated column name and a hex number that differs in every database the script runs
in. PostgreSQL's generated visit_notes_appointment_id_fkey is at least
readable. A name you chose is readable everywhere.
CASCADE and SET NULL
Section 6 deletes appointment 3. Before and after, the queue:
id | appointment_id | checked_in_at
----+----------------+------------------------
1 | 1 | 2026-09-28 08:52:00+00
2 | 3 | 2026-09-28 09:21:00+00
(2 rows)
DELETE FROM appointments WHERE id = 3;
id | appointment_id | checked_in_at
----+----------------+------------------------
1 | 1 | 2026-09-28 08:52:00+00
(1 row)
The entry for appointment 3 is gone, and psql's DELETE 1 counted only the
appointment; cascaded rows are never counted, on either engine, which is worth remembering
when a delete reports one row and removes a thousand. Section 7 closes Room 2:
DELETE FROM rooms WHERE id = 2;
id | patient_id | room_id | starts_at
----+------------+---------+------------------------
1 | 1 | 1 | 2026-09-28 09:00:00+00
2 | 1 | | 2026-10-05 09:00:00+00
4 | 3 | | 2026-09-28 10:00:00+00
(3 rows)
Appointments 2 and 4 kept their rows and lost their room. SET NULL needs a
nullable column: SQL Server refuses to create such a foreign key on a NOT NULL
column (Msg 1761), while PostgreSQL accepts the constraint and fails later, at
the first delete, with a not-null violation. Design the column and the rule together.
The index nobody made
Every query that joins appointments to a patient, and every cascade or refusal check,
looks up patient_id. Section 8 lists the indexes after all this work:
Schema | Name | Type | Owner | Table
--------+---------------------------------+-------+-------+---------------
public | pk_appointments | index | dd | appointments
public | pk_patients | index | dd | patients
public | pk_queue_entries | index | dd | queue_entries
public | pk_rooms | index | dd | rooms
public | uq_patients_phone | index | dd | patients
public | uq_queue_entries_appointment_id | index | dd | queue_entries
Primary keys and unique constraints made indexes. The three foreign keys made none. On both engines a foreign key is a rule, not an index, so every check of it on a large table is a scan until you add one:
CREATE INDEX ix_appointments_patient_id ON appointments (patient_id);
CREATE INDEX ix_appointments_room_id ON appointments (room_id);
The exception in the listing proves the rule: queue_entries.appointment_id is
indexed, but only because its unique constraint made the index. ClinicLive has
ix_appointments_patient_id because EF Core creates an index for every foreign
key by convention. The tool did it; the database would not have. If you write DDL by hand,
the index is your job, and Part 9 measures what it is worth.
Deleting a patient anyway, and the SQL Server limit
Section 9 does what NO ACTION asks: delete the children first. Removing
Maria's appointments cascades away her queue entry, and then her row goes, leaving 2
patients, 1 appointment and 0 queue entries. That is more typing than a cascade, and it is
the point: deleting a patient's history should be a decision, not a side effect.
Section 10 is the limit SQL Server readers hit when they decide to cascade everything. Notes that point at an appointment and at the patient give the patient two cascade paths into the same table, and SQL Server refuses the second one:
Msg 1785, Level 16, State 1, Line 1
Introducing FOREIGN KEY constraint 'fk_c_notes_c_patients' on table 'c_notes' may cause cycles or multiple cascade paths. Specify ON DELETE NO ACTION or ON UPDATE NO ACTION, or modify other FOREIGN KEY constraints.
Msg 1750, Level 16, State 1, Line 1
Could not create constraint or index. See previous errors.
PostgreSQL accepts both paths and deleting the patient empties both tables. SQL Server's
own advice works: make the second path NO ACTION, and the delete still removes
the note through the appointment, because the check runs after the statement.
CASCADE, SET NULL, SET DEFAULT, NO ACTION and RESTRICT, side by side
The three rules above are the ones a clinic needs. Both engines know five, and the names are
easy to confuse, so the companion script builds one parent table, doctors, one
child table per rule, and deletes one doctor per table. Run on SQL Server 2022 LocalDB
16.0.1210.3 and PostgreSQL 18.4, this is what each rule did to the child rows:
| Rule | The child rows when the parent is deleted | SQL Server | PostgreSQL |
|---|---|---|---|
CASCADE | Deleted with it. The delete reports 1 row on both engines, although three schedule rows went too. | Yes | Yes |
SET NULL | Kept; the column becomes NULL. The column must be nullable. | Yes | Yes |
SET DEFAULT | Kept; the column takes its DEFAULT. Two appointments moved to doctor 1, the placeholder row "On-call (unassigned)". | Yes | Yes |
NO ACTION | The delete is refused: Msg 547, SQLSTATE 23503. The default on both engines. | Yes | Yes |
RESTRICT | The delete is refused: SQLSTATE 23001, "violates RESTRICT setting". | No, Msg 156 | Yes |
SET DEFAULT needs a default that points at a row that exists; the script keeps a
placeholder doctor for it. What happens when the default row is missing was not tested.
PostgreSQL's refusal under RESTRICT reads differently from the one under
NO ACTION, and carries a different code:
ERROR: update or delete on table "doctors" violates RESTRICT setting of foreign key constraint "fk_sick_notes_doctors" on table "sick_notes"
DETAIL: Key (id)=(6) is referenced from table "sick_notes".
Code that catches 23503 only will miss a RESTRICT refusal. So what is the difference between
the two? Only when the check runs. NO ACTION is checked at the end of the
statement, and PostgreSQL can defer it to COMMIT; RESTRICT is checked
at once and cannot be deferred. Section 5 of the companion script declares a foreign key
ON DELETE NO ACTION DEFERRABLE INITIALLY DEFERRED, deletes a doctor and re-inserts
the same id before the commit, and the transaction goes through:
DELETE FROM doctors WHERE id = 7;
DELETE 1
INSERT INTO doctors (id, full_name) OVERRIDING SYSTEM VALUE VALUES (7, 'Dr. Nadia Petrova');
INSERT 0 1
COMMIT;
COMMIT
Without the re-insert the same transaction fails at COMMIT with the 23503 text from
section 4. The same DEFERRABLE on a RESTRICT key is accepted without a
word, and the DELETE fails at once; the re-insert then gets "current transaction is
aborted", and COMMIT prints ROLLBACK. SQL Server rejects
DEFERRABLE outright, Msg 102, Incorrect syntax near 'DEFERRABLE': on
SQL Server, NO ACTION is the only refusal there is, and it always runs at the end
of the statement.
What ClinicLive chose: every foreign key in the app is
ON DELETE CASCADE, the EF Core default for a required relationship, so
deleting a patient silently deletes appointments, queue entries and device
registrations. For a demo clinic that is convenient. For a real one, the patient link
should be NO ACTION and the delete a deliberate, logged operation, which
Part 10 turns into a soft delete.
Frequently asked
- Should I use ON DELETE CASCADE?
- Only when the child row means nothing without its parent, such as a queue entry without its appointment. For a parent with history, such as a patient, keep NO ACTION so the delete is refused and has to be a deliberate operation. Use SET NULL when the child should survive and simply forget the parent.
- Does a foreign key create an index automatically?
- No, on neither SQL Server nor PostgreSQL. A foreign key is a rule. Add an index on the foreign key column yourself, or check that your ORM did: EF Core creates one for every foreign key by convention.
- What is the difference between ON DELETE RESTRICT and NO ACTION?
- Both refuse to delete a parent row that children still reference. NO ACTION is checked at the end of the statement, and PostgreSQL can defer it to COMMIT, so a transaction may delete the parent and re-insert it before the check runs. RESTRICT is checked immediately and cannot be deferred. PostgreSQL reports them with different codes, 23503 for NO ACTION and 23001 for RESTRICT. SQL Server has no RESTRICT keyword; NO ACTION is its only refusal, and SET DEFAULT is its other option besides CASCADE and SET NULL.
Next: Part 4, many-to-many, where a doctor has several specialties and a specialty several doctors. The 2024 series drew these links in Finding relationships between entities.