Put the join condition in a correlated EXISTS, which every database accepts: DELETE FROM orders WHERE EXISTS (SELECT 1 FROM customers c WHERE c.id = orders.customer_id AND c.status = 'closed'). MySQL and SQL Server also take DELETE o FROM orders o JOIN customers c ON …, and PostgreSQL takes DELETE FROM orders USING customers c WHERE …. SQLite has no join in DELETE at all.
A DELETE removes rows from one table, but the rule that picks them often lives in another: the orders of closed customers, the items of those orders, the customers with no orders at all. Standard SQL has no JOIN clause in DELETE, so the portable way is a subquery in the WHERE clause, either EXISTS or IN. The examples run on SQLite, which only accepts those subquery forms; the table in section 7 gives the join syntax of PostgreSQL, MySQL and SQL Server, each run on that database. Every example builds its own small tables, so hit Run, then change the data or the condition and run it again.
1DELETE … WHERE EXISTS: the portable answerRecommended
Write the join condition inside a correlated subquery: for each row of orders, the subquery looks for a matching customer that is closed, and EXISTS is true when it finds one. The subquery refers to the outer table by its name, orders.customer_id, which is what ties the two together. SELECT 1 is a convention: EXISTS only checks whether a row comes back, not what it holds.
Output
Ben (orders 11 and 12) and Dee (order 14) are closed, so changes() reports 3 and orders 10, 13 and 15 remain. changes() is SQLite's count of rows the last statement touched; other databases print the count after the statement themselves. This exact DELETE also ran unchanged on PostgreSQL, MySQL and SQL Server (Babelfish), deleting the same three rows, which is why it is the form to reach for first. Each order is deleted once however many rows the subquery could find: EXISTS only asks whether there is a match.
2DELETE … WHERE id IN (SELECT … JOIN …)
When the rule needs a real join, for example two tables away from the rows you delete, write the join as an ordinary SELECT that returns the keys to remove, and feed it to IN. Here the items go when their order belongs to a closed customer: the subquery joins orders to customers and returns the order ids.
Output
The subquery returns orders 11 and 12, so items 2, 3 and 4 are deleted and only Ana's two items remain. You can run the subquery on its own to see the ids first. One MySQL catch: if the subquery reads the same table you delete from, as in DELETE FROM orders WHERE id IN (SELECT o.id FROM orders AS o JOIN …), MySQL 8.4 stops with ERROR 1093 (HY000): You can't specify target table 'orders' for update in FROM clause. SQLite, PostgreSQL and SQL Server accept it. In MySQL, use EXISTS or the DELETE … JOIN form from section 7 instead.
3Delete rows that have no match: NOT EXISTS
To delete the rows that have no partner in the other table, such as customers who never ordered, negate the test: NOT EXISTS. It is tempting to write NOT IN (SELECT customer_id FROM orders) instead, but a single NULL in that column makes NOT IN match nothing at all. The example runs both against the same data. The same choice comes up when you only want to list such rows; see find rows with no match.
Output
The NOT IN delete reports 0: for Ben, 2 NOT IN (1, 3, NULL) is unknown rather than true, because 2 might equal the unknown value, and WHERE keeps only rows that are true. NOT EXISTS deletes 2 rows, Ben and Dee, leaving Ana and Cy. If you do use NOT IN, add WHERE customer_id IS NOT NULL to the subquery.
4Preview what will be deleted, then delete
A DELETE with the wrong condition is hard to undo, so run the same WHERE clause in a SELECT first and read the rows it returns. Then copy that clause, unchanged, into the DELETE. Doing the delete inside a transaction adds a second check: compare the count with the preview, and COMMIT only if they agree.
Output
The preview lists orders 11, 12 and 14, and the DELETE reports 3, so the two agree. This example then rolls back to show the safety net: still_there is 6, every order is back. In real use you would write COMMIT where it says ROLLBACK. A preview written as a join (SELECT o.* FROM orders o JOIN customers c ON …) is fine for reading, but keep the delete's own WHERE clause identical to what you previewed.
5See the deleted rows with RETURNING
RETURNING at the end of a DELETE hands back each row as it is removed, so the statement reports exactly what it did, in one step. It is handy for logging, for moving rows to an archive from application code, or simply as a check. SQLite has supported it since version 3.35.
Output
The three deleted orders come back as a result set: 11, 12 and 14. PostgreSQL uses the same RETURNING clause, including after DELETE … USING. SQL Server's equivalent is OUTPUT deleted.id, deleted.total, written after the DELETE o line. MySQL 8.4 has no RETURNING on DELETE (it reports a syntax error near 'RETURNING id'), so run the preview SELECT instead.
6Why SQLite rejects DELETE … JOIN
The join syntax many answers show, DELETE o FROM orders o JOIN customers c ON …, is a MySQL and SQL Server extension. SQLite's DELETE takes exactly one table and a WHERE clause, so it fails at the first word it doesn't expect. Run it to see the error, then rewrite it with EXISTS from section 1.
Output
SQLite stops with Parse error near line 7: near "o": syntax error: it expected FROM straight after DELETE. The other variants fail the same way. DELETE FROM orders AS o JOIN customers … gives near "JOIN": syntax error, and PostgreSQL's DELETE FROM orders USING customers … gives near "USING": syntax error. The fix is always the same: move the join condition into WHERE EXISTS (…) or WHERE id IN (SELECT …).
7Other databases: DELETE with a join
Each form below ran on PostgreSQL 18, MySQL 8.4 or SQL Server (through Babelfish), deleting the orders of closed customers from the tables in section 1. The two subquery forms run everywhere, including SQLite; the join forms are each tied to their database. Oracle also accepts the EXISTS and IN forms.
| Syntax | Database | Notes |
|---|---|---|
| DELETE FROM t WHERE EXISTS (SELECT 1 FROM s WHERE …) | SQLite, PostgreSQL, MySQL, SQL Server | Portable; the correlated subquery holds the join condition |
| DELETE FROM t WHERE id IN (SELECT … JOIN …) | SQLite, PostgreSQL, MySQL, SQL Server | MySQL refuses it (error 1093) when the subquery reads t itself |
| DELETE t FROM t JOIN s ON … | MySQL, SQL Server | Name the table (or its alias) to delete from after DELETE |
| DELETE t, s FROM t JOIN s ON … | MySQL | Deletes the matching rows from both tables in one statement |
| DELETE FROM t USING s WHERE … | PostgreSQL | The join condition goes in WHERE; RETURNING works after it |
Frequently asked questions
How do I delete using an INNER JOIN in SQL Server?
Name the table to delete from, then join in the FROM clause: DELETE o FROM orders AS o JOIN customers AS c ON c.id = o.customer_id WHERE c.status = 'closed';. Only rows of o are deleted; customers is just read. The portable DELETE FROM orders WHERE EXISTS (SELECT 1 FROM customers AS c WHERE c.id = orders.customer_id AND c.status = 'closed') works in SQL Server too and deletes the same rows.
How do I delete with a join in MySQL?
Use DELETE o FROM orders AS o JOIN customers AS c ON c.id = o.customer_id WHERE c.status = 'closed';. To delete from both tables at once, list both: DELETE o, c FROM orders AS o JOIN customers AS c ON …, which removed 3 orders and 2 customers (5 rows) in one statement on MySQL 8.4. WHERE EXISTS (…) works as well.
Why does MySQL say "You can't specify target table for update in FROM clause"?
MySQL raises ERROR 1093 (HY000) when a DELETE (or UPDATE) has a subquery that reads the table being changed, e.g. DELETE FROM orders WHERE id IN (SELECT o.id FROM orders AS o JOIN customers AS c ON …). Rewrite it as DELETE o FROM orders AS o JOIN customers AS c ON …, or as a correlated EXISTS on the other table. Wrapping the subquery in a derived table, WHERE id IN (SELECT id FROM (SELECT o.id FROM orders AS o JOIN …) AS t), also works because MySQL builds the derived table first.
Does SQLite support DELETE with JOIN?
No. SQLite's DELETE names one table and filters it with WHERE; DELETE o FROM orders AS o JOIN … fails with Parse error near line 7: near "o": syntax error in the example above, and PostgreSQL's USING form fails with near "USING": syntax error. Put the condition in WHERE EXISTS (SELECT 1 FROM customers AS c WHERE c.id = orders.customer_id AND …) or WHERE id IN (SELECT …).
How do I delete rows that are not in another table?
Use DELETE FROM customers WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = customers.id);. Avoid WHERE id NOT IN (SELECT customer_id FROM orders) unless that column can never be NULL: with one NULL in orders.customer_id, the NOT IN version deleted 0 rows while NOT EXISTS deleted the 2 customers with no orders.
Can deleting a parent row delete its child rows automatically?
Yes, with a foreign key declared ON DELETE CASCADE: customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE. Then DELETE FROM customers WHERE status = 'closed' also removes that customer's orders. In SQLite, foreign keys are only enforced after PRAGMA foreign_keys = ON; on the connection; without it, the orders stay behind.
Run it yourself
Open any of these in the full SQLite editor to change the data, run and share.
SQLite playground