PostgreSQL Table Bloat and Defragmentation: Autovacuum vs VACUUM vs VACUUM FULL

 When rows are updated or deleted in PostgreSQL, the database does not immediately remove the old row versions from the table. Instead, PostgreSQL uses MVCC (Multi-Version Concurrency Control) and creates new row versions when necessary.

1. Initial Table

postgres=# CREATE TABLE tbl_fragmentation (
postgres(# id BIGSERIAL PRIMARY KEY,
postgres(# name TEXT,
postgres(# description TEXT,
postgres(# amount NUMERIC(12,2),
postgres(# created_at TIMESTAMP DEFAULT now()
postgres(# );
CREATE TABLE


postgres=# INSERT INTO tbl_fragmentation (name, description, amount)
postgres-# SELECT
postgres-# 'User_' || gs,
postgres-# repeat('X', 500),
postgres-# (random() * 10000)::numeric(12,2)
postgres-# FROM generate_series(1, 2000000) AS gs;
INSERT 0 2000000
Time: 48542.364 ms (00:48.542)


postgres=# SELECT count(*) FROM tbl_fragmentation;
count
---------
2000000
(1 row)
postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 1116 MB |
(1 row)

2. Table Defragmentation Using Autovacuum

postgres=# SHOW autovacuum;
autovacuum
------------

on
postgres=# DELETE FROM tbl_fragmentation WHERE id <= 1800000;
DELETE 1800000
Time: 27884.584 ms (00:27.885)
postgres=# SELECT count(*)FROM tbl_fragmentation;
count
--------
200000
(1 row)
postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 1116 MB |
(1 row)
postgres=# SELECT    pid,    backend_type,    state,    wait_event_type,    wait_event,    now() - query_start AS running_for,    query FROM pg_stat_activity WHERE backend_type LIKE 'autovacuum%' and state='active';
pid | backend_type | state | wait_event_type | wait_event | running_for | query
-------+-------------------+--------+-----------------+-------------+-----------------+---------------------------------------------
14080 | autovacuum worker | active | Timeout | VacuumDelay | 00:07:26.277602 | autovacuum: VACUUM public.tbl_fragmentation
SELECT
n_live_tup,
n_dead_tup,
last_autovacuum,
autovacuum_count
FROM pg_stat_user_tables
WHERE relname = 'tbl_fragmentation';
n_live_tup | n_dead_tup | last_autovacuum | autovacuum_count
------------+------------+----------------------------------+------------------
19889 | 0 | 2026-09-15 22:17:42.548493+03:30 | 2
postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 1116 MB |
(1 row)
 INSERT INTO tbl_fragmentation (name, description, amount)
SELECT
'User_' || gs,
repeat('X', 500),
(random() * 10000)::numeric(12,2)
FROM generate_series(2000001, 3800000) AS gs;
INSERT 0 1800000


postgres=# select count(*) from tbl_fragmentation;
count
---------
2000000


postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 1116 MB |
 SELECT
n_live_tup,
n_dead_tup,
last_autovacuum,
autovacuum_count
FROM pg_stat_user_tables
WHERE relname = 'tbl_fragmentation';
n_live_tup | n_dead_tup | last_autovacuum | autovacuum_count
------------+------------+----------------------------------+------------------
1999955 | 0 | 2026-09-15 22:27:41.696749+03:30 | 3
(1 row)
Become a Medium member
postgres=# select count(*) from tbl_fragmentation;
count
---------
2000000
(1 row)



postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 1116 MB |
(1 row)



postgres=# UPDATE tbl_fragmentation
postgres-# SET description = repeat('Y', 500)
postgres-# WHERE id <= 3600000;
UPDATE 1800000
Time: 76530.884 ms (01:16.531)


postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 2121 MB |
(1 row)



postgres=# select count(*) from tbl_fragmentation;
count
---------
2000000
(1 row)



postgres=# SELECT pid, backend_type, state, wait_event_type, wait_event, now() - query_start AS running_for, query FROM pg_stat_activity WHERE backend_type LIKE 'autovacuum%' and state='active';
pid | backend_type | state | wait_event_type | wait_event | running_for | query
-------+-------------------+--------+-----------------+-------------+-----------------+-----------------------------------------------------
12604 | autovacuum worker | active | Timeout | VacuumDelay | 00:08:47.232972 | autovacuum: VACUUM ANALYZE public.tbl_fragmentation
(1 row)



postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 2121 MB |
(1 row)



SELECT
n_live_tup,
n_dead_tup,
last_autovacuum,
autovacuum_count
FROM pg_stat_user_tables
WHERE relname = 'tbl_fragmentation';
n_live_tup | n_dead_tup | last_autovacuum | autovacuum_count
------------+------------+----------------------------------+------------------
2005317 | 53580 | 2026-09-15 22:45:08.785006+03:30 | 4
(1 row)

3. Manual VACUUM

postgres=# DROP TABLE IF EXISTS tbl_fragmentation;
DROP TABLE

postgres=# CREATE TABLE tbl_fragmentation (
postgres(# id BIGSERIAL PRIMARY KEY,
postgres(# name TEXT,
postgres(# description TEXT,
postgres(# amount NUMERIC(12,2),
postgres(# created_at TIMESTAMP DEFAULT now()
postgres(# );
CREATE TABLE

postgres=# ALTER TABLE tbl_fragmentation SET (autovacuum_enabled = false);
ALTER TABLE


postgres=# INSERT INTO tbl_fragmentation (name, description, amount)
postgres-# SELECT
postgres-# 'User_' || gs,
postgres-# repeat('X', 500),
postgres-# (random() * 10000)::numeric(12,2)
postgres-# FROM generate_series(1, 2000000) AS gs;
INSERT 0 2000000
Time: 49524.589 ms (00:49.525)


postgres=# SELECT count(*) FROM tbl_fragmentation;
count
---------
2000000
(1 row)
postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 1116 MB |
(1 row)
postgres=# UPDATE tbl_fragmentation
postgres-# SET description = repeat('Y', 500)
postgres-# WHERE id <= 1800000;
UPDATE 1800000
Time: 69229.523 ms (01:09.230)


postgres=# SELECT count(*) FROM tbl_fragmentation;
count
---------
2000000
(1 row)
postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 2121 MB |
(1 row)
postgres=# SELECT
postgres-# n_live_tup,
postgres-# n_dead_tup,
postgres-# last_vacuum,
postgres-# vacuum_count
postgres-# FROM pg_stat_user_tables
postgres-# WHERE relname = 'tbl_fragmentation';
n_live_tup | n_dead_tup | last_vacuum | vacuum_count
------------+------------+-------------+--------------
2006910 | 1793096 | | 0
(1 row)
postgres=# VACUUM tbl_fragmentation;
VACUUM
Time: 24971.367 ms (00:24.971)
postgres=# SELECT
postgres-# n_live_tup,
postgres-# n_dead_tup,
postgres-# last_vacuum,
postgres-# vacuum_count
postgres-# FROM pg_stat_user_tables
postgres-# WHERE relname = 'tbl_fragmentation';
n_live_tup | n_dead_tup | last_vacuum | vacuum_count
------------+------------+----------------------------------+--------------
1904225 | 0 | 2026-09-15 23:05:24.632805+03:30 | 1
(1 row)
postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 2121 MB |
(1 row)
postgres=# DELETE FROM tbl_fragmentation WHERE id <= 1800000;
DELETE 1800000
Time: 29234.591 ms (00:29.235)
postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 2121 MB |
(1 row)


postgres=# SELECT
postgres-# n_live_tup,
postgres-# n_dead_tup,
postgres-# last_vacuum,
postgres-# vacuum_count
postgres-# FROM pg_stat_user_tables
postgres-# WHERE relname = 'tbl_fragmentation';
n_live_tup | n_dead_tup | last_vacuum | vacuum_count
------------+------------+----------------------------------+--------------
105605 | 1800000 | 2026-09-15 23:22:02.167342+03:30 | 1
(1 row)


postgres=# VACUUM tbl_fragmentation;
VACUUM
Time: 25668.475 ms (00:25.668)

postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 1116 MB |
(1 row)

postgres=# SELECT count(*) FROM tbl_fragmentation;
count
--------
200000
(1 row)

4. VACUUM FULL

postgres=# DROP TABLE IF EXISTS tbl_fragmentation;
DROP TABLE
Time: 269.546 ms
postgres=# CREATE TABLE tbl_fragmentation (
postgres(# id BIGSERIAL PRIMARY KEY,
postgres(# name TEXT,
postgres(# description TEXT,
postgres(# amount NUMERIC(12,2),
postgres(# created_at TIMESTAMP DEFAULT now()
postgres(# );
CREATE TABLE
Time: 7.112 ms

postgres=# INSERT INTO tbl_fragmentation (name, description, amount)
postgres-# SELECT
postgres-# 'User_' || gs,
postgres-# repeat('X', 500),
postgres-# (random() * 10000)::numeric(12,2)
postgres-# FROM generate_series(1, 2000000) AS gs;
INSERT 0 2000000
Time: 48542.364 ms (00:48.542)
postgres=# SELECT count(*) FROM tbl_fragmentation;
count
---------
2000000
(1 row)


Time: 28598.005 ms (00:28.598)
postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 1116 MB |
(1 row)


postgres=# UPDATE tbl_fragmentation
postgres-# SET description = repeat('Y', 500)
postgres-# WHERE id <= 1800000;
UPDATE 1800000
Time: 67174.851 ms (01:07.175)
postgres=# SELECT count(*) FROM tbl_fragmentation;
count
---------
2000000
(1 row)


Time: 54238.243 ms (00:54.238)
postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 2121 MB |
(1 row)


postgres=# SELECT
postgres-# n_live_tup,
postgres-# n_dead_tup,
postgres-# last_vacuum,
postgres-# vacuum_count
postgres-# FROM pg_stat_user_tables
postgres-# WHERE relname = 'tbl_fragmentation';
n_live_tup | n_dead_tup | last_vacuum | vacuum_count
------------+------------+-------------+--------------
1990950 | 1809056 | | 0
(1 row)


Time: 1.124 ms
postgres=# VACUUM FULL tbl_fragmentation;
VACUUM
Time: 34663.258 ms (00:34.663)
postgres=# SELECT
postgres-# n_live_tup,
postgres-# n_dead_tup,
postgres-# last_vacuum,
postgres-# vacuum_count
postgres-# FROM pg_stat_user_tables
postgres-# WHERE relname = 'tbl_fragmentation';
n_live_tup | n_dead_tup | last_vacuum | vacuum_count
------------+------------+-------------+--------------
1990950 | 1809056 | | 0
(1 row)



postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 1116 MB |
(1 row)
postgres=# DELETE FROM tbl_fragmentation WHERE id <= 1800000;
DELETE 1800000
Time: 29704.323 ms (00:29.704)
postgres=# SELECT count(*)FROM tbl_fragmentation;
count
--------
200000
(1 row)


Time: 25574.595 ms (00:25.575)
postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+---------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 1116 MB |
(1 row)


postgres=# VACUUM FULL tbl_fragmentation;
VACUUM
Time: 4336.966 ms (00:04.337)
postgres=# \dt+
List of tables
Schema | Name | Type | Owner | Persistence | Access method | Size | Description
--------+-------------------+-------+----------+-------------+---------------+--------+-------------
public | tbl_fragmentation | table | postgres | permanent | heap | 112 MB |
(1 row)

Space Reclamation and Execution Time Comparison

Press enter or click to view image in full size
Vahid Yousefzadeh

Written by Vahid Yousefzadeh


I have been a DBA since 2011 and I work with Oracle technology. Linkdin: linkedin.com/in/vahidusefzadeh telegram channel ID:@oracledb vahidusefzadeh@gmail.com

Comments

Popular posts from this blog

Oracle 21c Enhancements for TTS Export/Import

Oracle 23ai — error_message_details Parameter for Displaying Error Details

Buffer Busy Wait and Read by Other Session in Oracle