Loading content...
The DELETE statement removes data from existence. Unlike UPDATE, which leaves a modified record behind, DELETE makes rows vanish—and in many cases, taking related data with them through cascading relationships.
This permanence makes DELETE the most consequential DML operation. A single DELETE statement executed without proper conditions can erase millions of records in seconds, potentially destroying years of accumulated data. Yet DELETE is essential—databases grow without bound if data is never removed, and many business processes require data removal (user account deletion, expired record cleanup, GDPR compliance).
This page teaches you to wield DELETE with surgical precision, understanding its mechanics, safeguards, and alternatives.
By the end of this page, you will master DELETE syntax and row identification, understand referential integrity and cascading deletes, implement soft delete patterns as an alternative, use DELETE with subqueries and joins, and apply rigorous safety practices for production deletions.
The DELETE statement removes rows from a table based on a condition. Its simplicity belies its power—and its danger.
The Basic DELETE Syntax:
1234567891011121314151617181920
-- Complete DELETE syntax patternDELETE FROM table_nameWHERE condition; -- Key components:-- 1. Target table: The table from which to remove rows-- 2. WHERE clause: Predicate identifying rows to delete-- (CRITICAL: Omitting WHERE deletes ALL rows!) -- Example: Delete a specific orderDELETE FROM ordersWHERE order_id = 5001; -- This removes exactly the row where order_id = 5001 -- Delete multiple rows matching a conditionDELETE FROM order_itemsWHERE order_id = 5001; -- Removes all line items for order 5001DELETE FROM table_name; (without WHERE) removes EVERY row in the table. The table structure remains, but all data is gone. There is no confirmation, no undo button, no recovery without backups. This is the most destructive single statement in SQL.
DELETE vs TRUNCATE:
SQL provides two ways to remove all rows from a table. Understanding the difference is crucial:
| Aspect | DELETE FROM table | TRUNCATE TABLE table |
|---|---|---|
| Row Removal | One row at a time | All rows at once |
| WHERE Clause | Supported (selective) | Not supported (all rows) |
| Transaction Log | Logs each row deletion | Minimal logging (page deallocation) |
| Performance | Slower for large tables | Very fast, even for huge tables |
| Identity/Auto-Increment | Does not reset | Resets to initial seed |
| Triggers | Fires DELETE triggers | Does not fire triggers |
| Rollback | Can be rolled back | Depends on database (often not) |
| Foreign Keys | Respects constraints | May fail if referenced |
| Use Case | Selective deletion | Clear entire table quickly |
1234567891011121314151617181920
-- DELETE all rows (slow but logged, triggers fire)DELETE FROM log_entries;-- Takes minutes for millions of rows-- Each deletion logged to transaction log-- DELETE triggers execute for each row -- TRUNCATE all rows (fast, minimal logging)TRUNCATE TABLE log_entries;-- Takes seconds regardless of row count-- Deallocates data pages directly-- No triggers fire-- Auto-increment resets to 1 -- Common pattern: Use TRUNCATE for test data cleanup-- Reset tables to clean state before running testsTRUNCATE TABLE test_orders;TRUNCATE TABLE test_customers; -- Note: TRUNCATE fails if table is referenced by foreign key-- Even if no child rows exist, the FK must be dropped firstEffective DELETE statements use precise WHERE clauses to target exactly the rows that should be removed. The same conditions available in SELECT work in DELETE.
Simple Conditions:
123456789101112131415161718192021222324252627
-- Delete by primary key (most precise)DELETE FROM customersWHERE customer_id = 42; -- Delete by equality conditionDELETE FROM productsWHERE discontinued = TRUE; -- Delete by comparisonDELETE FROM sessionsWHERE last_activity < CURRENT_TIMESTAMP - INTERVAL '24 hours'; -- Delete by range (BETWEEN)DELETE FROM audit_logsWHERE log_date BETWEEN '2023-01-01' AND '2023-12-31'; -- Delete by pattern matching (LIKE)DELETE FROM usersWHERE email LIKE '%@tempmail.%'; -- Delete by list membership (IN)DELETE FROM ordersWHERE status IN ('cancelled', 'refunded', 'failed'); -- Delete by NULL checkDELETE FROM productsWHERE category_id IS NULL;Compound Conditions:
1234567891011121314151617181920212223242526272829
-- Delete with AND (all conditions must be true)DELETE FROM ordersWHERE status = 'pending' AND order_date < CURRENT_DATE - INTERVAL '30 days' AND total_amount = 0; -- Delete with OR (any condition sufficient)DELETE FROM notificationsWHERE is_read = TRUE OR created_at < CURRENT_DATE - INTERVAL '90 days'; -- Delete with complex logicDELETE FROM sessionsWHERE ( -- Expired sessions (expires_at < CURRENT_TIMESTAMP) OR -- Inactive anonymous sessions older than 1 hour (user_id IS NULL AND last_activity < CURRENT_TIMESTAMP - INTERVAL '1 hour') OR -- Flagged as suspicious (is_suspicious = TRUE AND created_at < CURRENT_DATE - INTERVAL '1 day')); -- Delete with NOTDELETE FROM productsWHERE category_id NOT IN ( SELECT category_id FROM active_categories);Before every DELETE, replace DELETE FROM with SELECT * FROM using the same WHERE clause. Review the results. Are these exactly the rows you want to remove? Only after confirming should you execute the DELETE.
Subqueries in DELETE enable deletion based on data in other tables. This is essential for maintaining referential consistency and implementing complex business rules.
Subquery in WHERE Clause:
1234567891011121314151617181920212223242526272829303132
-- Delete orders for inactive customersDELETE FROM ordersWHERE customer_id IN ( SELECT customer_id FROM customers WHERE is_active = FALSE); -- Delete products with no salesDELETE FROM productsWHERE product_id NOT IN ( SELECT DISTINCT product_id FROM order_items); -- Delete using EXISTS (often more efficient than IN)DELETE FROM order_items oiWHERE EXISTS ( SELECT 1 FROM orders o WHERE o.order_id = oi.order_id AND o.status = 'cancelled'); -- Delete using NOT EXISTSDELETE FROM categories cWHERE NOT EXISTS ( SELECT 1 FROM products p WHERE p.category_id = c.category_id);-- Removes categories with no productsCorrelated Subqueries:
Correlated subqueries reference the rows being deleted, enabling row-by-row evaluation:
123456789101112131415161718192021222324252627
-- Delete duplicate rows, keeping the first occurrenceDELETE FROM contacts c1WHERE EXISTS ( SELECT 1 FROM contacts c2 WHERE c2.email = c1.email AND c2.contact_id < c1.contact_id -- Keep the lower ID (first inserted)); -- Delete rows where a threshold is exceededDELETE FROM order_items oiWHERE ( SELECT SUM(quantity * unit_price) FROM order_items sub WHERE sub.order_id = oi.order_id) > 10000 -- Delete items from orders over $10,000AND oi.is_bonus_item = TRUE; -- Only remove bonus items -- Delete old records keeping N most recent per groupDELETE FROM audit_logs alWHERE ( SELECT COUNT(*) FROM audit_logs newer WHERE newer.user_id = al.user_id AND newer.log_id > al.log_id) >= 100 -- Keep only 100 most recent per user;DELETE with correlated subqueries can be slow on large tables because the subquery executes for each candidate row. For bulk deletions, consider using temporary tables or EXISTS instead of IN for better performance.
Some databases support DELETE with JOIN syntax, making it easier to delete rows based on joined table conditions. Like UPDATE with JOIN, the syntax varies by database system.
DELETE with JOIN Syntax Variations:
1234567891011121314151617181920212223242526
-- PostgreSQL: DELETE ... USING -- Delete order items for cancelled ordersDELETE FROM order_items oiUSING orders oWHERE oi.order_id = o.order_id AND o.status = 'cancelled'; -- Delete with multiple tablesDELETE FROM products pUSING categories c, suppliers sWHERE p.category_id = c.category_id AND p.supplier_id = s.supplier_id AND c.is_active = FALSE AND s.is_active = FALSE; -- Delete based on aggregate from joined tableDELETE FROM customers cUSING ( SELECT customer_id, SUM(total_amount) as total_spent FROM orders GROUP BY customer_id) oWHERE c.customer_id = o.customer_id AND o.total_spent = 0 AND c.created_at < CURRENT_DATE - INTERVAL '2 years';MySQL uniquely supports deleting from multiple tables in a single statement. This can be useful for cleaning up related data but must be used with extreme caution. Specify exactly which tables to delete from (after DELETE keyword) when using joins.
Foreign key constraints protect referential integrity—ensuring that child records don't become orphans. When you attempt to DELETE a parent row, the database checks for referencing child rows and responds based on the constraint definition.
Foreign Key ON DELETE Options:
| Action | Behavior | Use Case |
|---|---|---|
| NO ACTION (default) | Raises error if child rows exist | When children must be manually handled first |
| RESTRICT | Same as NO ACTION (some DBs check timing differently) | Strict integrity enforcement |
| CASCADE | Automatically deletes all child rows | When deleting parent should remove all children |
| SET NULL | Sets foreign key column to NULL in child rows | When children can exist without parent reference |
| SET DEFAULT | Sets foreign key to default value | When a fallback parent exists |
1234567891011121314151617181920212223242526272829303132333435363738394041
-- Table structure with different ON DELETE actions CREATE TABLE customers ( customer_id INTEGER PRIMARY KEY, name VARCHAR(100) NOT NULL); -- Orders with CASCADE: deleting customer deletes their ordersCREATE TABLE orders ( order_id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES customers(customer_id) ON DELETE CASCADE, order_date DATE NOT NULL); -- Order items with CASCADE: deleting order deletes itemsCREATE TABLE order_items ( item_id INTEGER PRIMARY KEY, order_id INTEGER REFERENCES orders(order_id) ON DELETE CASCADE, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL); -- Now, deleting a customer cascades through the hierarchy:DELETE FROM customers WHERE customer_id = 42;-- This single DELETE removes:-- 1. The customer row-- 2. All orders for that customer-- 3. All order_items for those orders -- ================================================ -- Alternative: SET NULL for optional relationshipsCREATE TABLE products ( product_id INTEGER PRIMARY KEY, category_id INTEGER REFERENCES categories(category_id) ON DELETE SET NULL, product_name VARCHAR(100) NOT NULL); -- Deleting a category doesn't delete products—just sets category_id to NULLDELETE FROM categories WHERE category_id = 5;-- Products in category 5 now have category_id = NULL (uncategorized)CASCADE propagates through the entire foreign key graph. Deleting one row can trigger deletion of thousands of related rows across many tables. Always trace through CASCADE chains before defining them, and document the deletion behavior clearly.
1234567891011121314151617181920212223
-- When you need to delete with RESTRICT/NO ACTION constraints,-- delete children first, then parents (reverse dependency order) -- Cannot delete customer if orders exist (RESTRICT/NO ACTION)-- Solution: Delete in correct order BEGIN TRANSACTION; -- Step 1: Delete order items first (deepest level)DELETE FROM order_itemsWHERE order_id IN ( SELECT order_id FROM orders WHERE customer_id = 42); -- Step 2: Delete orders (middle level)DELETE FROM ordersWHERE customer_id = 42; -- Step 3: Delete customer (parent level)DELETE FROM customersWHERE customer_id = 42; COMMIT;Many applications avoid permanent deletion by using soft delete—marking records as deleted without actually removing them. This provides recoverability, audit trails, and historical accuracy at the cost of query complexity.
Implementing Soft Delete:
12345678910111213141516171819202122232425262728293031323334
-- Table structure for soft deleteCREATE TABLE users ( user_id INTEGER PRIMARY KEY, email VARCHAR(100) NOT NULL UNIQUE, display_name VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Soft delete columns is_deleted BOOLEAN DEFAULT FALSE, deleted_at TIMESTAMP NULL, deleted_by INTEGER REFERENCES users(user_id)); -- "Delete" a user (actually just marks as deleted)UPDATE usersSET is_deleted = TRUE, deleted_at = CURRENT_TIMESTAMP, deleted_by = @current_admin_idWHERE user_id = 42; -- Queries must filter out deleted records-- BAD: Returns deleted users tooSELECT * FROM users; -- GOOD: Excludes deleted usersSELECT * FROM users WHERE is_deleted = FALSE; -- View for easier queryingCREATE VIEW active_users ASSELECT * FROM users WHERE is_deleted = FALSE; -- Now this returns only active usersSELECT * FROM active_users;Soft Delete With Unique Constraints:
Soft delete complicates unique constraints. A deleted user with email 'john@example.com' still occupies that unique value:
123456789101112131415161718192021222324252627
-- Problem: Unique constraint blocks reusing deleted email-- User 42 with 'john@example.com' is soft-deleted-- New user tries to register with 'john@example.com'-- ERROR: Unique constraint violation! -- Solution 1: Partial unique index (PostgreSQL)CREATE UNIQUE INDEX users_email_active_unique ON users (email) WHERE is_deleted = FALSE;-- Only enforces uniqueness among active users -- Solution 2: Include deletion flag in unique constraint-- Modify email on soft deleteUPDATE usersSET email = email || '_deleted_' || user_idWHERE user_id = 42 AND is_deleted = FALSE; UPDATE usersSET is_deleted = TRUE, deleted_at = CURRENT_TIMESTAMPWHERE user_id = 42;-- Original email 'john@example.com' now 'john@example.com_deleted_42'-- New registrations can use 'john@example.com' -- Solution 3: Filtered unique constraint (SQL Server)CREATE UNIQUE NONCLUSTERED INDEX users_email_active_uniqueON users (email)WHERE is_deleted = 0;Soft delete adds complexity: every query needs the is_deleted filter, indexes grow with deleted data, storage increases over time, and UNIQUE constraints become harder. Evaluate whether you truly need recoverability before implementing soft delete.
DELETE statements are permanent. Rigorous safety practices prevent catastrophic data loss.
1234567891011121314151617181920212223242526272829303132
-- SAFE DELETE WORKFLOW -- 1. Start a transactionBEGIN TRANSACTION; -- 2. Preview rows to be deletedSELECT order_id, customer_id, order_date, status, total_amountFROM ordersWHERE status = 'cancelled' AND order_date < '2023-01-01';-- Result: 847 rows -- 3. Backup to temporary table (optional but recommended)CREATE TEMP TABLE orders_to_delete ASSELECT * FROM ordersWHERE status = 'cancelled' AND order_date < '2023-01-01'; -- 4. Execute the DELETEDELETE FROM ordersWHERE status = 'cancelled' AND order_date < '2023-01-01';-- Output: "847 rows affected" — matches preview ✓ -- 5. Verify deletionSELECT COUNT(*) FROM ordersWHERE status = 'cancelled' AND order_date < '2023-01-01';-- Result: 0 ✓ -- 6. Commit or rollbackCOMMIT; -- If everything is correct-- ROLLBACK; -- If something is wrong -- 7. If you need to recover: -- INSERT INTO orders SELECT * FROM orders_to_delete;Before executing DELETE in production: (1) Do you have a complete backup? (2) Have you tested the exact query in a non-production environment? (3) Have you previewed affected rows with SELECT? (4) Does the row count match expectations? (5) Are you using a transaction? (6) Have you informed stakeholders? Never skip these checks.
Like INSERT and UPDATE, DELETE can return the rows it removes. This is invaluable for audit logging, confirmation messages, and undo functionality.
RETURNING/OUTPUT Clause:
123456789101112131415161718192021222324252627282930313233343536373839404142434445
-- PostgreSQL: RETURNING clauseDELETE FROM sessionsWHERE expires_at < CURRENT_TIMESTAMPRETURNING session_id, user_id, expires_at; -- Returns all columns of deleted rowsDELETE FROM productsWHERE discontinued = TRUE AND units_in_stock = 0RETURNING *; -- Use RETURNING with archive patternWITH deleted_orders AS ( DELETE FROM orders WHERE order_date < '2022-01-01' RETURNING *)INSERT INTO orders_archiveSELECT *, CURRENT_TIMESTAMP AS archived_atFROM deleted_orders;-- Deletes old orders AND archives them in one statement! -- SQL Server: OUTPUT clauseDELETE FROM sessionsOUTPUT DELETED.*WHERE expiry_time < GETDATE(); -- Output to a table for auditDECLARE @DeletedOrders TABLE ( order_id INT, customer_id INT, total_amount DECIMAL(10,2), deleted_at DATETIME2); DELETE FROM ordersOUTPUT DELETED.order_id, DELETED.customer_id, DELETED.total_amount, GETDATE()INTO @DeletedOrdersWHERE status = 'cancelled'; -- View what was deletedSELECT * FROM @DeletedOrders;The DELETE ... RETURNING pattern with INSERT enables atomic archive-and-delete operations. Rows are archived and removed in a single transaction, ensuring consistency. This is the recommended approach for data lifecycle management.
DELETE is the most consequential DML operation—data removed is data gone. Mastery means knowing not just how to delete, but when to delete, what alternatives exist, and how to delete safely.
What's Next:
Having covered INSERT, UPDATE, and DELETE, the next page introduces MERGE (UPSERT)—the powerful operation that combines insertion and update logic into a single atomic statement.
You now have comprehensive knowledge of the DELETE statement. From simple deletions to complex cascading scenarios, from hard delete to soft delete patterns, you can remove data confidently and safely. Remember: delete with caution, backup always, and verify before committing.