From eb31d8f920de8369bb951bace975c4741699678a Mon Sep 17 00:00:00 2001
From: Nikhil Kumar Veldanda <veldanda.nikhilkumar17@gmail.com>
Date: Thu, 15 Oct 2026 00:11:33 -0400
Subject: [PATCH v16 4/5] Add TOAST compression dictionaries for zstd and lz4

This adds dictionary-aware TOAST compression methods, zstd_dict and
lz4_dict, that compress with a dictionary trained for the column.  It
builds on the extensible method encoding: a dictionary-compressed datum
is the long form of the compressed header followed by the 4-byte OID of
its dictionary, then the codec's payload.  The method byte says which
codec follows, so the same layout serves both.

The difficulty with dictionaries was never compression, it was knowing
when a dictionary can be dropped.  Earlier proposals either kept every
dictionary forever, or recorded in pg_depend every table a compressed
value might have been copied into; both were found wanting (dictionaries
that cannot be dropped accumulate, e.g. with tenants coming and going;
dependencies spread to every table and cannot be maintained under
RowExclusiveLock, and no list of "copy" commands is complete).

This patch instead keeps dictionary-compressed values inside the column
that owns their dictionary, which makes the lifecycle local:

- Ownership.  pg_toast_dictionary rows belong to one (relid, attnum),
  like pg_statistic rows, and are deleted with the table.  A dropped
  column's values stay on disk until the next rewrite, and so do its
  dictionaries.  No pg_depend entries.

- Containment.  heap_fill_tuple() sets HEAP_HASEXTERNAL for a tuple
  holding a dictionary-compressed datum, so every path that already makes
  a tuple self-contained before it can outlive its source (composite
  datums, tuplestores and holdable cursors, PL/pgSQL records and
  non-atomic variables, domain checks on record fields, index and BRIN
  tuples) reaches the datum, and
  detoast_external_attr() decompresses it as it would fetch a TOAST
  pointer, also behind an indirect pointer, and datumTransfer() (so
  SPI_datumTransfer()) flattens one.  When storing into a heap,
  toast_tuple_init() keeps a dictionary datum compressed only if the
  target column owns its dictionary and the dictionary is still
  current, and decompresses it otherwise, so it is recompressed with
  the target column's own method.  An UPDATE that leaves the value
  unchanged keeps it as is, as it keeps an unchanged TOAST pointer, so
  it costs no catalog lookup.

- Retirement.  Training a new dictionary marks the previous current one
  non-current; a non-current dictionary stays readable until the table is
  rewritten (VACUUM FULL, CLUSTER, REPACK, rewriting ALTER TABLE), during
  which toast_dictionary_keep() rejects it so every value using it is
  recompressed, and finish_heap_swap() then deletes it.  TRUNCATE,
  including that of an ON COMMIT DELETE ROWS table, deletes it too.

Dictionaries are trained by pg_train_toast_dictionary(rel, col,
dict_size), which samples the column and calls zstd's ZDICT builder.
It checks that the caller owns the table before locking it.
lz4 has no trainer of its own; its own documentation points at ZDICT, so
an lz4 dictionary is the same trained byte buffer, and only the
compress/decompress calls differ between the two codecs.

The codec-specific parts are confined to a DictCompressRoutine (compress
a value with a dictionary, decompress a whole value, decompress a prefix)
selected by the datum's method byte.  The catalog, ownership,
containment, retirement, and training are codec-independent, so adding a
future dictionary-aware codec is one routine plus a case.  zstd uses
ZSTD_compress_usingCDict / ZSTD_decompress_usingDDict with per-backend
cached digested dictionaries; lz4 uses LZ4_loadDict +
LZ4_compress_fast_continue and LZ4_decompress_safe_usingDict, caching the
raw dictionary bytes per backend since lz4 has no digested object.  Each
cache holds a few entries, and a change to a dictionary row evicts only
that dictionary.

The write path needs a column's current dictionary for every value it
compresses, so toast_dictionary_current() caches it in the relcache
entry, one slot per column.  dict_install() invalidates the entry
whenever a column's current dictionary changes.

Training an lz4_dict column needs a server built with both lz4 and zstd
(for ZDICT); without a dictionary either method behaves like its plain
codec.

pg_upgrade preserves dictionary OIDs (they are embedded in every datum)
via binary_upgrade_add_toast_dictionary(); the regression database keeps
a table with a current and a retired dictionary so that the pg_upgrade
test covers this.  amcheck and \d+ know the new methods, and pg_dump
dumps the column options.

Co-authored-by: Greg Burd <greg@burd.me>
Suggested-by: Dharin Shah <dharinshah95@gmail.com>
Reviewed-by: Manuel Reyes Bravo <manuelreyesbravo@gmail.com>
Reviewed-by: Hannu Krosing <hannuk@google.com>
Discussion: https://postgr.es/m/CAFAfj_GGY4W81XjtYLvYOhqPtvfZbBNj=YRS6Mq_0SA9AqEwoQ@mail.gmail.com
---
 contrib/amcheck/verify_heapam.c               |    2 +
 doc/src/sgml/catalogs.sgml                    |  106 +-
 doc/src/sgml/func/func-admin.sgml             |   24 +
 doc/src/sgml/ref/alter_table.sgml             |    9 +
 doc/src/sgml/ref/create_table.sgml            |   16 +
 src/backend/access/brin/brin_tuple.c          |    3 +-
 src/backend/access/common/Makefile            |    1 +
 src/backend/access/common/detoast.c           |   36 +
 src/backend/access/common/heaptuple.c         |   12 +
 src/backend/access/common/indextuple.c        |    3 +-
 src/backend/access/common/meson.build         |    1 +
 src/backend/access/common/toast_compression.c |   21 +-
 src/backend/access/common/toast_dictionary.c  | 1188 +++++++++++++++++
 src/backend/access/common/toast_internals.c   |   54 +
 src/backend/access/heap/heaptoast.c           |    7 +-
 src/backend/access/table/toast_helper.c       |   38 +-
 src/backend/catalog/heap.c                    |   24 +-
 src/backend/catalog/system_views.sql          |    3 +
 src/backend/commands/repack.c                 |    8 +
 src/backend/commands/tablecmds.c              |    7 +
 src/backend/executor/tstoreReceiver.c         |    4 +-
 src/backend/utils/adt/datum.c                 |   14 +
 src/backend/utils/adt/expandedrecord.c        |   19 +-
 src/backend/utils/adt/varlena.c               |    6 +
 src/backend/utils/cache/relcache.c            |    5 +
 src/bin/pg_dump/pg_dump.c                     |   70 +
 src/bin/psql/describe.c                       |    6 +-
 src/bin/psql/tab-complete.in.c                |    2 +-
 src/include/access/toast_compression.h        |   24 +-
 src/include/access/toast_dictionary.h         |   65 +
 src/include/access/toast_internals.h          |    2 +
 src/include/catalog/Makefile                  |    1 +
 src/include/catalog/meson.build               |    1 +
 src/include/catalog/pg_proc.dat               |   11 +
 src/include/catalog/pg_toast_dictionary.h     |   65 +
 src/include/utils/rel.h                       |   15 +
 src/pl/plpgsql/src/pl_exec.c                  |    4 +-
 .../regress/expected/compression_lz4_dict.out |  112 ++
 .../expected/compression_lz4_dict_1.out       |   12 +
 .../expected/compression_zstd_dict.out        |  543 ++++++++
 .../expected/compression_zstd_dict_1.out      |   10 +
 src/test/regress/expected/oidjoins.out        |    1 +
 src/test/regress/parallel_schedule            |    2 +-
 src/test/regress/sql/compression_lz4_dict.sql |   64 +
 .../regress/sql/compression_zstd_dict.sql     |  266 ++++
 src/tools/pgindent/typedefs.list              |    6 +
 46 files changed, 2870 insertions(+), 23 deletions(-)
 create mode 100644 src/backend/access/common/toast_dictionary.c
 create mode 100644 src/include/access/toast_dictionary.h
 create mode 100644 src/include/catalog/pg_toast_dictionary.h
 create mode 100644 src/test/regress/expected/compression_lz4_dict.out
 create mode 100644 src/test/regress/expected/compression_lz4_dict_1.out
 create mode 100644 src/test/regress/expected/compression_zstd_dict.out
 create mode 100644 src/test/regress/expected/compression_zstd_dict_1.out
 create mode 100644 src/test/regress/sql/compression_lz4_dict.sql
 create mode 100644 src/test/regress/sql/compression_zstd_dict.sql

diff --git a/contrib/amcheck/verify_heapam.c b/contrib/amcheck/verify_heapam.c
index 9fd5db69830..4c39db8070d 100644
--- a/contrib/amcheck/verify_heapam.c
+++ b/contrib/amcheck/verify_heapam.c
@@ -1839,6 +1839,8 @@ check_tuple_attribute(HeapCheckContext *ctx)
 			case TOAST_PGLZ_COMPRESSION_ID:
 			case TOAST_LZ4_COMPRESSION_ID:
 			case TOAST_ZSTD_COMPRESSION_ID:
+			case TOAST_ZSTD_DICT_COMPRESSION_ID:
+			case TOAST_LZ4_DICT_COMPRESSION_ID:
 				valid = (toast_compression_id_needs_cmid_byte(cmid) ==
 						 VARTAG_IS_ONDISK_LONG(va_tag_value));
 				break;
diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index c9858e5eaaf..ebc8ba3c0dc 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -325,6 +325,11 @@
       <entry>tablespaces within this database cluster</entry>
      </row>
 
+     <row>
+      <entry><link linkend="catalog-pg-toast-dictionary"><structname>pg_toast_dictionary</structname></link></entry>
+      <entry>compression dictionaries for TOAST</entry>
+     </row>
+
      <row>
       <entry><link linkend="catalog-pg-transform"><structname>pg_transform</structname></link></entry>
       <entry>transforms (data type to procedural language conversions)</entry>
@@ -1269,8 +1274,12 @@
        (see <xref linkend="guc-default-toast-compression"/>).  Otherwise,
        <literal>'p'</literal> selects pglz compression,
        <literal>'l'</literal> selects <productname>LZ4</productname>
-       compression, and <literal>'z'</literal> selects
-       <productname>Zstandard</productname> compression.
+       compression, <literal>'z'</literal> selects
+       <productname>Zstandard</productname> compression, and
+       <literal>'d'</literal> and <literal>'e'</literal> select
+       <productname>Zstandard</productname> and
+       <productname>LZ4</productname> compression with a compression
+       dictionary (see <xref linkend="catalog-pg-toast-dictionary"/>).
        However, this field is ignored
        whenever <structfield>attstorage</structfield> does not allow
        compression.
@@ -8506,6 +8515,99 @@ SCRAM-SHA-256$<replaceable>&lt;iteration count&gt;</replaceable>:<replaceable>&l
  </sect1>
 
 
+ <sect1 id="catalog-pg-toast-dictionary">
+  <title><structname>pg_toast_dictionary</structname></title>
+
+  <indexterm zone="catalog-pg-toast-dictionary">
+   <primary>pg_toast_dictionary</primary>
+  </indexterm>
+
+  <para>
+   The catalog <structname>pg_toast_dictionary</structname> stores the
+   compression dictionaries used by the <literal>zstd_dict</literal> and
+   <literal>lz4_dict</literal> TOAST compression methods.  Each dictionary
+   belongs to one column of one table; it is created by
+   <function>pg_train_toast_dictionary</function>, and removed when its table
+   is dropped, or when a rewrite or truncation of the table has removed every
+   value that used it.  The dictionaries of a dropped column stay until the
+   next rewrite of the table.  Values compressed with a dictionary are never
+   stored outside the column that owns it.
+  </para>
+
+  <para>
+   Since dictionaries are built from the contents of the column, this
+   catalog is not readable by the public.
+  </para>
+
+  <table>
+   <title><structname>pg_toast_dictionary</structname> Columns</title>
+   <tgroup cols="1">
+    <thead>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       Column Type
+      </para>
+      <para>
+       Description
+      </para></entry>
+     </row>
+    </thead>
+
+    <tbody>
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>oid</structfield> <type>oid</type>
+      </para>
+      <para>
+       Dictionary identifier, as recorded in each value compressed with it
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tdrelid</structfield> <type>oid</type>
+       (references <link linkend="catalog-pg-class"><structname>pg_class</structname></link>.<structfield>oid</structfield>)
+      </para>
+      <para>
+       The table owning the dictionary
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tdattnum</structfield> <type>int2</type>
+       (references <link linkend="catalog-pg-attribute"><structname>pg_attribute</structname></link>.<structfield>attnum</structfield>)
+      </para>
+      <para>
+       The column owning the dictionary
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tdcurrent</structfield> <type>bool</type>
+      </para>
+      <para>
+       True for the dictionary used to compress new values; a column has at
+       most one.  Other dictionaries of the column are kept only until the
+       next rewrite of the table.
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>tddict</structfield> <type>bytea</type>
+      </para>
+      <para>
+       Dictionary content, in the format of the zstd library (which is also
+       what an lz4 dictionary consists of)
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
  <sect1 id="catalog-pg-transform">
   <title><structname>pg_transform</structname></title>
 
diff --git a/doc/src/sgml/func/func-admin.sgml b/doc/src/sgml/func/func-admin.sgml
index ed872264f02..44038d77148 100644
--- a/doc/src/sgml/func/func-admin.sgml
+++ b/doc/src/sgml/func/func-admin.sgml
@@ -1586,6 +1586,30 @@ postgres=# SELECT '0/0'::pg_lsn + pd.segment_number * ps.setting::int + :offset
        </para></entry>
       </row>
 
+      <row>
+       <entry role="func_table_entry"><para role="func_signature">
+        <indexterm>
+         <primary>pg_train_toast_dictionary</primary>
+        </indexterm>
+        <function>pg_train_toast_dictionary</function> ( <parameter>rel</parameter> <type>regclass</type>, <parameter>col</parameter> <type>name</type> <optional>, <parameter>dict_size</parameter> <type>integer</type> <literal>DEFAULT</literal> <literal>65536</literal> </optional> )
+        <returnvalue>oid</returnvalue>
+       </para>
+       <para>
+        Trains a compression dictionary of at most
+        <parameter>dict_size</parameter> bytes from the current values of
+        column <parameter>col</parameter>, which must use the
+        <literal>zstd_dict</literal> or <literal>lz4_dict</literal>
+        compression method, and makes it the
+        dictionary used to compress new values of that column.  Returns the
+        dictionary's OID, or <literal>NULL</literal> if no dictionary could
+        be trained from the values found.  The column's previous dictionary,
+        if any, remains in use for the values compressed with it until the
+        next rewrite of the table, such as <command>VACUUM FULL</command>,
+        which recompresses them and removes it.  Only the table's owner can
+        call this function.
+       </para></entry>
+      </row>
+
       <row>
        <entry role="func_table_entry"><para role="func_signature">
         <indexterm>
diff --git a/doc/src/sgml/ref/alter_table.sgml b/doc/src/sgml/ref/alter_table.sgml
index 004bfe58812..601d1c2089f 100644
--- a/doc/src/sgml/ref/alter_table.sgml
+++ b/doc/src/sgml/ref/alter_table.sgml
@@ -475,6 +475,15 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       consulting the <xref linkend="guc-default-toast-compression"/> setting
       at the time of data insertion to determine the method to use.
      </para>
+     <para>
+      <literal>zstd_dict</literal> is <literal>zstd</literal> with a
+      compression dictionary trained for the column by
+      <function>pg_train_toast_dictionary</function>; until one is trained it
+      behaves like <literal>zstd</literal>.  <literal>lz4_dict</literal> is
+      the same arrangement for <literal>lz4</literal>.  Values compressed with
+      a dictionary are decompressed when copied to any other column or table.
+      See <link linkend="catalog-pg-toast-dictionary"><structname>pg_toast_dictionary</structname></link>.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index 7340c61c1b1..cfb1402051c 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -375,6 +375,22 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
       <xref linkend="guc-default-toast-compression"/> setting at the time of
       data insertion to determine the method to use.
      </para>
+     <para>
+      <literal>zstd_dict</literal> is <literal>zstd</literal> with a
+      compression dictionary trained for the column by
+      <function>pg_train_toast_dictionary</function>; until one is trained it
+      behaves like <literal>zstd</literal>.  Values compressed with a
+      dictionary are decompressed when copied to any other column or table.
+      See <link linkend="catalog-pg-toast-dictionary"><structname>pg_toast_dictionary</structname></link>.
+     </para>
+     <para>
+      <literal>lz4_dict</literal> is the same arrangement for
+      <literal>lz4</literal>: it uses a compression dictionary trained by
+      <function>pg_train_toast_dictionary</function> (the trainer is zstd's,
+      so the server must be built with both <literal>lz4</literal> and
+      <literal>zstd</literal>), and behaves like <literal>lz4</literal> until
+      one is trained.
+     </para>
     </listitem>
    </varlistentry>
 
diff --git a/src/backend/access/brin/brin_tuple.c b/src/backend/access/brin/brin_tuple.c
index e0cdff53b87..e0a6f629c52 100644
--- a/src/backend/access/brin/brin_tuple.c
+++ b/src/backend/access/brin/brin_tuple.c
@@ -206,7 +206,8 @@ brin_form_tuple(BrinDesc *brdesc, BlockNumber blkno, BrinMemTuple *tuple,
 			 * even for range with non-NULL data? E.g. degenerate bloom filter
 			 * may be thrown away, etc.
 			 */
-			if (VARATT_IS_EXTERNAL(DatumGetPointer(value)))
+			if (VARATT_IS_EXTERNAL(DatumGetPointer(value)) ||
+				VARATT_IS_COMPRESSED_DICT(DatumGetPointer(value)))
 			{
 				value = PointerGetDatum(detoast_external_attr((varlena *)
 															  DatumGetPointer(value)));
diff --git a/src/backend/access/common/Makefile b/src/backend/access/common/Makefile
index e78de312659..6f3e0a796a9 100644
--- a/src/backend/access/common/Makefile
+++ b/src/backend/access/common/Makefile
@@ -27,6 +27,7 @@ OBJS = \
 	syncscan.o \
 	tidstore.o \
 	toast_compression.o \
+	toast_dictionary.o \
 	toast_internals.o \
 	tupconvert.o \
 	tupdesc.o
diff --git a/src/backend/access/common/detoast.c b/src/backend/access/common/detoast.c
index 610c8c785a3..d75abc7a883 100644
--- a/src/backend/access/common/detoast.c
+++ b/src/backend/access/common/detoast.c
@@ -17,6 +17,7 @@
 #include "access/table.h"
 #include "access/tableam.h"
 #include "access/toast_compression.h"
+#include "access/toast_dictionary.h"
 #include "access/toast_internals.h"
 #include "common/int.h"
 #include "common/pg_lzcompress.h"
@@ -62,6 +63,15 @@ detoast_external_attr(varlena *attr)
 		 * This is an external stored plain value
 		 */
 		result = toast_fetch_datum(attr);
+
+		/* see below about values compressed with a dictionary */
+		if (VARATT_IS_COMPRESSED_DICT(result))
+		{
+			varlena    *tmp = result;
+
+			result = toast_dict_decompress_datum(tmp);
+			pfree(tmp);
+		}
 	}
 	else if (VARATT_IS_EXTERNAL_INDIRECT(attr))
 	{
@@ -80,6 +90,16 @@ detoast_external_attr(varlena *attr)
 		if (VARATT_IS_EXTERNAL(attr))
 			return detoast_external_attr(attr);
 
+		/*
+		 * The indirect pointer may reference an inline dictionary-compressed
+		 * datum, which is not VARATT_IS_EXTERNAL but still depends on a
+		 * catalog row of its owning column, so it must be decompressed here
+		 * too; otherwise a still-compressed datum would be returned and read
+		 * after its dictionary could be retired.
+		 */
+		if (VARATT_IS_COMPRESSED_DICT(attr))
+			return toast_dict_decompress_datum(attr);
+
 		/*
 		 * Copy into the caller's memory context, in case caller tries to
 		 * pfree the result.
@@ -100,6 +120,16 @@ detoast_external_attr(varlena *attr)
 		result = (varlena *) palloc(resultsize);
 		EOH_flatten_into(eoh, result, resultsize);
 	}
+	else if (VARATT_IS_COMPRESSED_DICT(attr))
+	{
+		/*
+		 * A value compressed with a dictionary depends on a catalog row of
+		 * the column it came from, so for anything that wants a value
+		 * independent of its source it counts as external; see
+		 * toast_dictionary.c.
+		 */
+		result = toast_dict_decompress_datum(attr);
+	}
 	else
 	{
 		/*
@@ -514,6 +544,9 @@ toast_decompress_datum(varlena *attr)
 			return lz4_decompress_datum(attr);
 		case TOAST_ZSTD_COMPRESSION_ID:
 			return zstd_decompress_datum(attr);
+		case TOAST_ZSTD_DICT_COMPRESSION_ID:
+		case TOAST_LZ4_DICT_COMPRESSION_ID:
+			return toast_dict_decompress_datum(attr);
 		default:
 			elog(ERROR, "invalid compression method id %d", cmid);
 			return NULL;		/* keep compiler quiet */
@@ -568,6 +601,9 @@ toast_decompress_datum_slice(varlena *attr, int32 slicelength)
 			return lz4_decompress_datum_slice(attr, slicelength);
 		case TOAST_ZSTD_COMPRESSION_ID:
 			return zstd_decompress_datum_slice(attr, slicelength);
+		case TOAST_ZSTD_DICT_COMPRESSION_ID:
+		case TOAST_LZ4_DICT_COMPRESSION_ID:
+			return toast_dict_decompress_datum_slice(attr, slicelength);
 		default:
 			elog(ERROR, "invalid compression method id %d", cmid);
 			return NULL;		/* keep compiler quiet */
diff --git a/src/backend/access/common/heaptuple.c b/src/backend/access/common/heaptuple.c
index 948d62f1888..ad890d04554 100644
--- a/src/backend/access/common/heaptuple.c
+++ b/src/backend/access/common/heaptuple.c
@@ -59,6 +59,7 @@
 
 #include "access/heaptoast.h"
 #include "access/sysattr.h"
+#include "access/toast_compression.h"
 #include "access/tupdesc_details.h"
 #include "access/xlogdefs.h"
 #include "catalog/pg_type_d.h"
@@ -399,6 +400,17 @@ fill_val(CompactAttribute *att,
 			data = (char *) att_nominal_alignby(data, att->attalignby);
 			data_length = VARSIZE(val);
 			memcpy(data, val, data_length);
+
+			/*
+			 * A value compressed with a dictionary depends on catalog state
+			 * of the column it belongs to, like a TOAST pointer does on its
+			 * TOAST table, so flag it the same way.  Code that makes a tuple
+			 * self-contained (toast_flatten_tuple and friends) tests
+			 * HEAP_HASEXTERNAL and then detoast_external_attr() on each
+			 * field, which decompresses it.
+			 */
+			if (VARATT_IS_COMPRESSED_DICT(val))
+				*infomask |= HEAP_HASEXTERNAL;
 		}
 	}
 	else if (att->attlen == -2)
diff --git a/src/backend/access/common/indextuple.c b/src/backend/access/common/indextuple.c
index 383319cd151..9a7938446e4 100644
--- a/src/backend/access/common/indextuple.c
+++ b/src/backend/access/common/indextuple.c
@@ -105,7 +105,8 @@ index_form_tuple_context(TupleDesc tupleDescriptor,
 		 * If value is stored EXTERNAL, must fetch it so we are not depending
 		 * on outside storage.  This should be improved someday.
 		 */
-		if (VARATT_IS_EXTERNAL(DatumGetPointer(values[i])))
+		if (VARATT_IS_EXTERNAL(DatumGetPointer(values[i])) ||
+			VARATT_IS_COMPRESSED_DICT(DatumGetPointer(values[i])))
 		{
 			untoasted_values[i] =
 				PointerGetDatum(detoast_external_attr((varlena *)
diff --git a/src/backend/access/common/meson.build b/src/backend/access/common/meson.build
index 35e89b5ea67..921f0c6f8b0 100644
--- a/src/backend/access/common/meson.build
+++ b/src/backend/access/common/meson.build
@@ -15,6 +15,7 @@ backend_sources += files(
   'syncscan.c',
   'tidstore.c',
   'toast_compression.c',
+  'toast_dictionary.c',
   'toast_internals.c',
   'tupconvert.c',
   'tupdesc.c',
diff --git a/src/backend/access/common/toast_compression.c b/src/backend/access/common/toast_compression.c
index f824212a7e9..4f686733191 100644
--- a/src/backend/access/common/toast_compression.c
+++ b/src/backend/access/common/toast_compression.c
@@ -74,7 +74,8 @@ zstd_get_dctx(void)
  * A backend-lifetime ZSTD_CCtx for plain (non-dictionary) compression, kept
  * for the same reason as the decompression context above: a one-shot
  * ZSTD_compress() allocates and frees a compression context of over a
- * megabyte for every value, which dominates a bulk load.
+ * megabyte for every value, which dominates a bulk load.  The dictionary
+ * path keeps its own context (dict_cctx in toast_dictionary.c).
  */
 static ZSTD_CCtx *zstd_cctx = NULL;
 
@@ -615,6 +616,20 @@ CompressionNameToMethod(const char *compression)
 #endif
 		return TOAST_ZSTD_COMPRESSION;
 	}
+	else if (strcmp(compression, "zstd_dict") == 0)
+	{
+#ifndef USE_ZSTD
+		NO_COMPRESSION_SUPPORT("zstd");
+#endif
+		return TOAST_ZSTD_DICT_COMPRESSION;
+	}
+	else if (strcmp(compression, "lz4_dict") == 0)
+	{
+#ifndef USE_LZ4
+		NO_COMPRESSION_SUPPORT("lz4");
+#endif
+		return TOAST_LZ4_DICT_COMPRESSION;
+	}
 
 	return InvalidCompressionMethod;
 }
@@ -633,6 +648,10 @@ GetCompressionMethodName(char method)
 			return "lz4";
 		case TOAST_ZSTD_COMPRESSION:
 			return "zstd";
+		case TOAST_ZSTD_DICT_COMPRESSION:
+			return "zstd_dict";
+		case TOAST_LZ4_DICT_COMPRESSION:
+			return "lz4_dict";
 		default:
 			elog(ERROR, "invalid compression method %c", method);
 			return NULL;		/* keep compiler quiet */
diff --git a/src/backend/access/common/toast_dictionary.c b/src/backend/access/common/toast_dictionary.c
new file mode 100644
index 00000000000..7efbdf97ba5
--- /dev/null
+++ b/src/backend/access/common/toast_dictionary.c
@@ -0,0 +1,1188 @@
+/*-------------------------------------------------------------------------
+ *
+ * toast_dictionary.c
+ *	  Lifecycle of compression dictionaries for TOAST.
+ *
+ * A dictionary-compressed datum cannot be read without its dictionary, so the
+ * hard part of dictionary support is knowing when a dictionary may be
+ * dropped.  Designs discussed on pgsql-hackers either kept every dictionary
+ * forever or tracked, through pg_depend, every table a compressed value might
+ * have been copied into; both were rejected.  This file keeps
+ * dictionary-compressed values inside the column that owns their dictionary
+ * instead, which makes the lifecycle local to that column:
+ *
+ * Ownership.  A pg_toast_dictionary row belongs to one (relid, attnum), like
+ * a pg_statistic row, and nothing else ever references it.  DROP TABLE
+ * deletes it through RemoveToastDictionaries().  The dictionaries of a
+ * dropped column stay as long as its values do, until the next rewrite.
+ *
+ * Containment.  heap_fill_tuple() sets HEAP_HASEXTERNAL for a tuple holding
+ * a dictionary-compressed datum, as it does for TOAST pointers, so every
+ * path that already makes a tuple self-contained before it outlives its
+ * source (composite datums, tuplestores, holdable cursors, syscache entries,
+ * PL/pgSQL records) also reaches the datum.  detoast_external_attr() then
+ * decompresses it, exactly as it fetches a TOAST pointer.  When a datum is
+ * stored into a heap table, toast_tuple_init() keeps it compressed only if
+ * the target column owns its dictionary (toast_dictionary_keep()), or if an
+ * UPDATE leaves it unchanged, and decompresses it otherwise, so it is
+ * recompressed, if at all, with the target column's own method.  The same
+ * applies to index and BRIN tuples, which inline external values already.
+ *
+ * Retirement.  Training a dictionary marks the previous one of the column
+ * non-current.  A non-current dictionary stays readable until the table is
+ * rewritten (VACUUM FULL, CLUSTER, REPACK, rewriting ALTER TABLE): during the
+ * rewrite toast_dictionary_keep() rejects it, so every value using it is
+ * recompressed with the current one, and finish_heap_swap() then deletes it
+ * with toast_dictionary_retire().  TRUNCATE removes every value, so it does
+ * the same.
+ *
+ * The cost is only paid when a dictionary-compressed value crosses a column
+ * boundary.  Copies of values compressed with any other method are unchanged.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/backend/access/common/toast_dictionary.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#ifdef USE_ZSTD
+#include <zstd.h>
+#include <zdict.h>
+#endif
+
+#ifdef USE_LZ4
+#include <lz4.h>
+#endif
+
+#include "access/detoast.h"
+#include "access/genam.h"
+#include "access/htup_details.h"
+#include "access/table.h"
+#include "access/tableam.h"
+#include "access/toast_compression.h"
+#include "access/toast_dictionary.h"
+#include "catalog/catalog.h"
+#include "catalog/indexing.h"
+#include "catalog/objectaddress.h"
+#include "catalog/pg_class.h"
+#include "catalog/pg_toast_dictionary.h"
+#include "catalog/pg_type.h"
+#include "executor/tuptable.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "miscadmin.h"
+#include "nodes/bitmapset.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/fmgroids.h"
+#include "utils/inval.h"
+#include "utils/lsyscache.h"
+#include "utils/memutils.h"
+#include "utils/rel.h"
+#include "utils/snapmgr.h"
+#include "utils/syscache.h"
+
+#define NO_ZSTD_SUPPORT() \
+	ereport(ERROR, \
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED), \
+			 errmsg("compression method %s not supported", "zstd_dict"), \
+			 errdetail("This functionality requires the server to be built with %s support.", "zstd")))
+
+#define NO_LZ4_SUPPORT() \
+	ereport(ERROR, \
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED), \
+			 errmsg("compression method %s not supported", "lz4_dict"), \
+			 errdetail("This functionality requires the server to be built with %s support.", "lz4")))
+
+#define DICT_MIN_SIZE		256
+#define DICT_MAX_SIZE		(1024 * 1024)
+#define DICT_MAX_SAMPLES	10000
+#define DICT_SAMPLE_LIMIT	(128 * 1024)
+/* zstd recommends samples totalling about 100 times the dictionary size */
+#define DICT_SAMPLE_FACTOR	100
+
+/*
+ * The relation owning the dictionaries of rel's columns.  During a table
+ * rewrite, values are written into a transient heap that stands for the
+ * table being rewritten.
+ */
+static Oid
+dict_owner_relid(Relation rel)
+{
+	if (OidIsValid(rel->rd_rel->relrewrite))
+		return rel->rd_rel->relrewrite;
+	return RelationGetRelid(rel);
+}
+
+/* Does this compression method char use a TOAST dictionary? */
+static inline bool
+dict_method(char cmethod)
+{
+	return (cmethod == TOAST_ZSTD_DICT_COMPRESSION ||
+			cmethod == TOAST_LZ4_DICT_COMPRESSION);
+}
+
+/*
+ * Does this column compress its values with a dictionary?  It needs a
+ * dictionary method and a storage mode under which the toaster compresses
+ * at all: PLAIN and EXTERNAL leave values uncompressed whatever the method.
+ */
+static inline bool
+dict_attr_uses_dict(Form_pg_attribute att)
+{
+	return (!att->attisdropped && dict_method(att->attcompression) &&
+			(att->attstorage == TYPSTORAGE_EXTENDED ||
+			 att->attstorage == TYPSTORAGE_MAIN));
+}
+
+/*
+ * Does relid.attnum (still) compress with a dictionary?  A dropped column
+ * does not: a rewrite has replaced its values with nulls.  Nor does one whose
+ * method or storage mode was changed away: a rewrite has decompressed its
+ * values.
+ */
+static bool
+dict_column_uses_dict(Oid relid, AttrNumber attnum)
+{
+	HeapTuple	tp;
+	Form_pg_attribute att;
+	bool		result;
+
+	tp = SearchSysCache2(ATTNUM, ObjectIdGetDatum(relid),
+						 Int16GetDatum(attnum));
+	if (!HeapTupleIsValid(tp))
+		return false;
+	att = (Form_pg_attribute) GETSTRUCT(tp);
+	result = dict_attr_uses_dict(att);
+	ReleaseSysCache(tp);
+	return result;
+}
+
+/* Look up one dictionary row of rel.attnum; current_only selects tdcurrent */
+static HeapTuple
+dict_lookup(Oid relid, AttrNumber attnum, bool current_only, Relation drel,
+			SysScanDesc *scanp)
+{
+	ScanKeyData key[2];
+	HeapTuple	tup;
+
+	ScanKeyInit(&key[0], Anum_pg_toast_dictionary_tdrelid,
+				BTEqualStrategyNumber, F_OIDEQ, ObjectIdGetDatum(relid));
+	ScanKeyInit(&key[1], Anum_pg_toast_dictionary_tdattnum,
+				BTEqualStrategyNumber, F_INT2EQ, Int16GetDatum(attnum));
+	*scanp = systable_beginscan(drel, ToastDictionaryRelidAttnumIndexId, true,
+								NULL, attnum != 0 ? 2 : 1, key);
+	while (HeapTupleIsValid(tup = systable_getnext(*scanp)))
+	{
+		if (!current_only ||
+			((Form_pg_toast_dictionary) GETSTRUCT(tup))->tdcurrent)
+			return tup;
+	}
+	return NULL;
+}
+
+/*
+ * Current dictionary of a column, or InvalidOid.
+ *
+ * Called once per compressed value on the write path, so the result is cached
+ * in the relcache entry, one slot per column, filled lazily from the catalog
+ * here.  dict_install() changes the current dictionary only at maintenance
+ * time and then issues CacheInvalidateRelcache(), which rebuilds this
+ * relcache entry and so empties the cache; the next call repopulates it with
+ * the new OID.
+ */
+Oid
+toast_dictionary_current(Relation rel, AttrNumber attnum)
+{
+	Relation	drel;
+	SysScanDesc scan;
+	HeapTuple	tup;
+	Oid			result = InvalidOid;
+	int			natts = RelationGetNumberOfAttributes(rel);
+	MemoryContext oldcxt;
+
+	Assert(attnum >= 1 && attnum <= natts);
+
+	/* fast path: a filled per-column cache entry */
+	if (rel->rd_toastdictoids != NULL &&
+		bms_is_member(attnum, rel->rd_toastdictvalid))
+		return rel->rd_toastdictoids[attnum - 1];
+
+	drel = table_open(ToastDictionaryRelationId, AccessShareLock);
+	tup = dict_lookup(dict_owner_relid(rel), attnum, true, drel, &scan);
+	if (tup)
+		result = ((Form_pg_toast_dictionary) GETSTRUCT(tup))->oid;
+	systable_endscan(scan);
+	table_close(drel, AccessShareLock);
+
+	/*
+	 * Cache the result in a per-column array, kept in CacheMemoryContext so
+	 * it lives as long as the relcache entry; RelationDestroyRelation() frees
+	 * it.
+	 */
+	oldcxt = MemoryContextSwitchTo(CacheMemoryContext);
+	if (rel->rd_toastdictoids == NULL)
+		rel->rd_toastdictoids = palloc0_array(Oid, natts);
+	rel->rd_toastdictoids[attnum - 1] = result;
+	rel->rd_toastdictvalid = bms_add_member(rel->rd_toastdictvalid, attnum);
+	MemoryContextSwitchTo(oldcxt);
+
+	return result;
+}
+
+/* Dictionary OID of a compressed-in-line dictionary datum */
+static Oid
+dict_datum_get_id(const varlena *value)
+{
+	Oid			dictid;
+
+	if (VARSIZE(value) < VARHDRSZ_COMPRESSED_DICT)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATA_CORRUPTED),
+				 errmsg_internal("compressed dictionary data is corrupt")));
+	memcpy(&dictid, (const char *) value + VARHDRSZ_COMPRESSED_LONG,
+		   sizeof(dictid));
+	return dictid;
+}
+
+/*
+ * May this compressed-in-line dictionary datum be stored as-is into
+ * rel.attnum?  Only if that column owns the dictionary, the dictionary is
+ * still current, and the column still compresses with a dictionary.  Anything
+ * else is recompressed whenever it is written, which is what lets a table
+ * rewrite retire dictionaries.
+ */
+bool
+toast_dictionary_keep(Relation rel, AttrNumber attnum, const varlena *value)
+{
+	HeapTuple	tup;
+	bool		result = false;
+	Oid			relid = dict_owner_relid(rel);
+
+	if (!dict_attr_uses_dict(TupleDescAttr(RelationGetDescr(rel), attnum - 1)))
+		return false;
+
+	tup = SearchSysCache1(TOASTDICTOID,
+						  ObjectIdGetDatum(dict_datum_get_id(value)));
+	if (HeapTupleIsValid(tup))
+	{
+		Form_pg_toast_dictionary d = (Form_pg_toast_dictionary) GETSTRUCT(tup);
+
+		result = (d->tdrelid == relid && d->tdattnum == attnum &&
+				  d->tdcurrent);
+		ReleaseSysCache(tup);
+	}
+	return result;
+}
+
+/*
+ * The per-backend dictionary caches below hold DICT_CACHE_SIZE entries each,
+ * keyed by dictionary OID, least recently used out.  More than one entry
+ * matters whenever reads alternate between dictionaries: two dictionary
+ * columns in one row, or one column retrained without a rewrite, whose old
+ * and new values sit side by side.  With a single entry each such switch
+ * re-fetches the dictionary and, for zstd, digests it again.
+ */
+#if defined(USE_ZSTD) || defined(USE_LZ4)
+#define DICT_CACHE_SIZE 4
+
+static uint64 dict_cache_clock = 0;
+
+/*
+ * Return the slot of dictid in ids[], or -1.  Marks a hit as just used.
+ */
+static int
+dict_cache_lookup(Oid *ids, uint64 *used, Oid dictid)
+{
+	for (int i = 0; i < DICT_CACHE_SIZE; i++)
+	{
+		if (ids[i] == dictid)
+		{
+			used[i] = ++dict_cache_clock;
+			return i;
+		}
+	}
+	return -1;
+}
+
+/*
+ * Pick the slot for a new entry: an empty one, else the least recently used.
+ * Call it only after dict_fetch(), which may run invalidation callbacks that
+ * empty the cache.
+ */
+static int
+dict_cache_victim(Oid *ids, uint64 *used)
+{
+	int			victim = 0;
+
+	for (int i = 0; i < DICT_CACHE_SIZE; i++)
+	{
+		if (!OidIsValid(ids[i]))
+			return i;
+		if (used[i] < used[victim])
+			victim = i;
+	}
+	return victim;
+}
+#endif
+
+#ifdef USE_ZSTD
+/*
+ * Per-backend cache of digested dictionaries.  Digesting builds the entropy
+ * tables and costs far more than one small value's compression, so the
+ * ZSTD_CDict/ZSTD_DDict objects are kept and dropped on invalidation of
+ * pg_toast_dictionary.
+ *
+ * DICT_CACHE_SIZE entries per direction.
+ */
+static Oid	cdict_ids[DICT_CACHE_SIZE];
+static uint32 cdict_hashes[DICT_CACHE_SIZE];
+static uint64 cdict_used[DICT_CACHE_SIZE];
+static ZSTD_CDict *cdicts[DICT_CACHE_SIZE];
+static Oid	ddict_ids[DICT_CACHE_SIZE];
+static uint32 ddict_hashes[DICT_CACHE_SIZE];
+static uint64 ddict_used[DICT_CACHE_SIZE];
+static ZSTD_DDict *ddicts[DICT_CACHE_SIZE];
+static ZSTD_CCtx *dict_cctx = NULL;
+static ZSTD_DCtx *dict_dctx = NULL;
+
+static void
+dict_cache_invalidate(Datum arg, SysCacheIdentifier cacheid, uint32 hashvalue)
+{
+	for (int i = 0; i < DICT_CACHE_SIZE; i++)
+	{
+		/*
+		 * hashvalue == 0 is the "reset everything" signal; otherwise evict
+		 * only entries whose dictionary row hashes to the changed one, so an
+		 * unrelated pg_toast_dictionary change elsewhere does not throw away
+		 * a backend's whole cache.  The hash is the one computed when the
+		 * entry was cached; this callback must not touch the catalogs, since
+		 * it can run when no transaction is in progress.
+		 */
+		if (cdicts[i] &&
+			(hashvalue == 0 || cdict_hashes[i] == hashvalue))
+		{
+			ZSTD_freeCDict(cdicts[i]);
+			cdicts[i] = NULL;
+			cdict_ids[i] = InvalidOid;
+		}
+		if (ddicts[i] &&
+			(hashvalue == 0 || ddict_hashes[i] == hashvalue))
+		{
+			ZSTD_freeDDict(ddicts[i]);
+			ddicts[i] = NULL;
+			ddict_ids[i] = InvalidOid;
+		}
+	}
+}
+#endif							/* USE_ZSTD */
+
+#if defined(USE_ZSTD) || defined(USE_LZ4)
+/*
+ * Fetch a dictionary's bytes from the catalog.  Used by both the zstd and
+ * the lz4 codecs, so it must not depend on USE_ZSTD alone.
+ */
+static bytea *
+dict_fetch(Oid dictid)
+{
+	HeapTuple	tup;
+	bytea	   *result;
+
+#ifdef USE_ZSTD
+	static bool registered = false;
+
+	if (!registered)
+	{
+		CacheRegisterSyscacheCallback(TOASTDICTOID, dict_cache_invalidate,
+									  (Datum) 0);
+		registered = true;
+	}
+#endif
+
+	tup = SearchSysCache1(TOASTDICTOID, ObjectIdGetDatum(dictid));
+	if (!HeapTupleIsValid(tup))
+		ereport(ERROR,
+				(errcode(ERRCODE_DATA_CORRUPTED),
+				 errmsg("compression dictionary %u does not exist", dictid)));
+	result = DatumGetByteaPCopy(SysCacheGetAttrNotNull(TOASTDICTOID, tup,
+													   Anum_pg_toast_dictionary_tddict));
+	ReleaseSysCache(tup);
+	return result;
+}
+#endif
+
+#ifdef USE_ZSTD
+static ZSTD_CDict *
+dict_get_cdict(Oid dictid)
+{
+	int			slot = dict_cache_lookup(cdict_ids, cdict_used, dictid);
+
+	if (slot < 0)
+	{
+		bytea	   *d = dict_fetch(dictid);
+
+		slot = dict_cache_victim(cdict_ids, cdict_used);
+		if (cdicts[slot])
+			ZSTD_freeCDict(cdicts[slot]);
+		cdict_ids[slot] = InvalidOid;
+		cdicts[slot] = ZSTD_createCDict(VARDATA_ANY(d), VARSIZE_ANY_EXHDR(d),
+										ZSTD_CLEVEL_DEFAULT);
+		pfree(d);
+		if (cdicts[slot] == NULL)
+			elog(ERROR, "could not load compression dictionary %u", dictid);
+		cdict_ids[slot] = dictid;
+		cdict_hashes[slot] = GetSysCacheHashValue1(TOASTDICTOID,
+												   ObjectIdGetDatum(dictid));
+		cdict_used[slot] = ++dict_cache_clock;
+	}
+	if (dict_cctx == NULL && (dict_cctx = ZSTD_createCCtx()) == NULL)
+		elog(ERROR, "could not create zstd compression context");
+	return cdicts[slot];
+}
+
+static ZSTD_DDict *
+dict_get_ddict(Oid dictid)
+{
+	int			slot = dict_cache_lookup(ddict_ids, ddict_used, dictid);
+
+	if (slot < 0)
+	{
+		bytea	   *d = dict_fetch(dictid);
+
+		slot = dict_cache_victim(ddict_ids, ddict_used);
+		if (ddicts[slot])
+			ZSTD_freeDDict(ddicts[slot]);
+		ddict_ids[slot] = InvalidOid;
+		ddicts[slot] = ZSTD_createDDict(VARDATA_ANY(d), VARSIZE_ANY_EXHDR(d));
+		pfree(d);
+		if (ddicts[slot] == NULL)
+			elog(ERROR, "could not load compression dictionary %u", dictid);
+		ddict_ids[slot] = dictid;
+		ddict_hashes[slot] = GetSysCacheHashValue1(TOASTDICTOID,
+												   ObjectIdGetDatum(dictid));
+		ddict_used[slot] = ++dict_cache_clock;
+	}
+	if (dict_dctx == NULL && (dict_dctx = ZSTD_createDCtx()) == NULL)
+		elog(ERROR, "could not create zstd decompression context");
+	return ddicts[slot];
+}
+#endif							/* USE_ZSTD */
+
+/*
+ * Compress with the given dictionary.  The datum is laid out with
+ * VARHDRSZ_COMPRESSED_DICT bytes of header; toast_compress_datum() fills in
+ * va_tcinfo and the method byte.
+ */
+static varlena *
+zstd_dict_compress_datum(const varlena *value, Oid dictid)
+{
+#ifndef USE_ZSTD
+	NO_ZSTD_SUPPORT();
+	return NULL;
+#else
+	int32		valsize = VARSIZE_ANY_EXHDR(value);
+	size_t		max_size = ZSTD_compressBound(valsize);
+	ZSTD_CDict *cd;
+	varlena    *tmp;
+	size_t		len;
+
+	if (ZSTD_isError(max_size) || !AllocSizeIsValid(max_size))
+		elog(ERROR, "toast object size is too big");
+	cd = dict_get_cdict(dictid);
+	tmp = (varlena *) palloc(max_size + VARHDRSZ_COMPRESSED_DICT);
+	len = ZSTD_compress_usingCDict(dict_cctx,
+								   (char *) tmp + VARHDRSZ_COMPRESSED_DICT,
+								   max_size, VARDATA_ANY(value), valsize, cd);
+	if (ZSTD_isError(len))
+		elog(ERROR, "zstd compression failed: %s", ZSTD_getErrorName(len));
+	if (len > (size_t) valsize)
+	{
+		pfree(tmp);
+		return NULL;
+	}
+	memcpy((char *) tmp + VARHDRSZ_COMPRESSED_LONG, &dictid, sizeof(dictid));
+	SET_VARSIZE_COMPRESSED(tmp, len + VARHDRSZ_COMPRESSED_DICT);
+	return tmp;
+#endif
+}
+
+static varlena *
+zstd_dict_decompress_datum(const varlena *value)
+{
+#ifndef USE_ZSTD
+	NO_ZSTD_SUPPORT();
+	return NULL;
+#else
+	Oid			dictid = dict_datum_get_id(value);
+	uint32		extsize = VARDATA_COMPRESSED_GET_EXTSIZE(value);
+	ZSTD_DDict *dd = dict_get_ddict(dictid);
+	varlena    *result;
+	size_t		rawsize;
+
+	Assert(AllocSizeIsValid(extsize));	/* VARLENA_EXTSIZE_MASK ensures it */
+	result = (varlena *) palloc(extsize + VARHDRSZ);
+	rawsize = ZSTD_decompress_usingDDict(dict_dctx, VARDATA(result), extsize,
+										 (const char *) value + VARHDRSZ_COMPRESSED_DICT,
+										 VARSIZE(value) - VARHDRSZ_COMPRESSED_DICT,
+										 dd);
+	if (ZSTD_isError(rawsize) || rawsize != extsize)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATA_CORRUPTED),
+				 errmsg_internal("compressed zstd data is corrupt")));
+	SET_VARSIZE(result, rawsize + VARHDRSZ);
+	return result;
+#endif
+}
+
+/*
+ * Prefix decompression.  For values below zstd's 128kB block size a prefix
+ * costs a full decode anyway (see zstd_decompress_datum_slice).
+ *
+ * XXX: larger values are decoded in full too.  If prefixes of large
+ * dictionary-compressed values matter, use the streaming loop of
+ * zstd_decompress_datum_slice() with ZSTD_DCtx_refDDict().  That loop stops
+ * in the middle of a frame, so dict_dctx must then be reset with
+ * ZSTD_DCtx_reset(ZSTD_reset_session_only) before each use, as
+ * zstd_get_dctx() does for plain zstd; without it the next value's decoding
+ * continues the previous frame.  The one-shot call used now starts a new
+ * frame each time and needs no reset.
+ */
+static varlena *
+zstd_dict_decompress_datum_slice(const varlena *value, int32 slicelength)
+{
+	varlena    *result;
+
+	/* Caller must have checked the requested size, but verify. */
+	if (!AllocSizeIsValid(slicelength))
+		elog(ERROR, "toast object size is too big");
+	result = zstd_dict_decompress_datum(value);
+	if (VARSIZE(result) - VARHDRSZ > slicelength)
+		SET_VARSIZE(result, slicelength + VARHDRSZ);
+	return result;
+}
+
+#ifdef USE_LZ4
+/*
+ * Per-backend cache of the raw dictionary bytes most recently used, keyed by
+ * OID, DICT_CACHE_SIZE entries.  Unlike zstd, lz4 has no digested dictionary object: LZ4_loadDict()
+ * and LZ4_decompress_safe_usingDict() take the dictionary bytes directly, so
+ * caching the bytea is all that is needed.  Dropped on invalidation of
+ * pg_toast_dictionary.
+ */
+static Oid	lz4dict_ids[DICT_CACHE_SIZE];
+static uint32 lz4dict_hashes[DICT_CACHE_SIZE];
+static uint64 lz4dict_used[DICT_CACHE_SIZE];
+static bytea *lz4dicts[DICT_CACHE_SIZE];
+
+/*
+ * Backend-static lz4 compression stream, reset before each use.  Keeping it
+ * static avoids LZ4_createStream()/LZ4_freeStream() per value (a malloc that
+ * would also leak on an OOM longjmp out of the compression path), as the zstd
+ * side keeps a static CCtx.  LZ4_stream_t is a fixed-size POD, safe as a
+ * file-scope static.
+ */
+static LZ4_stream_t lz4_cstream_storage;
+static LZ4_stream_t *lz4_cstream = NULL;
+
+static void
+lz4_cstream_reset(void)
+{
+	if (lz4_cstream == NULL)
+		lz4_cstream = LZ4_initStream(&lz4_cstream_storage,
+									 sizeof(lz4_cstream_storage));
+	else
+		LZ4_resetStream_fast(lz4_cstream);
+}
+
+static void
+lz4_dict_cache_invalidate(Datum arg, SysCacheIdentifier cacheid, uint32 hashvalue)
+{
+	for (int i = 0; i < DICT_CACHE_SIZE; i++)
+	{
+		if (lz4dicts[i] &&
+			(hashvalue == 0 || lz4dict_hashes[i] == hashvalue))
+		{
+			pfree(lz4dicts[i]);
+			lz4dicts[i] = NULL;
+			lz4dict_ids[i] = InvalidOid;
+		}
+	}
+}
+
+static bytea *
+lz4_dict_get(Oid dictid)
+{
+	static bool registered = false;
+	int			slot;
+
+	if (!registered)
+	{
+		CacheRegisterSyscacheCallback(TOASTDICTOID, lz4_dict_cache_invalidate,
+									  (Datum) 0);
+		registered = true;
+	}
+	slot = dict_cache_lookup(lz4dict_ids, lz4dict_used, dictid);
+	if (slot < 0)
+	{
+		bytea	   *d = dict_fetch(dictid);
+		bytea	   *cached;
+
+		/*
+		 * dict_fetch() allocates in the caller's (often short-lived) context,
+		 * but this cache outlives one call, so keep a copy in
+		 * CacheMemoryContext.  Unlike zstd, lz4 has no digested object to
+		 * hold instead, so the bytes themselves are the cache.
+		 */
+		cached = (bytea *) MemoryContextAlloc(CacheMemoryContext, VARSIZE(d));
+		memcpy(cached, d, VARSIZE(d));
+		pfree(d);
+		slot = dict_cache_victim(lz4dict_ids, lz4dict_used);
+		if (lz4dicts[slot])
+			pfree(lz4dicts[slot]);
+		lz4dicts[slot] = cached;
+		lz4dict_ids[slot] = dictid;
+		lz4dict_hashes[slot] = GetSysCacheHashValue1(TOASTDICTOID,
+													 ObjectIdGetDatum(dictid));
+		lz4dict_used[slot] = ++dict_cache_clock;
+	}
+	return lz4dicts[slot];
+}
+#endif							/* USE_LZ4 */
+
+/*
+ * Compress with the given dictionary using lz4.  Layout matches
+ * zstd_dict_compress_datum(): VARHDRSZ_COMPRESSED_DICT header then the lz4
+ * block.  The uncompressed size is recovered from va_tcinfo at read time, so
+ * lz4's block format (which does not store it) is enough.
+ */
+static varlena *
+lz4_dict_compress_datum(const varlena *value, Oid dictid)
+{
+#ifndef USE_LZ4
+	NO_LZ4_SUPPORT();
+	return NULL;
+#else
+	int32		valsize = VARSIZE_ANY_EXHDR(value);
+	int32		max_size = LZ4_compressBound(valsize);
+	bytea	   *dict;
+	varlena    *tmp;
+	int32		len;
+
+	/* LZ4_compressBound() returns 0 for input it cannot handle */
+	if (max_size <= 0 || !AllocSizeIsValid(max_size))
+		elog(ERROR, "toast object size is too big");
+	dict = lz4_dict_get(dictid);
+
+	/*
+	 * Use a backend-static stream, reset per call, rather than
+	 * LZ4_createStream(): a malloc'd stream between here and the palloc below
+	 * would leak on an OOM longjmp, and avoiding the per-value malloc/free is
+	 * cheaper on bulk loads.
+	 */
+	lz4_cstream_reset();
+	LZ4_loadDict(lz4_cstream, VARDATA_ANY(dict), VARSIZE_ANY_EXHDR(dict));
+
+	tmp = (varlena *) palloc(max_size + VARHDRSZ_COMPRESSED_DICT);
+	len = LZ4_compress_fast_continue(lz4_cstream,
+									 VARDATA_ANY(value),
+									 (char *) tmp + VARHDRSZ_COMPRESSED_DICT,
+									 valsize, max_size, 1);
+	if (len <= 0)
+		elog(ERROR, "lz4 compression failed");
+	if (len > valsize)
+	{
+		pfree(tmp);
+		return NULL;
+	}
+	memcpy((char *) tmp + VARHDRSZ_COMPRESSED_LONG, &dictid, sizeof(dictid));
+	SET_VARSIZE_COMPRESSED(tmp, len + VARHDRSZ_COMPRESSED_DICT);
+	return tmp;
+#endif
+}
+
+static varlena *
+lz4_dict_decompress_datum(const varlena *value)
+{
+#ifndef USE_LZ4
+	NO_LZ4_SUPPORT();
+	return NULL;
+#else
+	Oid			dictid = dict_datum_get_id(value);
+	uint32		extsize = VARDATA_COMPRESSED_GET_EXTSIZE(value);
+	bytea	   *dict = lz4_dict_get(dictid);
+	varlena    *result;
+	int32		rawsize;
+
+	Assert(AllocSizeIsValid(extsize));	/* VARLENA_EXTSIZE_MASK ensures it */
+	result = (varlena *) palloc(extsize + VARHDRSZ);
+	rawsize = LZ4_decompress_safe_usingDict((const char *) value + VARHDRSZ_COMPRESSED_DICT,
+											VARDATA(result),
+											VARSIZE(value) - VARHDRSZ_COMPRESSED_DICT,
+											extsize,
+											VARDATA_ANY(dict),
+											VARSIZE_ANY_EXHDR(dict));
+	if (rawsize < 0 || (uint32) rawsize != extsize)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATA_CORRUPTED),
+				 errmsg_internal("compressed lz4 data is corrupt")));
+	SET_VARSIZE(result, rawsize + VARHDRSZ);
+	return result;
+#endif
+}
+
+/*
+ * Prefix decompression.  lz4's block API cannot stop early, so a prefix of a
+ * dictionary-compressed value decodes the whole value and then truncates,
+ * exactly as the non-dictionary lz4 slice path does for values whose true
+ * size exceeds the slice.
+ */
+static varlena *
+lz4_dict_decompress_datum_slice(const varlena *value, int32 slicelength)
+{
+	varlena    *result;
+
+	/* Caller must have checked the requested size, but verify. */
+	if (!AllocSizeIsValid(slicelength))
+		elog(ERROR, "toast object size is too big");
+	result = lz4_dict_decompress_datum(value);
+	if (VARSIZE(result) - VARHDRSZ > slicelength)
+		SET_VARSIZE(result, slicelength + VARHDRSZ);
+	return result;
+}
+
+/*
+ * The lz4 dictionary codec.  See DictCompressRoutine in toast_dictionary.h.
+ */
+static const DictCompressRoutine lz4_dict_routine = {
+	.compress = lz4_dict_compress_datum,
+	.decompress = lz4_dict_decompress_datum,
+	.decompress_slice = lz4_dict_decompress_datum_slice,
+};
+
+/*
+ * The zstd dictionary codec.  See DictCompressRoutine in
+ * toast_dictionary.h.
+ */
+static const DictCompressRoutine zstd_dict_routine = {
+	.compress = zstd_dict_compress_datum,
+	.decompress = zstd_dict_decompress_datum,
+	.decompress_slice = zstd_dict_decompress_datum_slice,
+};
+
+/*
+ * Pick the dictionary codec for a compression method char (for compress) or
+ * for the method byte already in a datum (for decompress).  Adding a codec is
+ * one case here plus its DictCompressRoutine.
+ */
+static const DictCompressRoutine *
+dict_routine_for_method(char cmethod)
+{
+	switch (cmethod)
+	{
+		case TOAST_ZSTD_DICT_COMPRESSION:
+			return &zstd_dict_routine;
+		case TOAST_LZ4_DICT_COMPRESSION:
+			return &lz4_dict_routine;
+		default:
+			elog(ERROR, "invalid dictionary compression method %d", cmethod);
+			return NULL;		/* keep compiler quiet */
+	}
+}
+
+static const DictCompressRoutine *
+dict_routine_for_id(ToastCompressionId cmid)
+{
+	switch (cmid)
+	{
+		case TOAST_ZSTD_DICT_COMPRESSION_ID:
+			return &zstd_dict_routine;
+		case TOAST_LZ4_DICT_COMPRESSION_ID:
+			return &lz4_dict_routine;
+		case TOAST_INVALID_COMPRESSION_ID:
+			ereport(ERROR,
+					(errcode(ERRCODE_DATA_CORRUPTED),
+					 errmsg_internal("compressed dictionary data is corrupt")));
+			return NULL;		/* keep compiler quiet */
+		default:
+			elog(ERROR, "invalid dictionary compression method id %d", cmid);
+			return NULL;		/* keep compiler quiet */
+	}
+}
+
+/*
+ * Compress value with dictid using the codec named by cmethod, laying out the
+ * dictionary header.  Returns NULL if the value did not get smaller.
+ */
+varlena *
+toast_dict_compress_datum(const varlena *value, char cmethod, Oid dictid)
+{
+	return dict_routine_for_method(cmethod)->compress(value, dictid);
+}
+
+/*
+ * Decompress a dictionary-compressed datum, dispatching on its method byte.
+ * The datum may come from disk, so the header is checked first: a malformed
+ * one dispatches nowhere, and dict_datum_get_id() then also checks there is
+ * room for the dictionary OID.
+ */
+varlena *
+toast_dict_decompress_datum(const varlena *value)
+{
+	ToastCompressionId cmid = toast_inline_compression_id(value);
+
+	return dict_routine_for_id(cmid)->decompress(value);
+}
+
+varlena *
+toast_dict_decompress_datum_slice(const varlena *value, int32 slicelength)
+{
+	ToastCompressionId cmid = toast_inline_compression_id(value);
+
+	return dict_routine_for_id(cmid)->decompress_slice(value, slicelength);
+}
+
+/*
+ * Delete the dictionaries of a relation (attnum == 0) or of one column, next
+ * to RemoveStatistics().  The values that used them go away at the same time.
+ */
+void
+RemoveToastDictionaries(Oid relid, AttrNumber attnum)
+{
+	Relation	drel;
+	SysScanDesc scan;
+	HeapTuple	tup;
+
+	drel = table_open(ToastDictionaryRelationId, RowExclusiveLock);
+	tup = dict_lookup(relid, attnum, false, drel, &scan);
+	for (; HeapTupleIsValid(tup); tup = systable_getnext(scan))
+		CatalogTupleDelete(drel, &tup->t_self);
+	systable_endscan(scan);
+	table_close(drel, RowExclusiveLock);
+}
+
+/*
+ * After relid has been rewritten, no stored value references a dictionary
+ * that toast_dictionary_keep() rejects: non-current ones, and all of those
+ * of a column that no longer uses a dictionary method.  The rewrite
+ * recompressed those values, so delete such dictionaries.  TRUNCATE calls
+ * this too, having removed every value.  Caller holds AccessExclusiveLock on
+ * relid.
+ */
+void
+toast_dictionary_retire(Oid relid)
+{
+	Relation	drel;
+	SysScanDesc scan;
+	HeapTuple	tup;
+
+	drel = table_open(ToastDictionaryRelationId, RowExclusiveLock);
+	tup = dict_lookup(relid, 0, false, drel, &scan);
+	for (; HeapTupleIsValid(tup); tup = systable_getnext(scan))
+	{
+		Form_pg_toast_dictionary d = (Form_pg_toast_dictionary) GETSTRUCT(tup);
+
+		if (!d->tdcurrent || !dict_column_uses_dict(relid, d->tdattnum))
+			CatalogTupleDelete(drel, &tup->t_self);
+	}
+	systable_endscan(scan);
+	table_close(drel, RowExclusiveLock);
+}
+
+/*
+ * Insert a dictionary for rel.attnum and make it current, retiring the
+ * previous current one.  dictid may be given (binary upgrade) or chosen here.
+ */
+static Oid
+dict_install(Relation rel, AttrNumber attnum, Oid dictid, bool current,
+			 const void *dict, size_t dictlen)
+{
+	Relation	drel;
+	SysScanDesc scan;
+	HeapTuple	tup;
+	Datum		values[Natts_pg_toast_dictionary];
+	bool		nulls[Natts_pg_toast_dictionary] = {0};
+	bytea	   *dictval;
+
+	drel = table_open(ToastDictionaryRelationId, RowExclusiveLock);
+
+	if (current)
+	{
+		tup = dict_lookup(RelationGetRelid(rel), attnum, true, drel, &scan);
+		if (tup)
+		{
+			HeapTuple	newtup = heap_copytuple(tup);
+
+			((Form_pg_toast_dictionary) GETSTRUCT(newtup))->tdcurrent = false;
+			CatalogTupleUpdate(drel, &newtup->t_self, newtup);
+			heap_freetuple(newtup);
+		}
+		systable_endscan(scan);
+	}
+
+	if (!OidIsValid(dictid))
+		dictid = GetNewOidWithIndex(drel, ToastDictionaryOidIndexId,
+									Anum_pg_toast_dictionary_oid);
+	dictval = (bytea *) palloc(dictlen + VARHDRSZ);
+	SET_VARSIZE(dictval, dictlen + VARHDRSZ);
+	memcpy(VARDATA(dictval), dict, dictlen);
+
+	values[Anum_pg_toast_dictionary_oid - 1] = ObjectIdGetDatum(dictid);
+	values[Anum_pg_toast_dictionary_tdrelid - 1] =
+		ObjectIdGetDatum(RelationGetRelid(rel));
+	values[Anum_pg_toast_dictionary_tdattnum - 1] = Int16GetDatum(attnum);
+	values[Anum_pg_toast_dictionary_tdcurrent - 1] = BoolGetDatum(current);
+	values[Anum_pg_toast_dictionary_tddict - 1] = PointerGetDatum(dictval);
+	tup = heap_form_tuple(RelationGetDescr(drel), values, nulls);
+	CatalogTupleInsert(drel, tup);
+	heap_freetuple(tup);
+	table_close(drel, RowExclusiveLock);
+
+	/*
+	 * The current dictionary of this column changed.  Invalidate the owning
+	 * relation's relcache entry so backends drop the cached
+	 * current-dictionary OID (toast_dictionary_current()) and pick up the new
+	 * one.
+	 */
+	CacheInvalidateRelcache(rel);
+
+	/* let a later call in the same command see this one's changes */
+	CommandCounterIncrement();
+
+	return dictid;
+}
+
+#ifdef USE_ZSTD
+/* Checks for pg_train_toast_dictionary(); returns attnum */
+static AttrNumber
+dict_check_column(Relation rel, const char *colname)
+{
+	AttrNumber	attnum;
+	Form_pg_attribute att;
+
+	if (!object_ownercheck(RelationRelationId, RelationGetRelid(rel),
+						   GetUserId()))
+		aclcheck_error(ACLCHECK_NOT_OWNER,
+					   get_relkind_objtype(rel->rd_rel->relkind),
+					   RelationGetRelationName(rel));
+	if (rel->rd_rel->relkind != RELKIND_RELATION &&
+		rel->rd_rel->relkind != RELKIND_MATVIEW)
+		ereport(ERROR,
+				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				 errmsg("\"%s\" is not a table or materialized view",
+						RelationGetRelationName(rel))));
+	if (IsCatalogRelation(rel))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("cannot use compression dictionaries on a system catalog")));
+	if (RELATION_IS_OTHER_TEMP(rel))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("cannot use compression dictionaries on temporary tables of other sessions")));
+
+	attnum = get_attnum(RelationGetRelid(rel), colname);
+	if (attnum <= 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_COLUMN),
+				 errmsg("column \"%s\" of relation \"%s\" does not exist",
+						colname, RelationGetRelationName(rel))));
+	att = TupleDescAttr(RelationGetDescr(rel), attnum - 1);
+	if (!dict_method(att->attcompression))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("column \"%s\" does not use a dictionary compression method",
+						colname)));
+	if (!dict_attr_uses_dict(att))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("column \"%s\" does not compress its values",
+						colname),
+				 errdetail("Dictionaries are used only with storage EXTENDED or MAIN.")));
+	return attnum;
+}
+#endif							/* USE_ZSTD */
+
+/*
+ * pg_train_toast_dictionary(rel regclass, col name, dict_size int4)
+ *
+ * Train a zstd dictionary from the column's current values and make it the
+ * column's current dictionary.  The previous one stays readable until the
+ * next rewrite of the table retires it.  Returns the new dictionary's OID, or
+ * NULL if zstd could not train one from the values found.
+ *
+ * Takes ShareUpdateExclusiveLock, like ANALYZE: DML continues, concurrent
+ * training and DDL wait.
+ *
+ * XXX: this samples the first DICT_MAX_SAMPLES rows of a seqscan.  Training
+ * from ANALYZE's block sample is the natural follow-up once the lifecycle is
+ * agreed on.
+ */
+Datum
+pg_train_toast_dictionary(PG_FUNCTION_ARGS)
+{
+#ifndef USE_ZSTD
+	/* both codecs train with zstd's dictionary builder */
+	ereport(ERROR,
+			(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+			 errmsg("training compression dictionaries is not supported by this build"),
+			 errdetail("This functionality requires the server to be built with %s support.", "zstd")));
+	PG_RETURN_NULL();
+#else
+	Oid			relid = PG_GETARG_OID(0);
+	char	   *colname = NameStr(*PG_GETARG_NAME(1));
+	int32		dict_size = PG_GETARG_INT32(2);
+	Relation	rel;
+	AttrNumber	attnum;
+	TableScanDesc scan;
+	TupleTableSlot *slot;
+	StringInfoData samples;
+	size_t	   *sizes;
+	int			nsamples = 0;
+	void	   *dictbuf;
+	size_t		dictlen;
+	Oid			dictid;
+	HeapTuple	classtup;
+	Form_pg_class classform;
+	MemoryContext workcxt,
+				oldcxt;
+
+	if (dict_size < DICT_MIN_SIZE || dict_size > DICT_MAX_SIZE)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("dictionary size must be between %d and %d bytes",
+						DICT_MIN_SIZE, DICT_MAX_SIZE)));
+
+	PreventCommandDuringRecovery("pg_train_toast_dictionary()");
+
+	/*
+	 * Check ownership before taking a lock on the table, so that an
+	 * unprivileged caller (the function is executable by PUBLIC) cannot take
+	 * ShareUpdateExclusiveLock on an arbitrary relation and block its
+	 * maintenance just by failing the later ACL check, the way VACUUM and
+	 * ANALYZE check first.
+	 */
+	classtup = SearchSysCache1(RELOID, ObjectIdGetDatum(relid));
+	if (!HeapTupleIsValid(classtup))
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_TABLE),
+				 errmsg("relation with OID %u does not exist", relid)));
+	classform = (Form_pg_class) GETSTRUCT(classtup);
+	if (!has_privs_of_role(GetUserId(), classform->relowner))
+		aclcheck_error(ACLCHECK_NOT_OWNER,
+					   get_relkind_objtype(classform->relkind),
+					   NameStr(classform->relname));
+	ReleaseSysCache(classtup);
+
+	rel = table_open(relid, ShareUpdateExclusiveLock);
+	attnum = dict_check_column(rel, colname);
+
+	/*
+	 * Sampling and training allocate up to DICT_SAMPLE_FACTOR times the
+	 * dictionary size (about 100 MB at a 1 MB dictionary) plus the sizes
+	 * array and the trained dictionary.  Do it in a short-lived context so a
+	 * call across many columns or partitions in one query does not accumulate
+	 * that in the caller's (executor) context.
+	 */
+	workcxt = AllocSetContextCreate(CurrentMemoryContext,
+									"pg_train_toast_dictionary",
+									ALLOCSET_DEFAULT_SIZES);
+	oldcxt = MemoryContextSwitchTo(workcxt);
+
+	initStringInfo(&samples);
+	sizes = palloc_array(size_t, DICT_MAX_SAMPLES);
+	slot = table_slot_create(rel, NULL);
+	scan = table_beginscan(rel, GetActiveSnapshot(), 0, NULL, 0);
+	while (nsamples < DICT_MAX_SAMPLES &&
+		   table_scan_getnextslot(scan, ForwardScanDirection, slot))
+	{
+		bool		isnull;
+		Datum		d = slot_getattr(slot, attnum, &isnull);
+		varlena    *v;
+		size_t		len;
+
+		CHECK_FOR_INTERRUPTS();
+		if (isnull)
+			continue;
+		v = detoast_attr_slice((varlena *) DatumGetPointer(d), 0,
+							   DICT_SAMPLE_LIMIT);
+		len = VARSIZE_ANY_EXHDR(v);
+		if (samples.len + len > (size_t) dict_size * DICT_SAMPLE_FACTOR)
+		{
+			pfree(v);
+			break;
+		}
+		if (len >= 8)
+		{
+			appendBinaryStringInfo(&samples, VARDATA_ANY(v), len);
+			sizes[nsamples++] = len;
+		}
+		pfree(v);
+	}
+	table_endscan(scan);
+	ExecDropSingleTupleTableSlot(slot);
+
+	dictbuf = palloc(dict_size);
+	dictlen = nsamples == 0 ? 0 :
+		ZDICT_trainFromBuffer(dictbuf, dict_size, samples.data, sizes,
+							  nsamples);
+	if (nsamples == 0 || ZDICT_isError(dictlen))
+	{
+		ereport(NOTICE,
+				(errmsg("could not train a compression dictionary for column \"%s\" from %d values",
+						colname, nsamples)));
+		MemoryContextSwitchTo(oldcxt);
+		MemoryContextDelete(workcxt);
+		table_close(rel, NoLock);
+		PG_RETURN_NULL();
+	}
+
+	dictid = dict_install(rel, attnum, InvalidOid, true, dictbuf, dictlen);
+
+	MemoryContextSwitchTo(oldcxt);
+	MemoryContextDelete(workcxt);
+	table_close(rel, NoLock);
+
+	PG_RETURN_OID(dictid);
+#endif
+}
+
+/*
+ * binary_upgrade_add_toast_dictionary(rel regclass, attnum int2, dictid oid,
+ *									   current bool, dict bytea)
+ *
+ * pg_upgrade copies the heap files as they are, and with them the dictionary
+ * OIDs embedded in each datum, so dictionaries are restored with their OIDs.
+ */
+Datum
+binary_upgrade_add_toast_dictionary(PG_FUNCTION_ARGS)
+{
+	Oid			relid = PG_GETARG_OID(0);
+	AttrNumber	attnum = PG_GETARG_INT16(1);
+	Oid			dictid = PG_GETARG_OID(2);
+	bool		current = PG_GETARG_BOOL(3);
+	bytea	   *dict = PG_GETARG_BYTEA_PP(4);
+	Relation	rel;
+
+	if (!IsBinaryUpgrade)
+		ereport(ERROR,
+				(errcode(ERRCODE_CANT_CHANGE_RUNTIME_PARAM),
+				 errmsg("function can only be called when server is in binary upgrade mode")));
+
+	/*
+	 * No dict_check_column() here: the column may have been switched to
+	 * another compression method, or dropped, with old values still using
+	 * these dictionaries until the table is rewritten.  pg_dump restores
+	 * physical column numbers, so attnum is stable.
+	 */
+	rel = table_open(relid, AccessExclusiveLock);
+	if (attnum <= 0 || attnum > RelationGetNumberOfAttributes(rel))
+		elog(ERROR, "invalid attribute number %d for relation %u",
+			 attnum, relid);
+	dict_install(rel, attnum, dictid, current, VARDATA_ANY(dict),
+				 VARSIZE_ANY_EXHDR(dict));
+	table_close(rel, NoLock);
+
+	PG_RETURN_VOID();
+}
diff --git a/src/backend/access/common/toast_internals.c b/src/backend/access/common/toast_internals.c
index 174c7197b3c..a056823ebbe 100644
--- a/src/backend/access/common/toast_internals.c
+++ b/src/backend/access/common/toast_internals.c
@@ -19,6 +19,7 @@
 #include "access/heaptoast.h"
 #include "access/table.h"
 #include "access/toast_compression.h"
+#include "access/toast_dictionary.h"
 #include "access/toast_internals.h"
 #include "access/xact.h"
 #include "catalog/catalog.h"
@@ -81,6 +82,19 @@ toast_pointer_build(vartag_external tag, const void *ptr,
  */
 Datum
 toast_compress_datum(Datum value, char cmethod)
+{
+	return toast_compress_datum_rel(value, cmethod, NULL, 0);
+}
+
+/*
+ * Same, for a value about to be stored in column attnum of rel.  Only this
+ * variant can use a column's dictionary: zstd_dict needs to know which
+ * column owns the dictionary, and falls back to plain zstd without one (no
+ * dictionary trained yet, or a caller such as an index that passes no rel).
+ */
+Datum
+toast_compress_datum_rel(Datum value, char cmethod, Relation rel,
+						 AttrNumber attnum)
 {
 	varlena    *tmp = NULL;
 	int32		valsize;
@@ -112,6 +126,46 @@ toast_compress_datum(Datum value, char cmethod)
 			tmp = zstd_compress_datum((const varlena *) DatumGetPointer(value));
 			cmid = TOAST_ZSTD_COMPRESSION_ID;
 			break;
+		case TOAST_ZSTD_DICT_COMPRESSION:
+			{
+				Oid			dictid = InvalidOid;
+
+				if (rel != NULL)
+					dictid = toast_dictionary_current(rel, attnum);
+				if (OidIsValid(dictid))
+				{
+					tmp = toast_dict_compress_datum((const varlena *) DatumGetPointer(value),
+													TOAST_ZSTD_DICT_COMPRESSION,
+													dictid);
+					cmid = TOAST_ZSTD_DICT_COMPRESSION_ID;
+				}
+				else
+				{
+					tmp = zstd_compress_datum((const varlena *) DatumGetPointer(value));
+					cmid = TOAST_ZSTD_COMPRESSION_ID;
+				}
+			}
+			break;
+		case TOAST_LZ4_DICT_COMPRESSION:
+			{
+				Oid			dictid = InvalidOid;
+
+				if (rel != NULL)
+					dictid = toast_dictionary_current(rel, attnum);
+				if (OidIsValid(dictid))
+				{
+					tmp = toast_dict_compress_datum((const varlena *) DatumGetPointer(value),
+													TOAST_LZ4_DICT_COMPRESSION,
+													dictid);
+					cmid = TOAST_LZ4_DICT_COMPRESSION_ID;
+				}
+				else
+				{
+					tmp = lz4_compress_datum((const varlena *) DatumGetPointer(value));
+					cmid = TOAST_LZ4_COMPRESSION_ID;
+				}
+			}
+			break;
 		default:
 			elog(ERROR, "invalid compression method %c", cmethod);
 	}
diff --git a/src/backend/access/heap/heaptoast.c b/src/backend/access/heap/heaptoast.c
index e329662b683..1fdb1c94a0a 100644
--- a/src/backend/access/heap/heaptoast.c
+++ b/src/backend/access/heap/heaptoast.c
@@ -28,6 +28,7 @@
 #include "access/genam.h"
 #include "access/heapam.h"
 #include "access/heaptoast.h"
+#include "access/toast_compression.h"
 #include "access/toast_helper.h"
 #include "access/toast_internals.h"
 #include "utils/fmgroids.h"
@@ -379,7 +380,8 @@ toast_flatten_tuple(HeapTuple tup, TupleDesc tupleDesc)
 			varlena    *new_value;
 
 			new_value = (varlena *) DatumGetPointer(toast_values[i]);
-			if (VARATT_IS_EXTERNAL(new_value))
+			if (VARATT_IS_EXTERNAL(new_value) ||
+				VARATT_IS_COMPRESSED_DICT(new_value))
 			{
 				new_value = detoast_external_attr(new_value);
 				toast_values[i] = PointerGetDatum(new_value);
@@ -604,7 +606,8 @@ toast_build_flattened_tuple(TupleDesc tupleDesc,
 			varlena    *new_value;
 
 			new_value = (varlena *) DatumGetPointer(new_values[i]);
-			if (VARATT_IS_EXTERNAL(new_value))
+			if (VARATT_IS_EXTERNAL(new_value) ||
+				VARATT_IS_COMPRESSED_DICT(new_value))
 			{
 				new_value = detoast_external_attr(new_value);
 				new_values[i] = PointerGetDatum(new_value);
diff --git a/src/backend/access/table/toast_helper.c b/src/backend/access/table/toast_helper.c
index 2613d9dc095..c7c7dba7c82 100644
--- a/src/backend/access/table/toast_helper.c
+++ b/src/backend/access/table/toast_helper.c
@@ -15,6 +15,8 @@
 #include "postgres.h"
 
 #include "access/detoast.h"
+#include "access/toast_compression.h"
+#include "access/toast_dictionary.h"
 #include "access/toast_helper.h"
 #include "access/toast_internals.h"
 #include "catalog/pg_type_d.h"
@@ -49,7 +51,7 @@ toast_tuple_init(ToastTupleContext *ttc)
 	for (i = 0; i < numAttrs; i++)
 	{
 		Form_pg_attribute att = TupleDescAttr(tupleDesc, i);
-		varlena    *old_value;
+		varlena    *old_value = NULL;
 		varlena    *new_value;
 
 		ttc->ttc_attr[i].tai_colflags = 0;
@@ -150,6 +152,37 @@ toast_tuple_init(ToastTupleContext *ttc)
 				ttc->ttc_flags |= (TOAST_NEEDS_CHANGE | TOAST_NEEDS_FREE);
 			}
 
+			/*
+			 * A value compressed with a dictionary may only be stored in the
+			 * column owning that dictionary, and only while the dictionary is
+			 * current; otherwise decompress it, so that it is recompressed,
+			 * if at all, with this column's own method.  This is what keeps a
+			 * dictionary's values inside its column (see toast_dictionary.c).
+			 *
+			 * An UPDATE that leaves such a value as it was is the exception:
+			 * the value is this column's own, and like an unchanged external
+			 * value it can stay as it is until a rewrite (which never passes
+			 * an old tuple here) recompresses it.  REPACK CONCURRENTLY also
+			 * replays UPDATEs into the new heap, so check for that.
+			 */
+			if (VARATT_IS_COMPRESSED_DICT(new_value) &&
+				!(ttc->ttc_oldvalues != NULL && !ttc->ttc_oldisnull[i] &&
+				  !OidIsValid(ttc->ttc_rel->rd_rel->relrewrite) &&
+				  VARATT_IS_COMPRESSED_DICT(old_value) &&
+				  VARSIZE(old_value) == VARSIZE(new_value) &&
+				  memcmp(old_value, new_value, VARSIZE(new_value)) == 0) &&
+				!toast_dictionary_keep(ttc->ttc_rel, att->attnum, new_value))
+			{
+				varlena    *plain = toast_dict_decompress_datum(new_value);
+
+				if ((ttc->ttc_attr[i].tai_colflags & TOASTCOL_NEEDS_FREE) != 0)
+					pfree(new_value);
+				new_value = plain;
+				ttc->ttc_values[i] = PointerGetDatum(new_value);
+				ttc->ttc_attr[i].tai_colflags |= TOASTCOL_NEEDS_FREE;
+				ttc->ttc_flags |= (TOAST_NEEDS_CHANGE | TOAST_NEEDS_FREE);
+			}
+
 			/*
 			 * Remember the size of this attribute
 			 */
@@ -246,7 +279,8 @@ toast_tuple_try_compression(ToastTupleContext *ttc, int attribute)
 	Datum		new_value;
 	ToastAttrInfo *attr = &ttc->ttc_attr[attribute];
 
-	new_value = toast_compress_datum(*value, attr->tai_compression);
+	new_value = toast_compress_datum_rel(*value, attr->tai_compression,
+										 ttc->ttc_rel, attribute + 1);
 
 	if (DatumGetPointer(new_value) != NULL)
 	{
diff --git a/src/backend/catalog/heap.c b/src/backend/catalog/heap.c
index a2c76b22fb0..704a1019719 100644
--- a/src/backend/catalog/heap.c
+++ b/src/backend/catalog/heap.c
@@ -34,6 +34,7 @@
 #include "access/relation.h"
 #include "access/table.h"
 #include "access/tableam.h"
+#include "access/toast_dictionary.h"
 #include "access/xlog.h"
 #include "catalog/binary_upgrade.h"
 #include "catalog/catalog.h"
@@ -76,6 +77,7 @@
 #include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/pg_lsn.h"
+#include "utils/snapmgr.h"
 #include "utils/syscache.h"
 
 
@@ -2050,6 +2052,13 @@ RemoveAttributeById(Oid relid, AttrNumber attnum)
 
 	RemoveStatistics(relid, attnum);
 
+	/*
+	 * The column's compression dictionaries are not removed here: its values
+	 * stay in the heap until the next rewrite, and whole-row references and
+	 * REPLICA IDENTITY FULL still read them.  The rewrite nulls them and
+	 * toast_dictionary_retire() then deletes the dictionaries.
+	 */
+
 	relation_close(rel, NoLock);
 }
 
@@ -2202,6 +2211,11 @@ heap_drop_with_catalog(Oid relid)
 	 */
 	RemoveStatistics(relid, 0);
 
+	/*
+	 * delete compression dictionaries
+	 */
+	RemoveToastDictionaries(relid, 0);
+
 	/*
 	 * delete attribute tuples
 	 */
@@ -3934,7 +3948,11 @@ heap_truncate(List *relids)
 	/* Don't allow truncate on tables that are referenced by foreign keys */
 	heap_truncate_check_FKs(relations, true);
 
-	/* OK to do it */
+	/*
+	 * OK to do it.  Retiring compression dictionaries may delete toasted
+	 * catalog entries, so ensure we have a valid snapshot.
+	 */
+	PushActiveSnapshot(GetTransactionSnapshot());
 	foreach(cell, relations)
 	{
 		Relation	rel = lfirst(cell);
@@ -3942,9 +3960,13 @@ heap_truncate(List *relids)
 		/* Truncate the relation */
 		heap_truncate_one_rel(rel);
 
+		/* No value references its old compression dictionaries any more */
+		toast_dictionary_retire(RelationGetRelid(rel));
+
 		/* Close the relation, but keep exclusive lock on it until commit */
 		table_close(rel, NoLock);
 	}
+	PopActiveSnapshot();
 }
 
 /*
diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql
index 6dce9192ba0..c6aaca64c95 100644
--- a/src/backend/catalog/system_views.sql
+++ b/src/backend/catalog/system_views.sql
@@ -277,6 +277,9 @@ CREATE VIEW pg_stats WITH (security_barrier) AS
 
 REVOKE ALL ON pg_statistic FROM public;
 
+-- dictionaries are built from column contents, so protect them as such
+REVOKE ALL ON pg_toast_dictionary FROM public;
+
 CREATE VIEW pg_stats_ext WITH (security_barrier) AS
     SELECT cn.nspname AS schemaname,
            c.relname AS tablename,
diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index c2a27b7ad05..ee2198d6f20 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -37,6 +37,7 @@
 #include "access/multixact.h"
 #include "access/relscan.h"
 #include "access/tableam.h"
+#include "access/toast_dictionary.h"
 #include "access/toast_internals.h"
 #include "access/transam.h"
 #include "access/xact.h"
@@ -2152,6 +2153,13 @@ finish_heap_swap(Oid OIDOldHeap, Oid OIDNewHeap,
 		table_close(relRelation, RowExclusiveLock);
 	}
 
+	/*
+	 * Every value has been rewritten, and toast_tuple_init() recompressed any
+	 * that used a non-current compression dictionary, so those dictionaries
+	 * are no longer referenced and can go.
+	 */
+	toast_dictionary_retire(OIDOldHeap);
+
 	/* Destroy new heap with old filenumber */
 	object.classId = RelationRelationId;
 	object.objectId = OIDNewHeap;
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index aa89c9a4637..b231c683937 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -25,6 +25,7 @@
 #include "access/sysattr.h"
 #include "access/tableam.h"
 #include "access/toast_compression.h"
+#include "access/toast_dictionary.h"
 #include "access/tupconvert.h"
 #include "access/xact.h"
 #include "access/xlog.h"
@@ -2293,6 +2294,12 @@ ExecuteTruncateGuts(List *explicit_rels,
 							 &reindex_params);
 		}
 
+		/*
+		 * No value references a compression dictionary of this relation's
+		 * columns any more, so retire the ones a rewrite would.
+		 */
+		toast_dictionary_retire(RelationGetRelid(rel));
+
 		pgstat_count_truncate(rel);
 	}
 
diff --git a/src/backend/executor/tstoreReceiver.c b/src/backend/executor/tstoreReceiver.c
index 8531d4ca432..f020b19ebf7 100644
--- a/src/backend/executor/tstoreReceiver.c
+++ b/src/backend/executor/tstoreReceiver.c
@@ -23,6 +23,7 @@
 #include "postgres.h"
 
 #include "access/detoast.h"
+#include "access/toast_compression.h"
 #include "access/tupconvert.h"
 #include "executor/tstoreReceiver.h"
 #include "varatt.h"
@@ -159,7 +160,8 @@ tstoreReceiveSlot_detoast(TupleTableSlot *slot, DestReceiver *self)
 
 		if (!attr->attisdropped && attr->attlen == -1 && !slot->tts_isnull[i])
 		{
-			if (VARATT_IS_EXTERNAL(DatumGetPointer(val)))
+			if (VARATT_IS_EXTERNAL(DatumGetPointer(val)) ||
+				VARATT_IS_COMPRESSED_DICT(DatumGetPointer(val)))
 			{
 				val = PointerGetDatum(detoast_external_attr((varlena *)
 															DatumGetPointer(val)));
diff --git a/src/backend/utils/adt/datum.c b/src/backend/utils/adt/datum.c
index 1d622e31a83..cbc5545a067 100644
--- a/src/backend/utils/adt/datum.c
+++ b/src/backend/utils/adt/datum.c
@@ -43,6 +43,7 @@
 #include "postgres.h"
 
 #include "access/detoast.h"
+#include "access/toast_compression.h"
 #include "common/hashfn.h"
 #include "fmgr.h"
 #include "utils/datum.h"
@@ -196,6 +197,19 @@ datumTransfer(Datum value, bool typByVal, int typLen)
 	if (!typByVal && typLen == -1 &&
 		VARATT_IS_EXTERNAL_EXPANDED_RW(DatumGetPointer(value)))
 		value = TransferExpandedObject(value, CurrentMemoryContext);
+	else if (!typByVal && typLen == -1 &&
+			 VARATT_IS_COMPRESSED_DICT(DatumGetPointer(value)))
+
+		/*
+		 * An inline dictionary-compressed datum depends on a catalog row of
+		 * the column it came from.  Transferring it verbatim to an outer
+		 * context (as SPI_datumTransfer() does across SPI_commit()) could
+		 * leave it readable after a concurrent rewrite retires its
+		 * dictionary, so decompress it here, the way a self-contained copy of
+		 * an external value is detoasted.
+		 */
+		value = PointerGetDatum(detoast_external_attr((varlena *)
+													  DatumGetPointer(value)));
 	else
 		value = datumCopy(value, typByVal, typLen);
 	return value;
diff --git a/src/backend/utils/adt/expandedrecord.c b/src/backend/utils/adt/expandedrecord.c
index c0550ca8e2b..8de4f08d52d 100644
--- a/src/backend/utils/adt/expandedrecord.c
+++ b/src/backend/utils/adt/expandedrecord.c
@@ -20,6 +20,7 @@
 
 #include "access/detoast.h"
 #include "access/heaptoast.h"
+#include "access/toast_compression.h"
 #include "access/htup_details.h"
 #include "catalog/heap.h"
 #include "catalog/pg_type.h"
@@ -674,7 +675,8 @@ ER_get_flat_size(ExpandedObjectHeader *eohptr)
 
 			if (!erh->dnulls[i] &&
 				!attr->attbyval && attr->attlen == -1 &&
-				VARATT_IS_EXTERNAL(DatumGetPointer(erh->dvalues[i])))
+				(VARATT_IS_EXTERNAL(DatumGetPointer(erh->dvalues[i])) ||
+				 VARATT_IS_COMPRESSED_DICT(DatumGetPointer(erh->dvalues[i]))))
 			{
 				/*
 				 * expanded_record_set_field_internal can do the actual work
@@ -1042,7 +1044,8 @@ expanded_record_set_field_internal(ExpandedRecordHeader *erh, int fnumber,
 		if (expand_external)
 		{
 			if (attr->attlen == -1 &&
-				VARATT_IS_EXTERNAL(DatumGetPointer(newValue)))
+				(VARATT_IS_EXTERNAL(DatumGetPointer(newValue)) ||
+				 VARATT_IS_COMPRESSED_DICT(DatumGetPointer(newValue))))
 			{
 				/* Detoasting should be done in short-lived context. */
 				oldcxt = MemoryContextSwitchTo(get_short_term_cxt(erh));
@@ -1072,7 +1075,8 @@ expanded_record_set_field_internal(ExpandedRecordHeader *erh, int fnumber,
 		 * by itself have made the value non-external.)
 		 */
 		if (attr->attlen == -1 &&
-			VARATT_IS_EXTERNAL(DatumGetPointer(newValue)))
+			(VARATT_IS_EXTERNAL(DatumGetPointer(newValue)) ||
+			 VARATT_IS_COMPRESSED_DICT(DatumGetPointer(newValue))))
 			erh->flags |= ER_FLAG_HAVE_EXTERNAL;
 	}
 
@@ -1186,7 +1190,8 @@ expanded_record_set_fields(ExpandedRecordHeader *erh,
 			{
 				/* Is it an external toasted value? */
 				if (attr->attlen == -1 &&
-					VARATT_IS_EXTERNAL(DatumGetPointer(newValue)))
+					(VARATT_IS_EXTERNAL(DatumGetPointer(newValue)) ||
+					 VARATT_IS_COMPRESSED_DICT(DatumGetPointer(newValue))))
 				{
 					if (expand_external)
 					{
@@ -1198,7 +1203,8 @@ expanded_record_set_fields(ExpandedRecordHeader *erh,
 						/* Just copy the value */
 						newValue = datumCopy(newValue, false, -1);
 						/* If it's still external, remember that */
-						if (VARATT_IS_EXTERNAL(DatumGetPointer(newValue)))
+						if (VARATT_IS_EXTERNAL(DatumGetPointer(newValue)) ||
+							VARATT_IS_COMPRESSED_DICT(DatumGetPointer(newValue)))
 							erh->flags |= ER_FLAG_HAVE_EXTERNAL;
 					}
 				}
@@ -1445,7 +1451,8 @@ check_domain_for_new_field(ExpandedRecordHeader *erh, int fnumber,
 		CompactAttribute *attr = TupleDescCompactAttr(erh->er_tupdesc, fnumber - 1);
 
 		if (!attr->attbyval && attr->attlen == -1 &&
-			VARATT_IS_EXTERNAL(DatumGetPointer(newValue)))
+			(VARATT_IS_EXTERNAL(DatumGetPointer(newValue)) ||
+			 VARATT_IS_COMPRESSED_DICT(DatumGetPointer(newValue))))
 			dummy_erh->flags |= ER_FLAG_HAVE_EXTERNAL;
 	}
 
diff --git a/src/backend/utils/adt/varlena.c b/src/backend/utils/adt/varlena.c
index 25ca69ce78f..cb1e3704f7f 100644
--- a/src/backend/utils/adt/varlena.c
+++ b/src/backend/utils/adt/varlena.c
@@ -4243,6 +4243,12 @@ pg_column_compression(PG_FUNCTION_ARGS)
 		case TOAST_ZSTD_COMPRESSION_ID:
 			result = "zstd";
 			break;
+		case TOAST_ZSTD_DICT_COMPRESSION_ID:
+			result = "zstd_dict";
+			break;
+		case TOAST_LZ4_DICT_COMPRESSION_ID:
+			result = "lz4_dict";
+			break;
 		default:
 			elog(ERROR, "invalid compression method id %d", cmid);
 	}
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 07868bafee2..08c75d970d7 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -2517,6 +2517,9 @@ RelationDestroyRelation(Relation relation, bool remember_tupdesc)
 	bms_free(relation->rd_idattr);
 	bms_free(relation->rd_hotblockingattr);
 	bms_free(relation->rd_summarizedattr);
+	if (relation->rd_toastdictoids)
+		pfree(relation->rd_toastdictoids);
+	bms_free(relation->rd_toastdictvalid);
 	if (relation->rd_pubdesc)
 		pfree(relation->rd_pubdesc);
 	if (relation->rd_options)
@@ -6606,6 +6609,8 @@ load_relcache_init_file(bool shared)
 		rel->rd_statlist = NIL;
 		rel->rd_fkeyvalid = false;
 		rel->rd_fkeylist = NIL;
+		rel->rd_toastdictoids = NULL;
+		rel->rd_toastdictvalid = NULL;
 		rel->rd_createSubid = InvalidSubTransactionId;
 		rel->rd_newRelfilelocatorSubid = InvalidSubTransactionId;
 		rel->rd_firstRelfilelocatorSubid = InvalidSubTransactionId;
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 9ec402d73c9..0202335a120 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -391,6 +391,10 @@ static void binary_upgrade_set_type_oids_by_type_oid(Archive *fout,
 													 Oid pg_type_oid,
 													 bool force_array_type,
 													 bool include_multirange_type);
+static void binary_upgrade_dump_toast_dictionaries(Archive *fout,
+												   PQExpBuffer q,
+												   const TableInfo *tbinfo,
+												   const char *qualrelname);
 static void binary_upgrade_set_type_oids_by_rel(Archive *fout,
 												PQExpBuffer upgrade_buffer,
 												const TableInfo *tbinfo);
@@ -5838,6 +5842,53 @@ binary_upgrade_set_type_oids_by_type_oid(Archive *fout,
 	destroyPQExpBuffer(upgrade_query);
 }
 
+/*
+ * Emit calls restoring the compression dictionaries of a table, with their
+ * OIDs, for pg_upgrade.  Dictionaries are identified by physical column
+ * number, which binary upgrade preserves, so that those of dropped columns
+ * whose values are still on disk are carried over too.
+ *
+ * XXX: this runs one query per table; it could be folded into
+ * getTableAttrs() if dumps of databases with many thousands of tables
+ * notice it.
+ */
+static void
+binary_upgrade_dump_toast_dictionaries(Archive *fout, PQExpBuffer q,
+									   const TableInfo *tbinfo,
+									   const char *qualrelname)
+{
+	PQExpBuffer query;
+	PGresult   *res;
+
+	if (fout->remoteVersion < 200000)
+		return;
+
+	query = createPQExpBuffer();
+	appendPQExpBuffer(query,
+					  "SELECT d.oid, d.tdattnum, d.tdcurrent, d.tddict "
+					  "FROM pg_catalog.pg_toast_dictionary d "
+					  "WHERE d.tdrelid = '%u'::pg_catalog.oid "
+					  "ORDER BY d.oid",
+					  tbinfo->dobj.catId.oid);
+	res = ExecuteSqlQuery(fout, query->data, PGRES_TUPLES_OK);
+
+	for (int i = 0; i < PQntuples(res); i++)
+	{
+		appendPQExpBufferStr(q, "\n-- For binary upgrade, restore compression dictionary.\n");
+		appendPQExpBufferStr(q, "SELECT pg_catalog.binary_upgrade_add_toast_dictionary(");
+		appendStringLiteralAH(q, qualrelname, fout);
+		appendPQExpBuffer(q, "::pg_catalog.regclass, %s::pg_catalog.int2, '%s'::pg_catalog.oid, %s, ",
+						  PQgetvalue(res, i, 1),
+						  PQgetvalue(res, i, 0),
+						  PQgetvalue(res, i, 2)[0] == 't' ? "true" : "false");
+		appendStringLiteralAH(q, PQgetvalue(res, i, 3), fout);
+		appendPQExpBufferStr(q, "::pg_catalog.bytea);\n");
+	}
+
+	PQclear(res);
+	destroyPQExpBuffer(query);
+}
+
 static void
 binary_upgrade_set_type_oids_by_rel(Archive *fout,
 									PQExpBuffer upgrade_buffer,
@@ -17958,6 +18009,12 @@ dumpTableSchema(Archive *fout, const TableInfo *tbinfo)
 					case 'z':
 						cmname = "zstd";
 						break;
+					case 'd':
+						cmname = "zstd_dict";
+						break;
+					case 'e':
+						cmname = "lz4_dict";
+						break;
 					default:
 						cmname = NULL;
 						break;
@@ -18026,6 +18083,19 @@ dumpTableSchema(Archive *fout, const TableInfo *tbinfo)
 		appendPQExpBuffer(q, "\nALTER TABLE ONLY %s FORCE ROW LEVEL SECURITY;\n",
 						  qualrelname);
 
+	/*
+	 * pg_upgrade keeps the heap files, whose values embed the OIDs of the
+	 * dictionaries they were compressed with, so carry the dictionaries over
+	 * with their OIDs.  This must not depend on the columns' current
+	 * compression method: after SET COMPRESSION, existing values keep using
+	 * the old dictionaries until the table is rewritten.  A plain dump has no
+	 * compressed values and needs no dictionaries.
+	 */
+	if (dopt->binary_upgrade &&
+		(tbinfo->relkind == RELKIND_RELATION ||
+		 tbinfo->relkind == RELKIND_MATVIEW))
+		binary_upgrade_dump_toast_dictionaries(fout, q, tbinfo, qualrelname);
+
 	if (dopt->binary_upgrade)
 		binary_upgrade_extension_member(q, &tbinfo->dobj,
 										reltypename, qrelname,
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 77475632c11..61cabd63b77 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -2089,8 +2089,10 @@ describeOneTableDetails(const char *schemaname,
 			printTableAddCell(&cont, (compression[0] == 'p' ? "pglz" :
 									  (compression[0] == 'l' ? "lz4" :
 									   (compression[0] == 'z' ? "zstd" :
-										(compression[0] == '\0' ? "" :
-										 "???")))),
+										(compression[0] == 'd' ? "zstd_dict" :
+										 (compression[0] == 'e' ? "lz4_dict" :
+										  (compression[0] == '\0' ? "" :
+										   "???")))))),
 							  false, false);
 		}
 
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index bd1ae0b7a8e..24f3c65f2df 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2972,7 +2972,7 @@ match_previous_words(int pattern_id,
 	/* ALTER TABLE ALTER [COLUMN] <foo> SET COMPRESSION */
 	else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET", "COMPRESSION") ||
 			 Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET", "COMPRESSION"))
-		COMPLETE_WITH("DEFAULT", "PGLZ", "LZ4", "ZSTD");
+		COMPLETE_WITH("DEFAULT", "PGLZ", "LZ4", "ZSTD", "ZSTD_DICT", "LZ4_DICT");
 	/* ALTER TABLE ALTER [COLUMN] <foo> SET EXPRESSION */
 	else if (Matches("ALTER", "TABLE", MatchAny, "ALTER", "COLUMN", MatchAny, "SET", "EXPRESSION") ||
 			 Matches("ALTER", "TABLE", MatchAny, "ALTER", MatchAny, "SET", "EXPRESSION"))
diff --git a/src/include/access/toast_compression.h b/src/include/access/toast_compression.h
index 5c00f8e9aa4..1b2abde85ed 100644
--- a/src/include/access/toast_compression.h
+++ b/src/include/access/toast_compression.h
@@ -52,13 +52,15 @@ typedef enum ToastCompressionId
 	TOAST_LZ4_COMPRESSION_ID = 1,
 	TOAST_ZSTD_COMPRESSION_ID = 2,
 	TOAST_INVALID_COMPRESSION_ID = 3,
+	TOAST_ZSTD_DICT_COMPRESSION_ID = 4,
+	TOAST_LZ4_DICT_COMPRESSION_ID = 5,
 } ToastCompressionId;
 
 StaticAssertDecl(TOAST_INVALID_COMPRESSION_ID == VARLENA_COMPRESS_METHOD_LONG,
 				 "TOAST_INVALID_COMPRESSION_ID must match VARLENA_COMPRESS_METHOD_LONG");
 
 /* Largest compression method ID in use; IDs above it are not methods. */
-#define TOAST_LAST_COMPRESSION_ID	TOAST_ZSTD_COMPRESSION_ID
+#define TOAST_LAST_COMPRESSION_ID	TOAST_LZ4_DICT_COMPRESSION_ID
 
 /*
  * Does this compression method ID need a byte of its own?
@@ -111,6 +113,24 @@ toast_inline_compression_id(const void *PTR)
 	return (ToastCompressionId) method;
 }
 
+/*
+ * Is this a compressed-in-line datum that needs a compression dictionary to
+ * be read?  Such a datum depends on catalog state of the column it belongs
+ * to, so code that makes values self-contained treats it like a TOAST
+ * pointer (see toast_dictionary.c).  True for every dictionary-aware codec.
+ */
+static inline bool
+VARATT_IS_COMPRESSED_DICT(const void *PTR)
+{
+	ToastCompressionId cmid;
+
+	if (!VARATT_IS_COMPRESSED(PTR))
+		return false;
+	cmid = toast_inline_compression_id(PTR);
+	return (cmid == TOAST_ZSTD_DICT_COMPRESSION_ID ||
+			cmid == TOAST_LZ4_DICT_COMPRESSION_ID);
+}
+
 /*
  * Built-in compression methods.  pg_attribute will store these in the
  * attcompression column.  In attcompression, InvalidCompressionMethod
@@ -119,6 +139,8 @@ toast_inline_compression_id(const void *PTR)
 #define TOAST_PGLZ_COMPRESSION			'p'
 #define TOAST_LZ4_COMPRESSION			'l'
 #define TOAST_ZSTD_COMPRESSION			'z'
+#define TOAST_ZSTD_DICT_COMPRESSION		'd'
+#define TOAST_LZ4_DICT_COMPRESSION		'e'
 #define InvalidCompressionMethod		'\0'
 
 #define CompressionMethodIsValid(cm)  ((cm) != InvalidCompressionMethod)
diff --git a/src/include/access/toast_dictionary.h b/src/include/access/toast_dictionary.h
new file mode 100644
index 00000000000..41b4fdf5c85
--- /dev/null
+++ b/src/include/access/toast_dictionary.h
@@ -0,0 +1,65 @@
+/*-------------------------------------------------------------------------
+ *
+ * toast_dictionary.h
+ *	  Lifecycle of compression dictionaries for TOAST.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/include/access/toast_dictionary.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef TOAST_DICTIONARY_H
+#define TOAST_DICTIONARY_H
+
+#include "utils/relcache.h"
+#include "varatt.h"
+
+/*
+ * A dictionary-compressed datum stores the dictionary OID right after the
+ * method byte of the long compressed header, regardless of which codec
+ * produced the payload:
+ *
+ *	 va_header | va_tcinfo | va_cmid | dictid (4 bytes) | codec payload
+ *
+ * The method byte (va_cmid) says which codec's payload follows, so the same
+ * layout serves zstd, lz4, and any future dictionary-aware codec.
+ */
+#define VARHDRSZ_COMPRESSED_DICT	(VARHDRSZ_COMPRESSED_LONG + sizeof(Oid))
+
+/*
+ * Per-codec dictionary routines.  The catalog, lifecycle, training, and the
+ * datum layout above are codec-independent and live in toast_dictionary.c;
+ * only these three calls differ per codec.  compress() lays out the header
+ * and writes the codec payload after VARHDRSZ_COMPRESSED_DICT; it returns
+ * NULL if the value did not get smaller.  decompress() and
+ * decompress_slice() read a datum this codec produced.
+ */
+typedef struct DictCompressRoutine
+{
+	varlena    *(*compress) (const varlena *value, Oid dictid);
+	varlena    *(*decompress) (const varlena *value);
+	varlena    *(*decompress_slice) (const varlena *value, int32 slicelength);
+} DictCompressRoutine;
+
+/* Current dictionary of rel.attnum, or InvalidOid if none */
+extern Oid	toast_dictionary_current(Relation rel, AttrNumber attnum);
+
+/* May this dict-compressed datum be stored as-is in rel.attnum? */
+extern bool toast_dictionary_keep(Relation rel, AttrNumber attnum,
+								  const varlena *value);
+
+/*
+ * Generic dictionary compress/decompress.  The method byte in value (or, for
+ * compress, the compression method cmid) selects the codec's routine.
+ */
+extern varlena *toast_dict_compress_datum(const varlena *value, char cmethod,
+										  Oid dictid);
+extern varlena *toast_dict_decompress_datum(const varlena *value);
+extern varlena *toast_dict_decompress_datum_slice(const varlena *value,
+												  int32 slicelength);
+
+extern void RemoveToastDictionaries(Oid relid, AttrNumber attnum);
+extern void toast_dictionary_retire(Oid relid);
+
+#endif							/* TOAST_DICTIONARY_H */
diff --git a/src/include/access/toast_internals.h b/src/include/access/toast_internals.h
index a31b47f8df5..036b793fa23 100644
--- a/src/include/access/toast_internals.h
+++ b/src/include/access/toast_internals.h
@@ -18,6 +18,8 @@
 #include "utils/snapshot.h"
 
 extern Datum toast_compress_datum(Datum value, char cmethod);
+extern Datum toast_compress_datum_rel(Datum value, char cmethod, Relation rel,
+									  AttrNumber attnum);
 extern Oid	toast_get_valid_index(Oid toastoid, LOCKMODE lock);
 
 extern void toast_delete_datum(Relation rel, Datum value, bool is_speculative);
diff --git a/src/include/catalog/Makefile b/src/include/catalog/Makefile
index 444fc76eed6..49f65ec84b9 100644
--- a/src/include/catalog/Makefile
+++ b/src/include/catalog/Makefile
@@ -39,6 +39,7 @@ CATALOG_HEADERS := \
 	pg_statistic.h \
 	pg_statistic_ext.h \
 	pg_statistic_ext_data.h \
+	pg_toast_dictionary.h \
 	pg_rewrite.h \
 	pg_trigger.h \
 	pg_event_trigger.h \
diff --git a/src/include/catalog/meson.build b/src/include/catalog/meson.build
index bcc01c87c2e..8e111945a4e 100644
--- a/src/include/catalog/meson.build
+++ b/src/include/catalog/meson.build
@@ -26,6 +26,7 @@ catalog_headers = [
   'pg_statistic.h',
   'pg_statistic_ext.h',
   'pg_statistic_ext_data.h',
+  'pg_toast_dictionary.h',
   'pg_rewrite.h',
   'pg_trigger.h',
   'pg_event_trigger.h',
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 50fb182cb79..9a75ab158c0 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -7906,6 +7906,12 @@
 { oid => '2121', descr => 'compression method for the compressed datum',
   proname => 'pg_column_compression', provolatile => 's', prorettype => 'text',
   proargtypes => 'any', prosrc => 'pg_column_compression' },
+{ oid => '9265', descr => 'train a TOAST compression dictionary for a column',
+  proname => 'pg_train_toast_dictionary', provolatile => 'v',
+  proparallel => 'u', prorettype => 'oid',
+  proargtypes => 'regclass name int4',
+  proargnames => '{rel,col,dict_size}', proargdefaults => '{65536}',
+  prosrc => 'pg_train_toast_dictionary' },
 { oid => '6316', descr => 'chunk ID of on-disk TOASTed value',
   proname => 'pg_column_toast_chunk_id', provolatile => 's',
   prorettype => 'oid8', proargtypes => 'any',
@@ -12023,6 +12029,11 @@
   proname => 'binary_upgrade_set_missing_value', provolatile => 'v',
   proparallel => 'u', prorettype => 'void', proargtypes => 'oid text text',
   prosrc => 'binary_upgrade_set_missing_value' },
+{ oid => '9266', descr => 'for use by pg_upgrade',
+  proname => 'binary_upgrade_add_toast_dictionary', provolatile => 'v',
+  proparallel => 'u', prorettype => 'void',
+  proargtypes => 'regclass int2 oid bool bytea',
+  prosrc => 'binary_upgrade_add_toast_dictionary' },
 { oid => '4545', descr => 'for use by pg_upgrade',
   proname => 'binary_upgrade_set_next_heap_relfilenode', provolatile => 'v',
   proparallel => 'u', prorettype => 'void', proargtypes => 'oid',
diff --git a/src/include/catalog/pg_toast_dictionary.h b/src/include/catalog/pg_toast_dictionary.h
new file mode 100644
index 00000000000..a2abc91ae9c
--- /dev/null
+++ b/src/include/catalog/pg_toast_dictionary.h
@@ -0,0 +1,65 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_toast_dictionary.h
+ *	  definition of the "TOAST compression dictionary" system catalog
+ *	  (pg_toast_dictionary)
+ *
+ * Each row is one trained compression dictionary.  A dictionary is owned by
+ * exactly one column of one table, just as a pg_statistic row is: it is
+ * deleted with that column or table, never shared, and needs no pg_depend
+ * entries.  Values compressed with a dictionary are only ever stored in the
+ * column that owns it; see toast_dictionary.c for how that is enforced.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/catalog/pg_toast_dictionary.h
+ *
+ * NOTES
+ *	  The Catalog.pm module reads this file and derives schema
+ *	  information.
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_TOAST_DICTIONARY_H
+#define PG_TOAST_DICTIONARY_H
+
+#include "catalog/genbki.h"
+#include "catalog/pg_toast_dictionary_d.h"	/* IWYU pragma: export */
+
+/* ----------------
+ *		pg_toast_dictionary definition.  cpp turns this into
+ *		typedef struct FormData_pg_toast_dictionary
+ * ----------------
+ */
+BEGIN_CATALOG_STRUCT
+
+CATALOG(pg_toast_dictionary,9260,ToastDictionaryRelationId)
+{
+	Oid			oid;			/* dictionary ID, as stored in each datum */
+	Oid			tdrelid BKI_LOOKUP(pg_class);	/* owning relation */
+	int16		tdattnum;		/* owning column */
+	bool		tdcurrent;		/* used to compress new values? */
+
+#ifdef CATALOG_VARLEN			/* variable-length fields start here */
+	bytea		tddict BKI_FORCE_NOT_NULL;	/* zstd dictionary content */
+#endif
+} FormData_pg_toast_dictionary;
+
+END_CATALOG_STRUCT
+
+/* ----------------
+ *		Form_pg_toast_dictionary corresponds to a pointer to a tuple with
+ *		the format of pg_toast_dictionary relation.
+ * ----------------
+ */
+typedef FormData_pg_toast_dictionary *Form_pg_toast_dictionary;
+
+DECLARE_TOAST(pg_toast_dictionary, 9261, 9262);
+
+DECLARE_UNIQUE_INDEX_PKEY(pg_toast_dictionary_oid_index, 9263, ToastDictionaryOidIndexId, pg_toast_dictionary, btree(oid oid_ops));
+DECLARE_INDEX(pg_toast_dictionary_relid_attnum_index, 9264, ToastDictionaryRelidAttnumIndexId, pg_toast_dictionary, btree(tdrelid oid_ops, tdattnum int2_ops));
+
+MAKE_SYSCACHE(TOASTDICTOID, pg_toast_dictionary_oid_index, 16);
+
+#endif							/* PG_TOAST_DICTIONARY_H */
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index 7b8c2b1e362..71ad606adb1 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -157,6 +157,21 @@ typedef struct RelationData
 	/* data managed by RelationGetStatExtList: */
 	List	   *rd_statlist;	/* list of OIDs of extended stats */
 
+	/*
+	 * Per-column cache of each column's current TOAST compression dictionary
+	 * OID, filled lazily by toast_dictionary_current() to avoid a catalog
+	 * index probe per compressed value.  rd_toastdictoids is a natts-long
+	 * array in CacheMemoryContext (NULL until first use); an entry is
+	 * InvalidOid both when a column has no dictionary and when it has not
+	 * been looked up yet, so rd_toastdictvalid flags which entries are
+	 * filled.  A single array avoids thrashing on the write path when a table
+	 * has more than one dictionary column.  dict_install() issues
+	 * CacheInvalidateRelcache(), which rebuilds the entry and so drops this
+	 * cache, whenever a column's current dictionary changes.
+	 */
+	Oid		   *rd_toastdictoids;
+	Bitmapset  *rd_toastdictvalid;
+
 	/* data managed by RelationGetIndexAttrBitmap: */
 	bool		rd_attrsvalid;	/* are bitmaps of attrs valid? */
 	Bitmapset  *rd_keyattr;		/* cols that can be ref'd by foreign keys */
diff --git a/src/pl/plpgsql/src/pl_exec.c b/src/pl/plpgsql/src/pl_exec.c
index 915f0adde2d..bde3e3c0603 100644
--- a/src/pl/plpgsql/src/pl_exec.c
+++ b/src/pl/plpgsql/src/pl_exec.c
@@ -18,6 +18,7 @@
 #include <ctype.h>
 
 #include "access/detoast.h"
+#include "access/toast_compression.h"
 #include "access/htup_details.h"
 #include "access/tupconvert.h"
 #include "catalog/pg_proc.h"
@@ -9017,7 +9018,8 @@ assign_simple_var(PLpgSQL_execstate *estate, PLpgSQL_var *var,
 	 * not a problem since all array entries are always detoasted.)
 	 */
 	if (!estate->atomic && !isnull && var->datatype->typlen == -1 &&
-		VARATT_IS_EXTERNAL_NON_EXPANDED(DatumGetPointer(newvalue)))
+		(VARATT_IS_EXTERNAL_NON_EXPANDED(DatumGetPointer(newvalue)) ||
+		 VARATT_IS_COMPRESSED_DICT(DatumGetPointer(newvalue))))
 	{
 		MemoryContext oldcxt;
 		Datum		detoasted;
diff --git a/src/test/regress/expected/compression_lz4_dict.out b/src/test/regress/expected/compression_lz4_dict.out
new file mode 100644
index 00000000000..144f09f1ab6
--- /dev/null
+++ b/src/test/regress/expected/compression_lz4_dict.out
@@ -0,0 +1,112 @@
+-- Tests for lz4 TOAST compression with dictionaries.  The dictionary
+-- lifecycle (ownership, containment, retirement) is codec-independent and is
+-- covered in compression_zstd_dict; this file checks the lz4 codec path:
+-- values compress with the dictionary, read back whole and sliced, beat plain
+-- lz4, and stay inside their column.  Training a dictionary uses zstd's
+-- builder (lz4 has none), so the suite needs both lz4 and zstd.
+SELECT NOT(enumvals @> '{lz4}' AND enumvals @> '{zstd}') AS skip_test
+  FROM pg_settings WHERE name = 'default_toast_compression' \gset
+\if :skip_test
+   \echo '*** skipping TOAST tests with lz4 dictionaries (lz4 and zstd both required) ***'
+   \quit
+\endif
+CREATE SCHEMA lz4_dict;
+SET search_path TO lz4_dict, public;
+\set HIDE_TOAST_COMPRESSION false
+-- Many small, similar documents: the case dictionaries are made for.
+CREATE FUNCTION doc(i int) RETURNS text LANGUAGE SQL AS $$
+  SELECT format('{"customer_id": %s, "status": "active", "region": "us-east", '
+                '"plan": "enterprise", "features": ["sso", "audit", "backup"], '
+                '"tags": ["%s", "priority-%s"], "notes": "%s"}',
+                i, md5(i::text), i % 5, repeat('lorem ipsum dolor sit amet ', 80))
+$$;
+CREATE TABLE cmdict (id int, f1 text COMPRESSION lz4_dict);
+\d+ cmdict
+                                         Table "lz4_dict.cmdict"
+ Column |  Type   | Collation | Nullable | Default | Storage  | Compression | Stats target | Description 
+--------+---------+-----------+----------+---------+----------+-------------+--------------+-------------
+ id     | integer |           |          |         | plain    |             |              | 
+ f1     | text    |           |          |         | extended | lz4_dict    |              | 
+
+-- before a dictionary is trained, lz4_dict compresses with plain lz4
+INSERT INTO cmdict SELECT g, doc(g) FROM generate_series(1, 200) g;
+SELECT pg_column_compression(f1), count(*) FROM cmdict GROUP BY 1;
+ pg_column_compression | count 
+-----------------------+-------
+ lz4                   |   200
+(1 row)
+
+-- training works on an lz4_dict column, and is refused on a non-dict column
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+CREATE TABLE cmplain (f1 text COMPRESSION lz4);
+SELECT pg_train_toast_dictionary('cmplain', 'f1');
+ERROR:  column "f1" does not use a dictionary compression method
+-- new values use the dictionary and read back correctly, whole and sliced
+INSERT INTO cmdict SELECT g, doc(g) FROM generate_series(201, 400) g;
+SELECT pg_column_compression(f1), count(*) FROM cmdict GROUP BY 1 ORDER BY 1;
+ pg_column_compression | count 
+-----------------------+-------
+ lz4                   |   200
+ lz4_dict              |   200
+(2 rows)
+
+SELECT bool_and(f1 = doc(id)) AS all_equal FROM cmdict;
+ all_equal 
+-----------
+ t
+(1 row)
+
+SELECT bool_and(substr(f1, 10, 30) = substr(doc(id), 10, 30)) AS slices_ok
+  FROM cmdict WHERE id > 200;
+ slices_ok 
+-----------
+ t
+(1 row)
+
+-- the dictionary pays off: same data, smaller than without it
+SELECT avg(pg_column_size(f1)) FILTER (WHERE pg_column_compression(f1) = 'lz4_dict') <
+       avg(pg_column_size(f1)) FILTER (WHERE pg_column_compression(f1) = 'lz4')
+       AS dict_smaller FROM cmdict;
+ dict_smaller 
+--------------
+ t
+(1 row)
+
+-- large values are compressed with the dictionary and stored out of line,
+-- and still read back whole after reassembly from toast chunks
+INSERT INTO cmdict SELECT 1000 + g / 100, string_agg(doc(g), ',')
+  FROM generate_series(1, 299) g GROUP BY g / 100;
+SELECT pg_column_compression(f1), pg_column_toast_chunk_id(f1) IS NOT NULL AS external
+  FROM cmdict WHERE id > 1000 LIMIT 1;
+ pg_column_compression | external 
+-----------------------+----------
+ lz4_dict              | t
+(1 row)
+
+SELECT bool_and(length(f1) > 0) AS big_ok FROM cmdict WHERE id > 1000;
+ big_ok 
+--------
+ t
+(1 row)
+
+-- Containment: an lz4_dict value copied to a pglz column is recompressed
+CREATE TABLE cmcopy (f1 text COMPRESSION pglz);
+INSERT INTO cmcopy SELECT f1 FROM cmdict WHERE id BETWEEN 201 AND 300;
+SELECT count(*) FILTER (WHERE pg_column_compression(f1) = 'lz4_dict') AS leaked
+  FROM cmcopy;
+ leaked 
+--------
+      0
+(1 row)
+
+DROP SCHEMA lz4_dict CASCADE;
+NOTICE:  drop cascades to 4 other objects
+DETAIL:  drop cascades to function doc(integer)
+drop cascades to table cmdict
+drop cascades to table cmplain
+drop cascades to table cmcopy
diff --git a/src/test/regress/expected/compression_lz4_dict_1.out b/src/test/regress/expected/compression_lz4_dict_1.out
new file mode 100644
index 00000000000..1e45770adc4
--- /dev/null
+++ b/src/test/regress/expected/compression_lz4_dict_1.out
@@ -0,0 +1,12 @@
+-- Tests for lz4 TOAST compression with dictionaries.  The dictionary
+-- lifecycle (ownership, containment, retirement) is codec-independent and is
+-- covered in compression_zstd_dict; this file checks the lz4 codec path:
+-- values compress with the dictionary, read back whole and sliced, beat plain
+-- lz4, and stay inside their column.  Training a dictionary uses zstd's
+-- builder (lz4 has none), so the suite needs both lz4 and zstd.
+SELECT NOT(enumvals @> '{lz4}' AND enumvals @> '{zstd}') AS skip_test
+  FROM pg_settings WHERE name = 'default_toast_compression' \gset
+\if :skip_test
+   \echo '*** skipping TOAST tests with lz4 dictionaries (lz4 and zstd both required) ***'
+*** skipping TOAST tests with lz4 dictionaries (lz4 and zstd both required) ***
+   \quit
diff --git a/src/test/regress/expected/compression_zstd_dict.out b/src/test/regress/expected/compression_zstd_dict.out
new file mode 100644
index 00000000000..cdf736f308e
--- /dev/null
+++ b/src/test/regress/expected/compression_zstd_dict.out
@@ -0,0 +1,543 @@
+-- Tests for zstd TOAST compression with dictionaries, and their lifecycle:
+-- dictionaries belong to one column, values compressed with them never leave
+-- that column in compressed form, a rewrite retires old dictionaries, and
+-- dropping the column or table drops them.
+SELECT NOT(enumvals @> '{zstd}') AS skip_test FROM pg_settings WHERE
+  name = 'default_toast_compression' \gset
+\if :skip_test
+   \echo '*** skipping TOAST tests with zstd dictionaries (not supported) ***'
+   \quit
+\endif
+CREATE SCHEMA zstd_dict;
+SET search_path TO zstd_dict, public;
+\set HIDE_TOAST_COMPRESSION false
+-- Many small, similar JSON documents: the case dictionaries are made for.
+-- Each is below the TOAST threshold uncompressed only with a dictionary.
+CREATE FUNCTION doc(i int) RETURNS text LANGUAGE SQL AS $$
+  SELECT format('{"customer_id": %s, "status": "active", "region": "us-east", '
+                '"plan": "enterprise", "features": ["sso", "audit", "backup"], '
+                '"tags": ["%s", "priority-%s"], "notes": "%s"}',
+                i, md5(i::text), i % 5, repeat('lorem ipsum dolor sit amet ', 80))
+$$;
+CREATE TABLE cmdict (id int, f1 text COMPRESSION zstd_dict);
+\d+ cmdict
+                                        Table "zstd_dict.cmdict"
+ Column |  Type   | Collation | Nullable | Default | Storage  | Compression | Stats target | Description 
+--------+---------+-----------+----------+---------+----------+-------------+--------------+-------------
+ id     | integer |           |          |         | plain    |             |              | 
+ f1     | text    |           |          |         | extended | zstd_dict   |              | 
+
+-- before a dictionary is trained, zstd_dict compresses with plain zstd
+INSERT INTO cmdict SELECT g, doc(g) FROM generate_series(1, 200) g;
+SELECT pg_column_compression(f1), count(*) FROM cmdict GROUP BY 1;
+ pg_column_compression | count 
+-----------------------+-------
+ zstd                  |   200
+(1 row)
+
+-- only the owner can train; only for a zstd_dict column
+CREATE ROLE regress_dict_user;
+GRANT USAGE ON SCHEMA zstd_dict TO regress_dict_user;
+GRANT SELECT ON cmdict TO regress_dict_user;
+SET ROLE regress_dict_user;
+SELECT pg_train_toast_dictionary('zstd_dict.cmdict', 'f1');
+ERROR:  must be owner of table cmdict
+RESET ROLE;
+CREATE TABLE cmplain (f1 text COMPRESSION zstd);
+SELECT pg_train_toast_dictionary('cmplain', 'f1');
+ERROR:  column "f1" does not use a dictionary compression method
+SELECT pg_train_toast_dictionary('cmdict', 'nosuchcol');
+ERROR:  column "nosuchcol" of relation "cmdict" does not exist
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 10);
+ERROR:  dictionary size must be between 256 and 1048576 bytes
+-- the catalog is not readable by ordinary users
+SET ROLE regress_dict_user;
+SELECT count(*) FROM pg_toast_dictionary;
+ERROR:  permission denied for table pg_toast_dictionary
+RESET ROLE;
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+ ndicts | ncurrent 
+--------+----------
+      1 |        1
+(1 row)
+
+-- new values use the dictionary, and read back correctly, whole and sliced
+INSERT INTO cmdict SELECT g, doc(g) FROM generate_series(201, 400) g;
+SELECT pg_column_compression(f1), count(*) FROM cmdict GROUP BY 1 ORDER BY 1;
+ pg_column_compression | count 
+-----------------------+-------
+ zstd                  |   200
+ zstd_dict             |   200
+(2 rows)
+
+SELECT bool_and(f1 = doc(id)) AS all_equal FROM cmdict;
+ all_equal 
+-----------
+ t
+(1 row)
+
+SELECT bool_and(substr(f1, 10, 30) = substr(doc(id), 10, 30)) AS slices_ok
+  FROM cmdict WHERE id > 200;
+ slices_ok 
+-----------
+ t
+(1 row)
+
+-- the dictionary pays off: same data, smaller than without it
+SELECT avg(pg_column_size(f1)) FILTER (WHERE pg_column_compression(f1) = 'zstd_dict') <
+       avg(pg_column_size(f1)) FILTER (WHERE pg_column_compression(f1) = 'zstd')
+       AS dict_smaller FROM cmdict;
+ dict_smaller 
+--------------
+ t
+(1 row)
+
+-- large values are compressed with the dictionary and stored out of line
+INSERT INTO cmdict SELECT 1000 + g / 100, string_agg(doc(g), ',')
+  FROM generate_series(1, 299) g GROUP BY g / 100;
+SELECT pg_column_compression(f1), pg_column_toast_chunk_id(f1) IS NOT NULL AS external
+  FROM cmdict WHERE id > 1000 LIMIT 1;
+ pg_column_compression | external 
+-----------------------+----------
+ zstd_dict             | t
+(1 row)
+
+----------------------------------------------------------------------
+-- Containment: dictionary-compressed values do not leave their column
+----------------------------------------------------------------------
+-- CTAS and INSERT ... SELECT into another table: the target gets values that
+-- are not compressed with cmdict's dictionary
+CREATE TABLE cmcopy AS SELECT * FROM cmdict;
+SELECT count(*) FILTER (WHERE pg_column_compression(f1) = 'zstd_dict') AS dict_values
+  FROM cmcopy;
+ dict_values 
+-------------
+           0
+(1 row)
+
+CREATE TABLE cmcopy2 (id int, f1 text COMPRESSION zstd_dict);
+INSERT INTO cmcopy2 SELECT * FROM cmdict;
+SELECT count(*) FILTER (WHERE pg_column_compression(f1) = 'zstd_dict') AS dict_values
+  FROM cmcopy2;
+ dict_values 
+-------------
+           0
+(1 row)
+
+-- another column of the same table does not own the dictionary either
+ALTER TABLE cmdict ADD COLUMN f2 text COMPRESSION zstd_dict;
+UPDATE cmdict SET f2 = f1 WHERE id <= 400;
+SELECT count(*) FILTER (WHERE pg_column_compression(f2) = 'zstd_dict') AS dict_values
+  FROM cmdict;
+ dict_values 
+-------------
+           0
+(1 row)
+
+ALTER TABLE cmdict DROP COLUMN f2;
+-- values nested in containers: rows, arrays, ranges of text are flattened
+CREATE TYPE cmpair AS (a text, b text);
+CREATE TABLE cmnest (p cmpair, arr text[]);
+INSERT INTO cmnest SELECT ROW(f1, f1)::cmpair, ARRAY[f1, f1]
+  FROM cmdict WHERE id BETWEEN 201 AND 210;
+SELECT pg_column_compression((p).a) AS in_row, pg_column_compression(arr[1]) AS in_array
+  FROM cmnest LIMIT 1;
+ in_row | in_array 
+--------+----------
+        | 
+(1 row)
+
+-- a domain check on a PL/pgSQL record field sees the value flattened too
+CREATE TABLE cmsink (p cmpair);
+CREATE FUNCTION cmcapture(v cmpair) RETURNS bool LANGUAGE sql AS
+  $$ INSERT INTO cmsink VALUES (v) RETURNING true $$;
+CREATE DOMAIN cmdom AS cmpair CHECK (cmcapture(VALUE));
+DO $$
+DECLARE v cmdom;
+BEGIN
+  v.a := (SELECT f1 FROM cmdict WHERE id = 300);
+END $$;
+SELECT count(*) FILTER (WHERE pg_column_compression((p).a) = 'zstd_dict') AS leaked
+  FROM cmsink;
+ leaked 
+--------
+      0
+(1 row)
+
+-- a holdable cursor outlives the transaction; its rows must not depend on
+-- the dictionary
+BEGIN;
+DECLARE c CURSOR WITH HOLD FOR SELECT id, f1 FROM cmdict WHERE id BETWEEN 201 AND 205 ORDER BY id;
+COMMIT;
+FETCH 1 FROM c;
+ id  |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          f1                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          
+-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ 201 | {"customer_id": 201, "status": "active", "region": "us-east", "plan": "enterprise", "features": ["sso", "audit", "backup"], "tags": ["757b505cfd34c64c85ca5b5690ee5293", "priority-1"], "notes": "lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet lorem ipsum dolor sit amet "}
+(1 row)
+
+CLOSE c;
+-- PL/pgSQL variable across COMMIT in a procedure
+CREATE PROCEDURE cmproc() LANGUAGE plpgsql AS $$
+DECLARE v text;
+BEGIN
+  SELECT f1 INTO v FROM cmdict WHERE id = 300;
+  COMMIT;
+  RAISE NOTICE 'length %, ok %', length(v), v = doc(300);
+END $$;
+CALL cmproc();
+NOTICE:  length 2356, ok t
+-- values written to the table itself are unaffected by any of this
+SELECT bool_and(f1 = doc(id)) AS all_equal FROM cmdict WHERE id <= 400;
+ all_equal 
+-----------
+ t
+(1 row)
+
+-- indexes: an expression index on a dictionary-compressed column works
+CREATE INDEX cmdict_idx ON cmdict (left(f1, 40));
+CREATE INDEX cmdict_idx2 ON cmdict (f1) WHERE id = 300;
+SELECT id FROM cmdict WHERE f1 = doc(300);
+ id  
+-----
+ 300
+(1 row)
+
+----------------------------------------------------------------------
+-- Retirement
+----------------------------------------------------------------------
+-- retraining makes a new current dictionary; the old one stays readable
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+ ndicts | ncurrent 
+--------+----------
+      2 |        1
+(1 row)
+
+SELECT bool_and(f1 = doc(id)) AS all_equal FROM cmdict WHERE id <= 400;
+ all_equal 
+-----------
+ t
+(1 row)
+
+-- ordinary updates of other columns leave the value, and its old dictionary,
+-- alone; updates of the column recompress with the current one
+UPDATE cmdict SET id = id WHERE id = 250;
+UPDATE cmdict SET f1 = f1 || '' WHERE id = 251;
+-- a rewrite recompresses with the current dictionary and retires the rest
+VACUUM FULL cmdict;
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+ ndicts | ncurrent 
+--------+----------
+      1 |        1
+(1 row)
+
+SELECT bool_and(f1 = doc(id)) AS all_equal FROM cmdict WHERE id <= 400;
+ all_equal 
+-----------
+ t
+(1 row)
+
+SELECT pg_column_compression(f1), count(*) FROM cmdict WHERE id <= 400 GROUP BY 1 ORDER BY 1;
+ pg_column_compression | count 
+-----------------------+-------
+ zstd                  |   200
+ zstd_dict             |   200
+(2 rows)
+
+-- the same for CLUSTER and a rewriting ALTER TABLE
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+CLUSTER cmdict USING cmdict_idx;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+ ndicts 
+--------
+      1
+(1 row)
+
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+ALTER TABLE cmdict ALTER COLUMN id TYPE bigint;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+ ndicts 
+--------
+      1
+(1 row)
+
+SELECT bool_and(f1 = doc(id::int)) AS all_equal FROM cmdict WHERE id <= 400;
+ all_equal 
+-----------
+ t
+(1 row)
+
+-- switching the column away from zstd_dict: existing values stay readable
+-- until the next rewrite, which recompresses them and drops the dictionary
+ALTER TABLE cmdict ALTER COLUMN f1 SET COMPRESSION pglz;
+SELECT bool_and(f1 = doc(id::int)) AS all_equal FROM cmdict WHERE id <= 400;
+ all_equal 
+-----------
+ t
+(1 row)
+
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+ ndicts 
+--------
+      1
+(1 row)
+
+VACUUM FULL cmdict;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+ ndicts 
+--------
+      0
+(1 row)
+
+SELECT pg_column_compression(f1), count(*) FROM cmdict WHERE id <= 400 GROUP BY 1 ORDER BY 1;
+ pg_column_compression | count 
+-----------------------+-------
+ pglz                  |   200
+ zstd                  |   200
+(2 rows)
+
+SELECT bool_and(f1 = doc(id::int)) AS all_equal FROM cmdict WHERE id <= 400;
+ all_equal 
+-----------
+ t
+(1 row)
+
+ALTER TABLE cmdict ALTER COLUMN f1 SET COMPRESSION zstd_dict;
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+-- the same for a storage mode without compression, which also refuses to
+-- train
+UPDATE cmdict SET f1 = f1 || '' WHERE id <= 400;
+ALTER TABLE cmdict ALTER COLUMN f1 SET STORAGE EXTERNAL;
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096);
+ERROR:  column "f1" does not compress its values
+DETAIL:  Dictionaries are used only with storage EXTENDED or MAIN.
+VACUUM FULL cmdict;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+ ndicts 
+--------
+      0
+(1 row)
+
+SELECT bool_and(f1 = doc(id::int)) AS all_equal FROM cmdict WHERE id <= 400;
+ all_equal 
+-----------
+ t
+(1 row)
+
+ALTER TABLE cmdict ALTER COLUMN f1 SET STORAGE EXTENDED;
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+----------------------------------------------------------------------
+-- Cleanup with the owning objects
+----------------------------------------------------------------------
+ALTER TABLE cmdict ADD COLUMN f3 text COMPRESSION zstd_dict;
+UPDATE cmdict SET f3 = f1 WHERE id <= 400;
+SELECT pg_train_toast_dictionary('cmdict', 'f3', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+SELECT tdattnum > 2 AS f3_dict, count(*) FROM pg_toast_dictionary
+  WHERE tdrelid = 'cmdict'::regclass GROUP BY 1 ORDER BY 1;
+ f3_dict | count 
+---------+-------
+ f       |     1
+ t       |     1
+(2 rows)
+
+-- DROP COLUMN leaves the values on disk until a rewrite, and whole-row
+-- references and REPLICA IDENTITY FULL still read them, so the dictionary
+-- stays until then
+ALTER TABLE cmdict DROP COLUMN f3;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+ ndicts 
+--------
+      2
+(1 row)
+
+SELECT count(*) AS wholerow FROM (SELECT t FROM cmdict t) s;
+ wholerow 
+----------
+      403
+(1 row)
+
+VACUUM FULL cmdict;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+ ndicts 
+--------
+      1
+(1 row)
+
+-- two trainings in one command: only the last one stays current
+SELECT count(*) FROM (SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096)
+  FROM generate_series(1, 2)) s;
+ count 
+-------
+     2
+(1 row)
+
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+ ndicts | ncurrent 
+--------+----------
+      3 |        1
+(1 row)
+
+-- TRUNCATE retires old dictionaries too, also when the table was created in
+-- the same transaction and is truncated in place
+BEGIN;
+CREATE TABLE cmtrunc (id int, f1 text COMPRESSION zstd_dict);
+INSERT INTO cmtrunc SELECT g, doc(g) FROM generate_series(1, 200) g;
+SELECT pg_train_toast_dictionary('cmtrunc', 'f1', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+SELECT pg_train_toast_dictionary('cmtrunc', 'f1', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+TRUNCATE cmtrunc;
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmtrunc'::regclass;
+ ndicts | ncurrent 
+--------+----------
+      1 |        1
+(1 row)
+
+COMMIT;
+DROP TABLE cmtrunc;
+-- and so does the truncation of an ON COMMIT DELETE ROWS table
+CREATE TEMP TABLE cmtrunc (id int, f1 text COMPRESSION zstd_dict)
+  ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO cmtrunc SELECT g, doc(g) FROM generate_series(1, 200) g;
+SELECT pg_train_toast_dictionary('cmtrunc', 'f1', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+SELECT pg_train_toast_dictionary('cmtrunc', 'f1', 4096) IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+COMMIT;
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmtrunc'::regclass;
+ ndicts | ncurrent 
+--------+----------
+      1 |        1
+(1 row)
+
+DROP TABLE cmtrunc;
+-- the copies made above survive their source being dropped
+SELECT 'cmdict'::regclass::oid AS cmdict_oid \gset
+DROP TABLE cmdict;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = :cmdict_oid;
+ ndicts 
+--------
+      0
+(1 row)
+
+SELECT bool_and(f1 = doc(id)) AS copies_ok FROM cmcopy WHERE id <= 400;
+ copies_ok 
+-----------
+ t
+(1 row)
+
+SELECT bool_and(f1 = doc(id)) AS copies_ok FROM cmcopy2 WHERE id <= 400;
+ copies_ok 
+-----------
+ t
+(1 row)
+
+SELECT bool_and((p).a = arr[1]) AS nested_ok FROM cmnest;
+ nested_ok 
+-----------
+ t
+(1 row)
+
+-- Leave a table with a current and a retired dictionary behind, so that the
+-- pg_upgrade test carries dictionaries over.
+CREATE TABLE public.zstd_dict_upgrade (id int, f1 text COMPRESSION zstd_dict);
+INSERT INTO public.zstd_dict_upgrade SELECT g, doc(g) FROM generate_series(1, 100) g;
+SELECT pg_train_toast_dictionary('public.zstd_dict_upgrade', 'f1', 4096)
+  IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+INSERT INTO public.zstd_dict_upgrade SELECT g, doc(g) FROM generate_series(101, 200) g;
+SELECT pg_train_toast_dictionary('public.zstd_dict_upgrade', 'f1', 4096)
+  IS NOT NULL AS trained;
+ trained 
+---------
+ t
+(1 row)
+
+INSERT INTO public.zstd_dict_upgrade SELECT g, repeat(doc(g), 3)
+  FROM generate_series(201, 210) g;
+SELECT pg_column_compression(f1), count(*) FROM public.zstd_dict_upgrade
+  GROUP BY 1 ORDER BY 1;
+ pg_column_compression | count 
+-----------------------+-------
+ zstd                  |   100
+ zstd_dict             |   110
+(2 rows)
+
+\set HIDE_TOAST_COMPRESSION true
+RESET search_path;
+DROP SCHEMA zstd_dict CASCADE;
+NOTICE:  drop cascades to 10 other objects
+DETAIL:  drop cascades to function zstd_dict.doc(integer)
+drop cascades to table zstd_dict.cmplain
+drop cascades to table zstd_dict.cmcopy
+drop cascades to table zstd_dict.cmcopy2
+drop cascades to type zstd_dict.cmpair
+drop cascades to table zstd_dict.cmnest
+drop cascades to table zstd_dict.cmsink
+drop cascades to function zstd_dict.cmcapture(zstd_dict.cmpair)
+drop cascades to type zstd_dict.cmdom
+drop cascades to function zstd_dict.cmproc()
+DROP ROLE regress_dict_user;
diff --git a/src/test/regress/expected/compression_zstd_dict_1.out b/src/test/regress/expected/compression_zstd_dict_1.out
new file mode 100644
index 00000000000..13f0f1c3c70
--- /dev/null
+++ b/src/test/regress/expected/compression_zstd_dict_1.out
@@ -0,0 +1,10 @@
+-- Tests for zstd TOAST compression with dictionaries, and their lifecycle:
+-- dictionaries belong to one column, values compressed with them never leave
+-- that column in compressed form, a rewrite retires old dictionaries, and
+-- dropping the column or table drops them.
+SELECT NOT(enumvals @> '{zstd}') AS skip_test FROM pg_settings WHERE
+  name = 'default_toast_compression' \gset
+\if :skip_test
+   \echo '*** skipping TOAST tests with zstd dictionaries (not supported) ***'
+*** skipping TOAST tests with zstd dictionaries (not supported) ***
+   \quit
diff --git a/src/test/regress/expected/oidjoins.out b/src/test/regress/expected/oidjoins.out
index 51b9608a668..cae38074ec7 100644
--- a/src/test/regress/expected/oidjoins.out
+++ b/src/test/regress/expected/oidjoins.out
@@ -168,6 +168,7 @@ NOTICE:  checking pg_statistic_ext {stxnamespace} => pg_namespace {oid}
 NOTICE:  checking pg_statistic_ext {stxowner} => pg_authid {oid}
 NOTICE:  checking pg_statistic_ext {stxrelid,stxkeys} => pg_attribute {attrelid,attnum}
 NOTICE:  checking pg_statistic_ext_data {stxoid} => pg_statistic_ext {oid}
+NOTICE:  checking pg_toast_dictionary {tdrelid} => pg_class {oid}
 NOTICE:  checking pg_rewrite {ev_class} => pg_class {oid}
 NOTICE:  checking pg_trigger {tgrelid} => pg_class {oid}
 NOTICE:  checking pg_trigger {tgparentid} => pg_trigger {oid}
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 7dc272e4434..16286d39a3d 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -133,7 +133,7 @@ test: partition_join partition_prune reloptions hash_part indexing partition_agg
 # ----------
 # Another group of parallel tests (compression)
 # ----------
-test: compression compression_lz4 compression_pglz compression_zstd cluster
+test: compression compression_lz4 compression_pglz compression_zstd compression_zstd_dict compression_lz4_dict cluster
 
 # event_trigger depends on create_am and cannot run concurrently with
 # any test that runs DDL
diff --git a/src/test/regress/sql/compression_lz4_dict.sql b/src/test/regress/sql/compression_lz4_dict.sql
new file mode 100644
index 00000000000..773a5a6f7bc
--- /dev/null
+++ b/src/test/regress/sql/compression_lz4_dict.sql
@@ -0,0 +1,64 @@
+-- Tests for lz4 TOAST compression with dictionaries.  The dictionary
+-- lifecycle (ownership, containment, retirement) is codec-independent and is
+-- covered in compression_zstd_dict; this file checks the lz4 codec path:
+-- values compress with the dictionary, read back whole and sliced, beat plain
+-- lz4, and stay inside their column.  Training a dictionary uses zstd's
+-- builder (lz4 has none), so the suite needs both lz4 and zstd.
+
+SELECT NOT(enumvals @> '{lz4}' AND enumvals @> '{zstd}') AS skip_test
+  FROM pg_settings WHERE name = 'default_toast_compression' \gset
+\if :skip_test
+   \echo '*** skipping TOAST tests with lz4 dictionaries (lz4 and zstd both required) ***'
+   \quit
+\endif
+
+CREATE SCHEMA lz4_dict;
+SET search_path TO lz4_dict, public;
+\set HIDE_TOAST_COMPRESSION false
+
+-- Many small, similar documents: the case dictionaries are made for.
+CREATE FUNCTION doc(i int) RETURNS text LANGUAGE SQL AS $$
+  SELECT format('{"customer_id": %s, "status": "active", "region": "us-east", '
+                '"plan": "enterprise", "features": ["sso", "audit", "backup"], '
+                '"tags": ["%s", "priority-%s"], "notes": "%s"}',
+                i, md5(i::text), i % 5, repeat('lorem ipsum dolor sit amet ', 80))
+$$;
+
+CREATE TABLE cmdict (id int, f1 text COMPRESSION lz4_dict);
+\d+ cmdict
+
+-- before a dictionary is trained, lz4_dict compresses with plain lz4
+INSERT INTO cmdict SELECT g, doc(g) FROM generate_series(1, 200) g;
+SELECT pg_column_compression(f1), count(*) FROM cmdict GROUP BY 1;
+
+-- training works on an lz4_dict column, and is refused on a non-dict column
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+CREATE TABLE cmplain (f1 text COMPRESSION lz4);
+SELECT pg_train_toast_dictionary('cmplain', 'f1');
+
+-- new values use the dictionary and read back correctly, whole and sliced
+INSERT INTO cmdict SELECT g, doc(g) FROM generate_series(201, 400) g;
+SELECT pg_column_compression(f1), count(*) FROM cmdict GROUP BY 1 ORDER BY 1;
+SELECT bool_and(f1 = doc(id)) AS all_equal FROM cmdict;
+SELECT bool_and(substr(f1, 10, 30) = substr(doc(id), 10, 30)) AS slices_ok
+  FROM cmdict WHERE id > 200;
+-- the dictionary pays off: same data, smaller than without it
+SELECT avg(pg_column_size(f1)) FILTER (WHERE pg_column_compression(f1) = 'lz4_dict') <
+       avg(pg_column_size(f1)) FILTER (WHERE pg_column_compression(f1) = 'lz4')
+       AS dict_smaller FROM cmdict;
+
+-- large values are compressed with the dictionary and stored out of line,
+-- and still read back whole after reassembly from toast chunks
+INSERT INTO cmdict SELECT 1000 + g / 100, string_agg(doc(g), ',')
+  FROM generate_series(1, 299) g GROUP BY g / 100;
+SELECT pg_column_compression(f1), pg_column_toast_chunk_id(f1) IS NOT NULL AS external
+  FROM cmdict WHERE id > 1000 LIMIT 1;
+SELECT bool_and(length(f1) > 0) AS big_ok FROM cmdict WHERE id > 1000;
+
+-- Containment: an lz4_dict value copied to a pglz column is recompressed
+CREATE TABLE cmcopy (f1 text COMPRESSION pglz);
+INSERT INTO cmcopy SELECT f1 FROM cmdict WHERE id BETWEEN 201 AND 300;
+SELECT count(*) FILTER (WHERE pg_column_compression(f1) = 'lz4_dict') AS leaked
+  FROM cmcopy;
+
+DROP SCHEMA lz4_dict CASCADE;
diff --git a/src/test/regress/sql/compression_zstd_dict.sql b/src/test/regress/sql/compression_zstd_dict.sql
new file mode 100644
index 00000000000..3c204783bd6
--- /dev/null
+++ b/src/test/regress/sql/compression_zstd_dict.sql
@@ -0,0 +1,266 @@
+-- Tests for zstd TOAST compression with dictionaries, and their lifecycle:
+-- dictionaries belong to one column, values compressed with them never leave
+-- that column in compressed form, a rewrite retires old dictionaries, and
+-- dropping the column or table drops them.
+
+SELECT NOT(enumvals @> '{zstd}') AS skip_test FROM pg_settings WHERE
+  name = 'default_toast_compression' \gset
+\if :skip_test
+   \echo '*** skipping TOAST tests with zstd dictionaries (not supported) ***'
+   \quit
+\endif
+
+CREATE SCHEMA zstd_dict;
+SET search_path TO zstd_dict, public;
+
+\set HIDE_TOAST_COMPRESSION false
+
+-- Many small, similar JSON documents: the case dictionaries are made for.
+-- Each is below the TOAST threshold uncompressed only with a dictionary.
+CREATE FUNCTION doc(i int) RETURNS text LANGUAGE SQL AS $$
+  SELECT format('{"customer_id": %s, "status": "active", "region": "us-east", '
+                '"plan": "enterprise", "features": ["sso", "audit", "backup"], '
+                '"tags": ["%s", "priority-%s"], "notes": "%s"}',
+                i, md5(i::text), i % 5, repeat('lorem ipsum dolor sit amet ', 80))
+$$;
+
+CREATE TABLE cmdict (id int, f1 text COMPRESSION zstd_dict);
+\d+ cmdict
+
+-- before a dictionary is trained, zstd_dict compresses with plain zstd
+INSERT INTO cmdict SELECT g, doc(g) FROM generate_series(1, 200) g;
+SELECT pg_column_compression(f1), count(*) FROM cmdict GROUP BY 1;
+
+-- only the owner can train; only for a zstd_dict column
+CREATE ROLE regress_dict_user;
+GRANT USAGE ON SCHEMA zstd_dict TO regress_dict_user;
+GRANT SELECT ON cmdict TO regress_dict_user;
+SET ROLE regress_dict_user;
+SELECT pg_train_toast_dictionary('zstd_dict.cmdict', 'f1');
+RESET ROLE;
+CREATE TABLE cmplain (f1 text COMPRESSION zstd);
+SELECT pg_train_toast_dictionary('cmplain', 'f1');
+SELECT pg_train_toast_dictionary('cmdict', 'nosuchcol');
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 10);
+-- the catalog is not readable by ordinary users
+SET ROLE regress_dict_user;
+SELECT count(*) FROM pg_toast_dictionary;
+RESET ROLE;
+
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+
+-- new values use the dictionary, and read back correctly, whole and sliced
+INSERT INTO cmdict SELECT g, doc(g) FROM generate_series(201, 400) g;
+SELECT pg_column_compression(f1), count(*) FROM cmdict GROUP BY 1 ORDER BY 1;
+SELECT bool_and(f1 = doc(id)) AS all_equal FROM cmdict;
+SELECT bool_and(substr(f1, 10, 30) = substr(doc(id), 10, 30)) AS slices_ok
+  FROM cmdict WHERE id > 200;
+-- the dictionary pays off: same data, smaller than without it
+SELECT avg(pg_column_size(f1)) FILTER (WHERE pg_column_compression(f1) = 'zstd_dict') <
+       avg(pg_column_size(f1)) FILTER (WHERE pg_column_compression(f1) = 'zstd')
+       AS dict_smaller FROM cmdict;
+
+-- large values are compressed with the dictionary and stored out of line
+INSERT INTO cmdict SELECT 1000 + g / 100, string_agg(doc(g), ',')
+  FROM generate_series(1, 299) g GROUP BY g / 100;
+SELECT pg_column_compression(f1), pg_column_toast_chunk_id(f1) IS NOT NULL AS external
+  FROM cmdict WHERE id > 1000 LIMIT 1;
+
+----------------------------------------------------------------------
+-- Containment: dictionary-compressed values do not leave their column
+----------------------------------------------------------------------
+
+-- CTAS and INSERT ... SELECT into another table: the target gets values that
+-- are not compressed with cmdict's dictionary
+CREATE TABLE cmcopy AS SELECT * FROM cmdict;
+SELECT count(*) FILTER (WHERE pg_column_compression(f1) = 'zstd_dict') AS dict_values
+  FROM cmcopy;
+CREATE TABLE cmcopy2 (id int, f1 text COMPRESSION zstd_dict);
+INSERT INTO cmcopy2 SELECT * FROM cmdict;
+SELECT count(*) FILTER (WHERE pg_column_compression(f1) = 'zstd_dict') AS dict_values
+  FROM cmcopy2;
+-- another column of the same table does not own the dictionary either
+ALTER TABLE cmdict ADD COLUMN f2 text COMPRESSION zstd_dict;
+UPDATE cmdict SET f2 = f1 WHERE id <= 400;
+SELECT count(*) FILTER (WHERE pg_column_compression(f2) = 'zstd_dict') AS dict_values
+  FROM cmdict;
+ALTER TABLE cmdict DROP COLUMN f2;
+
+-- values nested in containers: rows, arrays, ranges of text are flattened
+CREATE TYPE cmpair AS (a text, b text);
+CREATE TABLE cmnest (p cmpair, arr text[]);
+INSERT INTO cmnest SELECT ROW(f1, f1)::cmpair, ARRAY[f1, f1]
+  FROM cmdict WHERE id BETWEEN 201 AND 210;
+SELECT pg_column_compression((p).a) AS in_row, pg_column_compression(arr[1]) AS in_array
+  FROM cmnest LIMIT 1;
+
+-- a domain check on a PL/pgSQL record field sees the value flattened too
+CREATE TABLE cmsink (p cmpair);
+CREATE FUNCTION cmcapture(v cmpair) RETURNS bool LANGUAGE sql AS
+  $$ INSERT INTO cmsink VALUES (v) RETURNING true $$;
+CREATE DOMAIN cmdom AS cmpair CHECK (cmcapture(VALUE));
+DO $$
+DECLARE v cmdom;
+BEGIN
+  v.a := (SELECT f1 FROM cmdict WHERE id = 300);
+END $$;
+SELECT count(*) FILTER (WHERE pg_column_compression((p).a) = 'zstd_dict') AS leaked
+  FROM cmsink;
+
+-- a holdable cursor outlives the transaction; its rows must not depend on
+-- the dictionary
+BEGIN;
+DECLARE c CURSOR WITH HOLD FOR SELECT id, f1 FROM cmdict WHERE id BETWEEN 201 AND 205 ORDER BY id;
+COMMIT;
+FETCH 1 FROM c;
+CLOSE c;
+
+-- PL/pgSQL variable across COMMIT in a procedure
+CREATE PROCEDURE cmproc() LANGUAGE plpgsql AS $$
+DECLARE v text;
+BEGIN
+  SELECT f1 INTO v FROM cmdict WHERE id = 300;
+  COMMIT;
+  RAISE NOTICE 'length %, ok %', length(v), v = doc(300);
+END $$;
+CALL cmproc();
+
+-- values written to the table itself are unaffected by any of this
+SELECT bool_and(f1 = doc(id)) AS all_equal FROM cmdict WHERE id <= 400;
+
+-- indexes: an expression index on a dictionary-compressed column works
+CREATE INDEX cmdict_idx ON cmdict (left(f1, 40));
+CREATE INDEX cmdict_idx2 ON cmdict (f1) WHERE id = 300;
+SELECT id FROM cmdict WHERE f1 = doc(300);
+
+----------------------------------------------------------------------
+-- Retirement
+----------------------------------------------------------------------
+
+-- retraining makes a new current dictionary; the old one stays readable
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+SELECT bool_and(f1 = doc(id)) AS all_equal FROM cmdict WHERE id <= 400;
+
+-- ordinary updates of other columns leave the value, and its old dictionary,
+-- alone; updates of the column recompress with the current one
+UPDATE cmdict SET id = id WHERE id = 250;
+UPDATE cmdict SET f1 = f1 || '' WHERE id = 251;
+
+-- a rewrite recompresses with the current dictionary and retires the rest
+VACUUM FULL cmdict;
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+SELECT bool_and(f1 = doc(id)) AS all_equal FROM cmdict WHERE id <= 400;
+SELECT pg_column_compression(f1), count(*) FROM cmdict WHERE id <= 400 GROUP BY 1 ORDER BY 1;
+
+-- the same for CLUSTER and a rewriting ALTER TABLE
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+CLUSTER cmdict USING cmdict_idx;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+ALTER TABLE cmdict ALTER COLUMN id TYPE bigint;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+SELECT bool_and(f1 = doc(id::int)) AS all_equal FROM cmdict WHERE id <= 400;
+
+-- switching the column away from zstd_dict: existing values stay readable
+-- until the next rewrite, which recompresses them and drops the dictionary
+ALTER TABLE cmdict ALTER COLUMN f1 SET COMPRESSION pglz;
+SELECT bool_and(f1 = doc(id::int)) AS all_equal FROM cmdict WHERE id <= 400;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+VACUUM FULL cmdict;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+SELECT pg_column_compression(f1), count(*) FROM cmdict WHERE id <= 400 GROUP BY 1 ORDER BY 1;
+SELECT bool_and(f1 = doc(id::int)) AS all_equal FROM cmdict WHERE id <= 400;
+ALTER TABLE cmdict ALTER COLUMN f1 SET COMPRESSION zstd_dict;
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+
+-- the same for a storage mode without compression, which also refuses to
+-- train
+UPDATE cmdict SET f1 = f1 || '' WHERE id <= 400;
+ALTER TABLE cmdict ALTER COLUMN f1 SET STORAGE EXTERNAL;
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096);
+VACUUM FULL cmdict;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+SELECT bool_and(f1 = doc(id::int)) AS all_equal FROM cmdict WHERE id <= 400;
+ALTER TABLE cmdict ALTER COLUMN f1 SET STORAGE EXTENDED;
+SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096) IS NOT NULL AS trained;
+
+----------------------------------------------------------------------
+-- Cleanup with the owning objects
+----------------------------------------------------------------------
+
+ALTER TABLE cmdict ADD COLUMN f3 text COMPRESSION zstd_dict;
+UPDATE cmdict SET f3 = f1 WHERE id <= 400;
+SELECT pg_train_toast_dictionary('cmdict', 'f3', 4096) IS NOT NULL AS trained;
+SELECT tdattnum > 2 AS f3_dict, count(*) FROM pg_toast_dictionary
+  WHERE tdrelid = 'cmdict'::regclass GROUP BY 1 ORDER BY 1;
+-- DROP COLUMN leaves the values on disk until a rewrite, and whole-row
+-- references and REPLICA IDENTITY FULL still read them, so the dictionary
+-- stays until then
+ALTER TABLE cmdict DROP COLUMN f3;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+SELECT count(*) AS wholerow FROM (SELECT t FROM cmdict t) s;
+VACUUM FULL cmdict;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+
+-- two trainings in one command: only the last one stays current
+SELECT count(*) FROM (SELECT pg_train_toast_dictionary('cmdict', 'f1', 4096)
+  FROM generate_series(1, 2)) s;
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmdict'::regclass;
+
+-- TRUNCATE retires old dictionaries too, also when the table was created in
+-- the same transaction and is truncated in place
+BEGIN;
+CREATE TABLE cmtrunc (id int, f1 text COMPRESSION zstd_dict);
+INSERT INTO cmtrunc SELECT g, doc(g) FROM generate_series(1, 200) g;
+SELECT pg_train_toast_dictionary('cmtrunc', 'f1', 4096) IS NOT NULL AS trained;
+SELECT pg_train_toast_dictionary('cmtrunc', 'f1', 4096) IS NOT NULL AS trained;
+TRUNCATE cmtrunc;
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmtrunc'::regclass;
+COMMIT;
+DROP TABLE cmtrunc;
+
+-- and so does the truncation of an ON COMMIT DELETE ROWS table
+CREATE TEMP TABLE cmtrunc (id int, f1 text COMPRESSION zstd_dict)
+  ON COMMIT DELETE ROWS;
+BEGIN;
+INSERT INTO cmtrunc SELECT g, doc(g) FROM generate_series(1, 200) g;
+SELECT pg_train_toast_dictionary('cmtrunc', 'f1', 4096) IS NOT NULL AS trained;
+SELECT pg_train_toast_dictionary('cmtrunc', 'f1', 4096) IS NOT NULL AS trained;
+COMMIT;
+SELECT count(*) AS ndicts, count(*) FILTER (WHERE tdcurrent) AS ncurrent
+  FROM pg_toast_dictionary WHERE tdrelid = 'cmtrunc'::regclass;
+DROP TABLE cmtrunc;
+
+-- the copies made above survive their source being dropped
+SELECT 'cmdict'::regclass::oid AS cmdict_oid \gset
+DROP TABLE cmdict;
+SELECT count(*) AS ndicts FROM pg_toast_dictionary WHERE tdrelid = :cmdict_oid;
+SELECT bool_and(f1 = doc(id)) AS copies_ok FROM cmcopy WHERE id <= 400;
+SELECT bool_and(f1 = doc(id)) AS copies_ok FROM cmcopy2 WHERE id <= 400;
+SELECT bool_and((p).a = arr[1]) AS nested_ok FROM cmnest;
+
+-- Leave a table with a current and a retired dictionary behind, so that the
+-- pg_upgrade test carries dictionaries over.
+CREATE TABLE public.zstd_dict_upgrade (id int, f1 text COMPRESSION zstd_dict);
+INSERT INTO public.zstd_dict_upgrade SELECT g, doc(g) FROM generate_series(1, 100) g;
+SELECT pg_train_toast_dictionary('public.zstd_dict_upgrade', 'f1', 4096)
+  IS NOT NULL AS trained;
+INSERT INTO public.zstd_dict_upgrade SELECT g, doc(g) FROM generate_series(101, 200) g;
+SELECT pg_train_toast_dictionary('public.zstd_dict_upgrade', 'f1', 4096)
+  IS NOT NULL AS trained;
+INSERT INTO public.zstd_dict_upgrade SELECT g, repeat(doc(g), 3)
+  FROM generate_series(201, 210) g;
+SELECT pg_column_compression(f1), count(*) FROM public.zstd_dict_upgrade
+  GROUP BY 1 ORDER BY 1;
+
+\set HIDE_TOAST_COMPRESSION true
+RESET search_path;
+DROP SCHEMA zstd_dict CASCADE;
+DROP ROLE regress_dict_user;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 6c6bf0eaab1..30db4375173 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -673,6 +673,7 @@ DependencyGeneratorData
 DependencyType
 DeserialIOData
 DestReceiver
+DictCompressRoutine
 DictISpell
 DictInt
 DictSimple
@@ -945,6 +946,7 @@ FormData_pg_statistic_ext_data
 FormData_pg_subscription
 FormData_pg_subscription_rel
 FormData_pg_tablespace
+FormData_pg_toast_dictionary
 FormData_pg_transform
 FormData_pg_trigger
 FormData_pg_ts_config
@@ -1005,6 +1007,7 @@ Form_pg_statistic_ext_data
 Form_pg_subscription
 Form_pg_subscription_rel
 Form_pg_tablespace
+Form_pg_toast_dictionary
 Form_pg_transform
 Form_pg_trigger
 Form_pg_ts_config
@@ -1600,6 +1603,7 @@ LZ4F_decompressionContext_t
 LZ4F_errorCode_t
 LZ4F_preferences_t
 LZ4State
+LZ4_stream_t
 LabelProvider
 LagTracker
 LargeObjectDesc
@@ -3542,8 +3546,10 @@ YYLTYPE
 YYSTYPE
 YY_BUFFER_STATE
 ZSTD_CCtx
+ZSTD_CDict
 ZSTD_CStream
 ZSTD_DCtx
+ZSTD_DDict
 ZSTD_DStream
 ZSTD_cParameter
 ZSTD_inBuffer
-- 
2.50.1

