Deleting Records Referenced from Another Table
Objective
Deleting records referenced from another table means the rows to remove aren't
identified by anything inside the target table itself — they're identified by
their relationship to rows in a second table. "Delete every employee who
works in a department that has had three or more accidents" can't be answered
by any predicate over EMP alone: the qualifying set lives in
DEPT_ACCIDENTS, and the DELETE has to reach across to it. That reach is
what makes this a distinct problem from a plain DELETE ... WHERE — you need
a subquery, a join, or a vendor-specific multi-table DELETE form, and each
of those three carries different portability and NULL-handling behaviour.
Use Cases
- Manual cascading cleanup when no foreign key with
ON DELETE CASCADEexists — purging child rows tied to a parent record you're about to remove, or that was deleted long ago by a process that didn't cascade. - Purging rows tied to a deprecated, closed, or blacklisted parent: employees in shut-down departments, orders for discontinued products, sessions belonging to deactivated accounts.
- Applying a rule that only a derived fact can express — "departments with ≥ 3 accidents", "customers with more than N chargebacks" — where the qualifying set comes out of an aggregate over the referencing table.
- Orphan cleanup, the inverse case: deleting rows in a child table whose
parent no longer exists in the reference table (
NOT IN/NOT EXISTS). - Deleting rows staged in a work/queue table after a downstream job has recorded them as processed in a second table.
Deep Dive
The IN subquery: portable, and correct on all three engines
Given a table recording one row per accident per department:
plaintextcreate table dept_accidents ( deptno integer, accident_name varchar(20) ); insert into dept_accidents values (10,'BROKEN FOOT'); insert into dept_accidents values (10,'FLESH WOUND'); insert into dept_accidents values (20,'FIRE'); insert into dept_accidents values (20,'FIRE'); insert into dept_accidents values (20,'FLOOD'); insert into dept_accidents values (30,'BRUISED GLUTE');
the qualifying departments come from an aggregate over that table:
plaintextselect deptno from dept_accidents group by deptno having count(*) >= 3; -- DEPTNO -- ------ -- 20
and the DELETE feeds that result into an IN predicate:
plaintextdelete from emp where deptno in ( select deptno from dept_accidents group by deptno having count(*) >= 3 );
This runs unmodified on PostgreSQL, MySQL, and SQL Server. Crucially — unlike
the same-table DELETE in the duplicate-removal recipe — the subquery reads
from a different table than the one being deleted from, so MySQL's error
1093 (Can't specify target table ... for update in FROM clause) never fires.
MySQL only forbids selecting from the delete target inside a subquery; reading
dept_accidents while deleting from emp is perfectly legal. Microsoft's own
docs label this shape the "SQL-2003 Standard subquery" solution, and it is
still the most portable way to write the statement today.
The correlated form: EXISTS
The same delete expressed as a correlated EXISTS — the subquery runs once
per candidate row and only its existence, not its value, matters:
plaintextdelete from emp e where exists ( select 1 from dept_accidents da where da.deptno = e.deptno group by da.deptno having count(*) >= 3 );
or, with a correlated scalar aggregate instead of GROUP BY/HAVING:
plaintextdelete from emp e where ( select count(*) from dept_accidents da where da.deptno = e.deptno ) >= 3;
Both run on all three engines. For the positive case (IN / EXISTS) the
two forms are semantically identical and every modern optimizer flattens them
into the same semi-join, so the choice is stylistic. It stops being stylistic
the moment the predicate is negated — see below.
The inverse case: NOT IN is where NULLs bite
Deleting rows not referenced by the second table — orphan cleanup — looks symmetric but isn't:
plaintext-- deletes NOTHING if dept_accidents.deptno contains even one NULL delete from emp where deptno not in (select deptno from dept_accidents);
If the subquery yields a NULL, x NOT IN (...) evaluates to NULL rather
than TRUE for every row, and the statement silently deletes zero rows
without raising an error. PostgreSQL documents the rule explicitly: "if the
left-hand expression yields null, or if there are no equal right-hand values
and at least one right-hand row yields null, the result of the NOT IN
construct will be null, not true." The same three-valued logic applies on
MySQL and SQL Server. NOT EXISTS has no such trap — it tests row existence,
not value equality, and an all-NULL match simply doesn't exist:
plaintextdelete from emp e where not exists ( select 1 from dept_accidents da where da.deptno = e.deptno );
Reach for NOT EXISTS by default on the negated case. The positive IN form
in the book's recipe is safe precisely because IN returning NULL behaves
like FALSE for a WHERE clause — the row just isn't deleted, which is the
conservative outcome. Negation inverts that safety.
Join-based DELETE: three vendors, three different syntaxes
Every engine offers a join form as an alternative to the subquery, and no two
of them spell it the same way. The aggregate has to move into a derived table,
because HAVING can't live in a join condition:
PostgreSQL — USING introduces the extra relation; the join predicate
goes in WHERE:
plaintextdelete from emp using ( select deptno from dept_accidents group by deptno having count(*) >= 3 ) risky where emp.deptno = risky.deptno;
SQL Server — a second FROM clause, a T-SQL extension. The target table
is named once as the delete target and again (aliased) in the join:
plaintextdelete e from emp as e inner join ( select deptno from dept_accidents group by deptno having count(*) >= 3 ) as risky on e.deptno = risky.deptno;
MySQL — the multiple-table DELETE, where the tables to delete from are
listed before FROM and the join lives after it:
plaintextdelete emp from emp inner join ( select deptno from dept_accidents group by deptno having count(*) >= 3 ) risky on emp.deptno = risky.deptno;
MySQL also accepts a USING spelling — and it is a false friend. Unlike
PostgreSQL's, MySQL's USING requires the target table to also appear in
the table references:
plaintext-- MySQL: target repeated after USING delete from emp using emp inner join dept_accidents da on da.deptno = emp.deptno; -- PostgreSQL: target must NOT be repeated after USING delete from emp using dept_accidents da where da.deptno = emp.deptno;
Copying a DELETE ... USING between the two engines produces either a syntax
error or, worse on PostgreSQL, an accidental self-join. PostgreSQL's manual
states the rule directly: do not repeat the target table as a from_item
unless you actually want a self-join.
Trade-offs
INis safe here;NOT INis the one to avoid. The book's positiveINform degrades gracefully when the subquery containsNULLs — the row simply isn't deleted. Invert the predicate and the sameNULLturns the whole statement into a no-op that reports success. UseNOT EXISTSfor every anti-join delete, or at minimum addwhere deptno is not nullinside the subquery.plaintext-- reports "0 rows deleted", raises nothing, and is almost never what was meant delete from emp where deptno not in (select deptno from dept_accidents);EXISTSversusINis a readability call, not a performance one. Modern PostgreSQL, MySQL, and SQL Server optimizers all rewrite both into a semi-join, so the folk rule that one is inherently faster no longer holds. PickINwhen the qualifying set is a standalone query worth reading on its own (as the aggregate here is), andEXISTSwhen the correlation to the outer row is the point.- Join forms can be faster, but they're the least portable thing in the
chapter. PostgreSQL's
USING, SQL Server's secondFROM, and MySQL's multiple-tableDELETEare three mutually incompatible vendor extensions for one idea — PostgreSQL's own docs flagUSINGas a non-standard extension, and Microsoft's flag the secondFROMthe same way. The subquery form is the only shape that survives a database migration untouched. - Join fan-out is harmless for
DELETE, unlike forUPDATE. Department 20 has three accident rows, so joiningempdirectly todept_accidentsmatches each employee three times. ADELETEstill removes each row exactly once, so the duplicate matches cost work but not correctness — the same fan-out in anUPDATE ... FROMwould apply a non-deterministically chosen one of the matching rows instead. - MySQL's multiple-table
DELETEgives upORDER BYandLIMIT. Both clauses are documented as single-table-only, so the common "delete in batches of 10,000" pattern can't be expressed in the join form — batching a cross-table delete on MySQL means going back to the subquery form with aLIMITed inner query. - Preview before you delete. Every technique above is trivially converted
to a
SELECTby swapping the leading clause, and the qualifying set is usually small enough to eyeball. Runningselect * from emp where deptno in (...)first costs one round trip and is the only real safety net, since none of these engines can undo a committedDELETE.
Documentation Links
- Anthony Molinaro and Robert de Graaf, "SQL Cookbook", 2nd Edition (O'Reilly, 2020) — Chapter 4, "Inserting, Updating, and Deleting", recipe 4.17, p. 87-89
- PostgreSQL Documentation — DELETE (USING clause, self-join warning, standard compatibility)
- PostgreSQL Documentation — Subquery Expressions (IN / NOT IN null semantics)
- MySQL Reference Manual — DELETE Statement (multiple-table syntax, ORDER BY/LIMIT restriction)
- Microsoft Learn — DELETE (Transact-SQL) (FROM table_source extension vs. ISO subquery)