DELETE removes rows. It's the simplest operation in the module and the one that demands the most judgement, because it raises a question INSERT and UPDATE never raise: does it really have to be deleted?

GreenStore already gave you the answer without telling you. Its products and suppliers tables have an active column. That boolean exists because, in a real system, a product that has been sold is never deleted: it's withdrawn from the catalogue. Deleting it would destroy the billing history, and in fact the foreign key order_lines.product_id → products.id is declared ON DELETE RESTRICT precisely to prevent it. That whole discussion —soft delete against hard delete— has its lesson here.

Before that we'll get to the concrete stuff: the reinforced safety protocol, the difference between DELETE and TRUNCATE, and exactly what happens when you delete a row other rows depend on, demonstrated with counts before and after for the three ON DELETE actions GreenStore declares.

⚠️ Safety warning

DELETE is a destructive and irreversible operation once committed. Unlike UPDATE, which replaces one value with another, DELETE makes the entire row disappear — and, with ON DELETE CASCADE, rows in other tables you never mentioned.

  • Run all the examples on your practice database (greenstore), never in production.
  • Take a backup beforehand: pg_dump -U sql_course -d greenstore -f backup.sql. To get back to the initial state, just run greenstore.sql again.
  • Always work inside BEGINROLLBACK / COMMIT while you're experimenting.
  • On a real system, a deletion over live data should be reviewed by another person, and personal-data deletion policies require legal review (section 10).

Contents

  1. The syntax, and the reinforced protocol
  2. DELETE with no WHERE against TRUNCATE
  3. Deleting a referenced row: RESTRICT
  4. Deleting a referenced row: CASCADE
  5. Deleting a referenced row: SET NULL
  6. The danger of CASCADE and the professional alternative
  7. DELETE ... USING: deleting according to another table
  8. RETURNING: keeping what you delete
  9. Soft delete against hard delete
  10. Personal data and the right to erasure
  11. Recovery after an accidental deletion
  12. Common Mistakes and Tips
  13. Exercises
  14. Conclusion

  1. The syntax, and the reinforced protocol

DELETE FROM table
WHERE  condition;

Only two pieces: which table and which rows. There's no SET, there are no values. All the responsibility falls on the WHERE.

DELETE FROM products WHERE id = 19;
DELETE 1

Just like UPDATE N, the number tells you how many rows it removed. And here it matters even more, because there's no new value you can inspect afterwards: if the number isn't the one you expected, it's already too late.

05-03's five-step protocol applies just the same, with two reinforcements:

Step In UPDATE In DELETE
1 SELECT with the final WHERE The same, but with SELECT *: you want to see the whole row that's about to disappear
2 Count rows The same
3 Preview the new values Replaced by: check which dependent rows it'll take with it
4 UPDATE copying the WHERE DELETE copying the WHERE, always inside BEGIN
5 Verify Verify counts in every affected table, not just in the one you're deleting from

Step 3 is the new one, and it's what separates a safe DELETE from a catastrophe. Before deleting an order, look at how many lines and returns it has:

SELECT (SELECT COUNT(*) FROM order_lines WHERE order_id = 6) AS lines_,
       (SELECT COUNT(*) FROM returns     WHERE order_id = 6) AS returns_;
lines_ returns_
2 1

Now you know that DELETE 1 is going to remove four rows across three tables. Section 4 demonstrates it.

And the complete flow:

flowchart TD
    A["SELECT * with the WHERE<br/>see the whole rows"] --> B["Count the dependent rows<br/>in the child tables"]
    B --> C{"Are the numbers<br/>the expected ones?"}
    C -->|No| A
    C -->|Yes| D["BEGIN"]
    D --> E["DELETE"]
    E --> F["Count in ALL<br/>the affected tables"]
    F --> G{"Does it match?"}
    G -->|No| H["ROLLBACK"]
    G -->|Yes| I["COMMIT"]
    H --> A

  1. DELETE with no WHERE against TRUNCATE

With no WHERE, DELETE empties the table:

-- ⚠️ INCORRECT outside a transaction
DELETE FROM reviews;
DELETE 12

All twelve reviews, gone. Exactly the same problem as the UPDATE with no WHERE, with the same absence of any warning.

To empty a table there's a specific statement, TRUNCATE:

TRUNCATE TABLE reviews;
TRUNCATE TABLE

They do the same thing and they're nothing alike:

DELETE FROM table TRUNCATE TABLE table
Sublanguage DML DDL
Accepts WHERE Yes No: it's all or nothing
Speed Proportional to the number of rows Almost instantaneous, regardless of volume
How it does it Marks each row as deleted, one by one Discards the whole data files
Write-ahead log (WAL) One entry per row Minimal
Disk space Not freed until a VACUUM Freed immediately
Transactional in PostgreSQL Yes Yes (it can be rolled back)
Transactional in MySQL Yes No: it commits implicitly
Fires row TRIGGERs Yes (BEFORE/AFTER DELETE) No (statement triggers only)
Resets the identity No Optional: TRUNCATE ... RESTART IDENTITY
Returns the row count Yes (DELETE 12) No
RETURNING Yes No
Permission needed DELETE TRUNCATE (more restrictive)
Respects the FKs Yes, it applies the ON DELETE actions It fails if there are FKs pointing at the table, unless CASCADE

That last row deserves a demonstration:

TRUNCATE TABLE orders;
ERROR:  cannot truncate a table referenced in a foreign key constraint
DETAIL:  Table "order_lines" references "orders".
HINT:  Truncate table "order_lines" at the same time, or use TRUNCATE ... CASCADE.

TRUNCATE doesn't run the ON DELETE actions: either you empty the whole tree at once, or you empty nothing.

TRUNCATE TABLE orders CASCADE;
NOTICE:  truncate cascades to table "order_lines"
NOTICE:  truncate cascades to table "returns"
TRUNCATE TABLE

And if you also want the sequences to start again at 1:

TRUNCATE TABLE orders, order_lines, returns RESTART IDENTITY;
TRUNCATE TABLE

When to use each. TRUNCATE for emptying working tables, staging tables or test tables, where you want to start from scratch and speed matters. DELETE for everything else, and always when there's a WHERE involved. A DELETE FROM table; with no condition on a large table is the worst of both worlds: slow, with the WAL going wild and without freeing any space.

  1. Deleting a referenced row: RESTRICT

Here's where it gets interesting. When the row you delete is the "parent" of a foreign key, the engine applies the action declared in the ON DELETE. GreenStore declares all three.

RESTRICT is the barrier: it forbids deletion. order_lines.product_id carries it, among others.

Product 1 (Extra virgin olive oil) appears in 5 order lines and has 2 reviews:

SELECT (SELECT COUNT(*) FROM order_lines WHERE product_id = 1) AS lines_,
       (SELECT COUNT(*) FROM reviews     WHERE product_id = 1) AS reviews;
lines_ reviews
5 2
DELETE FROM products WHERE id = 1;
ERROR:  update or delete on table "products" violates foreign key constraint "order_lines_product_id_fkey" on table "order_lines"
DETAIL:  Key (id)=(1) is still referenced from table "order_lines".

Read it slowly, because it's the module's most frequent error:

Fragment What it means
update or delete on table "products" The table you were trying to touch
violates foreign key constraint "order_lines_product_id_fkey" Which FK prevents it
on table "order_lines" Where that FK lives: in the child table
Key (id)=(1) is still referenced The value still in use

Compare it with the INSERT error from 05-02: there it said is not present in table (you were referencing something that doesn't exist); here it says is still referenced from table (something depends on what you want to delete). The two halves of referential integrity.

Now a product that can be deleted. Number 19 (Natural stick deodorant) is one of the three that have never been sold, and it has no reviews:

BEGIN;

SELECT (SELECT COUNT(*) FROM order_lines WHERE product_id = 19) AS lines_,
       (SELECT COUNT(*) FROM reviews     WHERE product_id = 19) AS reviews;
lines_ reviews
0 0
DELETE FROM products WHERE id = 19
RETURNING id, name, category_id, supplier_id, price, stock;
id name category_id supplier_id price stock
19 Natural stick deodorant 50 g 5 4 7.80 75
DELETE 1
SELECT COUNT(*) AS products FROM products;
products
19
ROLLBACK;

Of the catalogue of 20, 19 are left. The RESTRICT constraint isn't an obstacle: it's a function. It's telling you "this product has a history, don't destroy it". And product 19 doesn't have one.

  1. Deleting a referenced row: CASCADE

CASCADE propagates the deletion to the child rows. In GreenStore four foreign keys carry it: order_lines.order_id, returns.order_id, reviews.product_id and reviews.customer_id.

Order 6 is the perfect case: it's cancelled, it has 2 lines and 1 return.

BEGIN;

-- Count BEFORE
SELECT (SELECT COUNT(*) FROM orders)      AS orders,
       (SELECT COUNT(*) FROM order_lines) AS lines_,
       (SELECT COUNT(*) FROM returns)     AS returns_;
orders lines_ returns_
20 47 3

And what hangs off order 6:

SELECT ol.id, ol.product_id, p.name, ol.quantity, ol.unit_price
FROM   order_lines AS ol
JOIN   products    AS p ON ol.product_id = p.id
WHERE  ol.order_id = 6
ORDER BY ol.id;
id product_id name quantity unit_price
14 1 Extra virgin olive oil 500 ml 1 12.50
15 8 Almond body oil 200 ml 1 14.25
SELECT id, order_id, reason, date, amount FROM returns WHERE order_id = 6;
id order_id reason date amount
1 6 Order cancelled by the customer before shipping 2025-05-25 26.75

Now the deletion:

DELETE FROM orders WHERE id = 6;
DELETE 1

DELETE 1. One single row, says PostgreSQL. Let's look at the counts:

SELECT (SELECT COUNT(*) FROM orders)      AS orders,
       (SELECT COUNT(*) FROM order_lines) AS lines_,
       (SELECT COUNT(*) FROM returns)     AS returns_;
orders lines_ returns_
19 45 2

Four rows have disappeared across three tables, and the counter reported only one. There it is, in all its rawness, the danger of CASCADE: DELETE N counts the rows you deleted, not the ones the engine deleted down the chain.

flowchart TD
    A["DELETE FROM orders<br/>WHERE id = 6"] --> B["orders: 20 → 19<br/>DELETE 1"]
    B --> C["order_lines.order_id<br/>ON DELETE CASCADE"]
    B --> D["returns.order_id<br/>ON DELETE CASCADE"]
    C --> E["lines 14 and 15<br/>47 → 45"]
    D --> F["return 1<br/>3 → 2"]
    E --> G["Real total:<br/>4 rows in 3 tables"]
    F --> G
ROLLBACK;

And cascades can chain. If order_lines in turn had a child table with CASCADE, the deletion would keep going down. In a large schema, a single DELETE can propagate through half the database with nothing telling you.

Checking the reach before deleting

The way to avoid surprises is to ask the system catalogue which foreign keys point at a table:

SELECT tc.table_name   AS child_table,
       kcu.column_name AS column_name,
       rc.delete_rule  AS on_delete_action
FROM   information_schema.table_constraints    AS tc
JOIN   information_schema.key_column_usage     AS kcu
       ON tc.constraint_name = kcu.constraint_name
JOIN   information_schema.referential_constraints AS rc
       ON tc.constraint_name = rc.constraint_name
JOIN   information_schema.constraint_column_usage AS ccu
       ON rc.unique_constraint_name = ccu.constraint_name
WHERE  tc.constraint_type = 'FOREIGN KEY'
  AND  ccu.table_name = 'orders'
ORDER BY child_table;
child_table column_name on_delete_action
order_lines order_id CASCADE
returns order_id CASCADE

Quicker still, inside psql, the Referenced by section of \d orders:

Referenced by:
    TABLE "order_lines" CONSTRAINT "order_lines_order_id_fkey" FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
    TABLE "returns" CONSTRAINT "returns_order_id_fkey" FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE

Tip: run \d table before any DELETE on a table you don't know inside out. The Referenced by section tells you in five seconds whether you're about to trigger a cascade.

  1. Deleting a referenced row: SET NULL

SET NULL doesn't delete the child: it takes the reference away. GreenStore declares it on orders.employee_id, customers.referred_by_id and employees.manager_id.

Sales rep Óscar Peris Blasco (employee 4) leaves the company. He has 4 orders assigned:

BEGIN;

SELECT id, customer_id, employee_id, order_date, status
FROM   orders WHERE employee_id = 4 ORDER BY id;
id customer_id employee_id order_date status
2 2 4 2025-03-12 delivered
6 5 4 2025-05-23 cancelled
10 9 4 2025-08-03 delivered
16 4 4 2025-12-19 shipped
SELECT COUNT(*) AS orders_without_employee FROM orders WHERE employee_id IS NULL;
orders_without_employee
10
DELETE FROM employees WHERE id = 4
RETURNING id, name, last_name, job_title, manager_id, salary;
id name last_name job_title manager_id salary
4 Óscar Peris Blasco Sales rep 2 28500.00
DELETE 1
SELECT (SELECT COUNT(*) FROM employees)                          AS employees,
       (SELECT COUNT(*) FROM orders)                             AS orders,
       (SELECT COUNT(*) FROM orders WHERE employee_id IS NULL)   AS without_employee;
employees orders without_employee
7 20 14

The 20 orders are still there. What has changed is that Óscar's four now have employee_id at NULL: we've gone from 10 orders with no sales rep to 14.

And there's a lesson from 04-03 here worth underlining. Before, employee_id IS NULL unambiguously meant "order that came in through the web". Now it means two different things: "web order" or "the sales rep who handled it is no longer at the company". The NULL has lost semantic precision without anyone deciding it should. It's a classic side effect of SET NULL, and the reason many teams prefer to keep the employee with a leaver flag instead of deleting them.

ROLLBACK;

SET NULL in a reflexive relationship

A more spectacular case: deleting Andrés Company Talens (employee 2), Sales manager, who handles no orders but has three subordinates.

BEGIN;

SELECT id, name, last_name, job_title, manager_id FROM employees WHERE manager_id = 2 ORDER BY id;
id name last_name job_title manager_id
4 Óscar Peris Blasco Sales rep 2
5 Laia Puig Sanchis Sales rep 2
6 Marc Estévez Roig Customer support 2
DELETE FROM employees WHERE id = 2;
DELETE 1
SELECT id, name || ' ' || last_name AS employee, job_title, manager_id
FROM   employees ORDER BY id;
id employee job_title manager_id
1 Rosa Alcázar Vives General manager (null)
3 Beatriz Nadal Ripoll Logistics manager 1
4 Óscar Peris Blasco Sales rep (null)
5 Laia Puig Sanchis Sales rep (null)
6 Marc Estévez Roig Customer support (null)
7 Irene Salvador Mira Warehouse operator 3
8 Daniel Vercher Lluch Data analyst 1

Three employees have been left with no manager, and the org chart 01-06 drew so neatly now has four roots instead of one. The company hasn't reorganised: an intermediate node of the tree has simply disappeared and SET NULL has done the only thing it knows how to do.

ROLLBACK;

The three actions, in one table

Action What happens to the child When to pick it In GreenStore
RESTRICT / NO ACTION Nothing: the deletion is prevented The child can't be left without a parent and the parent shouldn't disappear orders.customer_id, order_lines.product_id, products.category_id, products.supplier_id
CASCADE It's deleted too The child is part of the parent (composition): it doesn't exist without it order_lines.order_id, returns.order_id, reviews.product_id, reviews.customer_id
SET NULL It loses the reference, it survives The relationship is optional and the child makes sense on its own orders.employee_id, customers.referred_by_id, employees.manager_id

  1. The danger of CASCADE and the professional alternative

CASCADE is convenient. One DELETE and the whole tree disappears cleanly, with no orphan rows. And that's exactly why it's dangerous:

Risk Detail
Invisible reach DELETE 1 can mean four rows, or four hundred thousand. The counter doesn't say
It propagates down the chain If the child has children with CASCADE, the deletion keeps going down with no limit
It's declared far away The cascade lives in the DDL, written three years ago by somebody else. Whoever runs the DELETE may not know it exists
Unpredictable performance A CASCADE on an unindexed FK scans the entire child table for every parent row deleted (module 8)
It can bypass business rules It deletes without going through the application's logic: counters, aggregates and audit trails end up out of sync

That's why many teams adopt a more conservative policy: RESTRICT on every FK, and explicit deletion in the correct order, inside a transaction.

BEGIN;

DELETE FROM returns     WHERE order_id = 6;   -- 1) the grandchildren
DELETE FROM order_lines WHERE order_id = 6;   -- 2) the children
DELETE FROM orders      WHERE id = 6;         -- 3) the parent

COMMIT;
BEGIN
DELETE 1
DELETE 2
DELETE 1
COMMIT

Compare the two outputs. With CASCADE: DELETE 1, and four rows gone. Explicitly: DELETE 1, DELETE 2, DELETE 1four rows, and you see all of them. You write three lines instead of one and you gain complete visibility over what you're destroying. In a system with real data, that trade is worth far more than it costs.

CASCADE RESTRICT + explicit deletion
Lines of code 1 N (one per level)
Visibility of the reach None Total
Risk of forgetting a level None It exists (the error will tell you)
Protection against an accidental DELETE None The FK stops you
Suitable for Strict, well-bounded composition Almost everything else

GreenStore uses CASCADE in four places and all four are genuine composition: an order line, a return and a review mean nothing without their parent. That's the correct use. 01-05's rule still stands: ask yourself whether the child row makes sense on its own, and if you're in doubt, RESTRICT.

  1. DELETE ... USING: deleting according to another table

Just as UPDATE has FROM, DELETE has USING: it lets you decide what to delete based on another table.

DELETE FROM target_table AS t
USING other_table AS o
WHERE t.key = o.key
  AND conditions;

Cleaning up the catalogue: removing the lines of the cancelled orders.

BEGIN;

-- Step 1: see what's going to be deleted
SELECT ol.id, ol.order_id, o.status, p.name, ol.quantity, ol.unit_price
FROM   order_lines AS ol
JOIN   orders      AS o ON ol.order_id   = o.id
JOIN   products    AS p ON ol.product_id = p.id
WHERE  o.status = 'cancelled'
ORDER BY ol.id;
id order_id status name quantity unit_price
14 6 cancelled Extra virgin olive oil 500 ml 1 12.50
15 6 cancelled Almond body oil 200 ml 1 14.25
-- Step 2: the DELETE
DELETE FROM order_lines AS ol
USING orders AS o
WHERE ol.order_id = o.id
  AND o.status    = 'cancelled'
RETURNING ol.id, ol.order_id, ol.product_id, ol.quantity;
id order_id product_id quantity
14 6 1 1
15 6 8 1
DELETE 2
SELECT COUNT(*) AS lines_ FROM order_lines;
lines_
45
ROLLBACK;

USING's rules, all inherited from UPDATE ... FROM:

  • You can put several tables, separated by commas or joined with JOIN.
  • The target table isn't repeated in the USING. If you do it, silent Cartesian product.
  • Unlike in UPDATE ... FROM, a 1:N matching isn't a problem here: a row that matches three others is deleted just once. DELETE is idempotent by nature.

Dialect note: USING is PostgreSQL's own. MySQL writes DELETE ol FROM order_lines ol JOIN orders o ON ... WHERE ... (notice the alias repeated after DELETE). SQL Server uses DELETE t FROM target t JOIN other o ON .... Oracle has neither and forces you into a correlated subquery. The portable form is always a subquery: DELETE FROM order_lines WHERE order_id IN (SELECT id FROM orders WHERE status = 'cancelled') — module 7.

  1. RETURNING: keeping what you delete

In DELETE, RETURNING gives back the rows exactly as they were just before disappearing. It's the only way to see them without having queried them beforehand.

DELETE FROM returns
WHERE  amount < 20
RETURNING id, order_id, reason, date, amount;
id order_id reason date amount
3 13 The format does not match what was expected 2025-10-30 19.80
DELETE 1

But seeing them isn't keeping them. For that, the professional pattern combines INSERT ... SELECT (05-02) with the DELETE, inside a transaction:

BEGIN;

-- 1) The archive table (once only)
CREATE TABLE returns_archive (
    id           INTEGER       NOT NULL,
    order_id     INTEGER       NOT NULL,
    reason       VARCHAR(200)  NOT NULL,
    date         DATE          NOT NULL,
    amount       NUMERIC(10,2) NOT NULL,
    removed_date DATE          NOT NULL DEFAULT CURRENT_DATE,
    CONSTRAINT pk_returns_archive PRIMARY KEY (id)
);

-- 2) Copy what's going to be deleted
INSERT INTO returns_archive (id, order_id, reason, date, amount)
SELECT rt.id, rt.order_id, rt.reason, rt.date, rt.amount
FROM   returns AS rt
WHERE  rt.amount < 20;

-- 3) Delete
DELETE FROM returns WHERE amount < 20;

COMMIT;
BEGIN
CREATE TABLE
INSERT 0 1
DELETE 1
COMMIT

Inside a transaction, either all three things happen or none of them does: it's impossible for you to delete without having archived. Notice one detail of the archive table's DDL: it carries no foreign keys. If it did, you couldn't archive a return whose order is also about to be deleted. Archive tables are deliberately lax.

RETURNING completes the trilogy: INSERT ... RETURNING gives you the generated id, UPDATE ... RETURNING the new value, DELETE ... RETURNING the row that's leaving. In all three cases it avoids an additional SELECT and the race condition that comes with it.

  1. Soft delete against hard delete

And we get to the heart of the matter.

  • Hard delete: DELETE FROM products WHERE id = 13. The row disappears.
  • Soft delete: UPDATE products SET active = FALSE WHERE id = 13. The row is still there, flagged as no longer current.

products.active and suppliers.active are exactly that. They aren't a design whim: they're the decision, taken in 01-05 and now explained, that in GreenStore nothing that has taken part in a commercial operation is ever deleted.

SELECT id, name, price, stock, active, added_date
FROM   products
WHERE  NOT active;
id name price stock active added_date
20 Spirulina capsules 120 units 16.40 55 false 2025-06-01
SELECT id, name, country, email, active FROM suppliers WHERE NOT active;
id name country email active
5 EcoNordic Supplies Germany [email protected] false

EcoNordic Supplies is inactive and keeps its four products in the catalogue (10, 13, 18 and 20). With a hard delete, either the RESTRICT FK would have prevented deleting it, or you'd have had to delete four products that are still being sold.

The complete comparison

Aspect Soft delete (active = FALSE) Hard delete (DELETE)
History and auditing Preserved: you know it existed and when it stopped being current Destroyed
Referential integrity Untouched: the children still point at something real You have to decide RESTRICT, CASCADE or SET NULL
Reversibility Trivial: SET active = TRUE Only from a backup
Historical reports They still add up They stop adding up retroactively
Query complexity Every query needs WHERE active No extra condition
Table size Grows indefinitely Stays bounded
Performance Bigger indexes; you always have to filter Optimal
Uniqueness It gets complicated: can a code be reused if the previous one is inactive? Trivial
GDPR compliance Problematic: the data is still there It satisfies the right to erasure

The real cost: WHERE active everywhere

This is the drawback everybody underestimates:

-- Revenue by category for the CURRENT catalogue
SELECT cat.id,
       cat.name AS category,
       COUNT(*)               AS products,
       ROUND(AVG(p.price), 2) AS avg_price
FROM   products   AS p
JOIN   categories AS cat ON p.category_id = cat.id
WHERE  p.active
GROUP BY cat.id, cat.name
ORDER BY products DESC, cat.id;
id category products avg_price
1 Food 5 6.18
2 Natural cosmetics 4 11.54
3 Sustainable home 4 10.09
4 Drinks 4 8.90
5 Personal hygiene 2 5.65

Five categories, not six. Supplements disappears from the report because its only product (number 20) is inactive. And that disappearance depends entirely on somebody remembering to write WHERE p.active. Forget it once in a management report and you'll be counting references that are no longer sold.

That forgetfulness has a solution, and it's a view:

-- A preview of module 10: don't write it yet
CREATE VIEW current_products AS
SELECT * FROM products WHERE active;

From then on, catalogue queries go against current_products and the filter is impossible to forget. Views are the canonical solution to soft delete's main drawback, and they're studied in lesson 10-01.

When to use each

Use a soft delete when… Use a hard delete when…
The data has taken part in operations (sales, invoices, contracts) The data is transient or working data (sessions, caches, staging)
There's a legal or accounting obligation to keep it It's junk: tests, duplicates, load errors
Other tables reference it Nobody references it and nobody ever did
It may need reactivating The volume is a genuine performance problem
You want to know it existed There's a legal obligation to erase it (next section)

And a third way, increasingly common: soft delete with a date. Instead of a boolean, a nullable removed_date DATE column. It takes up the same space, it distinguishes "current" from "withdrawn" just as well (removed_date IS NULL) and on top of that it tells you when it happened, which is usually exactly what somebody will ask six months later. If you're designing from scratch, prefer it to the boolean.

  1. Personal data and the right to erasure

There's one case where a soft delete isn't enough: personal data.

The European General Data Protection Regulation recognises the right to erasure (article 17, the so-called "right to be forgotten"): a person can demand that their personal data be removed, and flagging a row as inactive isn't removing it. The name, the email and the city are still in the table, in the backups and in the indexes.

In GreenStore, the customers table contains personal data: first name, surname, email and city. And its design already anticipates part of the problem:

-- What happens if a customer exercises the right to erasure
SELECT (SELECT COUNT(*) FROM orders    WHERE customer_id = 13)    AS orders,
       (SELECT COUNT(*) FROM reviews   WHERE customer_id = 13)    AS reviews,
       (SELECT COUNT(*) FROM customers WHERE referred_by_id = 13) AS referred;
orders reviews referred
0 0 0

Núria Bosch Ferrer (customer 13) is one of the three customers with no orders. Deleting her is clean:

BEGIN;

DELETE FROM customers WHERE id = 13
RETURNING id, name, last_name, email, city, country, signup_date;
id name last_name email city country signup_date
13 Núria Bosch Ferrer [email protected] Barcelona Spain 2025-06-20
DELETE 1
SELECT COUNT(*) AS customers FROM customers;
customers
14
ROLLBACK;

But with a customer who has actually bought something, the conflict appears immediately:

DELETE FROM customers WHERE id = 5;
ERROR:  update or delete on table "customers" violates foreign key constraint "orders_customer_id_fkey"
DETAIL:  Key (id)=(5) is still referenced from table "orders".

Ana Belmonte Roca has two orders (6 and 18), and orders.customer_id is RESTRICT. Here two legitimate obligations collide:

Obligation What it demands
Right to erasure (GDPR art. 17) Remove the person's personal data
Accounting and tax obligation Keep the invoices issued for the legal retention period (in Spain, several years)

The usual solution in real systems is neither to delete nor not to delete: it's to anonymise. The row is kept —and with it the order, the invoice and the totals— but the identifying data is replaced:

-- Anonymisation pattern. An illustrative example: do NOT apply it
-- in a real system without legal review.
UPDATE customers
SET    name      = 'Customer',
       last_name = 'anonymised',
       email     = 'anon-' || id || '@invalid.local',
       city      = NULL
WHERE  id = 5;
UPDATE 1

Order 6 still exists, the accounts add up, and the personal data has gone. The email is built with the id so as not to break the UNIQUE constraint, and with an invalid domain so it's impossible to send mail to that address by accident.

The reviews, on the other hand, are deleted: reviews.customer_id is declared ON DELETE CASCADE for exactly this reason, as you reasoned in exercise 1 of 01-05. A review is a personal opinion; an invoice is an accounting document.

⚖️ Important warning

The above is a technical explanation of common patterns, not legal advice. Retention periods, what counts as personal data, what degree of anonymisation is sufficient and which exceptions apply all depend on the legislation in force, on the sector and on the specific case.

Before implementing any policy for deleting or anonymising personal data on a real system, check it with your data protection officer or with legal counsel. A badly conceived DELETE can breach the GDPR; a well-intentioned one can breach accounting regulations. Security, permissions and access control over this data are studied in lesson 11-03.

  1. Recovery after an accidental deletion

It's happened. You've committed a DELETE you shouldn't have. What are the options?

Situation What you can do
You haven't committed yet ROLLBACK. It's the reason for the whole of 05-03's section 4
You've committed, there's a backup Restore the pg_dump into an auxiliary database and reinsert only what's missing with INSERT ... SELECT
You've committed, PITR is configured Point-in-time recovery: you restore the base backup and replay the WAL up to the instant before the DELETE
None of the above Nothing. The data isn't there

PITR (Point-In-Time Recovery) is the technique that lets you say "give me the database exactly as it was at 17:42:30 yesterday". It works because PostgreSQL writes every change to a sequential log —the WAL, Write-Ahead Log— before applying it to the data files. With a base backup and the subsequent WAL, any intermediate instant can be reconstructed. It's also the mechanism that makes durability and replication possible, and it's studied along with the rest of the transactional model in module 9.

The practical conclusion, which doesn't depend on any technology:

There's no recovery without a backup. The only question that matters isn't "what do I do if I delete something by mistake?", but "when was the last backup taken and when was restoring it last tested?". A backup that has never been restored isn't a backup: it's an assumption.

For the course, your safety net is far simpler: greenstore.sql is idempotent, and rerunning it takes you back to the initial state in two seconds.

Common Mistakes and Tips

  • Forgetting the WHERE. It empties the whole table with no warning. The same emblematic mistake as in UPDATE, with worse consequences.
  • Trusting the DELETE N with CASCADE. It counts the rows you delete, not the ones the engine deletes down the chain. DELETE 1 can be four rows, or four million.
  • Not checking what depends on the row before deleting it. \d table and its Referenced by section, in five seconds.
  • Using TRUNCATE believing it's a fast DELETE. It doesn't accept WHERE, it doesn't fire row triggers, it doesn't return a count, it needs a different permission and it fails if there are FKs pointing at the table.
  • Counting on TRUNCATE being transactional. In PostgreSQL it is; in MySQL it isn't, and there no ROLLBACK is possible.
  • Repeating the target table in the USING. Silent Cartesian product, just as in UPDATE ... FROM.
  • Deleting records with a commercial history. That's what active = FALSE is for. The RESTRICT FK will try to stop you; don't dodge it with CASCADE.
  • Implementing a soft delete and forgetting the WHERE active. The report will keep working and will count discontinued products. Use a view (module 10).
  • Believing active = FALSE satisfies the right to erasure. It doesn't: the personal data is still there. Anonymise or delete, depending on the case and with legal review.
  • Confusing "it has no children today" with "it can be deleted". Product 19 can be deleted today; tomorrow, the moment somebody buys it, it can't.
  • Tip: SELECT * before any DELETE. You want to see the complete row that's about to disappear, not just its id.
  • Tip: always RETURNING * in a DELETE. It costs nothing and it leaves you a record of what you deleted, even if only in the console's history.
  • Tip: prefer RESTRICT + explicit deletion to CASCADE. Three lines instead of one, in exchange for seeing exactly what you're destroying.
  • Tip: when designing, use removed_date DATE instead of active BOOLEAN. It costs the same and it also tells you when.

Exercises

Work on the freshly reloaded database and use BEGINROLLBACK in every exercise.

Exercise 1

For each of these five deletions, predict the result before running it: whether it works or fails, how many rows are removed in total and in which tables, and which ON DELETE action is involved. Then check it with counts before and after.

-- a)
DELETE FROM suppliers WHERE id = 5;

-- b)
DELETE FROM orders WHERE id = 10;

-- c)
DELETE FROM categories WHERE id = 6;

-- d)
DELETE FROM customers WHERE id = 14;

-- e)
DELETE FROM employees WHERE id = 1;

Exercise 2

Management asks to withdraw every product of the inactive supplier from the catalogue (EcoNordic Supplies, id 5).

  1. Check which they are and which of them have ever been sold.
  2. Attempt a hard delete of all of them and explain what happens.
  3. Propose and apply the correct solution, justifying why it's the correct one.
  4. Write the current-catalogue query the website should use from now on, grouped by supplier.

Exercise 3

A colleague wants to clean up the reviews of discontinued products and shows you this statement:

-- ⚠️ INCORRECT
DELETE FROM reviews
USING products, reviews
WHERE reviews.product_id = products.id
  AND products.active = FALSE;
  1. Find the error and explain what it would really do.
  2. Write it correctly and say how many rows it would delete on the freshly reloaded database.
  3. Rewrite it in a portable way, with no USING, saying which module covers that technique.
  4. Discuss whether this cleanup is a good idea: what's lost and what's gained?

Solutions

Solution 1

a) It fails. products.supplier_id is ON DELETE RESTRICT and EcoNordic has four products:

ERROR:  update or delete on table "suppliers" violates foreign key constraint "products_supplier_id_fkey" on table "products"
DETAIL:  Key (id)=(5) is still referenced from table "products".

The supplier being active = FALSE changes nothing: soft delete and referential integrity are independent mechanisms.

b) It works, and it deletes 4 rows across 3 tables. Order 10 has 2 lines (ids 24 and 25) and 1 return (number 2, of €34.02), both with CASCADE:

SELECT (SELECT COUNT(*) FROM orders)      AS orders,
       (SELECT COUNT(*) FROM order_lines) AS lines_,
       (SELECT COUNT(*) FROM returns)     AS returns_;
orders lines_ returns_
20 47 3
DELETE FROM orders WHERE id = 10;
DELETE 1
orders lines_ returns_
19 45 2

And something that doesn't show up in any count: GreenStore's total revenue has just dropped from €727.95 to €679.68, because order 10 contributed €48.27. The shipping costs drop from €118.25 to €105.75. No message told you.

c) It fails, and it's the trick exercise. Category 6 (Supplements) looks empty because its only product, number 20, is discontinued. But products.category_id is RESTRICT and that row still exists:

ERROR:  update or delete on table "categories" violates foreign key constraint "products_category_id_fkey" on table "products"
DETAIL:  Key (id)=(6) is still referenced from table "products".

Product 20 is active = FALSE, but it's still a row of products and the foreign key doesn't distinguish between active and inactive. A soft delete doesn't release referential constraints.

d) It works, 1 row. Hugo Iglesias Pardo (customer 14) is one of the three with no orders, has no reviews and has referred nobody:

DELETE 1

customers: 15 → 14. No cascade, no SET NULL. It's the only one of the five deletions that's genuinely harmless.

e) It works, and it dismantles the org chart. Rosa Alcázar Vives (employee 1) handles no orders, but she's the manager of three people (2, 3 and 8), and employees.manager_id is SET NULL:

DELETE 1
SELECT COUNT(*) AS without_manager FROM employees WHERE manager_id IS NULL;
without_manager
3

We go from 1 employee with no manager to 3. The hierarchy has been split into three subtrees and the company has been left with no general management in the data model. SET NULL doesn't complain: it does what it was told.

Summary:

Result Rows removed Action involved
a Fails 0 RESTRICT (products.supplier_id)
b Works 4 across 3 tables CASCADE (order_lines, returns)
c Fails 0 RESTRICT (products.category_id)
d Works 1 None: no dependants
e Works 1, plus 3 modified SET NULL (employees.manager_id)

Solution 2

-- 1) Which products and which have been sold
SELECT p.id,
       p.name,
       p.price,
       p.stock,
       p.active,
       COUNT(ol.id) AS times_sold
FROM   products AS p
LEFT JOIN order_lines AS ol ON ol.product_id = p.id
WHERE  p.supplier_id = 5
GROUP BY p.id, p.name, p.price, p.stock, p.active
ORDER BY p.id;
id name price stock active times_sold
10 Concentrated eco laundry detergent 1 L 11.20 70 true 2
13 Soy wax candles (pack of 2) 13.75 0 true 0
18 Bamboo toothbrush 3.50 240 true 3
20 Spirulina capsules 120 units 16.40 55 false 0

Two of the four have been sold (10 and 18). The LEFT JOIN with COUNT(ol.id) is 04-05's exact pattern: if we'd used an INNER JOIN, precisely the two we care about would have disappeared.

-- 2) Attempting a hard delete
DELETE FROM products WHERE supplier_id = 5;
ERROR:  update or delete on table "products" violates foreign key constraint "order_lines_product_id_fkey" on table "order_lines"
DETAIL:  Key (id)=(10) is still referenced from table "order_lines".

It fails, and it fails entirely. The two that could have been deleted aren't deleted either: a DELETE is atomic, just like an INSERT or an UPDATE. The RESTRICT FK protects the billing history: without it, lines 11 and 39 (the detergent) and 23, 32 and 47 (the toothbrush) would have been left pointing at nothing, and the €727.95 of revenue could no longer be reconstructed.

-- 3) The correct solution: a soft delete
BEGIN;

UPDATE products
SET    active = FALSE
WHERE  supplier_id = 5
  AND  active
RETURNING id, name, price, stock, active;
id name price stock active
10 Concentrated eco laundry detergent 1 L 11.20 70 false
13 Soy wax candles (pack of 2) 13.75 0 false
18 Bamboo toothbrush 3.50 240 false
UPDATE 3
SELECT COUNT(*) FILTER (WHERE active)     AS on_sale,
       COUNT(*) FILTER (WHERE NOT active) AS withdrawn,
       COUNT(*)                           AS total
FROM products;
on_sale withdrawn total
16 4 20
COMMIT;

Three updated, not four: product 20 was already inactive, and the AND active filter excludes it. That makes the statement idempotent (05-03): rerunning it would return UPDATE 0.

Why this is the correct solution, in three points: it keeps the billing history intact; it maintains referential integrity with no need to decide on cascades; and it's reversible with a SET active = TRUE if EcoNordic starts supplying again.

-- 4) The current catalogue by supplier
SELECT s.id,
       s.name AS supplier,
       s.country,
       COUNT(*)               AS products,
       ROUND(AVG(p.price), 2) AS avg_price,
       SUM(p.stock)           AS total_stock
FROM   products  AS p
JOIN   suppliers AS s ON p.supplier_id = s.id
WHERE  p.active
  AND  s.active
GROUP BY s.id, s.name, s.country
ORDER BY products DESC, s.id;
id supplier country products avg_price total_stock
1 Huerta del Turia Spain 5 5.74 770
3 Verde Atlántico Portugal 4 12.91 280
4 Maison Nature France 4 9.93 360
2 BioSierra Ibérica Spain 3 5.27 410

Four suppliers and 16 products, against the table's five suppliers and 20 products. Notice the two active filters: the product's and the supplier's. Forgetting either one would return a catalogue with references that can't be fulfilled. It's exactly the cost of a soft delete, and exactly what a view would solve (module 10).

Solution 3

1. The error. reviews appears twice: as the DELETE's target table and inside the USING. It's the same flaw as the UPDATE ... FROM in exercise 2 of 05-03. PostgreSQL treats the one in the USING as an independent instance, so the condition reviews.product_id = products.id is resolved against it and the target table is left uncorrelated. The result: if there's at least one review of an inactive product, every review in the table is deleted. With GreenStore's data there isn't one, so it would delete 0 — but that's pure luck, and the moment there was one, it would take all twelve.

2. The correct version:

-- ✅ CORRECT
DELETE FROM reviews AS r
USING products AS p
WHERE r.product_id = p.id
  AND NOT p.active
RETURNING r.id, r.product_id, r.customer_id, r.rating;
DELETE 0

Zero rows. The only inactive product is number 20 (Spirulina capsules), and it has no reviews at all — which is consistent with it never having been sold either. The check:

SELECT p.id, p.name, p.active, COUNT(r.id) AS reviews
FROM   products AS p
LEFT JOIN reviews AS r ON r.product_id = p.id
WHERE  NOT p.active
GROUP BY p.id, p.name, p.active;
id name active reviews
20 Spirulina capsules 120 units false 0

3. The portable version, with no USING:

DELETE FROM reviews
WHERE product_id IN (SELECT id FROM products WHERE NOT active);
DELETE 0

It works the same way in PostgreSQL, MySQL, SQLite, SQL Server and Oracle. Subqueries are module 7, and this is exactly why they're studied: USING and FROM are proprietary extensions, the subquery is standard. Watch out for one detail inherited from 04-02: if the subquery could return a NULL, a NOT IN would silently give zero rows. With IN there's no problem, but it's worth keeping in mind.

4. Is it a good idea?

Gained Lost
Fewer rows in reviews The history of opinions about the product
Apparent consistency of the catalogue The ability to answer "why did we withdraw this product?"
The historical average rating of the category and of the supplier
The possibility of reactivating the product keeping its reviews

The verdict is no, it's almost never a good idea. Soft delete exists to preserve information, and hard-deleting the reviews of a withdrawn product destroys precisely what makes the withdrawal useful: knowing that this product averaged two stars and that's why it was withdrawn.

The correct alternative is not to delete anything and to filter on reading: the website shows the reviews of current products, and internal reports see them all. A WHERE p.active in the public query solves the problem without destroying a single piece of data. It's the same design decision as the whole of section 9, applied one level down.

And a legitimate exception: if those reviews contained personal data of somebody who has exercised their right to erasure, then they would have to be deleted — but the criterion would then be the customer, not the product, and reviews.customer_id is already declared ON DELETE CASCADE for exactly that.

Conclusion

DELETE closes the DML trilogy and raises the question the other two don't:

  • The syntax DELETE FROM table WHERE ..., with all the weight on the WHERE, and the five-step protocol reinforced with a new one: count the dependent rows before deleting.
  • DELETE against TRUNCATE: DML against DDL, WHERE against all-or-nothing, slow against instantaneous, with row triggers against without them, and —critically— transactional in PostgreSQL but not in MySQL. TRUNCATE doesn't run the ON DELETE actions: it fails if there are FKs pointing at the table.
  • The three ON DELETE actions demonstrated with counts: RESTRICT prevents deleting product 1 with its is still referenced from table; CASCADE turns DELETE FROM orders WHERE id = 6 into four rows across three tables while reporting only one; SET NULL leaves Óscar's four orders with no sales rep (10 → 14 nulls) and three employees with no manager.
  • The danger of CASCADE: invisible reach, chained propagation, declared far from whoever runs it. And the professional alternative, RESTRICT + explicit deletion, which costs three lines and shows you exactly what you're destroying.
  • DELETE ... USING to delete according to another table, with the same rules as UPDATE ... FROM — except that here a 1:N matching isn't a problem.
  • RETURNING to see the row before it disappears, and the INSERT ... SELECT + DELETE pattern inside a transaction to archive it properly.
  • Soft delete against hard delete: products.active and suppliers.active are a soft delete. It preserves history, integrity and reversibility, in exchange for every query needing WHERE active — a problem module 10's views solve. And the third way, removed_date DATE, which also tells you when.
  • Personal data: active = FALSE does not satisfy the right to erasure, anonymisation is the usual pattern when it collides with accounting obligations, and any personal-data deletion policy requires legal review.
  • Recovery: ROLLBACK if you haven't committed, a backup or PITR if you have — and neither of the two exists if nobody prepared it beforehand.

You now know how to insert, modify and delete separately. What's missing is the operation real systems constantly need and none of the three solves: "insert this row if it doesn't exist, and update it if it does". Synchronising a catalogue with a supplier's file, recording the stock received for an item that may not even be in the system yet, saving the rating of a review the customer may have written before. In the next lesson, The UPSERT (MERGE) Statement, you'll see why the obvious solution —query and then decide— is wrong the moment there are two users at once, and the two ways PostgreSQL offers to solve it in a single atomic statement: INSERT ... ON CONFLICT and the standard's MERGE.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved