The first time TOAST got my attention was on a table that stored webhook payloads as JSONB. The row count was modest, the indexes were healthy, and yet the table's total footprint kept ballooning while queries that touched the payload column felt heavier than the visible row width justified. When I pulled apart the storage layout, most of the bytes were not in the main heap at all. They were sitting in the toast table, compressed with a codec nobody on the team had ever consciously picked. That is TOAST in a nutshell: it works silently, the defaults are sensible, and for most of PostgreSQL's history there was exactly one compression algorithm, so there was no decision to make. PostgreSQL 14 changed that by adding lz4 as an option, and ever since there has been a real storage-versus-CPU trade worth making deliberately.
This is the framework I use for that decision: how TOAST decides to compress, how to find out which columns actually get toasted, what pglz and lz4 each cost, and how to switch a column with ALTER TABLE ... SET COMPRESSION without fooling yourself about the data that is already on disk.
How TOAST decides to compress and move values
PostgreSQL stores rows in 8KB pages and does not allow a row to span pages, so any value that threatens to make a row too wide gets special handling. TOAST, The Oversized-Attribute Storage Technique, kicks in when a row exceeds roughly 2KB. The mechanics come in two steps. First, PostgreSQL tries to compress the widest variable-length values inline. If the row still does not fit, the compressed values are moved out of line into a companion toast table, sliced into chunks of about 2KB each, and the main heap keeps only a small pointer per toasted value. Reads then have to fetch and reassemble the chunks, and every fetch decompresses them again.
Whether a column is even eligible for this treatment is controlled by its storage attribute, which you can see as attstorage in pg_attribute. The default for most variable-length types is extended, meaning compress and toast out of line when needed. The alternatives matter: main allows compression but avoids out-of-line storage, plain disables both (this is what fixed-width types use), and external allows out-of-line storage but skips compression entirely. That last one is underrated, and I will come back to it.
The key operational point is that compression is not free and it is not optional by default. Every insert or update of a wide value pays CPU to attempt compression, and every read that touches a value that ended up compressed pays CPU to decompress it. If you have a hot JSONB column, that CPU cost sits on your critical path every single day, and the choice of codec determines how big it is.
pglz vs lz4: what each codec actually costs
pglz is PostgreSQL's built-in compressor, a homegrown LZ-family algorithm that was the only option for decades. It compresses reasonably well and decompresses reasonably fast, and "reasonably" was all anyone needed because there was nothing to compare it against. lz4 arrived in PostgreSQL 14 as an alternative TOAST compressor. Its design goal is speed, and it delivers: compression is faster, and decompression is dramatically faster, commonly several times faster than pglz on the same data. The price is the compression ratio. On typical JSON or text, lz4 tends to land a few percentage points worse than pglz, sometimes more, sometimes less, depending on how redundant the data is.
So the trade is concrete: lz4 buys you CPU on both the write path and the read path and costs you some disk; pglz buys you a bit less disk and costs you more CPU. On modern hardware, where disk is cheap and CPU on a busy primary is the thing you run out of first, lz4 wins more often than the default settings suggest. The workloads where I keep pglz are cold, write-once, read-almost-never archives, where the table is essentially online cold storage and every gigabyte saved is a gigabyte not backed up or replicated. Everything hot gets lz4.
One version nuance worth knowing: even on PostgreSQL 14 and later, the built-in default of default_toast_compression is still pglz. Nothing stamps lz4 in at initdb time, and pg_upgrade changes nothing either way, so a brand-new 16 or 17 cluster and a database carried across five major upgrades behave identically here: both keep using pglz until someone sets the value. If you want lz4 everywhere, you have to opt in deliberately. Check with SHOW default_toast_compression before assuming anything.
Finding out which columns are actually toasted
Before touching anything, I want evidence: which tables carry meaningful toast storage, and which columns are feeding it. The toast table of a relation is reachable through reltoastrelid, so a quick catalog query ranks the worst offenders by out-of-line size:
SELECT c.relname,
pg_size_pretty(pg_relation_size(c.oid)) AS heap_size,
pg_size_pretty(pg_relation_size(t.oid)) AS toast_size
FROM pg_class c
JOIN pg_class t ON t.oid = c.reltoastrelid
WHERE c.relkind = 'r'
ORDER BY pg_relation_size(t.oid) DESC
LIMIT 10;
For a suspect table, check which wide columns exist and how they are stored:
SELECT attname, atttypid::regtype AS type, attstorage
FROM pg_attribute
WHERE attrelid = 'public.events'::regclass
AND attnum > 0
ORDER BY attnum;
attstorage shows as x for extended, e for external, m for main, and p for plain. Pair that with avg_width from pg_stats for the same columns, which tells you the average value width the planner has observed. A text or JSONB column with an average width in the low kilobytes, on a table with a fat toast table, is your prime suspect. If you want per-value ground truth, pg_column_size reports the on-disk compressed size of an individual value, which is handy for sampling real rows instead of trusting averages.
The case for external storage on already-compressed data
There is a third option in this decision that people forget because the pglz-versus-lz4 debate hogs the stage: do not compress at all. If a column stores data that is already compressed, such as gzipped payloads, parquet files, images, or anything encrypted, both codecs will burn CPU trying to compress the incompressible and give up with nothing to show for it. PostgreSQL detects the failure and stores the value uncompressed, but you paid the attempt on every write. Setting the column's storage to external keeps the out-of-line chunking behavior and drops the compression step entirely:
ALTER TABLE events ALTER COLUMN payload SET STORAGE EXTERNAL;
For genuinely cold blobs, also ask whether they belong in the database at all. An object store plus a key column is often the honest answer, but that is an application change, while SET STORAGE EXTERNAL is a metadata-only change you can make this afternoon.
Switching a column to lz4, and what actually changes
The switch itself is one statement:
ALTER TABLE events ALTER COLUMN payload SET COMPRESSION lz4;
Here is the part that trips everyone: this does not rewrite the table. It changes metadata in pg_attribute (the attcompression column), and it applies only to tuples written after the change. Every existing out-of-line value stays compressed with whatever codec wrote it. PostgreSQL handles the mixed state transparently, because each toasted value records its own compression method, so reads stay correct regardless. But if you benchmark right after the ALTER and see no difference, that is why: your hot rows are still pglz until they are updated or the table is rewritten.
Verifying means writing new rows and inspecting them. pg_column_compression tells you the method used for a specific value:
SELECT id, pg_column_compression(payload) AS codec,
pg_column_size(payload) AS stored_bytes
FROM events
ORDER BY id DESC
LIMIT 20;
If you want the whole table converted rather than drifting to lz4 row by row, the blunt tool is an UPDATE that rewrites every row, with one trap: PostgreSQL preserves unchanged out-of-line values across an update, so a cosmetic SET payload = payload carries the old pglz chunks into the new row version untouched. The expression has to genuinely rebuild the value so it gets detoasted and re-toasted; on a text column, SET payload = payload || '' is the classic form. Do it in key-range batches so you do not hold millions of row locks at once, and be honest about what it costs: a full-table rewrite generates WAL for every row, roughly doubles the table's footprint until vacuum cleans up, and takes real time on a large table. The wholesale alternatives, VACUUM FULL or pg_repack, rewrite the table in one operation but carry their own locking stories. In practice I only force the rewrite when the table is read-hot enough that the old pglz rows are measurably costing CPU; otherwise the mixed state converges on its own as rows turn over.
To make lz4 the default for future columns, set default_toast_compression in postgresql.conf, or per database with ALTER DATABASE ... SET default_toast_compression = 'lz4'. New tables and new columns then inherit it without per-column DDL.
Keeping an eye on it with MonPG
After a switch like this I want two things on one screen: the toast table's growth curve bending the right way, and the CPU time of the query families that read those wide columns. Week-over-week drift like that is exactly what MonPG's PostgreSQL monitoring is built to surface, with pg_stat_statements history, table-level statistics, and bloat signals side by side, so a JSONB-heavy query that gets cheaper or more expensive after a storage change shows up against its own baseline rather than as a hunch. Our PostgreSQL monitoring overview covers the metrics involved, and the companion post on measuring table bloat pairs well with this one if your toast tables are part of a wider growth problem.