TRUNCATE vs DELETE vs DROP: Remove Rows Fast in PostgreSQL
TRUNCATE vs DELETE vs DROP: Remove Rows Fast in PostgreSQL
Where DELETE removes rows one logical version at a time and leaves the cleanup to vacuum, TRUNCATE discards an entire table's contents almost instantly and reclaims the space immediately, and DROP removes the table itself. Picking the wrong one for "empty this table" is a common cause of surprise WAL floods and runaway bloat.
Purpose and Overview
"Just clear the table" sounds like one operation, but PostgreSQL offers three commands with very different costs. The reason the choice matters comes down to how PostgreSQL stores data. Under multiversion concurrency control (MVCC), a DELETE does not physically remove a row — it marks each row version as deleted by stamping its xmax, leaving a dead tuple behind. Those dead tuples occupy space until vacuum reclaims them, and on a full-table delete that means writing WAL and generating dead tuples across the entire table.
TRUNCATE takes a structural shortcut. Instead of touching rows individually, it empties the table without scanning it, so it does not produce per-row dead tuples. It reclaims disk space immediately rather than deferring to vacuum, which makes it dramatically faster and far lighter on WAL for the "remove everything" case. The trade-off is concurrency: TRUNCATE takes an ACCESS EXCLUSIVE lock, blocking all other access to the table for its duration.
DROP TABLE goes one step further and removes the table definition along with its data, indexes, constraints, and triggers. It is the right tool when the table itself is no longer needed — not merely its current contents. Understanding which of the three matches your actual intent is what keeps a routine cleanup from becoming an incident.
Sample Code
1-- Remove some rows: DELETE is the only choice that filters
2DELETE FROM events WHERE created_at < now() - interval '90 days';
3
4-- Empty an entire table fast, and reset its identity sequence
5TRUNCATE TABLE staging_load RESTART IDENTITY;
6
7-- Empty a table and cascade to tables with FK references to it
8TRUNCATE TABLE parent_data CASCADE;
9
10-- Remove the table itself, contents and definition together
11DROP TABLE obsolete_report;
Notes: All four statements are transaction-safe in PostgreSQL — including TRUNCATE and DROP, which can be rolled back inside a transaction block, unlike in some other databases. RESTART IDENTITY resets owned sequences; the default CONTINUE IDENTITY leaves them untouched. If other tables hold foreign-key references, a plain TRUNCATE fails unless every referencing table is named in the same command; CASCADE adds those tables for you and empties them too — use it deliberately.
Code Breakdown
Each statement expresses a different intent, and the modifiers change behavior in ways worth knowing before running them in production.
DELETE With a Predicate
1DELETE FROM events WHERE created_at < now() - interval '90 days';
DELETE is the only one of the three that removes a subset of rows. The WHERE clause is what makes it indispensable for retention jobs and selective cleanup. The cost is proportional to the rows removed: each becomes a dead tuple, each generates WAL, and vacuum must later reclaim the space. For a large purge this is real overhead, which is why batching deletes and ensuring autovacuum keeps up both matter.
TRUNCATE With RESTART IDENTITY
1TRUNCATE TABLE staging_load RESTART IDENTITY;
TRUNCATE empties the whole table without scanning it, so there is no per-row work. RESTART IDENTITY additionally restarts any sequences owned by columns of the truncated table — the natural choice for a staging table that is reloaded from scratch. Without it, the default CONTINUE IDENTITY preserves the sequence position even though the rows are gone.
TRUNCATE CASCADE
1TRUNCATE TABLE parent_data CASCADE;
If foreign keys reference the target table, a plain TRUNCATE fails rather than silently break referential integrity, unless every referencing table is listed in the same command. CASCADE tells PostgreSQL to also truncate every table that references it. This is powerful and dangerous in equal measure: it can empty far more than the named table, so confirm the dependency graph first.
DROP TABLE
1DROP TABLE obsolete_report;
DROP removes the table and everything attached to it. Add IF EXISTS to make scripts idempotent, and CASCADE to drop dependent objects such as views or foreign-key constraints. Reach for it only when the table has no future, not as a way to empty one you intend to keep.
Key Decision Factors
WAL Volume
PostgreSQL logs changes to data files in WAL before they are written, so a full-table DELETE that changes every row generates WAL that grows with the table, which on a large table can be a replication-straining burst. TRUNCATE does not scan or change rows one by one, so it avoids that per-row work. If the exact figure matters, compare pg_current_wal_lsn() before and after on your own system.
Dead Tuples and Bloat
DELETE leaves dead tuples that vacuum must reclaim; until it does, the table retains its on-disk size and queries still scan the dead space. TRUNCATE produces no dead tuples and reclaims disk space immediately. A repeated delete-and-reload cycle on a staging table is a classic bloat generator that TRUNCATE eliminates.
Locking and Concurrency
DELETE takes a ROW EXCLUSIVE table lock plus row locks, and allows concurrent reads and writes to the rest of the table. TRUNCATE takes an ACCESS EXCLUSIVE lock, blocking every other session — including readers — until it completes. On a table that must stay continuously available, DELETE is the only option even when it is slower.
Transaction Safety and Triggers
All three are transactional in PostgreSQL and roll back cleanly. One caveat: TRUNCATE is not MVCC-safe, so after it commits the table appears empty to concurrent transactions whose snapshot predates it. The behavioral catch is triggers: TRUNCATE does not fire ON DELETE triggers, only ON TRUNCATE triggers. Logic that depends on per-row delete triggers — audit logging, cascade emulation — will be silently skipped by TRUNCATE, which is a correctness trap if you switch commands without checking.
Practical Applications
Reloading a Staging Table
For an ETL staging table truncated and refilled on every run, TRUNCATE ... RESTART IDENTITY is the obvious fit: instant, no bloat, sequences reset. A DELETE here would generate needless WAL and bloat on every cycle.
Time-Based Retention
Purging rows older than a retention window requires filtering, so DELETE with a WHERE clause is the only choice. Run it in batches on very large tables to bound WAL bursts and lock duration, and make sure autovacuum reclaims the freed space afterward.
Tearing Down Temporary Structures
When a feature is retired or a report table is no longer produced, DROP TABLE IF EXISTS removes it cleanly along with its indexes and constraints. Keeping an unused table around only invites confusion and wasted backups.
Emptying Related Tables Together
To reset a small cluster of FK-linked tables, TRUNCATE ... CASCADE clears the parent and its referencing tables in one consistent operation — provided you have confirmed exactly which tables the cascade will reach.
Version Compatibility
The semantics described here are long-standing. TRUNCATE has reclaimed space immediately and taken an ACCESS EXCLUSIVE lock for many major versions; RESTART IDENTITY and CASCADE options, transactional rollback of DDL, and the ON TRUNCATE trigger event are all well established and behave consistently across PostgreSQL 12 through 18. DELETE with USING and RETURNING are PostgreSQL extensions to the standard and are equally stable.
The practical differences between releases are at the margins — incremental planner and vacuum improvements that affect how quickly a large DELETE's dead tuples get reclaimed, for instance — rather than changes to which command does what. Any decision made on the WAL, bloat, locking, and trigger trade-offs above will hold across current supported versions.
Best Practices
- Match the command to the intent — filtered removal →
DELETE; empty a table you keep →TRUNCATE; remove the table entirely →DROP. - Batch large DELETEs — break a big purge into chunks to cap WAL bursts and lock hold time, and let autovacuum reclaim space between batches.
- Mind the ACCESS EXCLUSIVE lock — never
TRUNCATEa table that must stay readable under load; its lock blocks everyone. - Check for ON DELETE triggers before switching —
TRUNCATEskips them; do not swap aDELETEfor aTRUNCATEwhere per-row trigger logic matters. - Use CASCADE deliberately — confirm the foreign-key graph before
TRUNCATE ... CASCADEorDROP ... CASCADE, because both can reach further than expected.
References
- PostgreSQL Documentation — TRUNCATE — locking,
RESTART IDENTITY,CASCADE, transactional behavior, and theON TRUNCATEtrigger note. - PostgreSQL Documentation — DELETE — syntax,
USING/RETURNINGextensions, and the note thatTRUNCATEis faster for full-table removal. - PostgreSQL Documentation — DROP TABLE —
IF EXISTS,CASCADE/RESTRICT, and dependent-object removal. - PostgreSQL Documentation — Routine Vacuuming — why deleted row versions need
VACUUM, and the tip to useTRUNCATEfor tables emptied periodically. - PostgreSQL Documentation — Database Page Layout — the
t_xmaxfield in the heap tuple header, the delete XID stamp. - PostgreSQL Documentation — Caveats (MVCC) —
TRUNCATEis not MVCC-safe. - PostgreSQL Documentation — Write-Ahead Logging — data-file changes are logged in WAL before they are written.
- EDB — PostgreSQL VACUUM Guide and Best Practices — deleted rows stay in the table as dead tuples until
VACUUMremoves them.