Track Database Size and Bloat

Track database size and bloat in PostgreSQL to reclaim storage and boost performance. This lesson explains why bloat happens, how to measure it with pg_stat_user_tables and pgstattuple, and how to interpret the numbers. You'll run queries to identify bloated tables and indexes, then learn when to VACUUM, REINDEX, or pg

Focus: track database size and bloat

Sponsored

Your PostgreSQL database is quietly getting fatter and slower — not because you're storing more useful data, but because dead rows and orphaned index entries are piling up. This is bloat, and it's one of the most common reasons queries that used to fly now crawl. If you've ever run SELECT count(*) on a table and watched it take seconds when it should take milliseconds, bloat is likely your culprit. In this lesson, you'll learn how to track database size and bloat using built-in PostgreSQL views and extensions, so you can pinpoint exactly which tables and indexes are wasting storage — and decide when to clean them up.

The Problem This Lesson Solves

PostgreSQL uses Multi-Version Concurrency Control (MVCC) to let multiple transactions read and write the same table without blocking each other. When you UPDATE or DELETE a row, PostgreSQL doesn't overwrite the original — it marks it as dead and creates a new version. Over time, dead rows accumulate, and the table file grows far beyond what your live data actually needs. This is table bloat.

Indexes suffer too. Every index entry that points to a modified row becomes stale, and unless the index is rebuilt, those dead entries stay forever. Index bloat can make index scans slower than sequential scans because the index is physically larger and more fragmented.

At this point in your PostgreSQL journey, you've already learned about vacuuming in earlier lessons. But vacuuming alone doesn't reclaim disk space — it only makes dead rows reusable for future writes. The disk space is not returned to the operating system. So even after a VACUUM, your table files remain huge, and bloat keeps eating your storage and hurting performance.

The real question is: How do you know which of your tables and indexes are bloated, and by how much? The answer is tracking database size and bloat — measuring the actual disk usage versus the live data size, and identifying where the waste is. Once you know that, you can apply targeted cleanup: VACUUM FULL, REINDEX, or pg_repack.

Why it matters now: Storage is cheap, but performance is priceless. Bloated tables inflate I/O, cache pressure, and query planning time. Knowing how to track bloat is a critical skill for any PostgreSQL DBA or backend engineer.

Core Concept / Mental Model

Think of your table as a library with thousands of books on shelves. Each row is a book. When you update a book, you don't rewrite it in place — you place a new copy on a new shelf and mark the old one as "damaged." The damaged copy still occupies shelf space until a janitor (the VACUUM process) comes to remove it. But the janitor only marks the shelf as available for new books; they don't tear down the shelf or make the room smaller.

Bloat is the difference between the total shelf space (actual on-disk size) and the space occupied by books that people actually read (live rows).

In technical terms:

  • Live rows: rows that are visible to the current snapshot — your real data.
  • Dead rows: rows that are no longer visible to any transaction — old versions after UPDATE or DELETE.
  • Dead tuples: PostgreSQL's term for those dead rows, tracked in pg_stat_user_tables.

To measure bloat, you need two numbers:

  1. Total relation size — the sum of the table's heap file plus its indexes (and TOAST, if any). You can get this from pg_total_relation_size() or from catalog views.
  2. Live row count and average row width — from statistics or by sampling.

Bloat ratio = (total size - estimated live size) / total size. A healthy table has a bloat ratio close to 0; a bloated table can have 50% or more wasted space.

PostgreSQL provides two main ways to measure this:

  • pg_stat_user_tables (built-in) — gives you n_live_tup, n_dead_tup, n_tup_upd, n_tup_del, and more. It's free, but only counts rows, not bytes.
  • pgstattuple extension (third-party, but bundled with PostgreSQL) — gives table_len, tuple_len, dead_tuple_len, free_space, and more. It's accurate but scans the whole table, so it's heavier.

Key insight: Tracking bloat is a two-step process. First, use cheap row counts to find suspicious tables. Then, use pgstattuple for precise byte-level measurements on those suspects.

How It Works Step by Step

Let's break down the process of tracking database size and bloat into logical steps.

Step 1: Get the overall database size first

Before diving into individual tables, know the total size of your database. This gives you a baseline and helps you decide whether bloat is a systemic problem or isolated.

SELECT pg_database_size('your_database_name');

That returns bytes. Use pg_size_pretty() to make it human-readable:

SELECT pg_size_pretty(pg_database_size('your_database_name'));

Step 2: Identify the largest tables by total size

Bloat is most harmful on large tables, so sort by total size first.

SELECT
    relname AS table_name,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10;

This gives you the top 10 tables by total size (including indexes). Now you know where to focus.

Step 3: Check row statistics for dead rows

pg_stat_user_tables tracks activity since the last stats reset. Look for tables with a high n_dead_tup relative to n_live_tup. A dead ratio above 20% is a red flag.

SELECT
    relname,
    n_live_tup,
    n_dead_tup,
    CASE WHEN n_live_tup > 0 THEN round(n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100, 2) ELSE 0 END AS dead_pct
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

Step 4: Measure exact bloat with pgstattuple

For the suspect tables, use the pgstattuple extension to get byte-level details.

CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('your_table_name');

The key columns are:

  • table_len — total physical length of the table file
  • tuple_len — total length of live tuples
  • dead_tuple_len — total length of dead tuples
  • free_space — free space in the table (reusable, but doesn't shrink the file)

Simple bloat ratio: (table_len - tuple_len) / table_len. If it's high (say > 0.3), you have significant bloat.

Step 5: Check index bloat separately

Indexes can be bloated independent of the table. Use pgstatindex for a specific index.

SELECT * FROM pgstatindex('your_index_name');

Look at avg_leaf_density — a value below 80% indicates fragmentation and wasted space.

Hands-On Walkthrough

Let's do a practical exercise. We'll create a test table, insert rows, update/delete a lot, and then measure bloat.

Setup

-- Create a small test table
CREATE TABLE IF NOT EXISTS bloat_test (
    id integer PRIMARY KEY,
    payload text
);

-- Insert 100,000 rows
INSERT INTO bloat_test
SELECT generate_series(1,100000), repeat('a', 100);

Now simulate a typical OLTP workload: update every row 10 times and delete every other row.

-- Update each row 10 times
DO $$
BEGIN
    FOR i IN 1..10 LOOP
        UPDATE bloat_test SET payload = repeat(b, 100);
    END LOOP;
END$$;

-- Delete half the rows
DELETE FROM bloat_test WHERE id % 2 = 0;

Before running VACUUM, check the stats:

SELECT relname, n_live_tup, n_dead_tup, n_tup_upd, n_tup_del
FROM pg_stat_user_tables
WHERE relname = 'bloat_test';

You'll see a huge number of dead tuples because the stats are recent (unless autovacuum ran). Now check the table size before any vacuum:

SELECT pg_size_pretty(pg_total_relation_size('bloat_test'));

Now run a regular VACUUM and check again:

VACUUM bloat_test;
SELECT pg_size_pretty(pg_total_relation_size('bloat_test'));

You'll get the same size! Regular VACUUM doesn't shrink the file. That's bloat. Now use pgstattuple:

CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('bloat_test');

Look at table_len vs tuple_len. The tuple_len will be small because you only have 50k rows left, but table_len is still large. Calculate the bloat ratio: (0.5MB - 0.05MB)/0.5MB = 90% — almost all the file is dead or free space.

Now run VACUUM FULL:

VACUUM FULL bloat_test;
SELECT pg_size_pretty(pg_total_relation_size('bloat_test'));

Voila! The size drops dramatically because VACUUM FULL rewrites the table, reclaiming disk space. But note: VACUUM FULL takes an exclusive lock, blocking reads and writes.

For indexes, create a btree index and bloat it similarly, then check with pgstatindex.

CREATE INDEX idx_bloat_test_payload ON bloat_test (payload);
-- Run the update loop again on the payload column
-- Check index size
SELECT pg_size_pretty(pg_indexes_size('bloat_test'));
-- Then REINDEX
REINDEX INDEX idx_bloat_test_payload;
SELECT pg_size_pretty(pg_indexes_size('bloat_test'));

Pro tip: Always run ANALYZE after VACUUM FULL or REINDEX to refresh statistics for the query planner.

Compare Options / When to Choose What

When you've identified bloat, you have several cleanup tools. Here's a comparison:

Tool What it does Locks Can run during production Use-case
VACUUM (regular) Marks dead rows as reusable, doesn't shrink file Non-blocking (row-level) Yes, automatically Routine maintenance to keep bloat from growing
VACUUM FULL Rewrites the table, shrinks file size Exclusive (blocks all) No, take downtime High bloat, table can be offline
REINDEX Rebuilds index, reclaims index bloat SHARE (blocks writes) No, brief lock Index scan performance
pg_repack Rebuilds table/index without long locks Short lock at end Yes, online Large tables that can't go down
CLUSTER Reorders rows physically, rebuilds indexes Exclusive No When you want physical order to match an index

Choose pg_repack for busy production systems where downtime is unacceptable. pg_repack uses a log-based approach to rewrite the table in the background with minimal blocking.

When to use pgstattuple vs pg_stat_user_tables?

  • pg_stat_user_tables is cheap and always available — use it for monitoring and quick triage.
  • pgstattuple is accurate but expensive — use it for detailed analysis on suspect tables when you need byte-level precision.

For ongoing monitoring, set up a scheduled query that alerts when n_dead_tup ratio exceeds a threshold.

Troubleshooting & Edge Cases

Here are common issues you may face:

n_dead_tup is always zero

If n_dead_tup shows zero, it could be because autovacuum ran recently. Stats are reset on VACUUM, so dead tuples are cleaned and the counter resets. That doesn't mean bloat is gone — the file size is still large. Always also check total relation size and pgstattuple.

pgstattuple returns an error 'must be superuser'

This extension requires elevated privileges. In managed PostgreSQL (like RDS), you may not have superuser access. In that case, use pgstatstatements or rely on pg_stat_user_tables and system tables to estimate bloat. There are third-party scripts (e.g., pg_bloat_check) that use heuristics without pgstattuple.

Autovacuum doesn't shrink tables

Autovacuum runs regular VACUUM, which doesn't reclaim disk space. That's normal. The table file remains and will reuse free space for future writes. If you need to shrink it, schedule VACUUM FULL or pg_repack during low traffic.

Index bloat is invisible in pg_stat_user_tables

That view only tracks table-level rows. You need pgstatindex to see index bloat. For all indexes, use pg_stat_all_indexes to see scan counts, but not bloat. The only reliable way is pgstatindex or estimate with pg_index and pg_class.

VACUUM FULL is slow on huge tables

It rewrites the entire table, so it can take a long time and fill up disk space during operation. Always ensure you have enough free disk (at least the size of the table) before running it.

TOAST bloat

Large values stored in TOAST can also bloat. Check pg_stat_user_tables for n_tup_dead with reltoastrelid, and use pgstattuple on the toast table as well. Rare, but possible.

What You Learned & What's Next

You now understand the core idea of tracking database size and bloat in PostgreSQL. You can:

  • Explain why bloat happens (MVCC dead rows) and why it hurts performance.
  • Use pg_database_size() and pg_total_relation_size() to get quick size figures.
  • Interpret pg_stat_user_tables to spot tables with many dead tuples.
  • Use pgstattuple and pgstatindex for precise byte-level analysis.
  • Choose the right cleanup tool — regular VACUUM, VACUUM FULL, REINDEX, or pg_repack — based on your uptime requirements.

This knowledge gives you a proactive approach to storage management. Instead of waiting for disk full alerts, you can monitor and clean bloat before it impacts your application.

Next in the PostgreSQL Tutorial track, you'll learn how to optimize query performance with indexing strategies — building on your understanding of bloat, you'll see how index design can reduce both query time and maintenance overhead. Keep your databases lean and fast!

Practice recap

Try this mini exercise: create a table, insert 100k rows, update them 10 times, and delete half. Measure the table size before and after VACUUM, then after VACUUM FULL. Note the difference. Then create an index, bloat it with updates, and check its size before and after REINDEX INDEX CONCURRENTLY.

Common mistakes

  • Assuming VACUUM shrinks the table file — it only marks dead rows as reusable; you need VACUUM FULL or pg_repack to reclaim disk space.
  • Trusting n_dead_tup as bloat indicator — stats reset after vacuum, so a zero dead tuple count doesn't mean there's no bloat; always check relation size.
  • Running VACUUM FULL on large tables without checking free disk space — it requires headroom equal to the table size and locks the table for a long time.
  • Ignoring index bloat — indexes can have huge wasted space even when the table stats look fine; use pgstatindex to inspect.

Variations

  1. Use pg_squeeze instead of pg_repack for online bloat reduction — it's an alternative that also avoids long locks.
  2. For managed PostgreSQL where pgstattuple is not available, use heuristic scripts like pg_bloat_check.sql that estimate bloat from catalog statistics.
  3. Set up a cron job with a simple SQL query to periodically email alerts when bloat ratio exceeds a threshold.

Real-world use cases

  • A production web app's largest table grows to 50 GB even though live data is only 10 GB, slowing all queries — you use pgstattuple to confirm 80% bloat and schedule pg_repack during a maintenance window.
  • A reporting database suffers frequent UPDATE operations on order status; you monitor n_dead_tup daily and run VACUUM FULL weekly on weekends to keep table size stable.
  • An e-commerce platform notices index scans degrading; pgstatindex shows average leaf density at 45% on the order_items index, so you REINDEX CONCURRENTLY to restore performance without downtime.

Key takeaways

  • Bloat is caused by MVCC dead rows that VACUUM reuses but doesn't remove from disk.
  • Use pg_database_size() and pg_total_relation_size() for quick size checks, and pg_stat_user_tables for dead row counts.
  • pgstattuple gives precise table bloat: (table_len - tuple_len) / table_len.
  • pgstatindex reveals index bloat through average leaf density.
  • VACUUM FULL, REINDEX, and pg_repack are your cleanup tools, each with different locking and downtime trade-offs.
  • Monitor bloat regularly to prevent performance degradation and disk-full errors.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.