Hasib
Back to Blog
Database

PostgreSQL DBA Conversation: "You Know That One Big DELETE Could Hurt Production, Right?"

A senior PostgreSQL DBA's warning on why a single bulk DELETE can trigger WAL growth, replication lag, dead tuples, and locking issues — plus the batch-delete, partitioning, and retention strategies that keep production safe.

July 2, 20264 min read58 views

Today, I want to talk about a classic PostgreSQL production question that looks simple but is full of hidden risk:

"If you need to delete millions of records from a PostgreSQL table, what would you do?"

At first glance, many people answer:

DELETE FROM big_table
WHERE created_at < '2024-01-01';

But in a real production system, especially an online transactional database, this answer is dangerous.

A careless bulk DELETE can create huge WAL, increase replication lag, generate massive dead tuples, cause table and index bloat, put pressure on autovacuum, slow down business queries, and create a very bad day for the DBA team.

Let me explain it through a conversation.


The Online Conversation with a DBA

I was having an online discussion with a senior PostgreSQL DBA. We were talking about database optimization, indexing, query tuning, and production maintenance.

The DBA asked me:

DBA: "Suppose you have a large PostgreSQL table with hundreds of millions of rows. You need to delete all records older than one year. How would you do it?"

I replied confidently:

Me: "Simple. I will run this query."

DELETE FROM big_table
WHERE created_at < '2024-01-01';

The DBA paused for a moment and replied:

DBA: "You know that one big DELETE can seriously hurt production, right?"

That sentence changed the whole conversation.

I realized that the question was not only about SQL syntax. It was about production safety, PostgreSQL internals, replication, locking, WAL, vacuum, and business continuity.


Why One Big DELETE Is Dangerous in PostgreSQL

In PostgreSQL, a DELETE does not immediately remove rows physically from the table file. Because of PostgreSQL MVCC, deleted rows become dead tuples. Later, VACUUM is responsible for cleaning them up and making the space reusable. PostgreSQL documentation clearly states that VACUUM reclaims storage occupied by dead tuples created by UPDATE and DELETE.

So, when you delete millions of rows in one transaction, you are not simply "removing data." You are creating a large cleanup responsibility for PostgreSQL.

The DBA explained the major risks.


1. Massive WAL Generation

Every DELETE is a write operation. PostgreSQL must generate WAL records so that the change is durable and can be replayed during crash recovery or replication.

If you delete millions of rows at once, WAL volume can grow very fast.

This can cause:

  • Heavy disk I/O
  • WAL archive growth
  • Replication lag
  • Increased backup/archive pressure
  • Possible storage exhaustion if WAL is not monitored properly

In a PostgreSQL streaming replication setup, the standby servers must replay the same changes. A huge delete can make replicas fall behind the primary.


2. Dead Tuples and Table Bloat

This is one of the biggest PostgreSQL-specific issues.

After a large DELETE, the rows are marked as dead, but the table size does not immediately shrink. PostgreSQL needs VACUUM to clean up dead tuples and make the space reusable. Routine vacuuming is required to recover or reuse space, update planner statistics, update the visibility map, and protect against transaction ID wraparound.

If autovacuum cannot keep up, the table becomes bloated.

That means:

  • More disk space usage
  • Slower sequential scans
  • Larger indexes
  • Poorer cache efficiency
  • More I/O for future queries

The DELETE may finish, but the real performance problem may continue afterward.


3. Index Bloat

When rows are deleted, related index entries also become dead. PostgreSQL vacuum can clean index garbage, but after a very large delete, indexes may still remain physically large.

In some cases, after a huge delete, you may need to consider:

VACUUM (ANALYZE) big_table;

or, during a proper maintenance window:

REINDEX TABLE CONCURRENTLY big_table;

or use tools such as pg_repack, depending on the environment and business requirement.

The key point is this: deleting rows is only the first step. Cleaning up the table and indexes is also part of the operation.


4. Locking and Business Impact

A PostgreSQL DELETE takes locks. It does not normally block simple SELECT queries, but it can block or conflict with other operations such as updates on the same rows, DDL operations, maintenance operations, or long-running transactions. PostgreSQL provides multiple lock modes to control concurrent access to tables.

If the delete runs for a long time, it can create operational risk.

For example:

  • Application updates may wait.
  • Autovacuum may be blocked by long-running transactions.
  • DDL may be blocked.
  • Replication replay may fall behind.
  • Monitoring alerts may start firing.

So the question is not only "Can PostgreSQL delete the rows?"

The real question is:

Can PostgreSQL delete those rows safely while the business is still running?


5. Long Transaction Risk

A single massive DELETE is usually one huge transaction.

That creates several problems:

  • Rollback becomes expensive.
  • WAL generation becomes huge.
  • Locks are held for a long time.
  • Dead tuples cannot be cleaned properly until the transaction finishes.
  • Replication replay becomes heavier.
  • Application performance can degrade.

In production, smaller transactions are usually safer than one massive transaction.


The Professional PostgreSQL Playbook

So, what should we do instead?

Here are safer PostgreSQL approaches.


Method 1: Batch Delete Using Primary Key or Indexed Column

PostgreSQL does not support DELETE ... LIMIT directly like MySQL. So the common PostgreSQL pattern is to use a CTE and delete in small batches.

Example:

WITH rows_to_delete AS (
    SELECT id
    FROM big_table
    WHERE created_at < '2024-01-01'
    ORDER BY id
    LIMIT 10000
)
DELETE FROM big_table b
USING rows_to_delete r
WHERE b.id = r.id;

Then run this repeatedly from a script until zero rows are deleted.

Between batches, add a small sleep:

sleep 1

Why this is safer:

  • Each batch is a smaller transaction.
  • Locks are held for less time.
  • WAL generation is spread out.
  • Replication gets time to catch up.
  • Autovacuum pressure is reduced.
  • The operation is easier to pause or stop.

Important: the WHERE condition should use an indexed column. For example:

CREATE INDEX CONCURRENTLY idx_big_table_created_at
ON big_table (created_at);

Without a proper index, each batch may scan a large part of the table again and again.


Method 2: Delete by ID Range

If the table has a sequential primary key, deleting by ID range can be more controlled.

Example:

DELETE FROM big_table
WHERE id >= 1000000
  AND id < 1010000
  AND created_at < '2024-01-01';

This method is useful when you want predictable chunks.

A script can move through the ID range step by step:

Delete rows from ID 1,000,000 to 1,010,000
Sleep
Delete rows from ID 1,010,000 to 1,020,000
Sleep
Continue until complete

This is often easier to monitor and control.


Method 3: Partitioning — The Best Long-Term Design

If you already know that old data must be deleted regularly, partitioning is usually the best design.

For example, if the table stores transaction logs, audit logs, SMS logs, or event history, you can partition it by date.

Example:

CREATE TABLE big_table (
    id BIGINT NOT NULL,
    log_data TEXT,
    created_at DATE NOT NULL
)
PARTITION BY RANGE (created_at);

Then create monthly partitions:

CREATE TABLE big_table_2024_01
PARTITION OF big_table
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

CREATE TABLE big_table_2024_02
PARTITION OF big_table
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

When old data needs to be removed, you do not delete millions of rows.

You detach or drop the old partition:

ALTER TABLE big_table
DETACH PARTITION big_table_2024_01;

or:

DROP TABLE big_table_2024_01;

This is much faster than row-by-row deletion. PostgreSQL documentation also says that dropping or detaching a partition is far faster than a bulk operation and avoids the VACUUM overhead caused by bulk DELETE.

For production systems with regular retention policy, this is the cleanest approach.


Method 4: Create New Table and Swap

Sometimes, deleting rows is not the best approach.

Suppose you need to delete 80% of a table and keep only 20%. In that case, it may be faster and safer to create a new table with the rows you want to keep.

Example:

CREATE TABLE big_table_new AS
SELECT *
FROM big_table
WHERE created_at >= '2024-01-01';

Then create indexes, constraints, and permissions on the new table.

After validation, during a controlled maintenance window:

ALTER TABLE big_table RENAME TO big_table_old;
ALTER TABLE big_table_new RENAME TO big_table;

Then later:

DROP TABLE big_table_old;

This method requires careful planning.

You must consider:

  • Foreign keys
  • Indexes
  • Constraints
  • Grants
  • Triggers
  • Sequences
  • Application downtime
  • Replication impact
  • Backup and rollback plan

This is not always suitable for OLTP systems, but for some large cleanup operations, it can be much better than deleting hundreds of millions of rows.


Method 5: Retention Automation

For production systems, data cleanup should not be a one-time panic task.

It should be part of database design.

A mature PostgreSQL retention strategy may include:

  • Date-based partitioning
  • Scheduled partition detach/drop
  • pg_cron or external scheduler
  • Monitoring for table bloat
  • Monitoring for replication lag
  • WAL archive monitoring
  • Autovacuum tuning
  • Regular VACUUM ANALYZE
  • Periodic index maintenance where required

For time-series workloads, some teams also use extensions such as TimescaleDB retention policies. For partition management, many teams use tools like pg_partman.

The main idea is simple:

Do not wait until the table becomes too large. Design the retention strategy before the data becomes a problem.


What I Would Answer in an Interview

If a DBA asked me this question now, I would answer like this:

For deleting millions of records from a PostgreSQL table, I would not run one single large DELETE in production.

First, I would check whether the table is partitioned by date. If it is partitioned, the safest and fastest method would usually be to detach or drop the old partition instead of deleting rows one by one.

If the table is not partitioned, I would delete in small batches using a primary key or indexed column. In PostgreSQL, I would use a CTE-based batch delete pattern or delete by ID range. I would commit each batch separately and add a short sleep between batches to reduce pressure on WAL, locks, replication, and autovacuum.

I would also monitor replication lag, WAL generation, disk space, active locks, autovacuum activity, table bloat, and application performance during the operation.

After the delete, I would run VACUUM ANALYZE and evaluate whether index maintenance or table reorganization is required.

For a long-term solution, I would recommend partitioning and automated retention management so future cleanup can be done by dropping or detaching old partitions safely.

This answer shows that you understand PostgreSQL beyond basic SQL syntax.

A junior person may say:

DELETE FROM table WHERE condition;

But a production-minded DBA thinks about:

  • WAL
  • MVCC
  • Dead tuples
  • Vacuum
  • Replication lag
  • Locks
  • Index bloat
  • Batch size
  • Rollback strategy
  • Monitoring
  • Business impact

That is the real difference between writing SQL and operating PostgreSQL in production.


Final Thought

In PostgreSQL, deleting millions of rows is not only a query execution task.

It is a production operation.

The safest solution depends on table size, index design, partitioning, replication setup, business traffic, maintenance window, and rollback plan.

So before running a large delete, ask one simple question:

"Am I just deleting data, or am I protecting production?"

That mindset is what separates a normal SQL user from a real PostgreSQL DBA.


References

Sheikh Wasiu Al Hasib

Senior DevOps Engineer & DBA

Comments

Comments are reviewed before they're published.

No comments yet. Be the first to share your thoughts.