Archive Cold Rows to a History Table
Move old rows into an archive table in bounded batches so the hot table stays small and query plans stay stable.
Task
Archive rows older than a threshold into a history table using batched moves rather than one massive delete.
When to use this
A hot events table grows unbounded, slowing the working set and making autovacuum work harder than necessary.
Prerequisites
- PostgreSQL with a matching history table
- A retention threshold
- Maintenance window or throttle
Steps
1. Create the history table with the same shape and move one batch.
CREATE TABLE IF NOT EXISTS events_history (LIKE events INCLUDING ALL);
WITH moved AS (
DELETE FROM events WHERE created_at < now() - interval '90 days'
RETURNING *
)
INSERT INTO events_history SELECT * FROM moved;Expected output: A single statement moves and archives a batch atomically.
2. Loop in bounded batches to avoid long locks.
while true; do
psql -c "DELETE FROM events WHERE ctid IN (SELECT ctid FROM events WHERE created_at < now() - interval '90 days' LIMIT 10000)"
[ $(psql -tAc "SELECT count(*) FROM events WHERE created_at < now() - interval '90 days'") -eq 0 ] && break
sleep 1
doneExpected output: Rows are removed in small chunks so locks stay short and vacuum keeps up.
Troubleshooting
Problem
INSERT from a CTE is slow on huge volumes
Solution
Prefer batched loops with LIMIT and an explicit order to keep each transaction small.
Problem
Archived rows still queried
Solution
Update reporting queries to union the history table, or expose a view.
Limitations
- History table grows forever without its own retention policy.
- Batched deletes still generate WAL and vacuum load.
- Foreign keys may block moving rows.
What this recipe does
Archive rows older than a threshold into a history table using batched moves rather than one massive delete.
A hot events table grows unbounded, slowing the working set and making autovacuum work harder than necessary.
Prerequisites
- PostgreSQL with a matching history table
- A retention threshold
- Maintenance window or throttle
Procedure
- Create the history table with the same shape and move one batch.
CREATE TABLE IF NOT EXISTS events_history (LIKE events INCLUDING ALL);
WITH moved AS (
DELETE FROM events WHERE created_at < now() - interval '90 days'
RETURNING *
)
INSERT INTO events_history SELECT * FROM moved;
Expected result: A single statement moves and archives a batch atomically.
- Loop in bounded batches to avoid long locks.
while true; do
psql -c "DELETE FROM events WHERE ctid IN (SELECT ctid FROM events WHERE created_at < now() - interval '90 days' LIMIT 10000)"
[ $(psql -tAc "SELECT count(*) FROM events WHERE created_at < now() - interval '90 days'") -eq 0 ] && break
sleep 1
done
Expected result: Rows are removed in small chunks so locks stay short and vacuum keeps up.
How to confirm it worked
Run the commands above and confirm every expected result in order. If a step does not produce the stated output, use the troubleshooting notes before continuing. A passing run shows the hot table row count drops, autovacuum pressure falls, and archive queries return the moved rows.
Troubleshooting
INSERT from a CTE is slow on huge volumes — Prefer batched loops with LIMIT and an explicit order to keep each transaction small.
Archived rows still queried — Update reporting queries to union the history table, or expose a view.
Limitations
- History table grows forever without its own retention policy.
- Batched deletes still generate WAL and vacuum load.
- Foreign keys may block moving rows.