From 37427c37ff06c28dab7373316013b25968977c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20P=C3=A9rez-Aradros=20Herce?= Date: Thu, 28 May 2026 16:46:17 +0200 Subject: [PATCH] Add lesson 06-delete-and-lifecycle Covers DELETE with predicates and RETURNING, foreign-key interactions, TRUNCATE (including RESTART IDENTITY), and the soft-delete pattern that most production apps actually use. Part of #6. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../02-delete-and-lifecycle/lesson.mdx | 128 ++++++++++++++++++ .../02-delete-and-lifecycle/lesson.yaml | 32 +++++ .../02-delete-and-lifecycle/seed.sql | 38 ++++++ 3 files changed, 198 insertions(+) create mode 100644 lessons/02-changing-data/02-delete-and-lifecycle/lesson.mdx create mode 100644 lessons/02-changing-data/02-delete-and-lifecycle/lesson.yaml create mode 100644 lessons/02-changing-data/02-delete-and-lifecycle/seed.sql diff --git a/lessons/02-changing-data/02-delete-and-lifecycle/lesson.mdx b/lessons/02-changing-data/02-delete-and-lifecycle/lesson.mdx new file mode 100644 index 0000000..7cf2439 --- /dev/null +++ b/lessons/02-changing-data/02-delete-and-lifecycle/lesson.mdx @@ -0,0 +1,128 @@ +`INSERT` and `UPDATE` cover adding and changing rows; the third DML statement is `DELETE`. This lesson covers the basics, the `TRUNCATE` shortcut, and why most real applications don't actually delete much. + +The seed has a `users` table (with a `deleted_at` column for the soft-delete pattern) and a small `archived_users` staging table you'll wipe with `TRUNCATE`. + +## DELETE: remove rows by predicate + +The shape mirrors `UPDATE`: `DELETE FROM WHERE ;` + + +DELETE FROM users +WHERE email = 'dennis@example.com'; + + + +Delete Dennis Ritchie's row. + + +The `WHERE` is doing all the work. **Omit it and you delete every row in the table** — Postgres won't ask twice. Get into the habit of writing the `SELECT` first to confirm the predicate matches what you expect, *then* swap `SELECT *` for `DELETE`. + +## `RETURNING` works here too + +Same trick as `UPDATE`: see exactly what you just deleted. + + +DELETE FROM users +WHERE is_active = false +RETURNING id, full_name, email; + + +Two rows come back — Edsger and Don, the remaining inactive users. (Dennis was already gone from the previous step.) In production this is gold for audit logs. + +## DELETE vs UPDATE: foreign keys complicate things + +A `DELETE` that violates a foreign key reference fails by default. If `orders.user_id` references `users.id`, you can't delete a user that still has orders — Postgres will raise an error. + +The schema decides the policy at the FK definition: `ON DELETE CASCADE` (delete the orders too), `ON DELETE SET NULL` (orphan them), or the default `NO ACTION` (refuse). We'll touch this in the constraints lesson; for now just know that DELETE isn't always a one-liner. + +## TRUNCATE: wipe a whole table, fast + +When you want every row gone, `TRUNCATE` is faster than `DELETE` because it bypasses the row-by-row machinery and just resets the table's storage. + + +TRUNCATE TABLE archived_users; + + + +After `TRUNCATE`, `archived_users` has 0 rows. + + +Three things to know about `TRUNCATE`: + +1. It **can't be filtered** — there's no `WHERE`. It's all or nothing. +2. It **doesn't fire row-level triggers** by default (an old gotcha for audit setups). +3. It **does** fire statement-level triggers and is transactional, so a `TRUNCATE` inside a `BEGIN ... ROLLBACK` is undone like any other change. + +For occasional cleanup of small tables, `DELETE` is fine. `TRUNCATE` earns its keep on tables with hundreds of thousands of rows. + +### Resetting sequences + +By default `TRUNCATE` doesn't touch the `serial` sequence — the next inserted row keeps its previous `id`. Pass `RESTART IDENTITY` to reset: + +```sql +TRUNCATE TABLE archived_users RESTART IDENTITY; +``` + +Useful for test fixtures; rarely what you want in production, where stable ids matter even after a wipe. + +## Soft deletes: don't actually delete + +Most production apps don't `DELETE` user-facing data. They mark it deleted and filter it out at read time. Reasons: foreign keys keep working, audit trails stay intact, "oops, undo" is one update away, and compliance teams stop sending you angry emails. + +The minimum schema is a nullable timestamp: + +```sql +ALTER TABLE users ADD COLUMN deleted_at timestamptz; +``` + +Already in the seed. "Deleting" is now an `UPDATE`: + + +UPDATE users +SET deleted_at = now() +WHERE email = 'edsger@example.com'; + + + +Soft-delete Edsger by setting `deleted_at` — the row is still there, just marked. + + +Reads then filter live rows: + + +SELECT id, full_name, deleted_at +FROM users +WHERE deleted_at IS NULL; + + +That `WHERE deleted_at IS NULL` is the cost: every query that should see only live users has to remember to add it. Common solutions are (1) wrap the table in a view, (2) use row-level security, or (3) just be disciplined. We'll meet views and RLS later in the course. + +### Undoing a soft delete + +Trivial: set the column back to `NULL`. + + +UPDATE users +SET deleted_at = NULL +WHERE email = 'edsger@example.com'; + + +That's the *real* reason soft delete is popular — undoing a hard `DELETE` means restoring from a backup. + +## When to actually hard-delete + +- **Personal data under a "right to be forgotten" request.** Soft delete leaves data in the database; legal usually requires it gone. +- **High-churn ephemeral tables** — session rows, throwaway job queues, audit trim. Soft delete just grows forever. +- **Test data and fixtures.** + +A reasonable default: soft-delete user-facing entities, hard-delete operational rubbish. Keep the policy explicit in the schema. + +## What you learned + +- `DELETE FROM ... WHERE ...` — the `WHERE` is critical; without it you wipe the table. +- `RETURNING` echoes the deleted rows back, same as on `UPDATE` and `INSERT`. +- Foreign keys may block a delete or cascade it, depending on the FK's `ON DELETE` action. +- `TRUNCATE` clears a whole table fast; no `WHERE`, transactional, skips row-level triggers, optional `RESTART IDENTITY`. +- The soft-delete pattern (`deleted_at timestamptz` + `WHERE deleted_at IS NULL`) is what most apps reach for in practice. + +Up next: handling the race between two writers with `INSERT … ON CONFLICT` — upsert without tears. diff --git a/lessons/02-changing-data/02-delete-and-lifecycle/lesson.yaml b/lessons/02-changing-data/02-delete-and-lifecycle/lesson.yaml new file mode 100644 index 0000000..612e8a0 --- /dev/null +++ b/lessons/02-changing-data/02-delete-and-lifecycle/lesson.yaml @@ -0,0 +1,32 @@ +title: Deleting rows and row lifecycle +summary: Remove rows with DELETE, wipe tables with TRUNCATE, and the soft-delete pattern most apps actually use. +estimatedMinutes: 12 +tags: + - delete + - truncate + - soft-delete + - dml +authors: + - exekias +seed: seed.sql +checks: + - id: dennis-deleted + type: query-returns + description: Delete Dennis Ritchie's row from users. + sql: SELECT count(*)::int FROM users WHERE email = 'dennis@example.com' + expect: + rowCount: 1 + rows: [[0]] + - id: archived-cleared + type: row-count + description: Truncate the archived_users table so it's empty. + table: archived_users + expect: + rowCount: 0 + - id: edsger-soft-deleted + type: query-returns + description: Soft-delete Edsger Dijkstra by setting deleted_at; the row stays. + sql: SELECT (deleted_at IS NOT NULL) FROM users WHERE email = 'edsger@example.com' + expect: + rowCount: 1 + rows: [[true]] diff --git a/lessons/02-changing-data/02-delete-and-lifecycle/seed.sql b/lessons/02-changing-data/02-delete-and-lifecycle/seed.sql new file mode 100644 index 0000000..f983eef --- /dev/null +++ b/lessons/02-changing-data/02-delete-and-lifecycle/seed.sql @@ -0,0 +1,38 @@ +-- Seed for "02-delete-and-lifecycle": a users table with a deleted_at column +-- so we can show both hard DELETE and the soft-delete pattern, plus a small +-- archived_users table that TRUNCATE has a reason to wipe. + +CREATE TABLE users ( + id serial PRIMARY KEY, + email text NOT NULL UNIQUE, + full_name text NOT NULL, + is_active boolean NOT NULL DEFAULT true, + signed_up_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz -- NULL means "live" +); + +INSERT INTO users (email, full_name, is_active, signed_up_at) VALUES + ('ada@example.com', 'Ada Lovelace', true, '2024-01-12 09:00:00+00'), + ('alan@example.com', 'Alan Turing', true, '2024-02-03 14:30:00+00'), + ('grace@example.com', 'Grace Hopper', true, '2024-02-22 18:15:00+00'), + ('linus@example.com', 'Linus Torvalds', true, '2024-03-08 11:45:00+00'), + ('margaret@example.com', 'Margaret Hamilton', true, '2024-03-19 08:05:00+00'), + ('dennis@example.com', 'Dennis Ritchie', false, '2024-04-01 16:20:00+00'), + ('ken@example.com', 'Ken Thompson', true, '2024-04-15 10:10:00+00'), + ('barbara@example.com', 'Barbara Liskov', true, '2024-04-28 13:50:00+00'), + ('edsger@example.com', 'Edsger Dijkstra', false, '2024-05-09 07:25:00+00'), + ('don@example.com', 'Donald Knuth', false, '2024-05-21 19:40:00+00'); + +-- A staging table we'll wipe with TRUNCATE. Pretend an ETL job filled it. +CREATE TABLE archived_users ( + id int PRIMARY KEY, + email text NOT NULL, + archived_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO archived_users (id, email) VALUES + (101, 'old-1@example.com'), + (102, 'old-2@example.com'), + (103, 'old-3@example.com'), + (104, 'old-4@example.com'), + (105, 'old-5@example.com');