Home/Journal/PostgreSQL
PostgreSQL·14 min read·August 25, 2026

How to Reduce PostgreSQL Storage Costs (Make Postgres Cheaper in 2026)

A complete engineering guide to fix table bloat, setup Postgres cold storage, and make your PostgreSQL database cheaper without losing data.

Punit Nigam
Punit Nigam
Lead Systems Architect & Founder · KOLMOS Systems
How to Reduce PostgreSQL Storage Costs (Make Postgres Cheaper in 2026)
How to Reduce PostgreSQL Storage Costs (Make Postgres Cheaper in 2026)
TL;DR: Quick Checklist to Make PostgreSQL Cheaper in 2026:
• Eliminate Table & Index Bloat: Use pg_repack online to reclaim 30%–60% of disk space without downtime.
• Prune Unused Indexes: Identify and drop 0-scan B-Trees with DROP INDEX CONCURRENTLY.
• Set Up Postgres Cold Storage: Move older partitioned data from expensive EBS ($0.115/GB-mo) to Cloudflare R2 ($0.015/GB-mo).
• Deploy Self-Compressing Engines: Use drop-in engines like KOLMOS to achieve 85%–95% direct cloud storage savings with zero code changes.
PostgreSQL is universally recognized as one of the most reliable and feature-rich open-source relational databases. However, as your application scales from millions to billions of rows, your cloud infrastructure costs scale alongside it.
Left unchecked, a PostgreSQL cluster frequently consumes 2× to 5× more physical disk space than the raw logical data it contains, resulting in inflated AWS EBS (gp3/io2), GCP Persistent Disk, or Azure Managed Disk bills.
Reducing PostgreSQL storage costs is not simply a matter of deleting old rows. In this comprehensive engineering guide, we examine why PostgreSQL storage expands unnecessarily, how to pinpoint wasted space using production SQL queries, and actionable strategies to minimize your database storage footprint without compromising transactional SLAs.

3 Quick Ways to Make PostgreSQL Cheaper

If your cloud database bill is growing faster than your application traffic, here are the three highest-impact strategies to immediately cut costs:
  • 1.Move from Expensive gp3 EBS Volumes to Cloudflare R2 ($0.015/GB-mo): AWS RDS gp3 block storage costs ~$0.115 per GB-month (which triples with multi-AZ replicas and automated snapshots to over $0.35/GB-mo). Offloading historical tables to Cloudflare R2 or S3 at $0.015/GB-month with zero egress fees slashes base storage expenses by over 85%.
  • 2.Reclaim Dead Tuples & Page Fragmentation with pg_repack: High-volume UPDATE and DELETE operations generate invisible dead space in PostgreSQL. Running zero-downtime compaction with pg_repack releases physical storage back to your cloud provider without taking table locks or interrupting active traffic.
  • 3.Audit and Drop Redundant B-Tree Indexes: Indexes often account for 50%+ of your total database footprint. Removing unused indexes immediately frees gigabytes of SSD disk, reduces memory pressure on shared_buffers, and lowers backup costs.
  • Why Does PostgreSQL Storage Grow Unnecessarily?

    To effectively reduce your cloud storage bill, you must understand the underlying physical storage mechanics of PostgreSQL. Unchecked disk growth almost always stems from three core architectural factors:
    text
    ┌─────────────────────────────────────────────────────────────────────────────┐
    │                    ANATOMY OF POSTGRESQL STORAGE EXPANSION                  │
    ├──────────────────────────────┬──────────────────────┬───────────────────────┤
    │ 1. MVCC & Dead Tuples        │ 2. Index Overhead    │ 3. TOAST Sub-Tables   │
    │ - UPDATE creates new row     │ - B-Tree on disk     │ - 8KB Page Overflow   │
    │ - DELETE marks row dead      │ - Indexes don't auto-│ - JSONB & Text bloat  │
    │ - Space held until VACUUM    │   shrink on deletes  │ - Basic pglz/lz4 only │
    └──────────────────────────────┴──────────────────────┴───────────────────────┘
    

    1. Multi-Version Concurrency Control (MVCC) and Dead Tuples

    PostgreSQL manages concurrent transactions without read/write locking via MVCC. When you execute an UPDATE, PostgreSQL does not overwrite the existing bytes in place. Instead, it marks the original row version as a dead tuple and inserts an entirely new row version (live tuple).
    Similarly, a DELETE statement simply tags the row as dead without immediately freeing physical disk pages. Until these dead tuples are cleaned up and reclaimed, they continue to occupy space on disk. On write-intensive tables, dead tuples can easily account for 40%–70% of total table size.

    2. Index Overhead & Index Bloat

    Indexes accelerate read queries, but every index is a distinct, physical data structure (typically a B-Tree) stored on disk. If a table has 6 to 10 indexes, the combined index footprint frequently exceeds the physical table size itself. Furthermore, indexes also suffer from page fragmentation and bloat when keys are updated or deleted, leaving half-empty index leaf pages.

    3. TOAST (The Oversized-Attribute Storage Technique)

    PostgreSQL organizes data in fixed 8KB pages. When a single row contains large attributes (such as wide TEXT, large VARCHAR, arrays, or rich JSONB documents) that exceed the page threshold (typically ~2KB), PostgreSQL compresses the value and offloads it into a separate, hidden auxiliary table called a TOAST table. While TOAST provides basic compression, large unstructured payloads still consume substantial gigabytes over time.

    Step 1: Identify Large Tables and Storage Bloat

    Before applying any destructive or heavy maintenance procedures, audit your PostgreSQL instance to determine precisely where disk bytes are concentrated.

    Finding the Largest Tables and Indexes

    Run the following SQL query to inspect the top 10 space-consuming tables, separating table data from index overhead:
    sql
    SELECT
        schemaname || '.' || relname AS table_name,
        pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
        pg_size_pretty(pg_relation_size(relid)) AS table_size,
        pg_size_pretty(pg_indexes_size(relid)) AS index_size,
        ROUND(100 * pg_indexes_size(relid)::numeric / NULLIF(pg_total_relation_size(relid), 0), 2) AS index_ratio_pct
    FROM pg_catalog.pg_statio_user_tables
    ORDER BY pg_total_relation_size(relid) DESC
    LIMIT 10;
    
    Actionable Diagnostic: If index_size represents more than 50% of total_size, you are likely maintaining duplicate, overlapping, or unindexed write paths.

    Estimating Exact Table Bloat with pgstattuple

    To measure the exact proportion of dead tuples and free space inside a table's physical pages, enable the standard pgstattuple extension:
    sql
    -- Enable the extension (requires superuser or rds_superuser)
    CREATE EXTENSION IF NOT EXISTS pgstattuple;
    
    -- Inspect a specific suspect table
    SELECT 
        table_len,
        pg_size_pretty(table_len) AS physical_size,
        tuple_percent,
        dead_tuple_percent,
        pg_size_pretty(dead_tuple_len) AS dead_space,
        free_percent
    FROM pgstattuple('orders');
    
    If your dead_tuple_percent exceeds 20%, your autovacuum configuration is lagging behind your mutation rate, directly wasting cloud storage.

    Step 2: Remove Unused and Redundant Indexes

    Dropping unused indexes is the fastest, zero-risk method to reclaim disk space and immediately accelerate INSERT and UPDATE throughput.

    Query to Identify Unused Indexes

    The PostgreSQL statistical collector records every index scan in pg_stat_user_indexes. You can find indexes that have never been scanned by queries:
    sql
    SELECT
        schemaname || '.' || relname AS table_name,
        indexrelname AS index_name,
        idx_scan AS number_of_scans,
        pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
    FROM pg_stat_user_indexes
    WHERE idx_scan = 0
      AND indisunique IS FALSE
    ORDER BY pg_relation_size(indexrelid) DESC;
    
    Cautionary Rule: Ensure your PostgreSQL database statistics have accumulated over several weeks or months (including monthly reporting and billing cycles) before dropping an index with zero scans.
    To drop an unused index without locking concurrent queries on production:
    sql
    DROP INDEX CONCURRENTLY IF EXISTS idx_orders_customer_id_deprecated;
    

    Step 3: Master Autovacuum and Online Table Compaction

    Standard VACUUM marks dead space as reusable for subsequent INSERTs within the existing 8KB pages, but it does not return allocated disk blocks back to the host operating system.
    text
    ┌───────────────────────────────────────┬────────────────────────────┬─────────────────────────────┐
    │ Compaction Method                     │ Locks Taken                │ Production Downtime Impact  │
    ├───────────────────────────────────────┼────────────────────────────┼─────────────────────────────┤
    │ Standard VACUUM                       │ Concurrent ShareUpdateLock │ None (Online, non-blocking) │
    │ VACUUM FULL                           │ ACCESS EXCLUSIVE Lock      │ High (Complete Table Lock)  │
    │ pg_repack / pg_squeeze                │ Brief metadata locks       │ Zero (Online Table Rewrite) │
    └───────────────────────────────────────┴────────────────────────────┴─────────────────────────────┘
    

    1. Tuning Autovacuum Parameters in postgresql.conf

    Default PostgreSQL autovacuum settings are intentionally conservative. On large, high-throughput tables, autovacuum triggers too late. Tune the following parameters to ensure prompt dead tuple removal:
    ini
    # Trigger vacuum when 5% of rows change (default is 20%)
    autovacuum_vacuum_scale_factor = 0.05
    
    # Allow vacuum to perform more I/O operations before throttling
    autovacuum_vacuum_cost_limit = 2000
    
    # Decrease sleep delay when cost limit is hit (PostgreSQL 12+)
    autovacuum_vacuum_cost_delay = 2ms
    
    # Allocate dedicated memory for vacuum worker tuple tracking
    autovacuum_work_mem = 512MB
    
    You can also apply aggressive scale factors directly to high-write tables:
    sql
    ALTER TABLE user_sessions SET (
        autovacuum_vacuum_scale_factor = 0.02,
        autovacuum_vacuum_cost_limit = 3000
    );
    

    2. VACUUM FULL vs. pg_repack

  • VACUUM FULL: Rewrites the entire table into a new physical file and reclaims disk space back to the OS. However, it takes an ACCESS EXCLUSIVE lock, blocking all concurrent reads and writes. On a 200GB table, this causes hours of downtime.
  • pg_repack: A trusted extension that creates a temporary copy of the table, syncs mutations via triggers, and performs a momentary lock swap, reclaiming physical disk space with zero downtime.
  • bash
    # Reorganize and compact an active table online using pg_repack
    pg_repack -h your-db-host -U postgres -d production_db -t orders
    

    Step 4: Utilize Declarative Table Partitioning

    For append-heavy datasets (audit logs, IoT telemetry, clickstreams, and ledger events), keeping all rows in a single monolithic table creates unmanageable bloat.
    Declarative Range Partitioning enables you to slice tables into smaller physical tables (partitions) by date range:
    sql
    -- Create parent partitioned table
    CREATE TABLE events (
        event_id UUID NOT NULL,
        user_id BIGINT NOT NULL,
        payload JSONB,
        created_at TIMESTAMPTZ NOT NULL
    ) PARTITION BY RANGE (created_at);
    
    -- Create monthly partition
    CREATE TABLE events_2026_08 PARTITION OF events
        FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
    

    Why Partitioning Slashes Storage Bills:

  • 1.Instant, Zero-Cost Drops: Instead of running DELETE FROM events WHERE created_at < NOW() - INTERVAL '1 year' (which creates millions of dead tuples), you execute DROP TABLE events_2025_08;. This instantly frees disk space back to the file system.
  • 2.Cold Storage Tiering: Older partitions can be detached and migrated to cheaper object storage or compressed read-only tablespaces.

  • Step 5: Postgres Cold Storage & Object Storage Offloading

    Keeping multi-year historical logs, audit trails, and transactional records in expensive hot SSD block storage ($0.115–$0.25/GB-month) is economically unsustainable. Setting up a dedicated Postgres cold storage architecture allows you to retain petabytes of searchable history at cloud object storage pricing ($0.015/GB-month).

    How to Set Up Postgres Cold Storage:

  • 1.Partition Offloading to Cloudflare R2 / AWS S3: Detach historical monthly or quarterly partitions from your active PostgreSQL instance, convert them into compressed columnar formats (such as Parquet or KOLMOS chunks), and upload them directly to Cloudflare R2 or Amazon S3.
  • 2.Querying via Foreign Data Wrappers (postgres_fdw / parquet_fdw): Configure external foreign tables inside PostgreSQL. When an audit query or analytics report needs historical data, Postgres queries the cold storage tier on demand without consuming local SSD disk space.
  • 3.Zero-Code Drop-In Cold Storage with KOLMOS: For a fully automated Postgres cold storage solution, connect your application or read-replicas directly to KOLMOS. KOLMOS speaks the native PostgreSQL wire protocol (pgwire) while storing content-addressed data on Cloudflare R2, giving you instant cold storage pricing ($0.015/GB-mo with zero egress) with zero application code changes (learn more in our Systems Architecture).

  • Step 6: PostgreSQL Compression Options

    PostgreSQL offers several options to compress data at different architectural layers:
  • 1.TOAST LZ4 Compression (PostgreSQL 14+): Ensure your wide columns use LZ4 instead of legacy pglz for faster compression and smaller disk footprints:
  • sql
       ALTER TABLE system_logs ALTER COLUMN raw_payload SET COMPRESSION lz4;
       
  • 2.Filesystem Compression (ZFS / Btrfs): Self-hosted PostgreSQL instances running on ZFS with transparent LZ4 or Zstandard (ZSTD) compression can achieve 2× to 3× storage reduction at the OS block layer.
  • 3.Columnar Extensions (e.g. Citus): Enables table-level columnar storage with chunk compression for analytical rollups.

  • Practical Optimization Methodology: Step-by-Step Impact

    When auditing a live database, establish a quantifiable baseline and measure disk reclamation at each phase:
    text
    ┌────────────────────────┬─────────────────────────────┬───────────────────────────────┬────────────────────────┐
    │ Optimization Phase     │ Action Executed             │ Expected Outcome              │ Metric to Track        │
    ├────────────────────────┼─────────────────────────────┼───────────────────────────────┼────────────────────────┤
    │ Baseline Audit         │ Run pg_statio_user_tables   │ Establishes initial baseline  │ Total GB Provisioned   │
    │ Index Pruning          │ Drop 0-scan B-Trees         │ Immediate disk reclamation    │ pg_indexes_size()      │
    │ Dead Tuple Compaction  │ Run pg_repack online        │ OS-level disk space reclaimed │ pgstattuple dead_space │
    │ Range Partitioning     │ Drop expired partitions     │ Instant block release to OS   │ Partition drop latency │
    │ Cold Storage Engine    │ Migrate cold/hot to KOLMOS  │ 80%–95% structural reduction  │ Total AWS/R2 bill      │
    └────────────────────────┴─────────────────────────────┴───────────────────────────────┴────────────────────────┘
    

    An Alternative Approach: KOLMOS for Storage-Efficient Retention

    Traditional PostgreSQL optimization requires constant engineering overhead—tuning autovacuum daemons, tracking dead tuples, executing pg_repack, and managing complex S3 archiving pipelines.
    When dataset volumes exceed 10TB–50TB, traditional relational page layouts reach their economic limits.
    KOLMOS is a modern self-compressing database and storage engine designed to fundamentally eliminate storage bloat:
    text
    ┌─────────────────────────────────────────────────────────────────────────────┐
    │                          KOLMOS POSTGRESQL INTEGRATION                      │
    ├─────────────────────────────────────────────────────────────────────────────┤
    │ • Native pgwire Door : Speaks PostgreSQL protocol (Prisma, Django, TypeORM) │
    │ • Formula Discovery  : Minimum Description Length (MDL) mines relations     │
    │ • Lossless Bit-Exact : 100% mathematical fidelity verified on decode        │
    │ • Cloudflare R2 CAS  : Stores content-addressed chunks at $0.015/GB/mo      │
    │ • 10×–40× Cheaper    : Replaces expensive EBS disks with zero-egress R2     │
    └─────────────────────────────────────────────────────────────────────────────┘
    
  • Zero Application Rewrites: Connect your existing PostgreSQL client or ORM via standard postgresql:// connection strings (learn more in our Multi-Wire Protocol Architecture Guide).
  • Explanation-First Compression: Mines mathematical formulas and sequences, achieving 1.8× to 2.3× higher density than Parquet-zstd (see verified Benchmarks).
  • 50-Year Cryptographic Durability: Decoders are sandboxed in WebAssembly (WASM) pinned directly to segment headers.

  • Frequently Asked Questions (FAQs)

    1. What is the best way to make PostgreSQL cheaper?

    The most effective way to make PostgreSQL cheaper is a two-pronged strategy: first, reclaim wasted space on hot SSDs by running pg_repack to remove MVCC dead tuple bloat and dropping unused B-Tree indexes; second, offload historical and cold tables to object storage (like Cloudflare R2 or AWS S3 at $0.015/GB-mo) or use a drop-in self-compressing engine like KOLMOS to cut cloud storage bills by up to 90%.

    2. How do I set up cold storage for PostgreSQL?

    To set up Postgres cold storage, implement declarative range partitioning on timestamp fields, detach older partitions, and archive them into compressed Parquet or KOLMOS formats stored on Cloudflare R2 or AWS S3. You can then query this cold data transparently using Foreign Data Wrappers (FDW) or by routing read queries through a pgwire-compatible engine without provisioning expensive SSD block storage.

    3. Does increasing autovacuum frequency slow down my database?

    No. While autovacuum consumes some CPU and I/O cycles, it is throttled by default via autovacuum_vacuum_cost_limit. Allowing dead tuple bloat to accumulate causes full table scans to read millions of dead pages, degrading database performance far more than frequent, lightweight background vacuums.

    4. Can I compress existing PostgreSQL tables without changing my architecture?

    Natively, PostgreSQL only compresses large out-of-line attributes via TOAST. To compress entire tables and datasets without application changes, you can connect your existing services to a drop-in storage engine like KOLMOS, which speaks native PostgreSQL protocol while compressing data at the engine layer.

    5. Is VACUUM FULL safe to run in production?

    Generally no. VACUUM FULL acquires an ACCESS EXCLUSIVE lock on the entire table, preventing all read and write queries until the operation completes. For zero-downtime production compaction, use community-standard online tools such as pg_repack.

    6. How much can I save by switching from AWS RDS gp3 to object storage?

    AWS RDS gp3 block storage costs roughly $0.115 per GB-month, which triples across multi-AZ replicas and backups. Object storage on Cloudflare R2 costs $0.015 per GB-month with zero egress fees. When combined with KOLMOS's 2× explanation compression, teams regularly see 90%+ reductions in their monthly database storage line item (read our 90% Cloud Database Cost Reduction Guide).

    Connect with the KOLMOS Team

    Have questions about optimizing your PostgreSQL infrastructure or evaluating self-compressing storage engines?
  • LinkedIn Account: https://www.linkedin.com/company/kolmos
  • Official Website: https://kolmos.dev/
  • Systems Architecture: https://kolmos.dev/architecture
  • Try KOLMOS Today

    Deploy Your First Self-Compressing Store.

    Connect via PostgreSQL, MySQL, or MongoDB. 10 GB free developer storage included.