PostgreSQL migration
Does TRUNCATE lock the table in PostgreSQL?
Yes. TRUNCATE takes an ACCESS EXCLUSIVE lock on each table it empties, so reads and writes wait until the transaction ends. It’s instant because it swaps in a new empty file instead of deleting rows, and it can be rolled back, but transactions that started earlier may see the table as empty.
Tested on PostgreSQL 14, 15, 16, 17, 18 · Updated 9 October 2026
- Lock
- ACCESS EXCLUSIVE
- Blocks
- Reads and writes
- Rewrites the table
- No (swaps in a new empty file)
Short answer
TRUNCATE events takes an ACCESS EXCLUSIVE lock on the table: no reads, no
writes until the transaction ends. It doesn’t delete rows one by one; it gives the table a new, empty
data file, so it took 1 to 3 ms on our 3-million-row table.
Things to know before running it on production:
- It waits for every query using the table, and queries arriving meanwhile queue behind it. Set
lock_timeout. CASCADElocks and empties other tables too: every table with a foreign key to this one.- It is transactional: inside
BEGIN … ROLLBACK, the rows come back. - It isn’t MVCC-safe: a transaction that took its snapshot before the
TRUNCATEsees the table as empty afterwards. ON DELETEtriggers don’t fire.
If the table is in use and you only want to remove old rows, DELETE in batches is gentler.
What it locks
- ACCESS EXCLUSIVE on each table being truncated, plus ACCESS EXCLUSIVE on
their indexes (which are emptied too).
pg_locksalso showed a SHARE lock on the table, which changes nothing. - With
CASCADE, the same on every table that references it through a foreign key.
Without CASCADE, a referenced table can’t be truncated alone:
ERROR: cannot truncate a table referenced in a foreign key constraint
DETAIL: Table "tc" references "tp".
HINT: Truncate table "tc" at the same time, or use TRUNCATE ... CASCADE.
TRUNCATE tc, tp (listing both) works, and makes it explicit what gets emptied. CASCADE prints
NOTICE: truncate cascades to table "tc" and empties it without asking.
Does it rewrite the table?
No, and it doesn’t scan it either. It points the table at a new, empty data file (we saw
pg_relation_filenode change). As the PostgreSQL documentation puts it, the disk space is reclaimed
immediately, with no need for a later VACUUM, which isn’t true of DELETE.
Two consequences:
- Rollback works. In our test,
BEGIN; TRUNCATE …; ROLLBACK;left all 100 rows in place. - Older snapshots see an empty table. A
REPEATABLE READtransaction that had started (by reading another table) before aTRUNCATEcommitted then counted 0 rows in the truncated table, not the 100 it would have seen withDELETE.
Sequences aren’t reset unless you ask: after TRUNCATE, the next serial id was 4; after
TRUNCATE … RESTART IDENTITY, it was 1.
Run it safely
Set lock_timeout, and list the tables explicitly:
BEGIN;
SET LOCAL lock_timeout = '2s';
TRUNCATE staging_events, staging_event_tags;
COMMIT;
If it fails with ERROR: canceling statement due to lock timeout, nothing changed; retry when the
table is quiet. See lock timeout. When truncating several tables,
use the same order every time to avoid deadlocks.
Check what CASCADE would reach before using it:
SELECT conrelid::regclass AS referencing_table, conname
FROM pg_constraint
WHERE contype = 'f' AND confrelid = 'staging_events'::regclass;
On a table that’s in use, delete in batches instead. DELETE takes only a
ROW EXCLUSIVE lock, so reads and other writes continue:
DELETE FROM events
WHERE id IN (SELECT id FROM events WHERE created_at < '2026-01-01' LIMIT 10000);
-- repeat until it deletes 0 rows
The space is reused by new rows after vacuum, rather than returned to the operating system. If old data is partitioned by time, detaching and dropping a partition is cheaper still; see ATTACH and DETACH PARTITION.
How we checked
On PostgreSQL 14.24, 15.19, 16.14, 17.11 and 18.6 (Docker on a Mac), in rolled-back transactions:
BEGIN;
SELECT pg_relation_filenode('seo_mig.big');
TRUNCATE seo_mig.big; -- 3 million rows
SELECT relation::regclass, mode FROM pg_locks
WHERE pid = pg_backend_pid()
AND relation IN (SELECT oid FROM pg_class WHERE relnamespace = 'seo_mig'::regnamespace);
SELECT pg_relation_filenode('seo_mig.big'); -- a new number
ROLLBACK;
Foreign keys used seo_mig.tp (id int PRIMARY KEY) and seo_mig.tc (… tp_id int REFERENCES seo_mig.tp)
with 100 rows each. For blocking, a second session held TRUNCATE seo_mig.t open while a third ran a
SELECT and an INSERT with lock_timeout = '1s'.
| Version | Locks (TRUNCATE tp CASCADE) | Time, 3M rows (median of 3) | New data file | SELECT / INSERT meanwhile | Old REPEATABLE READ snapshot sees |
|---|---|---|---|---|---|
| 14.24 | AccessExclusiveLock on tp, tc and both primary keys | 3 ms | Yes | Waits / waits | 0 rows |
| 15.19 | AccessExclusiveLock on tp, tc and both primary keys | 2 ms | Yes | Waits / waits | 0 rows |
| 16.14 | AccessExclusiveLock on tp, tc and both primary keys | 1 ms | Yes | Waits / waits | 0 rows |
| 17.11 | AccessExclusiveLock on tp, tc and both primary keys | 2 ms | Yes | Waits / waits | 0 rows |
| 18.6 | AccessExclusiveLock on tp, tc and both primary keys | 2 ms | Yes | Waits / waits | 0 rows |
The snapshot test: session A ran BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM seo_mig.p;,
session B ran TRUNCATE seo_mig.tc and committed, then session A ran SELECT count(*) FROM seo_mig.tc.
The foreign-key error, rollback and RESTART IDENTITY results were the same on all five versions.
In Inlet
On connections tagged production, Inlet opens read-only, and on protected connections a TRUNCATE
asks you to type the table’s name before it runs. If a TRUNCATE is waiting for its lock, the
Activity monitor shows the session it’s waiting for.