← Back to Lessons
BEGINNER ⏱ 35 minutes

DELETE: Removing Data from Tables

Sometimes data needs to be permanently removed—cancelled orders, closed accounts, expired records, or test data. DELETE is the SQL command that removes rows from your tables. It's one of the most dangerous commands in SQL because deleted data is often impossible to recover. In this lesson, you'll learn how to delete specific rows, use conditions safely, understand the difference between DELETE and TRUNCATE, handle foreign key constraints, and—most importantly—how to avoid accidentally deleting data you need to keep.


📚 What You'll Learn

1. Basic DELETE Syntax

DELETE FROM removes rows from a table permanently. Once deleted (and committed), the data is gone forever unless you have backups.

Complete Syntax

DELETE FROM table_name
WHERE condition;

⚠️ EXTREME DANGER: If you forget the WHERE clause, ALL rows in the table will be deleted! This is catastrophic and often irreversible. ALWAYS include WHERE unless you genuinely intend to empty the entire table.

2. Deleting Specific Rows

Delete by Primary Key (Safest)

-- Delete a specific customer by ID
DELETE FROM customers
WHERE customer_id = 42;

-- Only the customer with ID 42 is deleted

Delete by Unique Column

-- Delete customer by email (if email is UNIQUE)
DELETE FROM customers
WHERE email = 'user@example.com';

-- Delete product by SKU
DELETE FROM products
WHERE sku = 'PROD-001';

Safety Check Process

-- ALWAYS follow this process:

-- Step 1: SELECT to see what you're about to delete
SELECT * FROM customers
WHERE customer_id = 42;

-- Step 2: Verify it's the correct row
-- Check the output carefully!

-- Step 3: Run the DELETE
DELETE FROM customers
WHERE customer_id = 42;

-- Step 4: Verify it's gone
SELECT * FROM customers
WHERE customer_id = 42;
-- Should return no rows

3. Deleting Multiple Rows

You can delete many rows at once using WHERE conditions that match multiple records. This requires extreme caution.

Example: Delete Old Records

-- Delete orders older than 5 years
DELETE FROM orders
WHERE order_date < DATE_SUB(CURRENT_DATE, INTERVAL 5 YEAR);

-- ALWAYS check count first!
SELECT COUNT(*) FROM orders
WHERE order_date < DATE_SUB(CURRENT_DATE, INTERVAL 5 YEAR);
-- If this returns 50,000 when you expected 50, DON'T DELETE!

Example: Delete by Status

-- Delete cancelled orders
DELETE FROM orders
WHERE status = 'cancelled'
  AND order_date < '2023-01-01';

-- Delete inactive users (not logged in for 2 years)
DELETE FROM users
WHERE last_login < DATE_SUB(CURRENT_DATE, INTERVAL 2 YEAR)
  AND account_status = 'inactive';

Example: Delete Duplicates

-- Delete duplicate emails, keeping the oldest account
DELETE c1 FROM customers c1
JOIN customers c2 ON c1.email = c2.email
WHERE c1.customer_id > c2.customer_id;

-- This keeps the customer with the lowest ID for each email

4. DELETE vs. TRUNCATE vs. DROP TABLE

Feature DELETE TRUNCATE DROP TABLE
What it does Deletes specific rows Deletes all rows Deletes entire table
Table structure ✓ Preserved ✓ Preserved ❌ Deleted
WHERE clause ✓ Yes ❌ No ❌ No
Speed Slower (row by row) Very fast Fast
Rollback ✓ Yes (in transaction) ❌ No (usually) ❌ No
Triggers ✓ Fires triggers ❌ No triggers ❌ No triggers
AUTO_INCREMENT Preserved Reset to 1 Reset to 1
Use case Remove specific rows Empty entire table Remove table completely

Examples

-- DELETE: Remove specific rows
DELETE FROM logs WHERE log_date < '2024-01-01';

-- TRUNCATE: Empty the entire table (fast)
TRUNCATE TABLE session_logs;

-- DROP TABLE: Remove the table completely
DROP TABLE temp_data;

⚠️ When to use each:
• Use DELETE when you need to remove specific rows or need transaction support
• Use TRUNCATE when you want to empty the entire table quickly (no WHERE clause)
• Use DROP TABLE only when removing the table structure permanently

5. Deleting with Foreign Key Constraints

Foreign keys can prevent or cascade deletions. Understanding this is crucial for maintaining referential integrity.

Problem: Cannot Delete Parent Row

-- Try to delete a customer who has orders
DELETE FROM customers WHERE customer_id = 5;

-- ERROR: Cannot delete or update a parent row: 
-- a foreign key constraint fails
-- (orders has rows referencing customer_id = 5)

Solution 1: Delete Child Rows First

-- Delete in correct order: children first, then parent

-- Step 1: Delete order items (grandchild)
DELETE FROM order_items
WHERE order_id IN (
    SELECT order_id FROM orders WHERE customer_id = 5
);

-- Step 2: Delete orders (child)
DELETE FROM orders
WHERE customer_id = 5;

-- Step 3: Delete customer (parent)
DELETE FROM customers
WHERE customer_id = 5;

Solution 2: Use ON DELETE CASCADE

-- If foreign key was created with ON DELETE CASCADE:
CREATE TABLE orders (
    order_id INT PRIMARY KEY,
    customer_id INT,
    FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
        ON DELETE CASCADE
);

-- Then deleting the parent automatically deletes children
DELETE FROM customers WHERE customer_id = 5;
-- All orders for customer 5 are automatically deleted too!

⚠️ CASCADE Warning: ON DELETE CASCADE is powerful but dangerous! Deleting one customer could automatically delete hundreds of orders and thousands of order items. Always verify what will be cascaded before deleting.

Check What Will Be Deleted

-- Before deleting customer 5, check related records
SELECT 'Orders' AS table_name, COUNT(*) AS count
FROM orders WHERE customer_id = 5
UNION ALL
SELECT 'Order Items', COUNT(*)
FROM order_items oi
JOIN orders o ON oi.order_id = o.order_id
WHERE o.customer_id = 5;

-- If CASCADE is enabled, all these will be deleted!

6. Deleting Based on Other Tables

Delete Using Subquery

-- Delete orders from inactive customers
DELETE FROM orders
WHERE customer_id IN (
    SELECT customer_id 
    FROM customers 
    WHERE account_status = 'inactive'
);

-- Delete products with no sales in last year
DELETE FROM products
WHERE product_id NOT IN (
    SELECT DISTINCT product_id 
    FROM order_items oi
    JOIN orders o ON oi.order_id = o.order_id
    WHERE o.order_date >= DATE_SUB(CURRENT_DATE, INTERVAL 1 YEAR)
);

Delete Using JOIN

-- Delete orders from customers in a specific country
DELETE o
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE c.country = 'TestCountry';

-- Delete expired sessions for deleted users
DELETE s
FROM sessions s
LEFT JOIN users u ON s.user_id = u.user_id
WHERE u.user_id IS NULL;

7. Controlled Deletion with LIMIT

When deleting large amounts of data, use LIMIT to process in batches. This prevents locking the table for too long.

Delete in Batches

-- Delete oldest 1000 log entries at a time
DELETE FROM logs
WHERE log_date < '2023-01-01'
ORDER BY log_date ASC
LIMIT 1000;

-- Run this multiple times until no more rows are deleted
-- Check how many rows are affected after each run

Batch Delete Script Pattern

-- Pattern for safe batch deletion
SET @rows_affected = 1;

-- Keep deleting until no more rows match
WHILE @rows_affected > 0 DO
    DELETE FROM logs
    WHERE log_date < '2023-01-01'
    LIMIT 1000;
    
    SET @rows_affected = ROW_COUNT();
    
    -- Small delay to avoid overwhelming the database
    SELECT SLEEP(0.1);
END WHILE;

💡 Pro Tip: When deleting millions of rows, use LIMIT with ORDER BY. Delete 1,000-10,000 rows at a time, pause briefly, then repeat. This keeps the database responsive and makes the operation interruptible if something goes wrong.

8. Soft Deletes (Recommended for Important Data)

Instead of permanently deleting data, mark it as deleted. This allows recovery and maintains audit trails.

Soft Delete Pattern

-- Add columns for soft delete
ALTER TABLE customers
ADD COLUMN is_deleted BOOLEAN DEFAULT FALSE,
ADD COLUMN deleted_at TIMESTAMP NULL;

-- "Delete" by marking, not removing
UPDATE customers
SET is_deleted = TRUE,
    deleted_at = CURRENT_TIMESTAMP
WHERE customer_id = 42;

-- Query only active records
SELECT * FROM customers
WHERE is_deleted = FALSE;

-- Or using IS NULL
SELECT * FROM customers
WHERE deleted_at IS NULL;

Advantages of Soft Deletes

When to Use Soft Deletes

9. Safe Deletion Process (Follow Every Time)

The Golden Rule: Never DELETE without this process

  1. Backup: Export or backup the table before major deletions
  2. SELECT first: Use SELECT to preview what will be deleted
  3. Count: Verify the number of rows to be deleted
  4. Transaction: Use BEGIN TRANSACTION for safety
  5. DELETE: Execute the DELETE statement
  6. Verify: Check that correct rows were deleted
  7. Commit or Rollback: Commit if correct, rollback if wrong

Complete Safe Deletion Example

-- Step 1: Backup (optional but recommended for large deletes)
CREATE TABLE customers_backup_20241217 AS
SELECT * FROM customers WHERE customer_id = 42;

-- Step 2: Start transaction
START TRANSACTION;

-- Step 3: Preview what will be deleted
SELECT * FROM customers WHERE customer_id = 42;

-- Step 4: Count rows
SELECT COUNT(*) FROM customers WHERE customer_id = 42;
-- Expected: 1 row

-- Step 5: Check for dependent records
SELECT 'Orders' AS related_table, COUNT(*) AS count
FROM orders WHERE customer_id = 42
UNION ALL
SELECT 'Addresses', COUNT(*)
FROM addresses WHERE customer_id = 42;

-- Step 6: If safe, execute DELETE
DELETE FROM customers WHERE customer_id = 42;

-- Step 7: Verify it's gone
SELECT * FROM customers WHERE customer_id = 42;
-- Should return 0 rows

-- Step 8: Check affected rows
-- MySQL shows "1 row affected"
-- If this doesn't match expectations, ROLLBACK!

-- Step 9: Commit or rollback
COMMIT;  -- If correct
-- ROLLBACK;  -- If something's wrong

10. Common DELETE Mistakes and Prevention

Mistake 1: Forgetting WHERE Clause

❌ CATASTROPHIC - Deletes ALL rows!

DELETE FROM customers;
-- DISASTER! Every customer is now deleted!

✅ SAFE - Deletes specific row

DELETE FROM customers
WHERE customer_id = 42;

Mistake 2: Wrong WHERE Condition

❌ WRONG - Deletes too many rows

-- Intend to delete test accounts, but this deletes EVERYONE
DELETE FROM users
WHERE email LIKE '%@test.com' OR email IS NOT NULL;
-- IS NOT NULL matches everyone!

✅ CORRECT - Only test accounts

-- Check first!
SELECT COUNT(*) FROM users WHERE email LIKE '%@test.com';

DELETE FROM users
WHERE email LIKE '%@test.com';

Mistake 3: Not Checking Foreign Keys

-- ❌ Forgetting to check for related records
DELETE FROM customers WHERE customer_id = 5;
-- ERROR: Foreign key constraint fails

-- ✅ Always check dependencies first
SELECT 
    'Orders' AS table_name,
    COUNT(*) AS dependent_records
FROM orders 
WHERE customer_id = 5
UNION ALL
SELECT 'Reviews', COUNT(*)
FROM reviews 
WHERE customer_id = 5;

-- Then delete in proper order if needed

Mistake 4: Deleting Without Backup

-- ❌ Dangerous: Delete important data without backup
DELETE FROM orders WHERE order_date < '2020-01-01';

-- ✅ Safe: Backup before deleting
-- Option 1: Export to file (mysqldump)
-- Option 2: Copy to backup table
CREATE TABLE orders_archive AS
SELECT * FROM orders WHERE order_date < '2020-01-01';

-- Now safe to delete
DELETE FROM orders WHERE order_date < '2020-01-01';

11. DELETE Best Practices

✅ DO:

  • ALWAYS use WHERE clause (unless deleting all rows intentionally)
  • Use SELECT to preview before DELETE
  • Check row count before deleting
  • Use transactions for important deletions
  • Backup data before major deletions
  • Check for foreign key dependencies
  • Use LIMIT for batch deletions of large datasets
  • Consider soft deletes for important data
  • Verify deletions after executing
  • Document why you're deleting (audit log)

❌ DON'T:

  • Run DELETE without WHERE (unless intentional)
  • Delete production data without testing WHERE clause
  • Ignore foreign key constraints
  • Delete millions of rows at once
  • Forget to backup before major deletions
  • Delete during peak traffic hours
  • Use DELETE when TRUNCATE is more appropriate
  • Hard delete important business records
  • Assume you can undo a DELETE

12. 🎯 Practice Exercises

Exercise 1: Safe Single Row Deletion

Given this customers table:

CREATE TABLE customers (
    customer_id INT PRIMARY KEY,
    first_name VARCHAR(100),
    last_name VARCHAR(100),
    email VARCHAR(255) UNIQUE,
    account_status VARCHAR(20)
);

Tasks:

  • Delete the customer with customer_id = 999 (using safe process)
  • Delete all customers with email ending in '@tempmail.com'
  • Delete the customer with email 'test@example.com'
Show Solution
-- Task 1: Delete customer 999 (safe process)
-- Step 1: Check what we're deleting
SELECT * FROM customers WHERE customer_id = 999;

-- Step 2: Verify it's only 1 row
SELECT COUNT(*) FROM customers WHERE customer_id = 999;

-- Step 3: Delete
DELETE FROM customers WHERE customer_id = 999;

-- Step 4: Verify it's gone
SELECT * FROM customers WHERE customer_id = 999;


-- Task 2: Delete temporary email accounts
-- Step 1: Preview what will be deleted
SELECT customer_id, email FROM customers 
WHERE email LIKE '%@tempmail.com';

-- Step 2: Count them
SELECT COUNT(*) FROM customers 
WHERE email LIKE '%@tempmail.com';

-- Step 3: If count is reasonable, delete
DELETE FROM customers
WHERE email LIKE '%@tempmail.com';


-- Task 3: Delete by specific email
-- Step 1: Find the customer
SELECT * FROM customers WHERE email = 'test@example.com';

-- Step 2: Delete (email is UNIQUE, so only 1 row)
DELETE FROM customers
WHERE email = 'test@example.com';

-- Step 3: Verify
SELECT * FROM customers WHERE email = 'test@example.com';

Key Points:

  • ✅ Always SELECT before DELETE
  • ✅ Check COUNT to know how many rows will be affected
  • ✅ Use primary key or UNIQUE column when possible
  • ✅ LIKE pattern for pattern matching
  • ✅ Verify deletion was successful

Exercise 2: Delete with Date Conditions

Given these tables:

CREATE TABLE orders (
    order_id INT PRIMARY KEY,
    customer_id INT,
    order_date DATE,
    status VARCHAR(20)
);

CREATE TABLE logs (
    log_id INT PRIMARY KEY,
    log_message TEXT,
    log_level VARCHAR(20),
    created_at TIMESTAMP
);

Tasks:

  • Delete all cancelled orders older than 2 years
  • Delete log entries older than 90 days with log_level 'DEBUG'
  • Delete all orders from 2020 (entire year)
Show Solution
-- Task 1: Delete old cancelled orders
-- Step 1: Check what will be deleted
SELECT order_id, order_date, status
FROM orders
WHERE status = 'cancelled'
  AND order_date < DATE_SUB(CURRENT_DATE, INTERVAL 2 YEAR);

-- Step 2: Count them
SELECT COUNT(*) FROM orders
WHERE status = 'cancelled'
  AND order_date < DATE_SUB(CURRENT_DATE, INTERVAL 2 YEAR);

-- Step 3: Delete
DELETE FROM orders
WHERE status = 'cancelled'
  AND order_date < DATE_SUB(CURRENT_DATE, INTERVAL 2 YEAR);


-- Task 2: Delete old debug logs
-- Step 1: Preview
SELECT log_id, log_level, created_at
FROM logs
WHERE log_level = 'DEBUG'
  AND created_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 90 DAY)
LIMIT 10;

-- Step 2: Count
SELECT COUNT(*) FROM logs
WHERE log_level = 'DEBUG'
  AND created_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 90 DAY);

-- Step 3: Delete in batches if many rows
DELETE FROM logs
WHERE log_level = 'DEBUG'
  AND created_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 90 DAY)
LIMIT 10000;
-- Repeat until no more rows


-- Task 3: Delete all 2020 orders
-- Step 1: Preview year range
SELECT MIN(order_date), MAX(order_date), COUNT(*)
FROM orders
WHERE YEAR(order_date) = 2020;

-- Alternative: Using date range
SELECT COUNT(*) FROM orders
WHERE order_date >= '2020-01-01' 
  AND order_date < '2021-01-01';

-- Step 2: Delete
DELETE FROM orders
WHERE order_date >= '2020-01-01' 
  AND order_date < '2021-01-01';

-- Verify
SELECT COUNT(*) FROM orders WHERE YEAR(order_date) = 2020;

Date Functions Used:

  • ✅ DATE_SUB() for relative dates
  • ✅ INTERVAL for time periods
  • ✅ YEAR() to extract year
  • ✅ Date range with >= and <
  • 💡 Use LIMIT for large deletions

Exercise 3: Delete with Foreign Keys

Given these related tables:

CREATE TABLE authors (
    author_id INT PRIMARY KEY,
    name VARCHAR(255)
);

CREATE TABLE books (
    book_id INT PRIMARY KEY,
    title VARCHAR(255),
    author_id INT,
    FOREIGN KEY (author_id) REFERENCES authors(author_id)
);

CREATE TABLE reviews (
    review_id INT PRIMARY KEY,
    book_id INT,
    rating INT,
    FOREIGN KEY (book_id) REFERENCES books(book_id)
);

Task: Delete author with author_id = 5 and all their books and reviews. Handle foreign keys correctly.

Show Solution
-- Step 1: Check what will be deleted (audit before deletion)
SELECT 
    'Author' AS level,
    a.author_id,
    a.name,
    NULL AS title,
    NULL AS review_count
FROM authors a
WHERE a.author_id = 5

UNION ALL

SELECT 
    'Books' AS level,
    b.author_id,
    NULL,
    b.title,
    COUNT(r.review_id) AS review_count
FROM books b
LEFT JOIN reviews r ON b.book_id = r.book_id
WHERE b.author_id = 5
GROUP BY b.book_id;

-- Step 2: Count everything that will be deleted
SELECT 
    'Books' AS item_type,
    COUNT(*) AS count
FROM books
WHERE author_id = 5
UNION ALL
SELECT 
    'Reviews',
    COUNT(*)
FROM reviews r
JOIN books b ON r.book_id = b.book_id
WHERE b.author_id = 5;

-- Step 3: Start transaction for safety
START TRANSACTION;

-- Step 4: Delete in correct order (children first)
-- Delete reviews (grandchild)
DELETE r FROM reviews r
JOIN books b ON r.book_id = b.book_id
WHERE b.author_id = 5;

-- Verify reviews deleted
SELECT COUNT(*) FROM reviews r
JOIN books b ON r.book_id = b.book_id
WHERE b.author_id = 5;
-- Should be 0

-- Delete books (child)
DELETE FROM books
WHERE author_id = 5;

-- Verify books deleted
SELECT COUNT(*) FROM books WHERE author_id = 5;
-- Should be 0

-- Delete author (parent)
DELETE FROM authors
WHERE author_id = 5;

-- Verify author deleted
SELECT * FROM authors WHERE author_id = 5;
-- Should return no rows

-- Step 5: If all correct, commit
COMMIT;

-- If anything wrong, rollback
-- ROLLBACK;

Key Principles:

  • ✅ Always delete children before parents
  • ✅ Order: grandchildren → children → parent
  • ✅ Use transaction for multi-step deletions
  • ✅ Verify after each deletion step
  • ✅ JOIN to find related records
  • 💡 In production, consider soft delete instead!

🔥 Challenge: Implement Soft Delete System

Convert the users table from hard deletes to soft deletes:

CREATE TABLE users (
    user_id INT PRIMARY KEY,
    username VARCHAR(100),
    email VARCHAR(255),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Tasks:

  • Add soft delete columns to the table
  • Create a "safe_delete" procedure that soft deletes users
  • Create a view that shows only active users
  • Create a procedure to permanently delete soft-deleted users older than 30 days
Show Solution
-- Task 1: Add soft delete columns
ALTER TABLE users
ADD COLUMN is_deleted BOOLEAN DEFAULT FALSE,
ADD COLUMN deleted_at TIMESTAMP NULL,
ADD COLUMN deleted_by VARCHAR(100) NULL;

-- Task 2: Create soft delete procedure
DELIMITER //

CREATE PROCEDURE safe_delete_user(
    IN p_user_id INT,
    IN p_deleted_by VARCHAR(100)
)
BEGIN
    -- Check if user exists and is not already deleted
    IF EXISTS (
        SELECT 1 FROM users 
        WHERE user_id = p_user_id 
        AND is_deleted = FALSE
    ) THEN
        -- Soft delete the user
        UPDATE users
        SET is_deleted = TRUE,
            deleted_at = CURRENT_TIMESTAMP,
            deleted_by = p_deleted_by
        WHERE user_id = p_user_id;
        
        SELECT CONCAT('User ', p_user_id, ' soft deleted successfully') AS message;
    ELSE
        SELECT 'User not found or already deleted' AS message;
    END IF;
END //

DELIMITER ;

-- Usage:
CALL safe_delete_user(42, 'admin@example.com');


-- Task 3: Create view for active users only
CREATE VIEW active_users AS
SELECT user_id, username, email, created_at
FROM users
WHERE is_deleted = FALSE;

-- Usage:
SELECT * FROM active_users;


-- Task 4: Permanently delete old soft-deleted users
DELIMITER //

CREATE PROCEDURE purge_old_deleted_users()
BEGIN
    DECLARE rows_deleted INT DEFAULT 0;
    
    -- Find how many will be deleted
    SELECT COUNT(*) INTO rows_deleted
    FROM users
    WHERE is_deleted = TRUE
      AND deleted_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 30 DAY);
    
    -- Log what we're about to do
    SELECT CONCAT('About to permanently delete ', rows_deleted, ' users') AS message;
    
    -- Permanently delete
    DELETE FROM users
    WHERE is_deleted = TRUE
      AND deleted_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 30 DAY);
    
    -- Confirm deletion
    SELECT CONCAT('Permanently deleted ', rows_deleted, ' soft-deleted users') AS result;
END //

DELIMITER ;

-- Usage:
CALL purge_old_deleted_users();


-- Bonus: Restore a soft-deleted user
DELIMITER //

CREATE PROCEDURE restore_user(IN p_user_id INT)
BEGIN
    IF EXISTS (
        SELECT 1 FROM users 
        WHERE user_id = p_user_id 
        AND is_deleted = TRUE
    ) THEN
        UPDATE users
        SET is_deleted = FALSE,
            deleted_at = NULL,
            deleted_by = NULL
        WHERE user_id = p_user_id;
        
        SELECT CONCAT('User ', p_user_id, ' restored successfully') AS message;
    ELSE
        SELECT 'User not found or not deleted' AS message;
    END IF;
END //

DELIMITER ;

-- Usage:
CALL restore_user(42);

Soft Delete Benefits Demonstrated:

  • ✅ Audit trail (who deleted, when)
  • ✅ Easy restoration with restore_user()
  • ✅ Active users view hides deleted records
  • ✅ Automatic cleanup after 30 days
  • ✅ No foreign key cascade issues
  • ✅ Maintains data integrity

Using the System:

-- Soft delete a user
CALL safe_delete_user(42, 'admin@example.com');

-- Query active users (deleted users hidden)
SELECT * FROM active_users;

-- Restore accidentally deleted user
CALL restore_user(42);

-- Purge old deleted users (run monthly)
CALL purge_old_deleted_users();

💡 Production Tip: Soft deletes are the industry standard for user accounts, orders, and any data that might need restoration. Only hard delete temporary data like sessions, caches, and logs.

📝 Key Takeaways