DELETE — Removing Rows
Remove rows from tables safely — DELETE vs TRUNCATE, soft delete patterns, cascade deletes, referential integrity, and why SELECT before DELETE is non-negotiable
Quick answer
DELETE FROM removes the rows that match its WHERE condition. Without WHERE it deletes every row, and outside a transaction that cannot be undone.
- SELECT with the same WHERE first, to see exactly what will be removed.
- Many apps soft-delete instead: they set a flag such as is_deleted and keep the row.
- A foreign key with ON DELETE CASCADE also deletes the child rows.
DELETE FROM order_items
WHERE order_id = 1006;
SELECT COUNT(*) AS items_left
FROM order_items
WHERE order_id = 1006;Result · 1 row
| items_left |
|---|
| 0 |
// Part 01
DELETE Is Permanent — Treat It That Way
DELETE removes rows from a table. Unlike UPDATE — which can sometimes be partially reversed by running the opposite update — a DELETE that completes outside a transaction removes data permanently. There is no recycle bin, no undo button, no confirmation dialog. The rows are gone.
At the same time, DELETE is a necessary and routine operation. Test data must be cleaned up. Cancelled orders past a retention period must be purged. Duplicate records must be removed. Knowing how to DELETE safely — not just syntactically — is a core professional skill.
The same golden rule from UPDATE applies here, with even higher stakes:
The Golden Rule — Non-Negotiable
Before every DELETE, run the equivalent SELECT with the same WHERE clause. Verify every row it returns is a row you intend to permanently remove. Zero exceptions.
// Part 02
Basic Syntax — Deleting Specific Rows
Delete one specific row by primary key
Delete rows matching a condition
// Part 03
DELETE vs TRUNCATE vs DROP — Three Different Operations
Three commands remove data from tables but they work very differently. Knowing which to use and when is important — using the wrong one can destroy more data than intended or leave orphaned rows in related tables.
| Command | What it removes | WHERE clause | Rollback | Speed | Triggers | Use when |
|---|---|---|---|---|---|---|
| DELETE | Specific rows | Yes — required for safety | Yes (in transaction) | Slow on large tables | Yes — fires per row | Removing specific rows by condition |
| TRUNCATE | All rows in table | No — removes everything | Yes (PostgreSQL), No (MySQL) | Very fast | No — skips triggers | Clearing a table completely (dev/staging reset) |
| DROP TABLE | The entire table + its structure | No | Yes (in transaction) | Instant | No | Removing a table permanently from the schema |
TRUNCATE — fastest way to empty a table
// Part 04
Soft Delete — The Production Alternative to DELETE
In production systems at most US tech companies, rows are almost never actually deleted from the database. Instead they use a pattern called soft delete — adding an is_deleted boolean column (or a deleted_at timestamp) and setting it to true/now when a row should be "deleted." The row stays in the database but is excluded from all normal queries.
Why soft delete instead of real delete
Implementing soft delete
Soft delete with views — transparent filtering
A common production pattern is creating a database view that automatically filters out soft-deleted rows. Application code queries the view instead of the base table — it never sees deleted rows without any extra WHERE condition.
// Part 05
DELETE with Subqueries — Deleting Based on Another Table
DELETE can use a subquery in the WHERE clause to identify which rows to remove based on data in another table — similar to the UPDATE FROM pattern.
// Part 06
Cascade Deletes — When FK Relationships Handle Cleanup
When tables have foreign keys with ON DELETE CASCADE, deleting a parent row automatically deletes all child rows that reference it. This is the database handling the cleanup for you — but it means a single DELETE can remove far more rows than you see in the one table you are targeting.
// Part 07
RETURNING on DELETE — Capture What You Removed
In PostgreSQL and DuckDB, RETURNING works on DELETE just like on INSERT and UPDATE — it returns the values of the deleted rows before they are removed. This is essential for audit logging, archiving before deletion, and confirming exactly what was deleted.
// Part 08
Batch Deletes — Safely Removing Large Numbers of Rows
Just like large UPDATE operations, deleting millions of rows in one statement holds locks, blocks other queries, and risks a long rollback if something goes wrong. The same batch pattern applies.
// Part 09
DELETE Safety Checklist
Run through this checklist before every DELETE that touches real data.
// Part 10
GDPR and Data Retention — Real-World Delete Scenarios
US tech companies that handle personal data are increasingly subject to data privacy regulations — California's CCPA, GDPR for European users, and GLBA guidelines for financial data. These regulations create specific requirements around when data must be deleted.
Right to erasure — deleting a specific user's data
Data retention — purging old records
// Part 11
What This Looks Like at Work
You are a backend engineer at Instacart. The product team runs a referral program — customers get a bonus when they refer a new user. The fraud team has identified 23 referral bonus credits that were fraudulently generated — a user created fake accounts to claim multiple bonuses. You need to delete these credits safely from the production database.
🎯 Pro Tip
Every DELETE on production data at a company with compliance requirements should generate an audit log entry — what was deleted, when, by whom, and why (ticket/ticket reference). This is not just good practice — in regulated industries (fintech, health, e-commerce) it is a legal requirement. Build audit logging into your delete workflow from the start, not as an afterthought.
// Part 12
Interview Prep — 5 Questions With Complete Answers
DELETE is a DML (Data Manipulation Language) command that removes specific rows based on a WHERE condition, or all rows if no WHERE is given. DELETE is fully transactional in all databases — it can be rolled back if inside an uncommitted transaction. DELETE fires triggers, respects foreign key constraints, and logs each row deletion to the transaction log. On large tables DELETE is slow because every row removal is individually logged.
TRUNCATE is a DDL (Data Definition Language) command that removes all rows from a table at once. It does not use a WHERE clause — it is all-or-nothing. TRUNCATE is much faster than DELETE because it deallocates data pages rather than removing rows one by one. In PostgreSQL, TRUNCATE is transactional and can be rolled back. In MySQL, TRUNCATE is NOT transactional and auto-commits — it cannot be rolled back. TRUNCATE does not fire row-level triggers and bypasses foreign key cascade rules (you must truncate in dependency order or use CASCADE).
DROP TABLE removes the entire table — both all the data and the table structure (column definitions, constraints, indexes). It is irreversible without a backup. Use DROP TABLE when decommissioning a feature or cleaning up a table that is no longer needed. The choice in practice: DELETE for removing specific rows in production, TRUNCATE for resetting test/staging environments, DROP TABLE for schema changes during development or migrations.
Soft delete is a pattern where rows are never physically removed from the database — instead a column (typically is_deleted BOOLEAN or deleted_at TIMESTAMP) is set to mark the row as logically deleted. All normal application queries include WHERE is_deleted = false (or WHERE deleted_at IS NULL) to exclude soft-deleted rows. The actual data remains in the database indefinitely or until a scheduled hard delete after a retention period.
Four reasons production applications prefer soft delete: First, auditability — regulators, auditors, and compliance teams need to see the full history of what happened to every record. A hard-deleted row leaves no trace. A soft-deleted row is permanently visible to audit queries. Second, recovery — users accidentally delete things daily. With soft delete, recovery is UPDATE is_deleted = false — instant. With hard delete, recovery requires a database backup restore, potentially losing hours of other changes. Third, referential integrity — a hard-deleted customer still has orders in the orders table that now reference a non-existent customer. Soft delete keeps the customer row, preserving all relationships. Fourth, analytics — churned customers, removed products, and cancelled subscriptions have analytical value. Their historical data answers questions about why users leave and which products fail.
The trade-offs: soft delete means the table grows indefinitely unless a periodic hard delete runs for very old soft-deleted rows. All queries must include the is_deleted filter — missing it returns deleted rows, which is a bug. Indexes must be designed to efficiently filter on is_deleted. And UNIQUE constraints become complex — if an email is soft-deleted, can a new user register with the same email? These are solvable problems, but they require deliberate design. Most US tech companies (DoorDash, Stripe, Brex) use soft delete for user-facing data and compliance-sensitive tables, and use hard delete only for truly ephemeral data like session tokens and temporary processing records.
When a foreign key is defined with ON DELETE CASCADE, deleting a row from the parent table automatically deletes all rows in child tables that reference the deleted parent. This cascades through multiple levels — if table A references B with CASCADE, and table C references A with CASCADE, deleting from B automatically deletes from A and then from C.
The mechanism: the database engine, after deleting the parent row, searches all FK relationships on that table for CASCADE constraints. For each found FK with CASCADE, it deletes the referencing rows from the child table — which may trigger further cascades in tables that reference the child. This all happens atomically within the same transaction. The entire cascade either succeeds or rolls back together.
The risks: a CASCADE delete from a parent table can silently remove far more rows than you intended. DELETE FROM orders WHERE order_id = 1007 might also delete 5 rows from order_items via CASCADE — if you did not know about the CASCADE relationship, the order_items deletion is invisible and surprising. Before deleting from any table, always check which FK relationships have CASCADE and estimate how many rows will be affected across all cascading tables. Count them with SELECT queries before the DELETE. Another risk: poorly designed CASCADE chains can make a single DELETE catastrophically broad — a DELETE from a top-level table cascades through 5 child tables, removing thousands of rows you did not intend to touch. Restrict CASCADE to genuinely containment relationships (order_items to orders) and use RESTRICT for association relationships (orders to customers).
Deleting duplicates requires identifying which rows to keep and which to delete. The standard approach uses a CTE (Common Table Expression) with ROW_NUMBER() to assign a rank to each duplicate group — keeping the row with rank 1 and deleting the rest.
The pattern in PostgreSQL: WITH duplicates AS (SELECT customer_id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY customer_id ASC) AS rn FROM customers) DELETE FROM customers WHERE customer_id IN (SELECT customer_id FROM duplicates WHERE rn > 1);. The PARTITION BY email groups rows by email address. ROW_NUMBER() assigns 1 to the first occurrence (by customer_id order) and 2, 3, etc. to subsequent duplicates. The DELETE removes all rows where rn > 1 — keeping exactly one row per email.
Choosing which duplicate to keep: ORDER BY customer_id ASC keeps the oldest (lowest ID, first inserted). ORDER BY customer_id DESC keeps the newest. ORDER BY updated_at DESC keeps the most recently modified. The choice depends on which duplicate has the most complete or most current data. Always run the CTE as a SELECT first — SELECT * FROM duplicates WHERE rn > 1 — to see exactly which rows will be deleted before converting it to a DELETE statement. For tables with hundreds of millions of rows, batch the duplicate deletion by adding a LIMIT to avoid locking the table for too long.
The first action depends on whether the statement is still running or has already completed. If it is still running — interrupt it immediately. In PostgreSQL, find the process with SELECT pid, query FROM pg_stat_activity WHERE state = 'active' and terminate it with SELECT pg_terminate_backend(pid). If the DELETE was running inside an explicit transaction and has not been committed, terminating the connection automatically rolls back the transaction — all rows are restored. This is why wrapping destructive operations in explicit transactions is critical.
If the DELETE has already committed: assess the damage immediately. Determine when the DELETE ran (check database logs or application logs) and how old the most recent backup is. In PostgreSQL, point-in-time recovery (PITR) using WAL (write-ahead logs) can restore the database to the exact state immediately before the DELETE — this is the fastest recovery path and preserves all changes made after the last base backup. Contact your DBA immediately — this is not a situation to handle alone.
Prevention for the future: enable query review requirements for destructive operations on production (require a second engineer to approve the SQL before execution), implement a "safe mode" in your SQL client that prevents DELETE/UPDATE without WHERE, use row-level security policies that limit which users can run DELETE, add automated backups with short RPO (Recovery Point Objective) so the maximum data loss window is small, and make soft delete the default for all user-facing tables so hard delete is rarely necessary. This incident should trigger a blameless post-mortem focused on system improvements rather than individual punishment — the system should make it impossible to cause this level of damage with a single command.
// Part 13
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓DELETE is permanent without a transaction. The golden rule: run SELECT with the identical WHERE first, verify every row is one you intend to remove, then DELETE.
- ✓Always include WHERE in DELETE. DELETE without WHERE removes every row in the table — immediately and permanently (without a transaction to roll back).
- ✓DELETE vs TRUNCATE vs DROP: DELETE removes specific rows (transactional, slow, fires triggers). TRUNCATE removes all rows (fast, no triggers, NOT transactional in MySQL). DROP removes the entire table structure.
- ✓Soft delete — UPDATE is_deleted = true — is the production standard for user-facing data. It preserves referential integrity, enables recovery, satisfies audit requirements, and retains analytical value.
- ✓CASCADE deletes propagate through FK relationships silently. Before deleting from a parent table, count all rows that will cascade-delete from child tables. Always know your FK relationships.
- ✓RETURNING on DELETE (PostgreSQL/DuckDB) returns the deleted row values — use it to archive before deleting, generate audit logs, or confirm what was removed.
- ✓Delete in dependency order: delete child table rows before parent table rows. Reversing the order causes FK constraint violations.
- ✓Batch large deletes: LIMIT 10000 in a loop until 0 rows affected. Keeps locks short, prevents application timeouts, and limits rollback cost per batch.
- ✓Every production DELETE should be logged in an audit table: what was deleted, when, by whom, and the ticket/reason reference. This is a legal requirement in regulated industries.
- ✓For GDPR/DPDPA right-to-erasure requests: delete in dependency order, or use soft delete and schedule a hard delete after the legal retention period. Log the deletion operation with the request reference.
What comes next
In Lesson 23, you learn constraints in depth — NOT NULL, UNIQUE, CHECK, PRIMARY KEY, FOREIGN KEY, and how to add, modify, and drop constraints on existing tables using ALTER TABLE.
Lesson 23 → ConstraintsQuick check
Which removes all rows but keeps the table and its structure?
HELP US IMPROVE
How was this lesson?
Your note is shared with the Chaduvuko team for review. Please don’t include personal or sensitive information.
Spotted a mistake or something unclear? Suggest a fix on GitHub or email us. Select the text first and it is quoted for you.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.
