Topic 24

TOAST — Where Big Values Go

Large Values

A tuple has to fit on an 8 KB page, so when a row grows too wide Postgres compresses its widest compressible values and, if that is still not enough, relocates them into a side table and leaves an 18-byte pointer in their place. The mechanism is called TOAST, it happens whether or not you asked for it, and it is the reason a text column can hold a gigabyte in a database whose page is eight kilobytes.

Cartwheel meets it through products.attributes, a jsonb document that averages 3 KB across the catalogue. Nothing in the schema says anything about TOAST and nothing was configured, and yet the storage of that table is split across two relations with different sizes, different vacuum behaviour and different read costs. Until you measure it, none of that is visible.

The Threshold and the Algorithm

The TOAST machinery is triggered only when a row about to be stored is wider than TOAST_TUPLE_THRESHOLD, normally 2 KB — roughly a quarter of a page. Once triggered, it compresses and moves values out of line until the row is shorter than TOAST_TUPLE_TARGET, also normally 2 KB, or until no further gains are available. That target is adjustable per table with the toast_tuple_target storage parameter, anywhere from 128 bytes up to the block size minus its header; the default is picked so that at least four tuples fit in a block, which works out at 2,040 bytes.

The order matters more than the numbers. Compression is attempted first, relocation second, and the whole process stops the instant the row drops under the target. A 3 KB attributes document that compresses below the target therefore stays in the row, compressed, and is decompressed on every read. One that does not compress far enough is moved out of the row entirely, leaving a pointer that records the TOAST table's OID, the value's chunk id, and its logical and stored sizes. Two rows in the same table can be stored in completely different ways depending on how compressible their JSON happened to be.

What happens to a row wider than the threshold, in the order it happens
The row is wider than 2 KBthe machinery is triggered
Compress the widest valuesfirst, and often enough
Relocate what is still too widean 18-byte pointer left in its place
The row drops under the targetand the process stops instantly

The Four Storage Strategies

Each column carries a storage strategy that decides which halves of that algorithm are allowed. PLAIN permits neither compression nor relocation and is the only option for fixed-length types such as integer. EXTENDED permits both and is the default for almost every type that supports anything else, including text, jsonb and arrays. EXTERNAL permits relocation but not compression. MAIN permits compression and treats relocation as a last resort, keeping the value in the row wherever it can.

Turning off compression for a column of already-compressed bytes
ALTER TABLE products ALTER COLUMN attributes SET STORAGE EXTERNAL;
ALTER TABLE products ALTER COLUMN attributes SET COMPRESSION lz4;

-- \d+ products shows the strategy per column
   Column   |     Type      | Storage  | Compression
------------+---------------+----------+-------------
 sku        | text          | extended |
 name       | text          | extended |
 price      | numeric(10,2) | main     |
 attributes | jsonb         | external | lz4

Switch to EXTERNAL when the bytes are already compressed — images, gzipped payloads, anything with high entropy — because Postgres will otherwise burn CPU on every write trying to compress incompressible data, and when the value is read by slices, because substring operations on large uncompressed text and bytea run faster: the server fetches only the chunks it needs instead of the whole value. Neither statement moves any existing data. SET STORAGE sets the strategy for future updates and changes nothing in the table today, and SET COMPRESSION applies to values inserted from now on while old values keep the algorithm they were written with. pg_column_compression() reports the method of an individual value.

Which half of the algorithm each storage strategy allows
PLAINneither
No compression and no relocation. The only option for fixed-length types such as integer.
EXTENDEDboth — the default
Compression and relocation, for almost every type that supports either: text, jsonb and arrays.
EXTERNALrelocation only
Right when the bytes are already compressed, and when the value is read by slices — the server then fetches only the chunks it needs instead of the whole value.
MAINcompression first
Compression allowed, relocation treated as a last resort, keeping the value in the row wherever it can.

The TOAST Table Itself

Any table with a TOAST-able column gets a companion relation, whose OID sits in that table's pg_class.reltoastrelid. It lives in the pg_toast schema, it has three columns — chunk_id, chunk_seq, chunk_data — and a unique index on the first two. Out-of-line values are split, after compression, into chunks sized so that four chunk rows fit on a page, which comes to about 2,000 bytes each. Nothing about it is special: it is an ordinary heap table with ordinary pages, tuples and line pointers, so everything in the previous two topics applies to it unchanged.

The other half of the products table
SELECT c.reltoastrelid::regclass                        AS toast_table,
       pg_size_pretty(pg_relation_size(c.oid))          AS main_fork,
       pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast_fork
  FROM pg_class c
 WHERE c.relname = 'products';

       toast_table        | main_fork | toast_fork
--------------------------+-----------+------------
 pg_toast.pg_toast_16412  | 18 MB     | 214 MB

Twelve times as much of products lives outside products as inside it. That is a genuinely good trade for the queries that never touch attributes: the main fork became small enough to scan quickly. It is a bad surprise for anything that reads the column in a loop, and it is invisible to a monitoring query that only ever asks for the size of products. A 3 KB document that ends up out of line becomes two chunk rows plus two index lookups on every read that touches the column, and the main tuple it left behind is around sixty bytes instead of three thousand.

pglz and LZ4

default_toast_compression is pglz, the built-in algorithm that has always been there, and lz4 is the alternative, available when the server was built with --with-lz4. LZ4 compresses somewhat less than pglz but compresses and decompresses several times faster, and for a document column written on every product update that trade is a straight throughput win — CPU is what a busy primary runs out of first, and disk is cheap.

The method is recorded per value, not per column, which has a practical consequence worth knowing before a migration review asks about it: changing the column setting does not rewrite anything. New and updated rows use the new method, existing rows keep the one they were written with, and the table reads correctly throughout with a mixture of both. A pg_restore rewrites everything with the configured method, so the switch completes on its own the next time the table is reloaded, or immediately if you rewrite it on purpose.

What It Costs at Read Time

The main tuple staying small is the benefit: every scan that does not touch the big column reads fewer pages, and on products that is most of them. The cost lands on every access that does touch it — the chunk lookups through the TOAST index, the reassembly, and the decompression, all before the value reaches the query. So SELECT * from an ORM on a table with a large jsonb column is a measurable I/O decision, and the fix is to name the columns the code actually uses.

The reverse claim deserves stating too, because it is where the chapter's opening observation comes from. TOAST moves bytes out of the row; it does not make row width stop mattering, and it does not make reading the relocated bytes free. SELECT id, price FROM products reads far more disk than eight bytes plus a numeric per row can explain, and TOAST is not what rescues it: the main fork still holds sku, name, tags and the pointers, and every page of it is read to reach the two columns you asked for.

Where TOAST Bloat Hides

The TOAST relation is a heap table, so it behaves like one. Updating a single key in a 3 KB document does not patch the document: the whole value is re-compressed and re-written as new chunk rows, the previous chunks become dead tuples, and the WAL generated is wildly out of proportion to the size of the change. A high-frequency update on one jsonb key is therefore one of the more effective ways to bloat a Postgres database. The TOAST table needs vacuum exactly like any other, and it has its own autovacuum settings through the toast. storage parameters when the parent table's defaults are wrong for it.

Three size functions, three different answers
SELECT pg_size_pretty(pg_relation_size('products'))       AS main_fork,
       pg_size_pretty(pg_table_size('products'))          AS with_toast,
       pg_size_pretty(pg_total_relation_size('products')) AS with_indexes;

 main_fork | with_toast | with_indexes
-----------+------------+--------------
 18 MB     | 232 MB     | 261 MB

Those are three different questions and it is worth being precise about which one you asked. pg_relation_size() reports one fork of the relation itself and knows nothing about TOAST. pg_table_size() adds the TOAST relation together with the free space and visibility maps. pg_total_relation_size() adds the indexes on top. A capacity dashboard built on the first of the three will report products as an 18 MB table indefinitely while the relation behind it grows to forty gigabytes.

TOAST vs a side table vs object storage

A TOASTed column — transparent, transactional and free to adopt, with the value committed and rolled back along with its row. Right for documents up to a few hundred kilobytes that are usually read together with the rest of the row.

A separate table with an explicit join — you decide when it is read, and it is vacuumed, cached and sized independently of the parent. Right when the value is read rarely and the parent table is scanned constantly, which is the case the hot path pays for otherwise.

Object storage with a URL in the row — right at megabyte scale and above. The database keeps the metadata and the transaction; the bytes live where bandwidth is cheap, at the cost of the two systems being able to disagree after a failure.

Common Mistakes
  • Letting an ORM issue SELECT * against a table with a large jsonb column — every row de-TOASTs and decompresses kilobytes the application immediately discards, on a code path that never gets reviewed.
  • Updating one key of a 3 KB document at high frequency — the entire value is re-compressed and re-written as fresh chunk rows, producing WAL and TOAST bloat far out of proportion to the change.
  • Measuring a table with pg_relation_size() and never seeing the TOAST relation — the "small" table is an order of magnitude larger than the number on the dashboard.
  • Leaving EXTENDED on a column of already-compressed data such as images or gzipped payloads — the server spends CPU on every single write trying to compress bytes that will not compress.
  • Excluding the TOAST relation from vacuum thinking — it is an ordinary heap with ordinary dead tuples, and it has its own toast. autovacuum parameters when the parent's settings do not suit it.
  • Expecting ALTER TABLE … SET STORAGE to reorganize the existing data — it sets the strategy for future updates only, so the table looks unchanged until the rows are rewritten.
Best Practices
  • Name the columns in every application query that runs against a table holding a TOASTed value, and treat SELECT * there as a defect rather than a shortcut.
  • Set EXTERNAL on columns of already-compressed bytes and on large values read by slices, so writes skip a pointless compression pass and substring reads fetch only the chunks they need.
  • Choose lz4 for write-heavy document columns where CPU is the constraint, and confirm the server was built with it before writing the migration.
  • Report table sizes with pg_total_relation_size(), and break out pg_relation_size(reltoastrelid) whenever a table looks unexpectedly large or unexpectedly small.
  • Split a document into the part read with every row and the part read on demand once the hot path is measurably paying for bytes it never uses.
  • Check pg_column_compression() on a sample of rows after changing a column's compression, since existing values keep the method they were written with.
Comparable toolsInnoDB off-page BLOB storage under ROW_FORMAT DYNAMICOracle LOBs, in-row or out-of-row by declarationSQL Server LOB and row-overflow allocation unitsSQLite overflow page chains for values wider than a page

Knowledge Check

A row reaches 2.5 KB, mostly from one jsonb column. In what order does Postgres act on it?

  • It compresses first, and only relocates the value if the row is still too wide
  • It relocates the column first, then compresses the chunks in the TOAST table
  • It splits the tuple across two pages and links them with the line pointer
  • It rejects the insert with an error until the storage strategy is changed

Which storage strategy suits a bytea column holding already-compressed image data that is read in slices?

  • EXTENDED, so the values are compressed before being moved out of line
  • EXTERNAL, which relocates the value but never compresses it
  • MAIN, which keeps the value inline and compresses it where possible
  • PLAIN, which stores the value inline exactly as the client sent it

A monitoring query reports products as an 18 MB table while the disk shows hundreds of megabytes. What is it missing?

  • The free space map and visibility map forks, which dominate the size
  • The TOAST relation, which pg_relation_size does not count at all
  • A stale relpages estimate that vacuum has not refreshed for months
  • The WAL segments generated by writes to that particular table

Why does updating one key of a 3 KB jsonb document generate so much more WAL than the change implies?

  • The whole value is re-written as new chunks and the old chunks die
  • Only the affected chunk is rewritten, but its index entry is rebuilt fully
  • The TOAST table's unique index is rebuilt from scratch on every update
  • Decompressing the document to read it is itself logged in the WAL

What does SELECT id, price FROM products still have to read, on a table whose attributes column is fully TOASTed?

  • Only the bytes of those two columns, since the rest is stored elsewhere
  • Every page of the main fork, along with all the other inline columns on it
  • Both the main fork and the TOAST chunks, which are fetched with each row
  • Only the index pages, because narrow projections become index-only scans

You got correct