Skip to main content
sql deletedatabase managementdata removalsql statements

SQL DELETE — Complete Guide to Removing Database Records

DELETE removes rows from a table based on a WHERE condition. Without WHERE, it removes every row. That distinction makes DELETE simultaneously the simplest and the most dangerous DML statement — one missing clause turns a targeted cleanup into a table wipe.

This guide covers safe deletion patterns, the difference between DELETE and TRUNCATE, batch deletes for large tables, foreign key CASCADE behavior, and auditing strategies across PostgreSQL, MySQL, and SQL Server.

TL;DR

SQL DELETE removes rows that match a WHERE condition. Always test with a matching SELECT first, and wrap critical deletes in a transaction so you can ROLLBACK. For bulk removal of all rows, TRUNCATE is faster and resets identity columns. For large-scale deletes, process in batches to avoid lock escalation and transaction log bloat. PostgreSQL's RETURNING clause and SQL Server's OUTPUT clause let you capture deleted rows.

DELETE Syntax and WHERE Clause

The basic syntax is minimal:

DELETE FROM employees WHERE employee_id = 101;

The WHERE clause is not technically required, but omitting it deletes every row in the table. Side by side, the difference is one line:

-- Deletes one row DELETE FROM orders WHERE order_id = 5042; -- Deletes ALL rows — probably not what you meant DELETE FROM orders;

Common WHERE patterns:

-- Multiple conditions DELETE FROM products WHERE category = 'Electronics' AND price < 10 AND stock_quantity = 0; -- IN operator for specific IDs DELETE FROM customers WHERE customer_id IN (15, 23, 47, 89); -- BETWEEN for date ranges DELETE FROM logs WHERE created_at BETWEEN '2024-01-01' AND '2024-03-31'; -- IS NULL for cleanup DELETE FROM contacts WHERE email IS NULL AND phone IS NULL;

DELETE with Subqueries and JOINs

The most common pattern is DELETE with a subquery — remove rows that match (or do not match) data in another table:

-- Remove employees in a specific department DELETE FROM employees WHERE department_id IN ( SELECT department_id FROM departments WHERE department_name = 'Temporary' ); -- Remove orphaned records (customers with no orders) DELETE FROM customers c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id );

Standard SQL does not include DELETE ... JOIN syntax. PostgreSQL provides DELETE ... USING as the standards-compliant alternative. MySQL and SQL Server support DELETE ... JOIN as vendor extensions, but the syntax is not portable between databases.

— PostgreSQL, DELETE documentation

MySQL and SQL Server support DELETE with JOIN directly:

-- MySQL / SQL Server DELETE e FROM employees e INNER JOIN departments d ON e.department_id = d.department_id WHERE d.department_name = 'Temporary'; -- PostgreSQL equivalent using USING DELETE FROM employees e USING departments d WHERE e.department_id = d.department_id AND d.department_name = 'Temporary';

DELETE vs TRUNCATE vs DROP

Three commands remove data, but they work differently and serve different purposes.

DELETE VS TRUNCATE VS DROPRemove data from a table?Remove specific rows only?YesDELETERow-by-row, fully loggedNoKeep the table structure?YesTRUNCATEFast, minimal loggingNoDROPTable gone entirelyTriggers fire: YesRollback: YesIdentity reset: NoWHERE clause: YesTriggers fire: NoRollback: Depends*Identity reset: YesRemoves table, indexes,constraints, permissionsCannot be undone
Click to enlarge

DELETE is for removing specific rows. It fires triggers, can be rolled back in a transaction, and leaves the table structure intact. On large tables it is slow because it logs every row individually.

TRUNCATE removes all rows from a table. It is much faster than DELETE FROM table (without WHERE) because it deallocates data pages instead of logging each row. TRUNCATE resets identity/auto-increment counters. In PostgreSQL and SQL Server, TRUNCATE is transactional; in MySQL with InnoDB, it performs an implicit commit.

DROP removes the table itself — structure, data, indexes, constraints, permissions. It is irreversible outside of a backup restore.

Safe Deletion Patterns

The SELECT-first pattern is the most important safety practice. Write the WHERE clause, run it with SELECT to verify which rows match, then replace SELECT with DELETE:

-- Step 1: verify SELECT order_id, customer_id, status FROM orders WHERE status = 'cancelled' AND order_date < '2024-01-01'; -- 847 rows — matches expectations -- Step 2: delete inside a transaction BEGIN; DELETE FROM orders WHERE status = 'cancelled' AND order_date < '2024-01-01'; -- 847 rows affected — matches the SELECT count COMMIT;

Soft delete avoids physical deletion entirely. Instead of DELETE, run an UPDATE that marks the row as deleted:

UPDATE customers SET deleted_at = NOW() WHERE customer_id = 42;

Application queries then filter with WHERE deleted_at IS NULL. The data stays recoverable. The trade-off: table size grows, indexes include dead rows, and every query must remember the filter.

Archive-before-delete copies rows to an archive table before removing them. This combines INSERT ... SELECT with DELETE:

BEGIN; INSERT INTO archived_orders SELECT * FROM orders WHERE order_date < '2023-01-01' AND status = 'completed'; DELETE FROM orders WHERE order_date < '2023-01-01' AND status = 'completed'; COMMIT;

Batch Deletes for Large Tables

Deleting millions of rows in a single statement is dangerous for the same reasons as large updates: lock escalation, transaction log growth, and replication lag.

In SQL Server, the transaction log for a single DELETE of 10 million rows can grow to 50+ GB before the transaction commits. If the log file runs out of space, the entire transaction rolls back — which takes even longer than the original delete.

— Microsoft, Transaction Log Architecture documentation

BATCH DELETE FLOWStartDELETE TOP(5000)WHERE conditionRows affected > 0?NoDoneYes — loopBetween batches:- Locks released- Transaction log can truncate- Replication catches up- Optional WAITFOR DELAY
Click to enlarge

Batch deletion patterns by database:

-- SQL Server: TOP(N) in a loop WHILE 1 = 1 BEGIN DELETE TOP(5000) FROM orders WHERE order_date < '2023-01-01' AND status = 'completed'; IF @@ROWCOUNT = 0 BREAK; WAITFOR DELAY '00:00:01'; -- optional breathing room END -- MySQL: LIMIT-based DELETE FROM orders WHERE order_date < '2023-01-01' AND status = 'completed' LIMIT 5000; -- Repeat in application code until 0 rows affected -- PostgreSQL: CTE-based WITH batch AS ( SELECT order_id FROM orders WHERE order_date < '2023-01-01' AND status = 'completed' LIMIT 5000 ) DELETE FROM orders WHERE order_id IN (SELECT order_id FROM batch);

Foreign Key Constraints and CASCADE

Attempting to delete a row that is referenced by a foreign key in another table raises an error. The behavior depends on how the foreign key was defined.

ON DELETE OPTIONS FOR FOREIGN KEYSON DELETE RESTRICTParent: orders (id=42)Child: order_items (order_id=42)DELETE blockedError: foreign keyconstraint violationSafest — prevents orphansON DELETE CASCADEParent: orders (id=42)Child: order_items (order_id=42)Both rows deletedChild rows automaticallyremoved with parentConvenient — can cascade deeplyON DELETE SET NULLParent: orders (id=42)Child: order_items (order_id=42)Parent deleted, child keptorder_items.order_idset to NULLPreserves child rows for audit
Click to enlarge

RESTRICT (the default in most databases) blocks the delete and raises an error. This is the safest option — it forces you to delete dependent records first.

CASCADE automatically deletes all child rows when the parent is deleted. Convenient, but dangerous — deleting one parent row can cascade through multiple levels of foreign keys and remove thousands of rows. Always check which tables have CASCADE relationships before deleting.

SET NULL sets the foreign key column in child rows to NULL instead of deleting them. Useful when the child records have independent value (e.g., keeping order_items for accounting after removing the parent order).

To check which tables reference a given table before deleting:

-- PostgreSQL: find foreign keys referencing a table SELECT tc.table_name, kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name WHERE tc.constraint_type = 'FOREIGN KEY' AND kcu.table_name = 'orders';

Database-Specific Features

PostgreSQL supports DELETE ... USING for join-based deletes and RETURNING to capture deleted rows:

DELETE FROM employees WHERE salary < 30000 RETURNING employee_id, first_name, last_name;

MySQL supports multi-table DELETE to remove rows from multiple tables in one statement:

DELETE customers, orders FROM customers INNER JOIN orders ON customers.customer_id = orders.customer_id WHERE customers.status = 'inactive';

MySQL also supports DELETE ... IGNORE which suppresses foreign key errors and skips rows that would cause violations.

SQL Server provides the OUTPUT clause (equivalent to PostgreSQL's RETURNING) and TOP(N) DELETE for batch operations:

DELETE FROM products OUTPUT DELETED.product_id, DELETED.product_name WHERE discontinue_date < GETDATE();

Auditing Deleted Data

In regulated industries — finance, healthcare, government — you cannot just delete records. Compliance requires a trail of what was deleted, when, and by whom.

The most common approach is an audit trigger that copies deleted rows to an audit table:

-- Works on PostgreSQL; MySQL and SQL Server have similar trigger syntax CREATE OR REPLACE FUNCTION audit_employee_delete() RETURNS TRIGGER AS $$ BEGIN INSERT INTO employee_audit (employee_id, action, deleted_by, deleted_at, row_data) VALUES (OLD.employee_id, 'DELETE', current_user, NOW(), row_to_json(OLD)); RETURN OLD; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_employee_delete BEFORE DELETE ON employees FOR EACH ROW EXECUTE FUNCTION audit_employee_delete();

An alternative is Change Data Capture (CDC), which captures all changes (inserts, updates, deletes) at the database level without triggers. SQL Server has built-in CDC; PostgreSQL uses logical replication; MySQL uses the binlog.

Knowing which tables feed downstream reports before deleting data prevents broken dashboards. Data lineage in a data catalog shows these dependencies. Dawiso's lineage view traces which reports and models consume a given table, so teams can assess the impact of a DELETE operation before running it. This matters for data governance — deletion decisions should consider downstream effects, not just the immediate table.

Dawiso
Built with love for our users
Make Data Simple for Everyone.
Try Dawiso for free today and discover its ease of use firsthand.
© Dawiso s.r.o. All rights reserved