TRUNCATE is often described as a faster DELETE for emptying tables. The performance gain is real and substantial: TRUNCATE on a large table is typically two or three orders of magnitude faster than the equivalent DELETE. The semantic differences are also real and substantial, and the surprises bite teams that reach for TRUNCATE without reading the manual carefully.
What TRUNCATE does differently
DELETE walks the table row by row, marks each row as dead via the MVCC mechanism, writes WAL records proportional to the deleted row count, fires per-row triggers, respects foreign key checks per row, and leaves the table physically intact with dead tuples accumulating until VACUUM runs. The work is linear in row count and proportional to row width.
TRUNCATE walks the table's file metadata, creates new empty files for the table and its indexes, and atomically swaps them in. The work is roughly constant time regardless of row count. The disk space used by the old files is reclaimed immediately rather than awaiting VACUUM. The WAL volume is small and fixed rather than proportional to row count.
The performance gap is largest on large tables. On a million-row table, DELETE takes seconds to minutes and produces gigabytes of WAL. TRUNCATE takes milliseconds and produces kilobytes of WAL. The advantage scales with table size and is why TRUNCATE matters for operations on large tables.
The surprises that bite
The first surprise is the lock level. TRUNCATE takes an ACCESS EXCLUSIVE lock on the table, which blocks all concurrent reads and writes. DELETE takes a row exclusive lock that allows concurrent reads. A TRUNCATE that runs while production traffic is reading the table will block that traffic until the TRUNCATE completes. The lock is brief in absolute time but the blocking behavior is different from DELETE in ways that matter for live systems.
The second surprise is the transactional behavior. TRUNCATE is transactional in Postgres, meaning it participates in the surrounding transaction and rolls back cleanly. The transactionality is implemented by deferring the file swap until commit. The implication is that a TRUNCATE inside a transaction is visible to that transaction immediately but is invisible to other sessions until commit. The behavior is consistent with other DDL statements in Postgres.
The third surprise is the trigger behavior. TRUNCATE does not fire row-level triggers because no rows are processed. TRUNCATE does fire statement-level TRUNCATE triggers if defined, but these are rarely set up in practice. The implication is that any application logic implemented in row-level triggers, including audit triggers and cascade triggers, is silently skipped by TRUNCATE. The skip is intentional but bites teams that depend on triggers for application invariants.
The fourth surprise is the foreign key behavior. TRUNCATE on a table referenced by a foreign key constraint fails by default. The error is the same as the equivalent DELETE would produce. The escape clauses are TRUNCATE ... CASCADE, which truncates referring tables, and TRUNCATE ... RESTRICT, which is the default. The CASCADE form is dangerous and should be used only with full awareness of which tables are about to be emptied.
The sequence behavior
TRUNCATE does not reset sequences by default. A table with an IDENTITY column or SERIAL column will continue producing the next sequence value after TRUNCATE. The behavior is usually correct for production tables where ID stability matters across truncate-and-reload cycles. The opposite behavior is available via TRUNCATE ... RESTART IDENTITY, which resets associated sequences to their starting value.
The RESTART IDENTITY option is useful for test data setup where each truncate should produce predictable IDs and is dangerous for production tables where reused IDs can collide with cached references. The default of CONTINUE IDENTITY is the safer choice and matches the principle that TRUNCATE should be a fast empty-the-table operation rather than a complete reset.
The multi-table form
TRUNCATE accepts multiple tables in a single statement: TRUNCATE table1, table2, table3. The multi-table form is more than syntactic sugar. It allows TRUNCATE to bypass foreign key restrictions when all referenced and referring tables are truncated together. The pattern is the right approach for emptying a related set of tables in development and test environments.
The multi-table form takes ACCESS EXCLUSIVE locks on all named tables together, in a consistent order, eliminating the deadlock risk of separate TRUNCATE statements. The atomic-set semantic is one of the underused features of Postgres TRUNCATE.
When to reach for TRUNCATE
TRUNCATE is the right tool for emptying tables of any size in operational maintenance, test data setup, and ETL workflows. The conditions that make it safe are: no production traffic on the table during the operation, no row-level triggers that need to fire, no foreign key constraints from external tables, and explicit awareness of the sequence behavior.
TRUNCATE is the wrong tool for selective deletion based on WHERE conditions, which only DELETE supports. TRUNCATE is also the wrong tool when application audit triggers must fire, when foreign key cascade processing matters for application semantics, and when the table is referenced by triggers in other tables that maintain derived state.
The vacuum interaction
TRUNCATE bypasses the MVCC dead-tuple accumulation that DELETE produces. The implication is that VACUUM on the table after TRUNCATE has nothing to do, in contrast to DELETE which leaves substantial dead tuple cleanup work for autovacuum. The autovacuum-load reduction is one of the underappreciated benefits of TRUNCATE on large tables.
The reverse is also true. A pattern of frequent DELETE-then-INSERT on the same rows accumulates dead tuples that VACUUM eventually cleans up. The same pattern as TRUNCATE-then-INSERT has no MVCC cost and no VACUUM cost. The total IO and CPU cost of the TRUNCATE pattern can be a substantial fraction less for workloads that periodically empty and refill tables.
What TRUNCATE does not solve
TRUNCATE does not solve the problem of needing to empty a table while production traffic is reading it. The ACCESS EXCLUSIVE lock makes this impossible without coordination. The pattern for online table emptying is either chunked DELETE with batched commits or table replacement via ALTER TABLE ... RENAME of an empty replacement table.
TRUNCATE does not solve the problem of needing to empty rows matching specific conditions. DELETE with a WHERE clause is the only option. The chunked-delete pattern with a partial index on the predicate column scales to large deletions with acceptable lock impact.
TRUNCATE does not solve the problem of recovering disk space from a table where some rows remain. The space reclamation is total: TRUNCATE empties the table and reclaims all its space. Partial space reclamation requires VACUUM FULL or pg_repack, both of which have their own operational characteristics.
Our use across the four products
Our four products run on SQLite, which has a similar TRUNCATE-vs-DELETE distinction but different mechanics. SQLite has DELETE FROM table without a WHERE clause, which is optimized to a fast metadata-level reset called the "truncate optimization" when no triggers are defined and no FOREIGN KEY constraints reference the table. The optimization is automatic and does not require explicit syntax.
The pattern we use most often is the daily-rotation of staging tables in ETL workflows. DocuMint stages PDF generation jobs into a table, processes them in batches, and clears the table at end of day. CronPing accumulates monitor check results into a hot table and rotates to a warm archive table monthly. FlagBit clears the evaluation log staging table after ETL ingestion. WebhookVault retains captured requests for the configured retention window and clears expired rows from the hot table via the truncate-optimization-eligible pattern.
Our planned Postgres migration will preserve the same patterns using TRUNCATE for the staging tables and DELETE with retention predicates for the live tables. The conscious choice between the two on a per-table basis is one of the discipline points we want to preserve.
Deeper observation
The deeper observation is that TRUNCATE is a non-trivial alternative to DELETE that exposes semantic distinctions most developers do not internalize until production failure. The pattern of fast operations that bypass standard machinery recurs across CREATE INDEX CONCURRENTLY versus CREATE INDEX, ALTER TABLE ADD COLUMN with constant DEFAULT versus the variable-DEFAULT rewrite path, and several other Postgres operations that have a fast path and a slow path. The choice between them depends on understanding both the performance characteristics and the semantic differences. Postgres rewards developers who read the manual carefully and bites those who reach for the obvious-looking tool without understanding what it does differently.
Our products: DocuMint (PDF invoice generation API), CronPing (cron job monitoring with status pages), FlagBit (feature flags API for modern teams), and WebhookVault (webhook capture and replay) put these patterns into production.