DEV Community

Cover image for PostgreSQL document compression: TOAST thresholds, storage policies, and page size
Franck Pachot
Franck Pachot

Posted on Edited on

PostgreSQL document compression: TOAST thresholds, storage policies, and page size

A common way to store documents in PostgreSQL is a table with an ID and a JSONB column. The open-source DocumentDB extension instead stores BSON, the binary document format used by MongoDB, to provide more data types and MongoDB compatibility.

Both approaches commonly combine two patterns that do not perfectly fit what SQL databases were designed for:

  • Rows with a single variable-length column, whose values can range from a few dozen bytes to many megabytes.
  • Repeated data or metadata, such as similar field names and arrays of objects with the same schema, like time-series buckets.

MongoDB's storage engine, WiredTiger, packs many documents into B-tree leaf pages. The default maximum page sizes — 32 KB for leaf pages and 4 KB for internal pages — are limits on the uncompressed page image fed to the block compressor, not on individual documents: MongoDB configures leaf_value_max=64MB, so a document larger than 32 KB simply produces a larger leaf page rather than overflow storage. Snappy compresses each page image for ordinary collections (time-series collections use Zstd, which ignores the global compressor setting), and the compressed result is written in multiples of the 4 KB allocation_size. Because compression runs over the whole page image, several small documents — including their repeated field names — contribute redundant byte sequences to the same compression input.

A JSON or BSON document can contain substantial byte-sequence redundancy: repeated field names, common name prefixes, and repeated values or structures. LZ77-family compressors such as Snappy and LZ4 can replace matching sequences with backreferences to earlier bytes. They don't need to understand JSON, and matches aren't restricted to the beginning of a document.

Binary does not mean compressed: BSON includes field names, and repeated embedded objects repeat those names. JSONB can contain similar redundancy. PostgreSQL can compress those sequences in JSON, JSONB, or a BSON extension type. However, the decision to attempt compression is not driven by document redundancy. It belongs to TOAST's generic heap-row storage policy: reduce a wide row, trying column compression before storing permitted attributes externally in chunks.

This distinction matters for small documents. It also makes increasing the page size potentially counterproductive: the same document can stop being compressed on a larger-page build.

I will reproduce both effects, then compare the SQL controls with the hardcoded values. The examples use PostgreSQL 19 and core JSONB, so they can run without an extension or a custom patch. The distinction between whole-row activation and individual-column compression also applies to the TOAST-backed BSON values stored by DocumentDB.

What is compressed, and what is measured?

TOAST measures the whole tuple (row) to decide whether to start. However, compression acts on individual eligible column values, not on the entire row or all the documents in a page.

PostgreSQL's ordinary table storage uses fixed-size pages, 8 KB by default. A row cannot span those pages. Large column values can instead be moved to an associated TOAST table, and the main row then contains a pointer to access the chunks in the TOAST table by index scan.

JSONB still contains compressible byte sequences. It is a binary representation, not an already-compressed JSON string. Within one object, it normalizes key order and duplicate keys, but an array of similarly structured objects can repeat the same field names. No shared key dictionary exists across those objects or across independently stored JSONB rows.

The distinction between repeated names within a document and repeated names across documents is important. PostgreSQL compresses one column value at a time. PostgreSQL calls each value a datum in its source code. That per-datum compression can use the former repetition, not the latter.

MongoDB's WiredTiger storage engine uses Snappy block compression on disk by default for ordinary collections. Small documents can share a page, so one block-compression input can include repeated names and structures from several documents. This is not a separate compression call per small document. Other codecs can be configured, and time-series collections have different defaults. This is a difference in compression scope, not a capability reserved to Snappy.

This post measures stored datum representations, not table allocation or query performance. I use:

  • pg_column_size(doc::text::jsonb) to reconstruct an uncompressed logical JSONB datum for these ordinary JSON inputs
  • pg_column_compression(doc) to report the actual accepted method
  • pg_column_size(doc) for its stored representation
  • pg_column_toast_chunk_id(doc) IS NOT NULL to identify external values

The last function is available in PostgreSQL 19. For an external value, pg_column_size is not the inline pointer size. The on-disk pointer in the main row occupies 18 bytes. The external size excludes the outer four-byte varlena length word, which explains some four-byte differences below without attributing them to the codec.

Reproduce the environment and output

All SQL needed for the experiments is included below. Run the statements in order in a fresh test database. For each page-size comparison, start a separate PostgreSQL instance.

I use aligned ASCII output, with NULL displayed as (null). To reproduce that format, start an existing server's session with these options:

psql -X -P pager=off -P 'null=(null)' -P format=aligned \
  -P linestyle=ascii -P border=1 -U postgres -d postgres
Enter fullscreen mode Exit fullscreen mode

These are the initial settings on the source-built 8 KB server:

postgres=# SHOW server_version;

 server_version 
----------------
 19rc1
(1 row)

postgres=# SHOW block_size;

 block_size 
------------
 8192
(1 row)

postgres=# SHOW default_toast_compression;

 default_toast_compression 
---------------------------
 lz4
(1 row)
Enter fullscreen mode Exit fullscreen mode

The selected codec is explicit in every test. PostgreSQL 14-18's upstream default is pglz, but an LZ4-enabled PostgreSQL 19 RC1 build defaults to LZ4. SET COMPRESSION has been available since PostgreSQL 14.

LZ4 is a sensible starting choice when compression/decompression CPU matters, but the workload still determines the trade-off. It does not always produce the smallest result. DocumentDB's current setup/tuning configuration selects default_toast_compression = 'lz4' when supported. This is a launcher-chosen PostgreSQL setting, not a property of BSON, nor a guarantee that every installation is configured that way. Check the live server and column settings rather than assuming them from a product name.

A compressible document below the activation threshold

I construct a JSONB array containing eight objects with repeated temperature and humidity names. Both rows receive the same document. The only difference is an unrelated column stored PLAIN, so it cannot be compressed or moved externally.

postgres=# CREATE TABLE small_documents(
    id integer, doc jsonb, other text
);

CREATE TABLE

postgres=# ALTER TABLE small_documents
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE small_documents
            ALTER COLUMN other SET STORAGE PLAIN
;

ALTER TABLE

postgres=# INSERT INTO small_documents
    SELECT id,
           jsonb_build_object('samples', (
             SELECT jsonb_agg(jsonb_build_object('temperature',20,'humidity',60))
             FROM generate_series(1,8)
           )),
           repeat('q', CASE id WHEN 1 THEN 0 ELSE 1600 END)
    FROM generate_series(1,2) AS g(id)
;

INSERT 0 2

postgres=# SELECT id,
           pg_column_size(doc::text::jsonb) AS logical_bytes,
           octet_length(other) AS other_bytes,
           pg_column_compression(doc) AS compression,
           pg_column_size(doc) AS stored_bytes,
           pg_column_toast_chunk_id(doc) IS NOT NULL AS external
    FROM small_documents ORDER BY id
;

 id | logical_bytes | other_bytes | compression | stored_bytes | external 
----+---------------+-------------+-------------+--------------+----------
  1 |           508 |           0 | (null)      |          508 | f
  2 |           508 |        1600 | lz4         |          109 | f
(2 rows)
Enter fullscreen mode Exit fullscreen mode

The first 508-byte JSONB datum remains uncompressed. The second compresses to 109 bytes, even though its uncompressed document bytes are identical. The additional 1,600-byte column made the tuple large enough to enter TOAST processing.

This proves both points:

  1. Repeated field names and values in JSONB are compressible.
  2. PostgreSQL does not independently ask whether that small JSONB datum would benefit from compression. The enclosing tuple controls activation.

I used an unrelated column for academic purposes. Of course, I'm not suggesting that adding more data to trigger compression is a good idea.

The hardcoded gate is not the per-table target

In the PostgreSQL source, the ordinary values are derived as follows:

#define TOAST_TUPLES_PER_PAGE 4
#define TOAST_TUPLE_THRESHOLD MaximumBytesPerTuple(TOAST_TUPLES_PER_PAGE)
#define TOAST_TUPLE_TARGET TOAST_TUPLE_THRESHOLD
Enter fullscreen mode Exit fullscreen mode

MaximumBytesPerTuple(4) accounts for page headers, line pointers, and alignment. It is not exactly block_size / 4. Here are the values for the default 8 KB page size and for PostgreSQL builds with 16 KB or 32 KB pages:

PostgreSQL page size Fixed tuple activation threshold Default tuple target Maximum native TOAST chunk payload
8,192 bytes 2,032 bytes 2,032 bytes 1,996 bytes
16,384 bytes 4,080 bytes 4,080 bytes 4,044 bytes
32,768 bytes 8,176 bytes 8,176 bytes 8,140 bytes

These are the values for the tested eight-byte-aligned builds. The chunk capacities were also observed from actual external data, not inferred from page size alone.

Both heap_prepare_insert and heap_update check the fixed threshold before invoking the toaster for fresh inline tuples. Inside the toaster, toast_tuple_target provides the reduction target, after subtracting tuple header overhead.

It’s not simply "compress when the row cannot fit on a page.” A tuple just over 2 KB can still fit on an 8 KB page. By default, wide rows are reduced earlier, with the goal of keeping their size consistent with roughly four tuples per page. This is a row-density and oversized-attribute policy, rather than a separately tunable document-compression policy.

These are fresh values with one eligible document column. If a row has several eligible columns, the toaster works in phases rather than compressing the entire row as one object.

Change the target without changing activation

The following input view constructs fresh JSONB values from repetitive text. It deliberately samples several payload sizes. It is not intended to represent a natural document distribution.

A view is used rather than copying stored compressed values from another table. Reusing an existing compressed datum would confound a test of whether the destination attempts compression.

postgres=# CREATE TEMP VIEW input_documents AS
    SELECT n, jsonb_build_object('v', repeat('x', 1024 * (1 << n))) AS doc
    FROM (VALUES (0),(1),(2),(6),(9),(10),(11)) AS sizes(n)
;

CREATE VIEW

postgres=# CREATE TABLE extended_default(
    n integer PRIMARY KEY, doc jsonb
);

CREATE TABLE

postgres=# CREATE TABLE extended_200(
    n integer PRIMARY KEY, doc jsonb
)
    WITH (toast_tuple_target=200)
;

CREATE TABLE

postgres=# CREATE TABLE extended_4k(
    n integer PRIMARY KEY, doc jsonb
)
    WITH (toast_tuple_target=4096)
;

CREATE TABLE

postgres=# ALTER TABLE extended_default
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE extended_200
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE extended_4k
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# INSERT INTO extended_default
    SELECT * FROM input_documents ORDER BY n
;

INSERT 0 7

postgres=# INSERT INTO extended_200
    SELECT * FROM input_documents ORDER BY n
;

INSERT 0 7

postgres=# INSERT INTO extended_4k
    SELECT * FROM input_documents ORDER BY n
;

INSERT 0 7

postgres=# SELECT policy, 1 << n AS payload_kib,
           pg_column_compression(doc) AS compression,
           pg_column_size(doc) AS stored_bytes,
           pg_column_toast_chunk_id(doc) IS NOT NULL AS external
    FROM (
      SELECT 1 AS position, 'default' AS policy, * FROM extended_default
      UNION ALL SELECT 2,'target_200',* FROM extended_200
      UNION ALL SELECT 3,'target_4096',* FROM extended_4k
    ) AS cases
    ORDER BY position,n
;

   policy    | payload_kib | compression | stored_bytes | external 
-------------+-------------+-------------+--------------+----------
 default     |           1 | (null)      |         1041 | f
 default     |           2 | lz4         |           39 | f
 default     |           4 | lz4         |           47 | f
 default     |          64 | lz4         |          288 | f
 default     |         512 | lz4         |         2083 | t
 default     |        1024 | lz4         |         4139 | t
 default     |        2048 | lz4         |         8252 | t
 target_200  |           1 | (null)      |         1041 | f
 target_200  |           2 | lz4         |           39 | f
 target_200  |           4 | lz4         |           47 | f
 target_200  |          64 | lz4         |          284 | t
 target_200  |         512 | lz4         |         2083 | t
 target_200  |        1024 | lz4         |         4139 | t
 target_200  |        2048 | lz4         |         8252 | t
 target_4096 |           1 | (null)      |         1041 | f
 target_4096 |           2 | (null)      |         2065 | f
 target_4096 |           4 | lz4         |           47 | f
 target_4096 |          64 | lz4         |          288 | f
 target_4096 |         512 | lz4         |         2087 | f
 target_4096 |        1024 | lz4         |         4139 | t
 target_4096 |        2048 | lz4         |         8252 | t
(21 rows)
Enter fullscreen mode Exit fullscreen mode

The default JSONB storage strategy is EXTENDED, which permits both compression and external storage.

Three observations distinguish the decisions:

  • Even with a 200-byte target, the 1 KB-payload document still remains uncompressed and inline at 1,041 bytes. Its tuple did not enter TOAST because it is smaller than the hardcoded MaximumBytesPerTuple(4).
  • Once TOAST is active, lowering the target externalizes values sooner: the 64 KB payload is inline with the default target but external with the 200-byte target.
  • With a 4 KB target, the 2 KB-payload document remains uncompressed at 2,065 bytes. Its tuple enters TOAST, but already meets the larger target. The same document compressed to 39 bytes in the other two tables.

Raising the target is therefore not an independent "retain more compressed documents inline" control. It can also prevent a compression attempt.

The default and 200-byte cases report 288 and 284 bytes respectively for the same compressed 64 KB-payload document. This is the inline/external varlena accounting difference, not a better compression result.

Storage strategies: what SQL can control

There are four column storage strategies:

Strategy Compression External storage
EXTENDED Permitted, attempted in the first phase when reduction is needed Permitted
EXTERNAL Not permitted Permitted
MAIN Permitted, attempted in a later phase when needed Last resort
PLAIN Not permitted Not permitted

MAIN does not guarantee inline storage. MAIN attributes are also skipped during the first compression pass: they get their own compression round only after EXTENDED/EXTERNAL attributes have been compressed and externalized. They are likewise skipped by the out-of-line loops until the last-resort pass, which uses TOAST_TUPLE_TARGET_MAIN (MaximumBytesPerTuple(1) ≈ 8,160 bytes on an 8 KB build). EXTERNAL does not mean "externalize every document,” and an explicit LZ4 method cannot override its prohibition on compression.

Here EXTERNAL and MAIN use the same 4 KB target, making the comparison independent of an accidental target difference:

postgres=# CREATE TABLE external_4k(
    n integer PRIMARY KEY, doc jsonb
)
    WITH (toast_tuple_target=4096)
;

CREATE TABLE

postgres=# CREATE TABLE main_4k(
    n integer PRIMARY KEY, doc jsonb
)
    WITH (toast_tuple_target=4096)
;

CREATE TABLE

postgres=# CREATE TABLE plain_case(
    n integer PRIMARY KEY, doc jsonb
);

CREATE TABLE

postgres=# ALTER TABLE external_4k
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE main_4k
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE plain_case
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE external_4k
            ALTER COLUMN doc SET STORAGE EXTERNAL
;

ALTER TABLE

postgres=# ALTER TABLE main_4k
            ALTER COLUMN doc SET STORAGE MAIN
;

ALTER TABLE

postgres=# ALTER TABLE plain_case
            ALTER COLUMN doc SET STORAGE PLAIN
;

ALTER TABLE

postgres=# INSERT INTO external_4k
    SELECT * FROM input_documents ORDER BY n
;

INSERT 0 7

postgres=# INSERT INTO main_4k
    SELECT * FROM input_documents ORDER BY n
;

INSERT 0 7

postgres=# INSERT INTO plain_case
    SELECT * FROM input_documents WHERE n<=2 ORDER BY n
;

INSERT 0 3

postgres=# SELECT policy, 1 << n AS payload_kib,
           pg_column_compression(doc) AS compression,
           pg_column_size(doc) AS stored_bytes,
           pg_column_toast_chunk_id(doc) IS NOT NULL AS external
    FROM (
      SELECT 1 AS position,'EXTERNAL' AS policy,* FROM external_4k
      UNION ALL SELECT 2,'MAIN',* FROM main_4k
      UNION ALL SELECT 3,'PLAIN',* FROM plain_case
    ) AS cases
    ORDER BY position,n
;

  policy  | payload_kib | compression | stored_bytes | external 
----------+-------------+-------------+--------------+----------
 EXTERNAL |           1 | (null)      |         1041 | f
 EXTERNAL |           2 | (null)      |         2065 | f
 EXTERNAL |           4 | (null)      |         4109 | t
 EXTERNAL |          64 | (null)      |        65549 | t
 EXTERNAL |         512 | (null)      |       524301 | t
 EXTERNAL |        1024 | (null)      |      1048589 | t
 EXTERNAL |        2048 | (null)      |      2097165 | t
 MAIN     |           1 | (null)      |         1041 | f
 MAIN     |           2 | (null)      |         2065 | f
 MAIN     |           4 | lz4         |           47 | f
 MAIN     |          64 | lz4         |          288 | f
 MAIN     |         512 | lz4         |         2087 | f
 MAIN     |        1024 | lz4         |         4143 | f
 MAIN     |        2048 | lz4         |         8252 | t
 PLAIN    |           1 | (null)      |         1041 | f
 PLAIN    |           2 | (null)      |         2065 | f
 PLAIN    |           4 | (null)      |         4113 | f
(17 rows)
Enter fullscreen mode Exit fullscreen mode

For EXTERNAL, the 2 KB payload is still inline, but the 4 KB payload is external without compression.

For MAIN, the 1 MB payload compresses to 4,143 inline bytes. It can stay inline because MAIN postpones externalization to its last-resort target: the maximum tuple size that fits on one page. The larger 2 MB-payload document still becomes external after compression.

For PLAIN, no value compresses or becomes external. A row that cannot fit on the heap page fails. Run this separate statement expecting an error:

postgres=# INSERT INTO plain_case
    VALUES (3,jsonb_build_object('v',repeat('x',8192)))
;

ERROR:  54000: row is too big: size 8240, maximum size 8160
LOCATION:  RelationGetBufferForTuple, hio.c:531
Enter fullscreen mode Exit fullscreen mode

The 8,160-byte maximum in this error is the heap-page tuple limit, not the 2,032-byte TOAST activation threshold. They answer different questions.

This is the complete SQL control surface relevant to these cases:

Control Scope Changes the fixed activation threshold?
ALTER COLUMN ... SET STORAGE ... Column's permitted/preferred representation No
ALTER COLUMN ... SET COMPRESSION pglz/lz4 Codec when compression is attempted No
default_toast_compression Default codec when the column has no override No
ALTER TABLE ... SET (toast_tuple_target=...) Whole-tuple reduction target after entry No
TOAST_TUPLE_THRESHOLD, TOAST_TUPLES_PER_PAGE Compiled activation policy Source change
TOAST_MAX_CHUNK_SIZE, EXTERN_TUPLES_PER_PAGE Compiled external chunk capacity Separate from activation
BLCKSZ Compiled page size Changes several defaults together

Changing page and chunk sizes requires a freshly initialized cluster. They are not runtime GUCs. Changing only a main-tuple threshold is not inherently an on-disk page-format change, but a correct implementation still has to consider all heap entry paths and TOAST-table creation.

The compression algorithm still matters after activation

Compression is an attempt, not a storage guarantee. Pglz's default strategy has a 32-byte minimum input, a 25% minimum reduction policy, and a 1,024-byte encoded-output first-success limit: it aborts if it has emitted that much without finding a match.

Those are pglz strategy rules, not user-configurable document thresholds. LZ4 does not impose pglz's first-success or minimum-rate rules. Both must still pass TOAST's header/alignment savings check.

Changing the column codec does not rewrite existing values:

postgres=# CREATE TABLE codec_example(
    id integer PRIMARY KEY, doc jsonb
);

CREATE TABLE

postgres=# ALTER TABLE codec_example
            ALTER COLUMN doc SET COMPRESSION pglz
;

ALTER TABLE

postgres=# INSERT INTO codec_example
    VALUES
      (1,jsonb_build_object('v',repeat('x',4096)))
;

INSERT 0 1

postgres=# ALTER TABLE codec_example
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# INSERT INTO codec_example
    VALUES
      (2,jsonb_build_object('v',repeat('x',4096)))
;

INSERT 0 1

postgres=# SELECT id, pg_column_compression(doc) AS compression,
           pg_column_size(doc) AS stored_bytes
    FROM codec_example ORDER BY id
;

 id | compression | stored_bytes 
----+-------------+--------------
  1 | pglz        |           71
  2 | lz4         |           47
(2 rows)
Enter fullscreen mode Exit fullscreen mode

The old value remains pglz. The freshly constructed new value uses LZ4. An update that leaves the document unchanged, or a copy of an already compressed datum, need not recompress it. Therefore, changing the setting is not proof that existing data now uses the new codec.

Larger pages: fewer documents become compression candidates

Larger pages can reduce the number of external chunks. That does not mean they preserve compression behavior.

I rebuilt PostgreSQL 19 RC1 with --with-blocksize=8, 16, and 32, keeping the other configure options the same, then initialized fresh clusters.

Do not reuse a data directory across page sizes. A different page size also requires rebuilding linked extensions against that PostgreSQL installation. This comparison deliberately uses core JSONB rather than mixing extension versions or a stock image with a different source-built baseline.

8 KB: all but the smallest repeated-field document compress

The samples array now contains 8, 64, 128, or 256 similarly structured objects. All tests explicitly select LZ4. One table retains the build's default target. The other sets it to 2040.

postgres=# CREATE TEMP VIEW page_inputs AS
    SELECT n, jsonb_build_object('samples', (
             SELECT jsonb_agg(jsonb_build_object('temperature',20,'humidity',60))
             FROM generate_series(1,n)
           )) AS doc
    FROM (VALUES (8),(64),(128),(256)) AS counts(n)
;

CREATE VIEW

postgres=# CREATE TABLE page_default(
    n integer PRIMARY KEY, doc jsonb
);

CREATE TABLE

postgres=# CREATE TABLE page_target(
    n integer PRIMARY KEY, doc jsonb
)
    WITH (toast_tuple_target=2040)
;

CREATE TABLE

postgres=# ALTER TABLE page_default
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# ALTER TABLE page_target
            ALTER COLUMN doc SET COMPRESSION lz4
;

ALTER TABLE

postgres=# INSERT INTO page_default
    SELECT * FROM page_inputs ORDER BY n
;

INSERT 0 4

postgres=# INSERT INTO page_target
    SELECT * FROM page_inputs ORDER BY n
;

INSERT 0 4

postgres=# CREATE VIEW page_observations AS
    SELECT policy, n AS objects,
           pg_column_size(doc::text::jsonb) AS logical_bytes,
           pg_column_compression(doc) AS compression,
           pg_column_size(doc) AS stored_bytes,
           pg_column_toast_chunk_id(doc) IS NOT NULL AS external
    FROM (
      SELECT 1 AS position,'default' AS policy,* FROM page_default
      UNION ALL SELECT 2,'target_2040',* FROM page_target
    ) AS cases ORDER BY position,n
;

CREATE VIEW

postgres=# SELECT * FROM page_observations
;

   policy    | objects | logical_bytes | compression | stored_bytes | external 
-------------+---------+---------------+-------------+--------------+----------
 default     |       8 |           508 | (null)      |          508 | f
 default     |      64 |          3868 | lz4         |          126 | f
 default     |     128 |          7708 | lz4         |          150 | f
 default     |     256 |         15388 | lz4         |          198 | f
 target_2040 |       8 |           508 | (null)      |          508 | f
 target_2040 |      64 |          3868 | lz4         |          126 | f
 target_2040 |     128 |          7708 | lz4         |          150 | f
 target_2040 |     256 |         15388 | lz4         |          198 | f
(8 rows)
Enter fullscreen mode Exit fullscreen mode

16 KB: the 3,868-byte JSONB document no longer compresses

The complete setup is unchanged. This is the same final query on the 16 KB build:

postgres=# SELECT * FROM page_observations
;

   policy    | objects | logical_bytes | compression | stored_bytes | external 
-------------+---------+---------------+-------------+--------------+----------
 default     |       8 |           508 | (null)      |          508 | f
 default     |      64 |          3868 | (null)      |         3868 | f
 default     |     128 |          7708 | lz4         |          150 | f
 default     |     256 |         15388 | lz4         |          198 | f
 target_2040 |       8 |           508 | (null)      |          508 | f
 target_2040 |      64 |          3868 | (null)      |         3868 | f
 target_2040 |     128 |          7708 | lz4         |          150 | f
 target_2040 |     256 |         15388 | lz4         |          198 | f
(8 rows)
Enter fullscreen mode Exit fullscreen mode

The tuple containing 3,868 JSONB bytes remains below the 4,080-byte activation gate. Lowering its target to 2040 does not make the caller invoke TOAST.

32 KB: the 7,708-byte document also stops compressing

The same query on the 32 KB build:

postgres=# SELECT * FROM page_observations
;

   policy    | objects | logical_bytes | compression | stored_bytes | external 
-------------+---------+---------------+-------------+--------------+----------
 default     |       8 |           508 | (null)      |          508 | f
 default     |      64 |          3868 | (null)      |         3868 | f
 default     |     128 |          7708 | (null)      |         7708 | f
 default     |     256 |         15388 | lz4         |          198 | f
 target_2040 |       8 |           508 | (null)      |          508 | f
 target_2040 |      64 |          3868 | (null)      |         3868 | f
 target_2040 |     128 |          7708 | (null)      |         7708 | f
 target_2040 |     256 |         15388 | lz4         |          198 | f
(8 rows)
Enter fullscreen mode Exit fullscreen mode

The 7,708-byte JSONB value compressed to 150 bytes on the 8- and 16-KB builds. On the 32 KB build, the tuple stays below the 8,176-byte gate, and the datum remains at 7,708 bytes. A 2040-byte target does not restore compression.

The 15,388-byte positive control compresses on all three builds. Its tuple exceeds even the largest activation threshold.

UPDATE has the same outer gate

The following statements reconstruct each JSONB value on UPDATE, rather than reassigning an existing compressed value. On the 32 KB build:

postgres=# UPDATE page_default d
    SET doc=jsonb_build_object('samples', (
        SELECT jsonb_agg(jsonb_build_object('temperature',20,'humidity',60))
        FROM generate_series(1,d.n)
    ))
;

UPDATE 4

postgres=# UPDATE page_target d
    SET doc=jsonb_build_object('samples', (
        SELECT jsonb_agg(jsonb_build_object('temperature',20,'humidity',60))
        FROM generate_series(1,d.n)
    ))
;

UPDATE 4

postgres=# SELECT * FROM page_observations
;

   policy    | objects | logical_bytes | compression | stored_bytes | external 
-------------+---------+---------------+-------------+--------------+----------
 default     |       8 |           508 | (null)      |          508 | f
 default     |      64 |          3868 | (null)      |         3868 | f
 default     |     128 |          7708 | (null)      |         7708 | f
 default     |     256 |         15388 | lz4         |          198 | f
 target_2040 |       8 |           508 | (null)      |          508 | f
 target_2040 |      64 |          3868 | (null)      |         3868 | f
 target_2040 |     128 |          7708 | (null)      |         7708 | f
 target_2040 |     256 |         15388 | lz4         |          198 | f
(8 rows)
Enter fullscreen mode Exit fullscreen mode

The INSERT and UPDATE results matched on all three servers. This is why looking only at the inner compression loop, without the INSERT/UPDATE callers, can give the wrong interpretation of toast_tuple_target.

Chunk capacity does grow

This 8 KB-page case inserts a 65,536-character text value with STORAGE EXTERNAL and reads its actual TOAST chunks. \gset assigns the TOAST relation name to a psql variable, avoiding a hardcoded relation OID:

postgres=# CREATE TABLE chunk_probe(
    doc text
);

CREATE TABLE

postgres=# ALTER TABLE chunk_probe
            ALTER COLUMN doc SET STORAGE EXTERNAL
;

ALTER TABLE

postgres=# INSERT INTO chunk_probe
    VALUES (repeat('z',65536))
;

INSERT 0 1

postgres=# SELECT reltoastrelid::regclass AS toast_relation
    FROM pg_class WHERE oid='chunk_probe'::regclass
    \gset

postgres=# SELECT max(octet_length(chunk_data)) AS max_chunk_bytes FROM :toast_relation
;

 max_chunk_bytes 
-----------------
            1996
(1 row)
Enter fullscreen mode Exit fullscreen mode

Run the same statements in the 16 KB instance:

postgres=# CREATE TABLE chunk_probe(
    doc text
);

CREATE TABLE

postgres=# ALTER TABLE chunk_probe
            ALTER COLUMN doc SET STORAGE EXTERNAL
;

ALTER TABLE

postgres=# INSERT INTO chunk_probe
    VALUES (repeat('z',65536))
;

INSERT 0 1

postgres=# SELECT reltoastrelid::regclass AS toast_relation
    FROM pg_class WHERE oid='chunk_probe'::regclass
    \gset

postgres=# SELECT max(octet_length(chunk_data)) AS max_chunk_bytes FROM :toast_relation
;

 max_chunk_bytes 
-----------------
            4044
(1 row)
Enter fullscreen mode Exit fullscreen mode

And in the 32 KB instance:

postgres=# CREATE TABLE chunk_probe(
    doc text
);

CREATE TABLE

postgres=# ALTER TABLE chunk_probe
            ALTER COLUMN doc SET STORAGE EXTERNAL
;

ALTER TABLE

postgres=# INSERT INTO chunk_probe
    VALUES (repeat('z',65536))
;

INSERT 0 1

postgres=# SELECT reltoastrelid::regclass AS toast_relation
    FROM pg_class WHERE oid='chunk_probe'::regclass
    \gset

postgres=# SELECT max(octet_length(chunk_data)) AS max_chunk_bytes FROM :toast_relation
;

 max_chunk_bytes 
-----------------
            8140
(1 row)
Enter fullscreen mode Exit fullscreen mode

The maximum payloads are 1,996, 4,044, and 8,140 bytes.

That is an independent benefit larger pages can provide for sufficiently large values. It does not compensate automatically for the loss of compression on smaller documents. Neither fewer chunks nor larger pages alone establishes lower allocation or better read performance.

Why this behavior remains

The documented TOAST design handles values that cannot fit directly in heap tuples, and keeps main rows smaller so queries not needing large attributes can avoid fetching them. It is a general relational storage mechanism, predating JSONB, not a policy specifically optimized for repeated document schemas.

I searched the pgsql-hackers discussions with the pghacker MCP. This separation was already being discussed explicitly in 2019:

The subsequent discussion identified two broader problems: a bounded-width relation may have no TOAST table, so lowering its trigger could require creating one under a stronger lock. Rejecting previously accepted low targets could break dump restores. These are implementation and compatibility constraints, not evidence that the current fraction is optimal for small JSONB documents.

If you want to build and run the three page sizes

The SQL above is sufficient for an existing PostgreSQL 19 server. The following commands run in Bash on a machine with Docker. The Dockerfile is supplied inline. No repository checkout or external code file is required.

set -euo pipefail

for kb in 8 16 32; do
  docker build --build-arg BLOCK_SIZE="$kb" \
    -t "toast-pg19rc1:${kb}k" - <<'DOCKERFILE'
FROM postgres:19rc1-alpine
RUN apk add --no-cache build-base curl bison flex perl linux-headers \
    icu-dev lz4-dev openssl-dev readline-dev zlib-dev
ARG BLOCK_SIZE=8
RUN set -eu; \
    case "$BLOCK_SIZE" in 8|16|32) ;; *) exit 1 ;; esac; \
    mkdir /tmp/page-build; \
    curl --fail --location \
      "https://ftp.postgresql.org/pub/source/v${PG_VERSION}/postgresql-${PG_VERSION}.tar.bz2" \
      -o /tmp/page-build/postgres.tar.bz2; \
    tar -xjf /tmp/page-build/postgres.tar.bz2 -C /tmp/page-build; \
    cd "/tmp/page-build/postgresql-${PG_VERSION}"; \
    ./configure --prefix=/usr/local --with-blocksize="$BLOCK_SIZE" \
      --with-lz4 --with-icu --with-openssl; \
    make -s -j2; \
    make -s install; \
    cd /; \
    rm -rf /tmp/page-build
DOCKERFILE
done
Enter fullscreen mode Exit fullscreen mode

Choose one page size, start a fresh disposable instance, and open psql:

kb=8                      # repeat with 16 and 32
pg="toast-research-${kb}k-$$"
docker run --detach --name "$pg" --network none \
  -e POSTGRES_HOST_AUTH_METHOD=trust "toast-pg19rc1:${kb}k"

ready=no
for attempt in $(seq 1 60); do
  if docker exec "$pg" pg_isready -U postgres; then
    ready=yes
    break
  fi
  sleep 1
done
if [ "$ready" != yes ]; then
  docker logs "$pg"
  docker rm --force --volumes "$pg"
  exit 1
fi

docker exec -it "$pg" psql -X -v ON_ERROR_STOP=1 \
  -P pager=off -P 'null=(null)' -P format=aligned \
  -P linestyle=ascii -P border=1 -U postgres -d postgres
Enter fullscreen mode Exit fullscreen mode

Use the 8 KB instance for the storage-strategy cases. For the page comparison, run the same page_inputs, page_default, page_target, and observation statements on each instance. Repeat the chunk case on all three.

The deliberately failing PLAIN insert is a separate statement: do not include it in a successful setup batch with ON_ERROR_STOP.

After leaving psql with \q, remove only the container created above:

docker rm --force --volumes "$pg"
Enter fullscreen mode Exit fullscreen mode

Conclusion: separate compression eligibility from row placement

The experiment does not show that PostgreSQL cannot compress document redundancy. It shows that the eligibility policy is tied to heap tuple size and page size, rather than to a document-column compression policy.

Under the ordinary defaults, increasing page size from 8 to 16 to 32 KB raises activation from about 2 to 4 to 8 KB. Some highly compressible documents therefore stop reaching the compressor. A lower toast_tuple_target cannot recover those missed attempts. A higher target can suppress attempts further.

A useful direction is to make the hardcoded activation policy configurable, separately from the reduction/inline target. For example, a 32 KB-page server could retain a lower activation threshold without adopting a smaller page. For document workloads, a per-column compression-eligibility threshold may be more directly useful than a whole-row threshold.

This isn't as simple as making the hardcoded value a parameter. A safe implementation must cover INSERT, UPDATE, rewrites, TOAST-table creation, locking, and existing representations. The appropriate default would still need insertion CPU and read/update benchmarks on representative document sizes. There is no basis here for declaring that every 500-byte document should be compressed.

The narrower conclusion is reproducible: the quarter-page activation gate can prevent useful compression, and larger pages may widen the gap. Currently, the DocumentDB extension relies on the TOAST mechanism, trading MongoDB’s compression behavior for compatibility with PostgreSQL storage. I've seen many MongoDB workloads store small documents (and ran a poll on LinkedIn), much like a relational model—even though document models are meant to store larger aggregates to favor single-document data locality and single-IO transactions.

Top comments (0)