Skip to content

SQL DELETE for Deleting Data

Learn how to safely use SQL DELETE to remove rows from tables, understand the risks of missing WHERE clauses, and compare DELETE with TRUNCATE and DROP for data cleanup.

Databases grow over time. Test records need cleanup, cancelled orders need removal, expired accounts need purging, and outdated data needs to go. The SQL DELETE statement is the command that removes rows from a table, and knowing how to use it safely is just as important as knowing how to use it at all.

Like the UPDATE statement, DELETE carries significant risk. A missing WHERE clause wipes out every row in a table instantly, with no confirmation dialog and no undo button. Beyond basic deletion, you also need to understand when DELETE is the right tool versus TRUNCATE or DROP, and why many production systems avoid physical deletion entirely in favor of a soft delete pattern.

This guide walks through every aspect of deleting data in SQL, from targeted single-row removal to bulk cleanup, explains the critical differences between the three data-removal commands, and shows you a battle-tested soft delete approach used by real-world applications.

The Sample Schema

All examples use the following tables and data:

CREATE TABLE customers (
    customer_id   INT PRIMARY KEY,
    first_name    VARCHAR(50),
    last_name     VARCHAR(50),
    email         VARCHAR(100),
    city          VARCHAR(50),
    status        VARCHAR(20) DEFAULT 'active'
);

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

CREATE TABLE products (
    product_id    INT PRIMARY KEY,
    product_name  VARCHAR(100),
    category      VARCHAR(50),
    price         DECIMAL(10,2),
    in_stock      BOOLEAN DEFAULT TRUE
);

CREATE TABLE logs (
    log_id        INT PRIMARY KEY,
    event_type    VARCHAR(50),
    event_date    DATE,
    details       VARCHAR(200)
);

-- customers
INSERT INTO customers VALUES (1, 'Alice',  'Martin', 'alice@example.com',  'New York',  'active');
INSERT INTO customers VALUES (2, 'Bob',    'Jones',  'bob@example.com',    'Chicago',   'active');
INSERT INTO customers VALUES (3, 'Carol',  'Smith',  'carol@example.com',  'Denver',    'inactive');
INSERT INTO customers VALUES (4, 'Dave',   'Wilson', 'dave@example.com',   'Boston',    'inactive');
INSERT INTO customers VALUES (5, 'Eve',    'Turner', 'eve@example.com',    'Chicago',   'active');
INSERT INTO customers VALUES (6, 'Frank',  'Lee',    'frank@example.com',  'Denver',    'inactive');

-- orders
INSERT INTO orders VALUES (1001, 1, '2023-06-15', 'completed',  150.00);
INSERT INTO orders VALUES (1002, 2, '2023-09-22', 'completed',  299.99);
INSERT INTO orders VALUES (1003, 1, '2024-01-10', 'processing', 420.00);
INSERT INTO orders VALUES (1004, 3, '2024-03-05', 'cancelled',   85.50);
INSERT INTO orders VALUES (1005, 4, '2024-04-20', 'cancelled',  200.00);
INSERT INTO orders VALUES (1006, 5, '2024-05-01', 'pending',    310.00);
INSERT INTO orders VALUES (1007, 1, '2024-06-12', 'pending',    175.00);

-- products
INSERT INTO products VALUES (101, 'Wireless Mouse',      'Electronics', 25.99, TRUE);
INSERT INTO products VALUES (102, 'USB-C Cable',          'Electronics',  9.99, TRUE);
INSERT INTO products VALUES (103, 'Notebook A5',          'Stationery',   4.50, TRUE);
INSERT INTO products VALUES (104, 'Mechanical Keyboard',  'Electronics', 89.99, FALSE);
INSERT INTO products VALUES (105, 'Desk Lamp',            'Furniture',   34.50, FALSE);
INSERT INTO products VALUES (106, 'Pen Pack',             'Stationery',   2.99, TRUE);

-- logs
INSERT INTO logs VALUES (1, 'login',    '2023-01-10', 'User 1 logged in');
INSERT INTO logs VALUES (2, 'purchase', '2023-03-15', 'Order 999 placed');
INSERT INTO logs VALUES (3, 'login',    '2023-06-20', 'User 2 logged in');
INSERT INTO logs VALUES (4, 'error',    '2024-01-05', 'Payment failed');
INSERT INTO logs VALUES (5, 'login',    '2024-04-18', 'User 5 logged in');
INSERT INTO logs VALUES (6, 'purchase', '2024-05-22', 'Order 1006 placed');

Basic Syntax: DELETE FROM ... WHERE

The DELETE statement removes rows from a table. Its structure is simpler than UPDATE because there is no SET clause: you just specify which table and which rows.

DELETE FROM table_name
WHERE condition;
  • DELETE FROM table_name specifies the table to delete rows from.
  • WHERE condition filters which rows should be removed. Only rows matching the condition are deleted.

Example: Deleting a Single Row

Remove a specific cancelled order:

DELETE FROM orders
WHERE order_id = 1004;

To verify:

SELECT order_id, customer_id, status FROM orders ORDER BY order_id;
order_id customer_id status
1001 1 completed
1002 2 completed
1003 1 processing
1005 4 cancelled
1006 5 pending
1007 1 pending

Order 1004 is gone. Every other row remains untouched.

Example: Deleting Multiple Rows

Remove all cancelled orders in one statement:

DELETE FROM orders
WHERE status = 'cancelled';

To verify:

SELECT order_id, status FROM orders ORDER BY order_id;
order_id status
1001 completed
1002 completed
1003 processing
1006 pending
1007 pending

Both cancelled orders (1004 and 1005) were removed. The WHERE clause matched two rows, and both were deleted.

Example: Deleting with Multiple Conditions

You can combine conditions with AND, OR, IN, BETWEEN, and any other valid WHERE expressions:

-- Delete inactive customers from Denver
DELETE FROM customers
WHERE status = 'inactive'
  AND city = 'Denver';

To verify:

SELECT customer_id, first_name, city, status FROM customers;
customer_id first_name city status
1 Alice New York active
2 Bob Chicago active
4 Dave Boston inactive
5 Eve Chicago active

Carol (inactive, Denver) and Frank (inactive, Denver) were deleted. Dave (inactive, Boston) was not affected because his city did not match.

-- Delete old logs from 2023
DELETE FROM logs
WHERE event_date < '2024-01-01';

To verify:

SELECT * FROM logs;
log_id event_type event_date details
4 error 2024-01-05 Payment failed
5 login 2024-04-18 User 5 logged in
6 purchase 2024-05-22 Order 1006 placed

Three log entries from 2023 were removed. Only 2024 entries remain.

The Danger of DELETE Without WHERE

Just like UPDATE, running a DELETE statement without a WHERE clause affects every row in the table.

The Catastrophic Example

-- ⚠️ CATASTROPHIC: No WHERE clause!
DELETE FROM customers;
SELECT COUNT(*) AS remaining_rows FROM customers;
remaining_rows
0

Every customer is gone. The table still exists (its structure and columns are intact), but it contains zero rows. If those customers had years of accumulated data, that data is now lost.

How to Protect Yourself

The protective measures mirror those for UPDATE, but they are worth repeating because the consequences of a mistake are even more severe. With a bad UPDATE, data is corrupted but still present. With a bad DELETE, data is gone.

1. Always write and test the WHERE clause as a SELECT first.

-- Step 1: See exactly which rows will be deleted
SELECT customer_id, first_name, status
FROM customers
WHERE status = 'inactive';
customer_id first_name status
3 Carol inactive
4 Dave inactive
6 Frank inactive

Three rows. That matches your expectation. Now proceed.

-- Step 2: Convert SELECT to DELETE
DELETE FROM customers
WHERE status = 'inactive';
-- DELETE 3

2. Use transactions.

BEGIN;

DELETE FROM customers
WHERE status = 'inactive';
-- DELETE 3

-- Verify what remains
SELECT COUNT(*) FROM customers;
-- 3 rows remaining. Correct!

-- Looks good. Commit.
COMMIT;

-- Something wrong? Undo everything.
-- ROLLBACK;

3. Check the affected row count immediately.

If you expected to delete 3 rows but the database reports 3000, execute ROLLBACK immediately.

4. Enable safe mode in your tooling.

-- MySQL: prevent DELETE without WHERE
SET SQL_SAFE_UPDATES = 1;

DELETE FROM customers;
-- ERROR 1175: You are using safe update mode and you tried to update a table
-- without a WHERE that uses a KEY column.

5. Back up before bulk deletes.

Before running any DELETE that affects more than a handful of rows, create a backup. The simplest approach is to copy the data into a temporary table:

-- Create a safety backup before deleting
CREATE TABLE customers_backup AS
SELECT * FROM customers;

-- Now safe to proceed
DELETE FROM customers WHERE status = 'inactive';

-- If disaster strikes, restore:
-- INSERT INTO customers SELECT * FROM customers_backup;

Using Subqueries in DELETE

Subqueries let you determine which rows to delete based on data from other tables or computed values.

Delete Based on a Subquery

Delete orders placed by inactive customers:

DELETE FROM orders
WHERE customer_id IN (
    SELECT customer_id
    FROM customers
    WHERE status = 'inactive'
);

First, let's verify what will be deleted:

SELECT o.order_id, o.customer_id, c.first_name, c.status
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.customer_id
WHERE c.status = 'inactive';
order_id customer_id first_name status
1004 3 Carol inactive
1005 4 Dave inactive

Two orders belong to inactive customers. After the delete:

SELECT order_id, customer_id FROM orders ORDER BY order_id;
order_id customer_id
1001 1
1002 2
1003 1
1006 5
1007 1

Delete Using EXISTS

EXISTS is often safer than IN, especially when dealing with potential NULL values:

Delete products that have never been ordered:

CREATE TABLE order_items (
    item_id     INT PRIMARY KEY,
    order_id    INT,
    product_id  INT,
    quantity    INT
);

INSERT INTO order_items VALUES (1, 1001, 101, 2);
INSERT INTO order_items VALUES (2, 1001, 103, 1);
INSERT INTO order_items VALUES (3, 1002, 104, 1);
INSERT INTO order_items VALUES (4, 1006, 101, 3);
DELETE FROM products
WHERE NOT EXISTS (
    SELECT 1
    FROM order_items AS oi
    WHERE oi.product_id = products.product_id
);

Products 102, 105, and 106 were never ordered and get deleted. Products 101, 103, and 104 remain because they appear in order_items.

SELECT product_id, product_name FROM products;
product_id product_name
101 Wireless Mouse
103 Notebook A5
104 Mechanical Keyboard

Delete with a Correlated Subquery

Delete all orders except each customer's most recent one:

DELETE FROM orders
WHERE order_date < (
    SELECT MAX(o2.order_date)
    FROM orders AS o2
    WHERE o2.customer_id = orders.customer_id
);

For each order, the subquery finds the most recent order date for that customer. If the current order is older than the max, it is deleted.

SELECT order_id, customer_id, order_date FROM orders ORDER BY customer_id, order_date;
order_id customer_id order_date
1007 1 2024-06-12
1002 2 2023-09-22
1006 5 2024-05-01

Only the most recent order per customer remains. Alice had three orders; only her latest (1007) survived.

DELETE with JOIN (Database-Specific)

Some databases support join syntax directly in DELETE:

MySQL:

DELETE o
FROM orders AS o
JOIN customers AS c ON o.customer_id = c.customer_id
WHERE c.status = 'inactive';

PostgreSQL (using USING):

DELETE FROM orders AS o
USING customers AS c
WHERE o.customer_id = c.customer_id
  AND c.status = 'inactive';

SQL Server:

DELETE o
FROM orders AS o
INNER JOIN customers AS c ON o.customer_id = c.customer_id
WHERE c.status = 'inactive';

DELETE vs TRUNCATE vs DROP

SQL offers three different commands for removing data, and each one operates at a different level. Choosing the wrong one can either leave you with more than you intended or destroy more than you expected.

DELETE

DELETE FROM table_name;
DELETE FROM table_name WHERE condition;

DELETE removes specific rows from a table. The table structure, indexes, constraints, and permissions remain intact.

TRUNCATE

TRUNCATE TABLE table_name;

TRUNCATE removes all rows from a table in a single, fast operation. The table structure remains intact, but the data is gone.

DROP

DROP TABLE table_name;

DROP removes the entire table: all rows, the table definition, all indexes, constraints, and permissions. The table ceases to exist.

Side-by-Side Comparison

Feature DELETE TRUNCATE DROP
What it removes Specific rows (or all, without WHERE) All rows The entire table
Table structure preserved Yes Yes No
Indexes preserved Yes Yes No
Can filter with WHERE Yes No No
Fires row-level triggers Yes No (in most databases) No
Can be rolled back Yes (if in transaction) Depends on database Depends on database
Resets auto-increment No Yes (in most databases) N/A (table is gone)
Speed on large tables Slow (row by row) Very fast Very fast
Logged Fully logged (each row) Minimally logged Minimally logged
Foreign key checks Yes Yes (fails if referenced) Yes (fails if referenced)

When to Use Each

Use DELETE when:

  • You need to remove specific rows based on a condition.
  • You need the operation to fire triggers.
  • You need to be able to roll back the operation.
  • You need to delete rows from a table with foreign key references pointing to it (with cascading rules).
-- Remove completed orders older than 2 years
DELETE FROM orders
WHERE status = 'completed'
  AND order_date < CURRENT_DATE - INTERVAL '2 years';

Use TRUNCATE when:

  • You want to remove all rows quickly.
  • You do not need row-level triggers to fire.
  • You want to reset the auto-increment counter.
  • You are clearing staging or temporary tables.
-- Clear the staging table before loading fresh data
TRUNCATE TABLE staging_imports;

Use DROP when:

  • You want to remove the entire table and its definition.
  • The table is temporary or no longer needed.
  • You are cleaning up during schema migrations.
-- Remove a temporary backup table after confirming data integrity
DROP TABLE customers_backup;

TRUNCATE Rollback Behavior

This is a critical distinction that varies by database:

Database Can TRUNCATE be rolled back?
PostgreSQL Yes (transactional)
SQL Server Yes (transactional)
MySQL (InnoDB) No (implicit commit)
Oracle No (implicit commit)
SQLite Yes (transactional)
-- PostgreSQL: TRUNCATE inside a transaction is safe
BEGIN;
TRUNCATE TABLE logs;
-- Oops, wrong table!
ROLLBACK;
-- Data is restored in PostgreSQL

-- MySQL: TRUNCATE cannot be rolled back
BEGIN;
TRUNCATE TABLE logs;
-- ROLLBACK has NO effect. Data is gone.

A Visual Summary

                    ┌───────────────────────────────────┐
                    │         Your Table                │
                    │  ┌────────────────────────────┐   │
                    │  │   Row 1  │  Row 2  │ Row 3 │   │
                    │  └────────────────────────────┘   │
                    │  Indexes, Constraints, Structure  │
                    └───────────────────────────────────┘

DELETE WHERE id=2:  Removes Row 2 only. Everything else stays.

TRUNCATE:           Removes ALL rows. Table structure stays.

DROP:               Removes EVERYTHING. Table is gone entirely.

The Soft Delete Pattern

In many production systems, data is never physically deleted. Instead, rows are marked as deleted using a flag column. This is called a soft delete (or logical delete), and it is one of the most widely used patterns in application development.

Why Soft Delete?

Physical deletion has several problems in real-world applications:

  • Audit requirements. Regulations like GDPR, HIPAA, or SOX may require you to retain records for a specific period, even after a user requests deletion.
  • Accidental deletion recovery. If a user accidentally deletes an important record, soft delete makes recovery trivial. Physical deletion requires restoring from backups.
  • Referential integrity. Deleting a customer who has orders creates foreign key violations. Soft delete avoids this entirely.
  • Data analytics. Historical data is valuable. Soft-deleted records can still be included in reports and trend analysis.
  • Undo functionality. Many applications offer an "undo" or "restore from trash" feature, which soft delete enables naturally.

Implementing Soft Delete

Step 1: Add a deletion flag column to your table.

The most common approaches are a boolean flag, a timestamp, or both:

-- Option A: Boolean flag
ALTER TABLE customers ADD COLUMN is_deleted BOOLEAN DEFAULT FALSE;

-- Option B: Timestamp (NULL means not deleted)
ALTER TABLE customers ADD COLUMN deleted_at TIMESTAMP DEFAULT NULL;

-- Option C: Both (best for auditing)
ALTER TABLE customers ADD COLUMN is_deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE customers ADD COLUMN deleted_at TIMESTAMP DEFAULT NULL;

Step 2: Replace DELETE with UPDATE.

Instead of physically removing rows, update the deletion flag:

-- Physical delete (traditional)
DELETE FROM customers WHERE customer_id = 3;

-- Soft delete (preferred in many applications)
UPDATE customers
SET is_deleted = TRUE,
    deleted_at = CURRENT_TIMESTAMP
WHERE customer_id = 3;

To verify:

SELECT customer_id, first_name, is_deleted, deleted_at FROM customers;
customer_id first_name is_deleted deleted_at
1 Alice FALSE NULL
2 Bob FALSE NULL
3 Carol TRUE 2024-06-15 14:30:00
4 Dave FALSE NULL
5 Eve FALSE NULL
6 Frank FALSE NULL

Carol's record is still in the database, but it is marked as deleted.

Step 3: Filter soft-deleted rows in all queries.

Every query that reads "active" data must exclude soft-deleted rows:

-- Show only active customers
SELECT customer_id, first_name, email
FROM customers
WHERE is_deleted = FALSE;
customer_id first_name email
1 Alice alice@example.com
2 Bob bob@example.com
4 Dave dave@example.com
5 Eve eve@example.com
6 Frank frank@example.com

Carol does not appear in the results even though her row still exists in the table.

Step 4: Restoring soft-deleted records.

Recovery is a simple UPDATE:

-- Restore Carol's account
UPDATE customers
SET is_deleted = FALSE,
    deleted_at = NULL
WHERE customer_id = 3;

Carol is back. No backup restoration, no data recovery tools, no downtime.

Creating a View for Active Records

Adding WHERE is_deleted = FALSE to every query is tedious and error-prone. A view solves this by creating a virtual table that only shows active records:

CREATE VIEW active_customers AS
SELECT customer_id, first_name, last_name, email, city, status
FROM customers
WHERE is_deleted = FALSE;

Now application queries can use the view instead of the base table:

-- Clean and simple: no need to remember the filter
SELECT * FROM active_customers;
customer_id first_name last_name email city status
1 Alice Martin alice@example.com New York active
2 Bob Jones bob@example.com Chicago active
4 Dave Wilson dave@example.com Boston inactive
5 Eve Turner eve@example.com Chicago active
6 Frank Lee frank@example.com Denver inactive

Soft-deleted rows are automatically excluded.

Querying Soft-Deleted Records

When you need to see deleted records (for auditing, admin panels, or recovery), query the base table directly:

-- Show only deleted customers
SELECT customer_id, first_name, deleted_at
FROM customers
WHERE is_deleted = TRUE;

-- Show all customers including deleted ones
SELECT customer_id, first_name, is_deleted, deleted_at
FROM customers;

Permanent Deletion of Soft-Deleted Records

Over time, soft-deleted records accumulate. A scheduled purge job can physically remove old soft-deleted rows after a retention period:

-- Permanently remove records soft-deleted more than 90 days ago
DELETE FROM customers
WHERE is_deleted = TRUE
  AND deleted_at < CURRENT_TIMESTAMP - INTERVAL '90 days';

This gives you the best of both worlds: immediate "deletion" from the user's perspective, a grace period for recovery, and eventual cleanup to keep the table manageable.

Performance Considerations for Soft Delete

Soft-deleted rows remain in the table, which means the table grows larger over time. This can affect query performance if not managed properly.

Index the deletion flag:

-- Partial index (PostgreSQL): only indexes active rows
CREATE INDEX idx_customers_active
ON customers (customer_id)
WHERE is_deleted = FALSE;

-- Standard index (all databases)
CREATE INDEX idx_customers_deleted ON customers (is_deleted);

Partial indexes (supported in PostgreSQL and SQLite) are ideal for soft delete because most queries only need active records. The index is smaller and faster because it excludes soft-deleted rows.

Soft Delete vs Physical Delete: When to Use Each

Scenario Recommended Approach
User-facing data (customers, posts, comments) Soft delete
Audit-sensitive data (financial records, healthcare) Soft delete (with retention policy)
Temporary or staging data Physical delete
Cache or session tables Physical delete or TRUNCATE
Log tables with retention policies Physical delete (after archiving)
GDPR "right to erasure" requests Soft delete, then physical purge after retention period
Development/testing cleanup Physical delete or TRUNCATE

Common Mistakes

Mistake 1: DELETE Without WHERE

Already covered extensively, but it belongs at the top of every mistakes list.

Wrong:

DELETE FROM orders;
-- Every order is gone. All of them.

Correct:

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

Mistake 2: Deleting Parent Rows with Existing Children

If a table has foreign key constraints, deleting a parent row while child rows still reference it will fail.

Wrong:

-- orders table has customer_id referencing customers
DELETE FROM customers WHERE customer_id = 1;
ERROR: update or delete on table "customers" violates foreign key constraint
on table "orders"
DETAIL: Key (customer_id)=(1) is still referenced from table "orders".

Solutions:

-- Option A: Delete child rows first, then the parent
DELETE FROM orders WHERE customer_id = 1;
DELETE FROM customers WHERE customer_id = 1;

-- Option B: Use CASCADE in the foreign key definition (set during table creation)
-- ALTER TABLE orders ADD CONSTRAINT fk_customer
--     FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
--     ON DELETE CASCADE;
-- Now deleting a customer automatically deletes their orders.

-- Option C: Use soft delete instead (no foreign key violation)
UPDATE customers SET is_deleted = TRUE WHERE customer_id = 1;

Mistake 3: Using DELETE When TRUNCATE Is Appropriate

If you need to remove all rows from a table, DELETE FROM table_name (without WHERE) works but is unnecessarily slow on large tables.

Slow on large tables:

-- Deletes row by row, logs each deletion, fires triggers
DELETE FROM logs;
-- Took 45 seconds on 10 million rows

Fast:

-- Deallocates data pages in bulk, minimal logging
TRUNCATE TABLE logs;
-- Took 0.1 seconds on 10 million rows

Mistake 4: Not Verifying with SELECT First

Deleting based on a condition you have not tested is gambling with your data.

Risky:

-- "I think this will delete about 5 rows..."
DELETE FROM products
WHERE price < 10 AND category != 'Electronics';

Safe:

-- Step 1: See exactly what will be deleted
SELECT product_id, product_name, price, category
FROM products
WHERE price < 10 AND category != 'Electronics';

-- Step 2: Verify the results match your expectations
-- Step 3: Only then, run the DELETE
BEGIN;
DELETE FROM products
WHERE price < 10 AND category != 'Electronics';
-- DELETE 2. Expected 2. Good.
COMMIT;

Mistake 5: Forgetting That DELETE Does Not Reset Auto-Increment

After deleting rows, the auto-increment counter does not go back.

-- Table has rows with IDs 1, 2, 3, 4, 5
DELETE FROM customers WHERE customer_id IN (4, 5);

-- Next insert gets ID 6, not 4
INSERT INTO customers (first_name, last_name) VALUES ('Grace', 'Hall');
-- New row has customer_id = 6

If you need the counter to reset, use TRUNCATE (which resets it in most databases) or manually reset it:

-- PostgreSQL
ALTER SEQUENCE customers_customer_id_seq RESTART WITH 4;

-- MySQL
ALTER TABLE customers AUTO_INCREMENT = 4;

Returning Deleted Rows

Some databases let you see the deleted rows as part of the DELETE statement:

PostgreSQL:

DELETE FROM orders
WHERE status = 'cancelled'
RETURNING order_id, customer_id, status, total_amount;
order_id customer_id status total_amount
1004 3 cancelled 85.50
1005 4 cancelled 200.00

This is invaluable for logging or auditing what was deleted without a separate SELECT.

SQL Server:

DELETE FROM orders
OUTPUT DELETED.order_id, DELETED.customer_id, DELETED.status
WHERE status = 'cancelled';

MySQL: Does not support RETURNING on DELETE. Use a SELECT before the DELETE.

Performance Considerations

  • Index your WHERE columns. A DELETE must locate rows before removing them. Unindexed columns force a full table scan.

  • Batch large deletes. Deleting millions of rows in one transaction can lock the table, fill up the transaction log, and even crash the database. Break it into smaller chunks:

-- Delete 5,000 rows at a time (MySQL)
DELETE FROM logs
WHERE event_date < '2023-01-01'
LIMIT 5000;
-- Repeat until 0 rows affected

-- PostgreSQL equivalent using ctid
DELETE FROM logs
WHERE ctid IN (
    SELECT ctid FROM logs
    WHERE event_date < '2023-01-01'
    LIMIT 5000
);
  • Foreign key checks slow things down. Each deleted row triggers a check to ensure no child rows reference it. On tables with many foreign key relationships, this can be the biggest bottleneck.

  • Trigger overhead. Row-level DELETE triggers fire once per deleted row. On bulk deletes, this can be extremely slow. Consider disabling triggers temporarily for large cleanup operations (with caution).

  • VACUUM after large deletes (PostgreSQL). In PostgreSQL, deleted rows are not physically removed immediately. They are marked as dead tuples and reclaimed later by VACUUM. After a large delete, run VACUUM ANALYZE to reclaim disk space and update query planner statistics:

VACUUM ANALYZE customers;

Quick Reference

Operation Syntax Rows Removed Table Preserved
Delete specific rows DELETE FROM t WHERE condition Matching rows only Yes
Delete all rows DELETE FROM t All Yes
Truncate TRUNCATE TABLE t All (fast) Yes
Drop DROP TABLE t All + structure No
Soft delete UPDATE t SET is_deleted = TRUE WHERE condition None (flagged) Yes

Summary

The SQL DELETE statement removes rows from a table based on a condition specified in the WHERE clause. It is a straightforward command with serious consequences when misused.

Key takeaways:

  • The basic syntax is DELETE FROM table WHERE condition. The WHERE clause is not technically required, but omitting it deletes every row in the table.
  • Never run a DELETE without a WHERE clause unless you genuinely intend to empty the table. Always verify your condition with a SELECT first and use transactions for safety.
  • Subqueries in the WHERE clause let you delete rows based on data from other tables. Use IN, EXISTS, or correlated subqueries for dynamic filtering.
  • DELETE vs TRUNCATE vs DROP serve different purposes. DELETE removes specific rows (slow, logged, can be rolled back). TRUNCATE removes all rows quickly (minimal logging, may not be rollbackable). DROP destroys the entire table.
  • The soft delete pattern replaces physical deletion with a flag column (is_deleted or deleted_at). This preserves data for auditing, enables easy recovery, avoids foreign key issues, and is the standard approach in most production applications.
  • Create views that automatically exclude soft-deleted rows to simplify application queries and prevent accidental exposure of deleted data.
  • Batch large deletes to avoid locking tables and overwhelming transaction logs.
  • Use RETURNING (PostgreSQL) or OUTPUT (SQL Server) to see or archive deleted rows in a single atomic operation.

Treat every DELETE with the same caution you would treat a permanent, irreversible action. Verify first, use transactions, and when possible, choose soft delete to give yourself a safety net.

Navigation