Dropping Old Data with Partition Detach Instead of DELETE

The retention job deletes audit rows older than 400 days in batches of 10,000, every night. It deletes about 25 million rows, generates roughly 20 GB of WAL that every replica must replay, leaves the table and its indexes bloated until autovacuum catches up, and has started overlapping with the morning traffic peak. The table is partitioned by month — so all of those rows live in one partition that could be removed as a unit. DETACH PARTITION ... CONCURRENTLY followed by DROP TABLE removes a month of data in seconds, with almost no WAL and no bloat. This guide switches retention from deleting rows to detaching partitions, covers the concurrent detach’s locking and failure recovery, and shows how to archive before dropping. It belongs to Partitioning Live Tables Without Downtime.

Cost of Removing One Month of Audit Rows Stacked bar comparing batched DELETE of 25 million rows against DETACH CONCURRENTLY plus DROP. DELETE: 80 minutes of deleting and an estimated 45 minutes of vacuum afterwards. Detach and drop: 0.1 minutes, no vacuum. Cost of Removing One Month of Audit Rows batched DELETE 80 min 45 min DETACH + DROP delete / detach vacuum afterwards
Row-by-row deletion pays twice — once to delete and again to vacuum; detaching pays for neither.

Symptom / Error Signatures

Row-based retention on a large table shows up as:

  • Nightly jobs issuing DELETE FROM audit_log WHERE created_at < now() - interval '400 days' AND id IN (...) for hours.
  • WAL volume and replica lag spiking during the retention window.
  • n_dead_tup climbing on the table and autovacuum running continuously; index bloat growing.
  • Retention falling behind, so the table keeps growing despite the job.

Once switched to detaching, the errors to recognise are:

ERROR:  DETACH PARTITION ... CONCURRENTLY cannot run inside a transaction block
ERROR:  cannot detach partitions concurrently when a default partition exists
ERROR:  partition "audit_log_2025_07" already pending detach in partitioned table "public.audit_log"
HINT:  Use ALTER TABLE ... DETACH PARTITION ... FINALIZE to complete the pending detach operation.

Root Cause Analysis

DELETE removes rows one at a time: each deleted row is marked dead, logged to WAL, and later reclaimed by vacuum, and every index entry pointing to it must be cleaned too. The cost is proportional to the number of rows. Removing a partition is proportional to nothing: DETACH updates the catalog so the table is no longer part of the parent, and DROP TABLE unlinks its files.

A plain DETACH PARTITION takes ACCESS EXCLUSIVE on the parent, so it blocks every query on the partitioned table while it waits for and holds that lock. DETACH PARTITION ... CONCURRENTLY (PostgreSQL 14+) runs in two transactions and takes only SHARE UPDATE EXCLUSIVE on the parent and the partition: first it marks the partition as pending detach, then it waits for all queries that might still see it to finish, then it completes. Because it uses multiple transactions it cannot run inside a transaction block, and it is not allowed when the parent has a default partition. If it is interrupted — cancelled, or the session dies — the partition stays “pending detach”, and DETACH PARTITION ... FINALIZE completes it.

Method Parent lock WAL / bloat Duration
batched DELETE row locks proportional to rows hours
DETACH PARTITION ACCESS EXCLUSIVE (brief, but queues) negligible seconds, if the lock is available
DETACH PARTITION CONCURRENTLY SHARE UPDATE EXCLUSIVE negligible seconds to minutes (waits for old queries)
DROP TABLE on the detached table none on parent negligible seconds
DETACH PARTITION CONCURRENTLY in Two Transactions Sequence between the retention job, PostgreSQL and in-flight queries. Transaction one marks the partition pending detach under SHARE UPDATE EXCLUSIVE and commits. PostgreSQL waits for queries that started before to finish. Transaction two removes the partition from the parent. The job then optionally archives and drops the table. DETACH PARTITION CONCURRENTLY in Two Transactions Retention job PostgreSQL In-flight queries DETACH … CONCURRENTLY (txn 1) partition marked pending detach wait for older snapshots finished txn 2: detach completes archive, then DROP TABLE
The wait between the two transactions is for in-flight queries only — new queries already ignore the pending partition.

Immediate Mitigation

1. Detach the oldest partition concurrently. Run it outside any transaction block, with a lock timeout.

-- PostgreSQL 14+ · migration/retention role · must not run inside BEGIN … COMMIT
-- WARNING: not allowed when the parent has a default partition.
SET lock_timeout = '5s';
ALTER TABLE audit_log DETACH PARTITION audit_log_2025_07 CONCURRENTLY;
-- ROLLBACK PATH: ALTER TABLE audit_log ATTACH PARTITION audit_log_2025_07 FOR VALUES FROM ('2025-07-01') TO ('2025-08-01');

2. If the detach was interrupted, finalize it.

-- PostgreSQL 14+ · completes a pending concurrent detach
SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'audit_log'::regclass AND i.inhdetachpending;
ALTER TABLE audit_log DETACH PARTITION audit_log_2025_07 FINALIZE;

3. Archive, then drop. The detached table is an ordinary table: dump it to object storage, or move it to a cheaper tablespace, before dropping.

# Shell · retention host · read access to the detached table · archive before dropping
# WARNING: verify the archive (row count, checksum) before the DROP runs.
pg_dump --table=public.audit_log_2025_07 --format=custom --file=audit_log_2025_07.dump "$DATABASE_URL"
pg_restore --list audit_log_2025_07.dump > /dev/null && \
  psql "$DATABASE_URL" -c "DROP TABLE public.audit_log_2025_07"

4. Stop the old DELETE job once detach-based retention is running, so the two do not compete.

Permanent Fix / Long-Term Pattern

Encode retention as a scheduled job — or pg_partman’s retention setting, as in automating partition creation with pg_partman — that detaches partitions older than the policy, archives them if required, and drops them after the archive is verified. Keep the parent free of a default partition so concurrent detach is always available, and make the job idempotent: on start, finalize any pending detach, then continue.

Align partition size with retention granularity. Monthly partitions with a 400-day policy mean data is kept for up to 430 days, because a partition can only be dropped when all its rows have expired; if exact retention matters, use daily or weekly partitions. For tables not yet partitioned, the conversion in converting a Postgres table to declarative partitioning is the prerequisite, and until then, keep deletes batched and throttled as in throttling backfills to protect OLTP latency. MySQL has the equivalent in ALTER TABLE audit_log DROP PARTITION p2025_07, which removes the partition’s data without row-by-row deletion; use EXCHANGE PARTITION with an empty table first if you need to keep the data as a standalone table for archiving.

Retention Job Flow Retention job. Finalize any pending detach first; select partitions entirely older than the retention policy; a gate checks there is no default partition; detach concurrently; archive and verify; a gate checks the archive; drop. Retention Job Flow Finalize pending DETACH … FINALIZE default? none exists? Detach concurrently expired partitions archive verified? DROP TABLE space reclaimed use plain DETACH off-peak keep table, alert fail
The job is safe to rerun at any point — pending detaches are finalized and archives are verified before anything is dropped.

Verification Checklist

Frequently Asked Questions

Why is DETACH so much cheaper than DELETE? Detaching changes catalog metadata and dropping unlinks files, so the cost does not depend on the number of rows. Deleting marks every row dead, logs every change to WAL and leaves space for vacuum to reclaim.

Does DETACH PARTITION CONCURRENTLY block queries? No. It takes SHARE UPDATE EXCLUSIVE on the parent and the partition, which allow reads and writes. It waits for queries that might still see the partition before completing, so it can take a while if long queries are running.

What happens if a concurrent detach is interrupted? The partition remains in a “pending detach” state: new queries no longer see it, but it is still linked to the parent. Run ALTER TABLE ... DETACH PARTITION ... FINALIZE to complete it.

Can I reattach a detached partition? Yes, with ATTACH PARTITION and its original bounds — and it will be quick if the table still carries a validated CHECK implying those bounds, as explained in attaching partitions without long locks.