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
DELETEis a destructive and irreversible operation once committed. UnlikeUPDATE, which replaces one value with another,DELETEmakes the entire row disappear — and, withON 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 rungreenstore.sqlagain.- Always work inside
BEGIN…ROLLBACK/COMMITwhile 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
- The syntax, and the reinforced protocol
DELETEwith noWHEREagainstTRUNCATE- Deleting a referenced row:
RESTRICT - Deleting a referenced row:
CASCADE - Deleting a referenced row:
SET NULL - The danger of
CASCADEand the professional alternative DELETE ... USING: deleting according to another tableRETURNING: keeping what you delete- Soft delete against hard delete
- Personal data and the right to erasure
- Recovery after an accidental deletion
- Common Mistakes and Tips
- Exercises
- Conclusion
- The syntax, and the reinforced protocol
Only two pieces: which table and which rows. There's no SET, there are no values. All the responsibility falls on the WHERE.
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
DELETE with no WHERE against TRUNCATE
DELETE with no WHERE against TRUNCATEWith no WHERE, DELETE empties the table:
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:
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:
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.
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:
When to use each.
TRUNCATEfor emptying working tables, staging tables or test tables, where you want to start from scratch and speed matters.DELETEfor everything else, and always when there's aWHEREinvolved. ADELETE 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.
- Deleting a referenced row:
RESTRICT
RESTRICTHere'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 |
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 |
| id | name | category_id | supplier_id | price | stock |
|---|---|---|---|---|---|
| 19 | Natural stick deodorant 50 g | 5 | 4 | 7.80 | 75 |
| products |
|---|
| 19 |
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.
- Deleting a referenced row:
CASCADE
CASCADECASCADE 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 |
| id | order_id | reason | date | amount |
|---|---|---|---|---|
| 1 | 6 | Order cancelled by the customer before shipping | 2025-05-25 | 26.75 |
Now the deletion:
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
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 CASCADETip: run
\d tablebefore anyDELETEon a table you don't know inside out. TheReferenced bysection tells you in five seconds whether you're about to trigger a cascade.
- Deleting a referenced row:
SET NULL
SET NULLSET 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 |
| orders_without_employee |
|---|
| 10 |
| id | name | last_name | job_title | manager_id | salary |
|---|---|---|---|---|---|
| 4 | Óscar | Peris Blasco | Sales rep | 2 | 28500.00 |
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.
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 |
| 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.
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 |
- The danger of
CASCADE and the professional alternative
CASCADE and the professional alternativeCASCADE 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;Compare the two outputs. With CASCADE: DELETE 1, and four rows gone. Explicitly: DELETE 1, DELETE 2, DELETE 1 — four 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.
DELETE ... USING: deleting according to another table
DELETE ... USING: deleting according to another tableJust as UPDATE has FROM, DELETE has USING: it lets you decide what to delete based on another table.
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 |
| lines_ |
|---|
| 45 |
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.DELETEis idempotent by nature.
Dialect note:
USINGis PostgreSQL's own. MySQL writesDELETE ol FROM order_lines ol JOIN orders o ON ... WHERE ...(notice the alias repeated afterDELETE). SQL Server usesDELETE 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.
RETURNING: keeping what you delete
RETURNING: keeping what you deleteIn 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.
| id | order_id | reason | date | amount |
|---|---|---|---|---|
| 3 | 13 | The format does not match what was expected | 2025-10-30 | 19.80 |
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;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.
RETURNINGcompletes the trilogy:INSERT ... RETURNINGgives you the generatedid,UPDATE ... RETURNINGthe new value,DELETE ... RETURNINGthe row that's leaving. In all three cases it avoids an additionalSELECTand the race condition that comes with it.
- 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.
| id | name | price | stock | active | added_date |
|---|---|---|---|---|---|
| 20 | Spirulina capsules 120 units | 16.40 | 55 | false | 2025-06-01 |
| id | name | country | active | |
|---|---|---|---|---|
| 5 | EcoNordic Supplies | Germany | sales@econordic.de | 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.
- 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 | city | country | signup_date | |
|---|---|---|---|---|---|---|
| 13 | Núria | Bosch Ferrer | nuria.bosch@example.com | Barcelona | Spain | 2025-06-20 |
| customers |
|---|
| 14 |
But with a customer who has actually bought something, the conflict appears immediately:
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;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
DELETEcan 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.
- 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 inUPDATE, with worse consequences. - Trusting the
DELETE NwithCASCADE. It counts the rows you delete, not the ones the engine deletes down the chain.DELETE 1can be four rows, or four million. - Not checking what depends on the row before deleting it.
\d tableand itsReferenced bysection, in five seconds. - Using
TRUNCATEbelieving it's a fastDELETE. It doesn't acceptWHERE, 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
TRUNCATEbeing transactional. In PostgreSQL it is; in MySQL it isn't, and there noROLLBACKis possible. - Repeating the target table in the
USING. Silent Cartesian product, just as inUPDATE ... FROM. - Deleting records with a commercial history. That's what
active = FALSEis for. TheRESTRICTFK will try to stop you; don't dodge it withCASCADE. - 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 = FALSEsatisfies 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 anyDELETE. You want to see the complete row that's about to disappear, not just itsid. - Tip: always
RETURNING *in aDELETE. 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 toCASCADE. Three lines instead of one, in exchange for seeing exactly what you're destroying. - Tip: when designing, use
removed_date DATEinstead ofactive BOOLEAN. It costs the same and it also tells you when.
Exercises
Work on the freshly reloaded database and use BEGIN … ROLLBACK 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).
- Check which they are and which of them have ever been sold.
- Attempt a hard delete of all of them and explain what happens.
- Propose and apply the correct solution, justifying why it's the correct one.
- 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;- Find the error and explain what it would really do.
- Write it correctly and say how many rows it would delete on the freshly reloaded database.
- Rewrite it in a portable way, with no
USING, saying which module covers that technique. - 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 |
| 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:
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:
| 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.
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 |
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 |
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;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:
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 theWHERE, and the five-step protocol reinforced with a new one: count the dependent rows before deleting. DELETEagainstTRUNCATE: DML against DDL,WHEREagainst all-or-nothing, slow against instantaneous, with row triggers against without them, and —critically— transactional in PostgreSQL but not in MySQL.TRUNCATEdoesn't run theON DELETEactions: it fails if there are FKs pointing at the table.- The three
ON DELETEactions demonstrated with counts:RESTRICTprevents deleting product 1 with itsis still referenced from table;CASCADEturnsDELETE FROM orders WHERE id = 6into four rows across three tables while reporting only one;SET NULLleaves Ó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 ... USINGto delete according to another table, with the same rules asUPDATE ... FROM— except that here a 1:N matching isn't a problem.RETURNINGto see the row before it disappears, and theINSERT ... SELECT+DELETEpattern inside a transaction to archive it properly.- Soft delete against hard delete:
products.activeandsuppliers.activeare a soft delete. It preserves history, integrity and reversibility, in exchange for every query needingWHERE active— a problem module 10's views solve. And the third way,removed_date DATE, which also tells you when. - Personal data:
active = FALSEdoes 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:
ROLLBACKif 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
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
