Compressing Cold Partitions for Cheaper Storage
Old partitions are read rarely and stored expensively, which makes them the obvious place to trade CPU for bytes. This guide measures the achievable ratio, applies compression and tiering to partitions that no longer take writes, and avoids the two ways this work goes wrong: rewriting a hot partition, and assuming a ratio nobody verified. It implements the warm and cold stages of Partition Lifecycle & Retention Management inside Partitioning Implementation Patterns & Routing.
Prerequisites
Step 1 β Measure the real ratio on one partition
Never plan a compression project on an assumed ratio. Copy one partition into a scratch table with the new setting and compare:
-- baseline
SELECT pg_size_pretty(pg_total_relation_size('events_2025_06')) AS current_size;
-- a scratch copy with LZ4 on the wide columns
CREATE TABLE events_2025_06_lz4 (LIKE events_2025_06 INCLUDING ALL);
ALTER TABLE events_2025_06_lz4 ALTER COLUMN payload SET COMPRESSION lz4;
INSERT INTO events_2025_06_lz4 SELECT * FROM events_2025_06;
SELECT pg_size_pretty(pg_total_relation_size('events_2025_06_lz4')) AS lz4_size;
current_size
--------------
391 GB
lz4_size
--------------
148 GB
Operational note: pg_total_relation_size includes indexes and TOAST. Compare it rather than pg_relation_size, or the measured ratio will flatter the result by ignoring the part that does not compress.
DBA tip: Drop the scratch copy immediately. A duplicated 148 GB table that nobody remembers creating is its own capacity incident.
Step 2 β Set the compression method for future writes
For partitions still receiving writes, the setting applies going forward with no rewrite:
-- new values in this column use LZ4 from now on
ALTER TABLE events ALTER COLUMN payload SET COMPRESSION lz4;
-- confirm what each column will use
SELECT attname, attcompression
FROM pg_attribute
WHERE attrelid = 'events'::regclass AND attnum > 0 AND NOT attisdropped;
Setting it on the parent means every partition created afterwards inherits it, which is the cheapest possible rollout: no rewrite, no lock, and the benefit accrues as new partitions fill.
Operational note: attcompression shows l for LZ4, p for pglz and blank for the default. A blank means the column follows default_toast_compression, which is a server-wide setting worth changing to lz4 on any recent version.
SRE tip: LZ4 is faster to compress and decompress than the legacy pglz, so switching it on the hot path is usually a latency improvement as well as a space one. It is one of the few settings that is not a trade.
Step 3 β Rewrite cold partitions to apply it retroactively
Existing rows keep their old compression until the data is rewritten. On a cold partition, the simplest rewrite is VACUUM FULL:
-- takes ACCESS EXCLUSIVE on this partition only; other partitions serve normally
ALTER TABLE events_2025_06 ALTER COLUMN payload SET COMPRESSION lz4;
VACUUM FULL VERBOSE events_2025_06;
For a partition large enough that the lock duration matters, detach it first, rewrite it while it is off the parent, then reattach:
ALTER TABLE events DETACH PARTITION events_2025_06 CONCURRENTLY;
ALTER TABLE events_2025_06 ALTER COLUMN payload SET COMPRESSION lz4;
VACUUM FULL events_2025_06;
ALTER TABLE events ATTACH PARTITION events_2025_06
FOR VALUES FROM ('2025-06-01') TO ('2025-07-01');
Operational note: The reattach re-validates that every row falls inside the bounds unless a matching CHECK constraint already exists on the child. Add the constraint before detaching and the attach is instant instead of a full scan.
DBA tip: VACUUM FULL needs free space equal to the new table plus its indexes while it runs. On a nearly full volume the rewrite fails partway and you are left with the original table plus a wasted attempt β check free space first.
Step 4 β Tier the rewritten partition onto cheaper storage
Compression and tiering compose: shrink the partition, then move it somewhere cheaper per byte.
CREATE TABLESPACE cold_storage LOCATION '/mnt/cold/pgdata';
-- move the rewritten partition; ACCESS EXCLUSIVE for the copy duration
ALTER TABLE events_2025_06 SET TABLESPACE cold_storage;
-- and its indexes, which do not follow automatically
ALTER INDEX events_2025_06_occurred_at_idx SET TABLESPACE cold_storage;
Operational note: Indexes have their own tablespace and do not follow the table. A tiered table with hot-storage indexes still costs the index bytes at the fast rate β and on a cold partition the indexes are often the majority of what remains after compression.
SRE tip: Set temp_tablespaces away from the cold volume. A rewrite that spills sort work onto slow storage takes far longer than it needs to.
Verification
Confirm the space actually came back and the data is intact:
SELECT pg_size_pretty(pg_total_relation_size('events_2025_06')) AS size_after,
(SELECT count(*) FROM events_2025_06) AS rows_after,
spcname AS tablespace
FROM pg_class c
LEFT JOIN pg_tablespace t ON t.oid = c.reltablespace
WHERE c.relname = 'events_2025_06';
size_after | rows_after | tablespace
------------+-------------+--------------
148 GB | 418223104 | cold_storage
Row count must be identical to the pre-rewrite value. Then run a representative query against the compressed partition and compare its latency with the same query against an untouched one β the decompression cost is real and should be measured rather than assumed.
Failure mode table
| Failure mode | Root cause | SRE mitigation |
|---|---|---|
VACUUM FULL fails partway with a disk error |
the rewrite needs free space equal to the new table plus indexes, and the volume was too full | check free space before starting; rewrite after detaching so the work can happen on a different volume |
| Compression ratio far below expectation | the bulk of the bytes are indexes or already-dense numeric columns, not compressible text | measure per component with pg_total_relation_size minus pg_indexes_size; consider dropping cold-partition indexes instead |
| Reattach takes hours | no matching CHECK constraint existed, so PostgreSQL scanned the whole partition to validate the bounds |
add the constraint before detaching; the attach then validates from the catalog and returns immediately |
FAQ
Does changing a column's compression method rewrite existing rows?
No. ALTER TABLE ... ALTER COLUMN ... SET COMPRESSION affects values written after the change; existing values keep whatever method compressed them. To apply it to historical data you must rewrite the partition, either with VACUUM FULL, with a table rewrite, or by copying into a fresh table and swapping it in.
Is VACUUM FULL safe on a cold partition?
On a partition that receives no writes it is safe in the sense that nothing is lost, but it takes an ACCESS EXCLUSIVE lock on that partition for the whole rewrite and needs free space equal to the tableβs size. Queries touching only other partitions are unaffected; queries that span the partition being rewritten will block, so run it in a low-traffic window.
How much compression should I actually expect?
It depends entirely on the data. Repetitive JSON and text commonly reach 3:1 or better with LZ4 and 4:1 with zstd; numeric and timestamp columns barely move because they are already dense. Measure on one partition before planning a fleet-wide job β the difference between an assumed 4:1 and an actual 1.3:1 is the difference between a project worth doing and one that is not.
Related
- Partition Lifecycle & Retention Management β where compression sits in the hot, warm, cold progression
- Detaching and Archiving Old Partitions in PostgreSQL β the next stage for partitions past the retention window
- Calculating Storage Costs for Multi-Region Database Scaling β modelling the saving before scheduling the work