InletDownload

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.
  • CASCADE locks 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 TRUNCATE sees the table as empty afterwards.
  • ON DELETE triggers 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_locks also 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 READ transaction that had started (by reading another table) before a TRUNCATE committed then counted 0 rows in the truncated table, not the 100 it would have seen with DELETE.

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'.

VersionLocks (TRUNCATE tp CASCADE)Time, 3M rows (median of 3)New data fileSELECT / INSERT meanwhileOld REPEATABLE READ snapshot sees
14.24AccessExclusiveLock on tp, tc and both primary keys3 msYesWaits / waits0 rows
15.19AccessExclusiveLock on tp, tc and both primary keys2 msYesWaits / waits0 rows
16.14AccessExclusiveLock on tp, tc and both primary keys1 msYesWaits / waits0 rows
17.11AccessExclusiveLock on tp, tc and both primary keys2 msYesWaits / waits0 rows
18.6AccessExclusiveLock on tp, tc and both primary keys2 msYesWaits / waits0 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.

Related

Sources