DEV Community

Philip McClarence
Philip McClarence

Posted on

Postgres TOAST Storage: Where Your Big JSONB Really Lives

I got paged once for a "small" table. Twelve million rows, one jsonb column, pg_relation_size said 2.1GB. The disk said 41GB. The backup job said "still running" at 6am. That table taught me more about TOAST than any doc page ever did, mostly because I was reading the wrong size function for three years.

📖 Read the full guide: Postgres TOAST: How It Works, Bloats, and Slows Queries

Here is the whole thing, with the SQL I actually run.

TL;DR

  • Postgres pages are 8KB by default (BLCKSZ=8192), and a heap tuple cannot span pages. Once a row crosses ~2000 bytes, TOAST gets involved.
  • TOAST compresses first, then moves values out of line into a hidden pg_toast table in chunks of roughly 2KB, leaving an 18-byte pointer in the heap.
  • Four strategies: PLAIN, EXTENDED, EXTERNAL, MAIN. pg_attribute.attstorage records them as p, x, e, m. EXTENDED is the default for text, varchar, jsonb, bytea, arrays.
  • pg_relation_size() excludes the TOAST table. pg_total_relation_size() includes it. That gap is where 39GB hides.
  • pg_column_size() vs octet_length() shows you the compression ratio for free.
  • TOAST tables live in the pg_toast schema, so every monitoring query filtered to public misses their bloat entirely.
  • Companion video if you want the visual version: https://www.youtube.com/watch?v=Eqwf4Yo1R-4

Postgres TOAST Storage: Where Your Big JSONB Really Lives

The 8KB wall

Postgres reads and writes in 8KB pages. That is a compile-time constant, and unless you built your own binaries you have 8192. A heap tuple must fit inside one page, no spanning.

So the practical ceiling for a row before Postgres starts intervening is TOAST_TUPLE_THRESHOLD, 2000 bytes by default, roughly a quarter of a page. Four fat rows per page is the design target.

Which raises the obvious question: you have a 50KB JSON document. It does not fit. It does not fit by a factor of six even after compression. Where does it go?

What TOAST actually does, in order

TOAST is The Oversized-Attribute Storage Technique. That is the one acronym joke you get from me.

The algorithm, in the order it runs:

  1. Is the tuple bigger than TOAST_TUPLE_THRESHOLD (2000 bytes)? If no, stop. Nothing happens.
  2. Pick the widest TOAST-able attribute in the row.
  3. If its strategy allows compression (EXTENDED or MAIN), compress it in place.
  4. If the tuple is still over TOAST_TUPLE_TARGET (also 2000 by default) and the strategy allows it, move the value out of line into the TOAST table.
  5. Repeat with the next widest attribute until the tuple fits.

Compression happens first. Out-of-line storage is the fallback. Most people I talk to have this backwards and assume any big value goes straight to the TOAST table. A 3KB text field that compresses to 900 bytes stays right there in the heap and never touches TOAST.

Also: only variable-length (varlena) types are eligible. Your bigint, timestamptz, and integer columns are never TOASTed, no matter how many of them you have.

When a value does go out of line, it is split into chunks of at most TOAST_MAX_CHUNK_SIZE, about 2KB, sized so four chunk rows fit per TOAST page. Those chunks land in pg_toast.pg_toast_ with columns chunk_id oid, chunk_seq int, chunk_data bytea, plus a unique index on (chunk_id, chunk_seq). What stays in your heap tuple is an 18-byte TOAST pointer holding the total size, compressed size, chunk_id, and the TOAST relation OID.

Maximum size of a single TOASTed value is 1GB, same as any varlena. If you are near that, we need a different conversation.

The four storage strategies

Strategy attstorage Compress? Out-of-line? Default for When I reach for it
PLAIN p no no fixed-length types Never manually. It errors if the value can't fit.
EXTENDED x yes yes text, varchar, jsonb, bytea, arrays The default, and correct 90% of the time.
EXTERNAL e yes-ish (no) yes none Big text/bytea where you do substring() or prefix LIKE.
MAIN m yes last resort none Values that compress well and are read on every query.

EXTERNAL exists for a real reason. With compression off, Postgres can fetch only the chunks it needs to satisfy a substring() on a long text or bytea value instead of pulling and decompressing the entire datum. If you store 200KB documents and routinely read the first 500 bytes, EXTERNAL is a genuine win. It costs you disk. What it will not do is help jsonb key extraction — ->> has no partial-fetch path regardless of storage strategy, so don't bother flipping a jsonb column to EXTERNAL expecting cheaper key lookups.

The video keeps this simple; here is the exact nuance: EXTERNAL does not "disable TOAST," it disables compression while still allowing out-of-line storage. MAIN does not "keep it in the heap forever" either. MAIN still goes out of line if the row cannot be made to fit any other way.

Look at your own database

Per-column strategies:

SELECT a.attname,
       format_type(a.atttypid, a.atttypmod) AS type,
       a.attstorage,
       CASE a.attstorage
         WHEN 'p' THEN 'plain' WHEN 'x' THEN 'extended'
         WHEN 'e' THEN 'external' WHEN 'm' THEN 'main'
       END AS strategy
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'orders' AND n.nspname = 'public'
  AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum;
Enter fullscreen mode Exit fullscreen mode
  attname  |           type           | attstorage | strategy
-----------+--------------------------+------------+----------
 id        | bigint                   | p          | plain
 status    | text                     | x          | extended
 metadata  | jsonb                    | x          | extended
 created_at| timestamp with time zone | p          | plain
Enter fullscreen mode Exit fullscreen mode

Compression in action:

SELECT octet_length(metadata::text) AS raw_bytes,
       pg_column_size(metadata)      AS on_disk_bytes,
       round(100.0 * pg_column_size(metadata)
             / octet_length(metadata::text), 1) AS pct
FROM orders WHERE id = 88123;
Enter fullscreen mode Exit fullscreen mode
 raw_bytes | on_disk_bytes | pct
-----------+---------------+------
     51234 |          9418 | 18.4
Enter fullscreen mode Exit fullscreen mode

That is my 50KB document. It compressed to 9,418 bytes, still way over 2000, so it went out of line. Five chunks:

SELECT chunk_id, count(*) AS chunks, sum(length(chunk_data)) AS bytes
FROM pg_toast.pg_toast_16419
GROUP BY chunk_id ORDER BY bytes DESC LIMIT 1;
Enter fullscreen mode Exit fullscreen mode
 chunk_id | chunks | bytes
----------+--------+-------
    20117 |      5 |  9418
Enter fullscreen mode Exit fullscreen mode

Five index lookups plus five heap fetches, every time something evaluates that column.

How big is the TOAST table really

This is the single most useful query in this article. Bookmark it.

SELECT c.relname,
       pg_size_pretty(pg_relation_size(c.oid))              AS heap,
       pg_size_pretty(pg_relation_size(t.oid))              AS toast,
       pg_size_pretty(pg_indexes_size(t.oid))               AS toast_idx,
       pg_size_pretty(pg_total_relation_size(c.oid))        AS total
FROM pg_class c
LEFT JOIN pg_class t ON t.oid = c.reltoastrelid
WHERE c.relkind = 'r' AND c.relnamespace = 'public'::regnamespace
ORDER BY pg_total_relation_size(c.oid) DESC LIMIT 5;
Enter fullscreen mode Exit fullscreen mode
 relname  |  heap   |  toast  | toast_idx |  total
----------+---------+---------+-----------+---------
 orders   | 2141 MB | 36 GB   | 2380 MB   | 41 GB
 events   | 1802 MB | 118 MB  | 9648 kB   | 2014 MB
Enter fullscreen mode Exit fullscreen mode

reltoastrelid is 0 when a table has no TOAST table at all — usually one made entirely of fixed-length columns. If your dashboards graph pg_relation_size, you are graphing 5% of that table.

Why your jsonb reads got slower

Detoasting is lazy. SELECT id FROM orders does not touch a single TOAST chunk, because nothing ever evaluates metadata. I hear the opposite claim constantly and it is wrong: selecting other columns from a wide table pays nothing for the TOASTed one. Same reason COUNT(*) on a 41GB table is fast.

But the moment you apply any operator to the value, Postgres fetches and decompresses the whole datum. metadata->>'status' does not do a partial read of one key. There is no partial-detoast path for -> or ->>. You asked for 12 bytes and paid for 9,418 bytes across five chunk fetches plus decompression, per row.

That is the difference between a 40ms scan and a 9-second one, and it will not show up as anything obvious in EXPLAIN output beyond mysteriously high runtime.

The escape hatch is an expression index:

CREATE INDEX orders_status_idx ON orders ((metadata->>'status'));
Enter fullscreen mode Exit fullscreen mode

Now a predicate on that key is answered from the index without detoasting every document at scan time. If the query only needs that key, an index-only scan skips the heap and TOAST entirely.

Why your jsonb updates got expensive

Second myth, more damaging than the first: "every UPDATE rewrites the TOAST value." No. If an UPDATE does not modify the TOASTed column, Postgres copies the existing TOAST pointer into the new row version. Eighteen bytes, not nine kilobytes. Updating status on a row with a 50KB document is cheap.

Modify the column and everything changes. There's no diffing and no partial chunk update — the new value is compressed from scratch, chunked from scratch, and written as fresh rows in the TOAST table. The old chunks become dead and sit there until vacuum. Change one key in a 50KB document and you have written 9KB of new chunks and orphaned 9KB of old ones.

Consequence: a high-churn jsonb column bloats its TOAST table faster than anything else in your database. And because the toasted column changed, you cannot get a HOT update, so every index on the table takes a new entry too. If you're updating a jsonb column on every request just to bump one field — a last_seen timestamp, a status flag — pull that field into its own narrow column. You are otherwise paying full-document recompression on every request to move one value.

Tuning knobs I actually use

ALTER TABLE orders ALTER COLUMN raw_payload SET STORAGE EXTERNAL;
Enter fullscreen mode Exit fullscreen mode

Critical caveat that bites everyone: this only affects rows inserted or updated after the change. Existing rows keep their current physical representation. To apply it retroactively you need a rewrite:

VACUUM FULL orders;                              -- takes ACCESS EXCLUSIVE
-- or a no-op type change, also a full rewrite:
ALTER TABLE orders ALTER COLUMN raw_payload TYPE text;
Enter fullscreen mode Exit fullscreen mode

Both take heavy locks. Plan the window, or use pg_repack if you cannot.

Compression method: since PG14, default_toast_compression = lz4 if your build has --with-lz4. I switch to lz4 on anything write-heavy. Lower ratio than pglz, considerably faster both directions. The method is recorded per value, so old pglz values and new lz4 values coexist happily in the same column. No rewrite required to start benefiting.

toast_tuple_target (128 bytes up to TOAST_TUPLE_THRESHOLD) lets you push values out of line more aggressively per table. I have used this exactly twice, both times on tables where the wide column was almost never read and I wanted the heap dense for sequential scans.

And the option nobody wants to hear: if the blob is a 2MB PDF, put it in object storage and keep a URL in Postgres. TOAST is good engineering, but it is not a file server.

The bloat trap nobody watches

TOAST tables are autovacuumed as part of the parent's autovacuum. They also accept independent settings via toast.autovacuum_* storage parameters on the parent. And they show up in pg_stat_all_tables under the pg_toast schema, which is exactly why the monitoring query you inherited (WHERE schemaname = 'public') has never once reported them.

SELECT s.relname, s.n_dead_tup, s.last_autovacuum, s.autovacuum_count,
       pg_size_pretty(pg_relation_size(s.relid)) AS size
FROM pg_stat_all_tables s
WHERE s.schemaname = 'pg_toast'
ORDER BY s.n_dead_tup DESC LIMIT 10;
Enter fullscreen mode Exit fullscreen mode
     relname     | n_dead_tup |        last_autovacuum        | count | size
-----------------+------------+-------------------------------+-------+-------
 pg_toast_16419  |   48211903 | 2026-06-19 04:11:02.338+00     |    12 | 36 GB
Enter fullscreen mode Exit fullscreen mode

Seven weeks since the last autovacuum on the table that holds 88% of the cluster's data. That is the incident. Autovacuum was starved by cost limits and a long-running replication slot holding back the horizon, TOAST bloat grew unbounded, base backups doubled in wall time and size, WAL archive volume climbed, and the disk alert fired at 3am on the standby first because it had the smaller volume.

Tune it directly:

ALTER TABLE orders SET (
  toast.autovacuum_vacuum_scale_factor = 0.02,
  toast.autovacuum_vacuum_cost_limit   = 2000
);
Enter fullscreen mode Exit fullscreen mode

If you want this kind of thing surfaced without writing the queries yourself, MyDBA covers it.

A checklist for wide-column tables

  1. I always look at pg_total_relation_size, never pg_relation_size, when sizing anything with a text or jsonb column.
  2. I add pg_stat_all_tables rows from the pg_toast schema to bloat monitoring on day one.
  3. I never assume SELECT id is expensive on a wide table. It is not.
  4. I do assume metadata->>'k' in a WHERE clause on a large table is a full detoast per row, and I add an expression index.
  5. I do not split a jsonb column into a side table for read performance before checking whether the queries touch it at all.
  6. If I'm updating one small field on every request, I pull it out of the wide jsonb column into its own column rather than rewriting the whole document.
  7. I set toast.autovacuum_vacuum_scale_factor explicitly on any table with a high-churn large column. The default 0.2 on a 36GB TOAST table is absurd.
  8. I switch default_toast_compression to lz4 on write-heavy clusters and leave read-mostly archives on pglz.
  9. When I change SET STORAGE, I schedule the rewrite in the same maintenance window, or I write down that I did not and why.

Further reading

The 3am lesson, distilled

TOAST is not an edge case you'll hit someday — it's the default fate of any text or jsonb column past 2000 bytes, and the gap between pg_relation_size and reality is where most storage surprises live. Read the pointer arithmetic once, and jsonb bloat, mystery-slow queries, and starved autovacuum jobs stop being mysterious. Check pg_total_relation_size, watch the pg_toast schema, and index the keys you actually query instead of detoasting the whole document every time.

pgdba Editorial builds MyDBA, a Postgres monitoring and health-check tool — https://mydba.dev/?utm_source=devto&utm_medium=platform&utm_campaign=postgres-toast-storage-explained

If you'd rather not write these queries by hand every incident, point MyDBA at your cluster and let it flag TOAST bloat and dead tuples before they page you.

Top comments (0)