From 70d36e8fd1387233cc01f5aa130bdc8f8d3ccd13 Mon Sep 17 00:00:00 2001
From: Haibo Yan <tristan.yim@gmail.com>
Date: Wed, 24 Jun 2026 14:52:46 -0700
Subject: [PATCH] Add pg_formatter and CREATE/DROP FORMATTER

Add pg_formatter, a catalog for registering formatted conversions by
source and target type.  A formatter function has the signature

    formatter(source_type, text) returns target_type

where the second argument receives the FORMAT expression coerced to text.

Add CREATE FORMATTER and DROP FORMATTER, syscache support, object-address
and dependency handling, and pg_dump support.  Formatter objects are
separate from pg_cast because formatted casts are always explicit,
function-backed, and keyed by a source/target type pair rather than by
ordinary cast semantics such as implicit, assignment, binary, or in/out
casts.

This patch only adds catalog and DDL infrastructure.  CAST ... FORMAT
execution is added separately.
---
 doc/src/sgml/catalogs.sgml               |  83 ++++++++
 doc/src/sgml/ref/allfiles.sgml           |   2 +
 doc/src/sgml/ref/create_formatter.sgml   | 131 ++++++++++++
 doc/src/sgml/ref/drop_formatter.sgml     | 126 ++++++++++++
 doc/src/sgml/reference.sgml              |   2 +
 src/backend/catalog/aclchk.c             |   2 +
 src/backend/catalog/dependency.c         |   2 +
 src/backend/catalog/objectaddress.c      | 105 ++++++++++
 src/backend/commands/Makefile            |   1 +
 src/backend/commands/dropcmds.c          |  22 ++
 src/backend/commands/event_trigger.c     |   2 +
 src/backend/commands/formattercmds.c     | 245 +++++++++++++++++++++++
 src/backend/commands/meson.build         |   1 +
 src/backend/commands/seclabel.c          |   1 +
 src/backend/parser/gram.y                |  40 +++-
 src/backend/tcop/utility.c               |  17 ++
 src/bin/pg_dump/common.c                 |   3 +
 src/bin/pg_dump/pg_dump.c                | 166 +++++++++++++++
 src/bin/pg_dump/pg_dump.h                |  10 +
 src/bin/pg_dump/pg_dump_sort.c           |   9 +
 src/include/catalog/Makefile             |   1 +
 src/include/catalog/catversion.h         |   2 +-
 src/include/catalog/meson.build          |   1 +
 src/include/catalog/pg_formatter.h       |  62 ++++++
 src/include/commands/formatter.h         |  24 +++
 src/include/nodes/parsenodes.h           |  17 ++
 src/include/parser/kwlist.h              |   1 +
 src/include/tcop/cmdtaglist.h            |   2 +
 src/test/regress/expected/formatters.out | 116 +++++++++++
 src/test/regress/expected/oidjoins.out   |   3 +
 src/test/regress/parallel_schedule       |   2 +-
 src/test/regress/sql/formatters.sql      |  99 +++++++++
 src/tools/pgindent/typedefs.list         |   4 +
 33 files changed, 1300 insertions(+), 4 deletions(-)
 create mode 100644 doc/src/sgml/ref/create_formatter.sgml
 create mode 100644 doc/src/sgml/ref/drop_formatter.sgml
 create mode 100644 src/backend/commands/formattercmds.c
 create mode 100644 src/include/catalog/pg_formatter.h
 create mode 100644 src/include/commands/formatter.h
 create mode 100644 src/test/regress/expected/formatters.out
 create mode 100644 src/test/regress/sql/formatters.sql

diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index 4b474c13917..75f4e4840fd 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -170,6 +170,11 @@
       <entry>additional foreign table information</entry>
      </row>
 
+     <row>
+      <entry><link linkend="catalog-pg-formatter"><structname>pg_formatter</structname></link></entry>
+      <entry>formatter functions for formatted casts</entry>
+     </row>
+
      <row>
       <entry><link linkend="catalog-pg-index"><structname>pg_index</structname></link></entry>
       <entry>additional index information</entry>
@@ -4391,6 +4396,84 @@ SCRAM-SHA-256$<replaceable>&lt;iteration count&gt;</replaceable>:<replaceable>&l
  </sect1>
 
 
+ <sect1 id="catalog-pg-formatter">
+  <title><structname>pg_formatter</structname></title>
+
+  <indexterm zone="catalog-pg-formatter">
+   <primary>pg_formatter</primary>
+  </indexterm>
+
+  <para>
+   The catalog <structname>pg_formatter</structname> stores formatting
+   conversion functions for formatted casts.  A formatter is identified by a
+   source type and a target type.  The associated function is called with the
+   source value and the <literal>FORMAT</literal> expression coerced to
+   <type>text</type>, and returns the target type.  At most one formatter
+   exists for any given pair of source and target types.  See <xref
+   linkend="sql-createformatter"/> for more information.
+  </para>
+
+  <table>
+   <title><structname>pg_formatter</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>
+       Row identifier
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>fmtsource</structfield> <type>oid</type>
+       (references <link linkend="catalog-pg-type"><structname>pg_type</structname></link>.<structfield>oid</structfield>)
+      </para>
+      <para>
+       OID of the source data type of the formatted cast
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>fmttarget</structfield> <type>oid</type>
+       (references <link linkend="catalog-pg-type"><structname>pg_type</structname></link>.<structfield>oid</structfield>)
+      </para>
+      <para>
+       OID of the target data type of the formatted cast
+      </para></entry>
+     </row>
+
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>fmtfunc</structfield> <type>oid</type>
+       (references <link linkend="catalog-pg-proc"><structname>pg_proc</structname></link>.<structfield>oid</structfield>)
+      </para>
+      <para>
+       OID of the formatter function, which has the signature
+       <replaceable>function</replaceable>(<replaceable>source_type</replaceable>, <type>text</type>)
+       returning <replaceable>target_type</replaceable>
+      </para></entry>
+     </row>
+    </tbody>
+   </tgroup>
+  </table>
+ </sect1>
+
+
  <sect1 id="catalog-pg-index">
   <title><structname>pg_index</structname></title>
 
diff --git a/doc/src/sgml/ref/allfiles.sgml b/doc/src/sgml/ref/allfiles.sgml
index e1a56c36221..b0bda54d87b 100644
--- a/doc/src/sgml/ref/allfiles.sgml
+++ b/doc/src/sgml/ref/allfiles.sgml
@@ -70,6 +70,7 @@ Complete list of usable sgml source files in this directory.
 <!ENTITY createExtension    SYSTEM "create_extension.sgml">
 <!ENTITY createForeignDataWrapper SYSTEM "create_foreign_data_wrapper.sgml">
 <!ENTITY createForeignTable SYSTEM "create_foreign_table.sgml">
+<!ENTITY createFormatter    SYSTEM "create_formatter.sgml">
 <!ENTITY createFunction     SYSTEM "create_function.sgml">
 <!ENTITY createGroup        SYSTEM "create_group.sgml">
 <!ENTITY createIndex        SYSTEM "create_index.sgml">
@@ -118,6 +119,7 @@ Complete list of usable sgml source files in this directory.
 <!ENTITY dropExtension      SYSTEM "drop_extension.sgml">
 <!ENTITY dropForeignDataWrapper SYSTEM "drop_foreign_data_wrapper.sgml">
 <!ENTITY dropForeignTable   SYSTEM "drop_foreign_table.sgml">
+<!ENTITY dropFormatter      SYSTEM "drop_formatter.sgml">
 <!ENTITY dropFunction       SYSTEM "drop_function.sgml">
 <!ENTITY dropGroup          SYSTEM "drop_group.sgml">
 <!ENTITY dropIndex          SYSTEM "drop_index.sgml">
diff --git a/doc/src/sgml/ref/create_formatter.sgml b/doc/src/sgml/ref/create_formatter.sgml
new file mode 100644
index 00000000000..268b53e7d2c
--- /dev/null
+++ b/doc/src/sgml/ref/create_formatter.sgml
@@ -0,0 +1,131 @@
+<!--
+doc/src/sgml/ref/create_formatter.sgml
+PostgreSQL documentation
+-->
+
+<refentry id="sql-createformatter">
+ <indexterm zone="sql-createformatter">
+  <primary>CREATE FORMATTER</primary>
+ </indexterm>
+
+ <refmeta>
+  <refentrytitle>CREATE FORMATTER</refentrytitle>
+  <manvolnum>7</manvolnum>
+  <refmiscinfo>SQL - Language Statements</refmiscinfo>
+ </refmeta>
+
+ <refnamediv>
+  <refname>CREATE FORMATTER</refname>
+  <refpurpose>define a new formatter for a formatted cast</refpurpose>
+ </refnamediv>
+
+ <refsynopsisdiv>
+<synopsis>
+CREATE FORMATTER FOR CAST (<replaceable>source_type</replaceable> AS <replaceable>target_type</replaceable>)
+    WITH FUNCTION <replaceable>function_name</replaceable> [ (<replaceable>argument_type</replaceable> [, ...]) ]
+</synopsis>
+ </refsynopsisdiv>
+
+ <refsect1 id="sql-createformatter-description">
+  <title>Description</title>
+
+  <para>
+   <command>CREATE FORMATTER</command> defines a formatting conversion for
+   <literal>CAST(<replaceable>expr</replaceable> AS <replaceable>target_type</replaceable> FORMAT <replaceable>format_expr</replaceable>)</literal>.
+   A formatter is selected by the source type and target type of the cast.
+   Unlike an ordinary cast (see <xref linkend="sql-createcast"/>), a formatted
+   cast is always explicit and always uses a function, and the function
+   additionally receives the <literal>FORMAT</literal> expression.
+  </para>
+
+  <para>
+   The formatter function must take exactly two arguments and return the
+   target type:
+<synopsis>
+<replaceable>function_name</replaceable>(<replaceable>source_type</replaceable>, text) RETURNS <replaceable>target_type</replaceable>
+</synopsis>
+   The first argument is the value being formatted, and the second argument
+   receives the <literal>FORMAT</literal> expression coerced to
+   <type>text</type>.  Only one formatter may be registered for a given
+   <literal>(<replaceable>source_type</replaceable>,
+   <replaceable>target_type</replaceable>)</literal> pair.  Neither the source
+   type nor the target type may be a pseudo-type.
+  </para>
+
+  <para>
+   To create a formatter, you must be the owner of the source type or the
+   target type, and you must have <literal>EXECUTE</literal> privilege on the
+   formatter function.
+  </para>
+ </refsect1>
+
+ <refsect1>
+  <title>Parameters</title>
+
+   <variablelist>
+    <varlistentry>
+     <term><replaceable>source_type</replaceable></term>
+     <listitem>
+      <para>
+       The data type of the value passed to the formatter (the source of the
+       cast).
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><replaceable>target_type</replaceable></term>
+     <listitem>
+      <para>
+       The data type returned by the formatter (the target of the cast).
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><replaceable>function_name</replaceable>[(<replaceable>argument_type</replaceable> [, ...])]</term>
+     <listitem>
+      <para>
+       The function used to perform the formatted cast.  The argument list, if
+       given, must name the two argument types
+       (<replaceable>source_type</replaceable> and <type>text</type>).
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+ </refsect1>
+
+ <refsect1 id="sql-createformatter-examples">
+  <title>Examples</title>
+
+  <para>
+   Register a formatter that converts an <type>integer</type> to
+   <type>text</type> using the supplied format string:
+<programlisting>
+CREATE FUNCTION int4_to_text_fmt(integer, text) RETURNS text
+    LANGUAGE sql IMMUTABLE RETURN $1::text || ':' || $2;
+
+CREATE FORMATTER FOR CAST (integer AS text)
+    WITH FUNCTION int4_to_text_fmt(integer, text);
+</programlisting></para>
+ </refsect1>
+
+ <refsect1 id="sql-createformatter-compat">
+  <title>Compatibility</title>
+
+  <para>
+   <command>CREATE FORMATTER</command> is a
+   <productname>PostgreSQL</productname> extension.
+  </para>
+ </refsect1>
+
+ <refsect1>
+  <title>See Also</title>
+
+  <simplelist type="inline">
+   <member><xref linkend="sql-dropformatter"/></member>
+   <member><xref linkend="sql-createcast"/></member>
+  </simplelist>
+ </refsect1>
+
+</refentry>
diff --git a/doc/src/sgml/ref/drop_formatter.sgml b/doc/src/sgml/ref/drop_formatter.sgml
new file mode 100644
index 00000000000..b7f367a590e
--- /dev/null
+++ b/doc/src/sgml/ref/drop_formatter.sgml
@@ -0,0 +1,126 @@
+<!--
+doc/src/sgml/ref/drop_formatter.sgml
+PostgreSQL documentation
+-->
+
+<refentry id="sql-dropformatter">
+ <indexterm zone="sql-dropformatter">
+  <primary>DROP FORMATTER</primary>
+ </indexterm>
+
+ <refmeta>
+  <refentrytitle>DROP FORMATTER</refentrytitle>
+  <manvolnum>7</manvolnum>
+  <refmiscinfo>SQL - Language Statements</refmiscinfo>
+ </refmeta>
+
+ <refnamediv>
+  <refname>DROP FORMATTER</refname>
+  <refpurpose>remove a formatter</refpurpose>
+ </refnamediv>
+
+ <refsynopsisdiv>
+<synopsis>
+DROP FORMATTER [ IF EXISTS ] FOR CAST (<replaceable>source_type</replaceable> AS <replaceable>target_type</replaceable>) [ CASCADE | RESTRICT ]
+</synopsis>
+ </refsynopsisdiv>
+
+ <refsect1 id="sql-dropformatter-description">
+  <title>Description</title>
+
+  <para>
+   <command>DROP FORMATTER</command> removes a previously defined formatter for
+   the given pair of source and target types.
+  </para>
+
+  <para>
+   Dropping a formatter requires ownership of either the source type or the
+   target type.
+  </para>
+ </refsect1>
+
+ <refsect1>
+  <title>Parameters</title>
+
+   <variablelist>
+    <varlistentry>
+     <term><literal>IF EXISTS</literal></term>
+     <listitem>
+      <para>
+       Do not throw an error if the formatter does not exist.  A notice is
+       issued in this case.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><replaceable>source_type</replaceable></term>
+     <listitem>
+      <para>
+       The source data type of the formatter.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><replaceable>target_type</replaceable></term>
+     <listitem>
+      <para>
+       The target data type of the formatter.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><literal>CASCADE</literal></term>
+     <listitem>
+      <para>
+       Automatically drop objects that depend on the formatter,
+       and in turn all objects that depend on those objects
+       (see <xref linkend="ddl-depend"/>).
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><literal>RESTRICT</literal></term>
+     <listitem>
+      <para>
+       Refuse to drop the formatter if any objects depend on it.  This is the
+       default.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+ </refsect1>
+
+ <refsect1 id="sql-dropformatter-examples">
+  <title>Examples</title>
+
+  <para>
+   To drop the formatter for the cast from <type>integer</type> to
+   <type>text</type>:
+<programlisting>
+DROP FORMATTER FOR CAST (integer AS text);
+</programlisting></para>
+ </refsect1>
+
+ <refsect1 id="sql-dropformatter-compat">
+  <title>Compatibility</title>
+
+  <para>
+   <command>DROP FORMATTER</command> is a
+   <productname>PostgreSQL</productname> extension.  See <xref
+   linkend="sql-createformatter"/> for details.
+  </para>
+ </refsect1>
+
+ <refsect1>
+  <title>See Also</title>
+
+  <simplelist type="inline">
+   <member><xref linkend="sql-createformatter"/></member>
+  </simplelist>
+ </refsect1>
+
+</refentry>
diff --git a/doc/src/sgml/reference.sgml b/doc/src/sgml/reference.sgml
index 674ac17e82c..a1d055509c8 100644
--- a/doc/src/sgml/reference.sgml
+++ b/doc/src/sgml/reference.sgml
@@ -98,6 +98,7 @@
    &createExtension;
    &createForeignDataWrapper;
    &createForeignTable;
+   &createFormatter;
    &createFunction;
    &createGroup;
    &createIndex;
@@ -146,6 +147,7 @@
    &dropExtension;
    &dropForeignDataWrapper;
    &dropForeignTable;
+   &dropFormatter;
    &dropFunction;
    &dropGroup;
    &dropIndex;
diff --git a/src/backend/catalog/aclchk.c b/src/backend/catalog/aclchk.c
index 007ede997c5..0f0211dd6c3 100644
--- a/src/backend/catalog/aclchk.c
+++ b/src/backend/catalog/aclchk.c
@@ -2794,6 +2794,7 @@ aclcheck_error(AclResult aclerr, ObjectType objtype,
 					case OBJECT_AMPROC:
 					case OBJECT_ATTRIBUTE:
 					case OBJECT_CAST:
+					case OBJECT_FORMATTER:
 					case OBJECT_DEFAULT:
 					case OBJECT_DEFACL:
 					case OBJECT_DOMCONSTRAINT:
@@ -2937,6 +2938,7 @@ aclcheck_error(AclResult aclerr, ObjectType objtype,
 					case OBJECT_AMPROC:
 					case OBJECT_ATTRIBUTE:
 					case OBJECT_CAST:
+					case OBJECT_FORMATTER:
 					case OBJECT_DEFAULT:
 					case OBJECT_DEFACL:
 					case OBJECT_DOMCONSTRAINT:
diff --git a/src/backend/catalog/dependency.c b/src/backend/catalog/dependency.c
index c54774b3275..7eeac91264f 100644
--- a/src/backend/catalog/dependency.c
+++ b/src/backend/catalog/dependency.c
@@ -41,6 +41,7 @@
 #include "catalog/pg_extension.h"
 #include "catalog/pg_foreign_data_wrapper.h"
 #include "catalog/pg_foreign_server.h"
+#include "catalog/pg_formatter.h"
 #include "catalog/pg_init_privs.h"
 #include "catalog/pg_language.h"
 #include "catalog/pg_largeobject.h"
@@ -1534,6 +1535,7 @@ doDeletion(const ObjectAddress *object, int flags)
 		case DefaultAclRelationId:
 		case EventTriggerRelationId:
 		case TransformRelationId:
+		case FormatterRelationId:
 		case AuthMemRelationId:
 			DropObjectById(object);
 			break;
diff --git a/src/backend/catalog/objectaddress.c b/src/backend/catalog/objectaddress.c
index af0e4703616..fcf38db8c55 100644
--- a/src/backend/catalog/objectaddress.c
+++ b/src/backend/catalog/objectaddress.c
@@ -37,6 +37,7 @@
 #include "catalog/pg_extension.h"
 #include "catalog/pg_foreign_data_wrapper.h"
 #include "catalog/pg_foreign_server.h"
+#include "catalog/pg_formatter.h"
 #include "catalog/pg_language.h"
 #include "catalog/pg_largeobject.h"
 #include "catalog/pg_largeobject_metadata.h"
@@ -70,6 +71,7 @@
 #include "commands/defrem.h"
 #include "commands/event_trigger.h"
 #include "commands/extension.h"
+#include "commands/formatter.h"
 #include "commands/policy.h"
 #include "commands/proclang.h"
 #include "commands/tablespace.h"
@@ -179,6 +181,20 @@ static const ObjectPropertyType ObjectProperty[] =
 		OBJECT_CAST,
 		false
 	},
+	{
+		"formatter",
+		FormatterRelationId,
+		FormatterOidIndexId,
+		FORMATTEROID,
+		SYSCACHEID_INVALID,
+		Anum_pg_formatter_oid,
+		InvalidAttrNumber,
+		InvalidAttrNumber,
+		InvalidAttrNumber,
+		InvalidAttrNumber,
+		OBJECT_FORMATTER,
+		false
+	},
 	{
 		"collation",
 		CollationRelationId,
@@ -796,6 +812,9 @@ static const struct object_type_map
 	{
 		"cast", OBJECT_CAST
 	},
+	{
+		"formatter", OBJECT_FORMATTER
+	},
 	{
 		"collation", OBJECT_COLLATION
 	},
@@ -1165,6 +1184,21 @@ get_object_address(ObjectType objtype, Node *object,
 					address.objectSubId = 0;
 				}
 				break;
+			case OBJECT_FORMATTER:
+				{
+					TypeName   *sourcetype = linitial_node(TypeName, castNode(List, object));
+					TypeName   *targettype = lsecond_node(TypeName, castNode(List, object));
+					Oid			sourcetypeid;
+					Oid			targettypeid;
+
+					sourcetypeid = LookupTypeNameOid(NULL, sourcetype, missing_ok);
+					targettypeid = LookupTypeNameOid(NULL, targettype, missing_ok);
+					address.classId = FormatterRelationId;
+					address.objectId =
+						get_formatter_oid(sourcetypeid, targettypeid, missing_ok);
+					address.objectSubId = 0;
+				}
+				break;
 			case OBJECT_TRANSFORM:
 				{
 					TypeName   *typename = linitial_node(TypeName, castNode(List, object));
@@ -2239,6 +2273,7 @@ pg_get_object_address(PG_FUNCTION_ARGS)
 	 * exceptions.
 	 */
 	if (type == OBJECT_TYPE || type == OBJECT_DOMAIN || type == OBJECT_CAST ||
+		type == OBJECT_FORMATTER ||
 		type == OBJECT_TRANSFORM || type == OBJECT_DOMCONSTRAINT)
 	{
 		Datum	   *elems;
@@ -2291,6 +2326,7 @@ pg_get_object_address(PG_FUNCTION_ARGS)
 		type == OBJECT_ROUTINE ||
 		type == OBJECT_OPERATOR ||
 		type == OBJECT_CAST ||
+		type == OBJECT_FORMATTER ||
 		type == OBJECT_AMOP ||
 		type == OBJECT_AMPROC)
 	{
@@ -2336,6 +2372,7 @@ pg_get_object_address(PG_FUNCTION_ARGS)
 			pg_fallthrough;
 		case OBJECT_DOMCONSTRAINT:
 		case OBJECT_CAST:
+		case OBJECT_FORMATTER:
 		case OBJECT_PUBLICATION_REL:
 		case OBJECT_DEFACL:
 		case OBJECT_TRANSFORM:
@@ -2424,6 +2461,7 @@ pg_get_object_address(PG_FUNCTION_ARGS)
 			objnode = (Node *) typename;
 			break;
 		case OBJECT_CAST:
+		case OBJECT_FORMATTER:
 		case OBJECT_DOMCONSTRAINT:
 		case OBJECT_TRANSFORM:
 			objnode = (Node *) list_make2(typename, linitial(args));
@@ -2583,6 +2621,7 @@ check_object_ownership(Oid roleid, ObjectType objtype, ObjectAddress address,
 								address.objectId)));
 			break;
 		case OBJECT_CAST:
+		case OBJECT_FORMATTER:
 			{
 				/* We can only check permissions on the source/target types */
 				TypeName   *sourcetype = linitial_node(TypeName, castNode(List, object));
@@ -3111,6 +3150,31 @@ getObjectDescription(const ObjectAddress *object, bool missing_ok)
 				break;
 			}
 
+		case FormatterRelationId:
+			{
+				HeapTuple	fmtTup;
+				Form_pg_formatter fmtForm;
+
+				fmtTup = SearchSysCache1(FORMATTEROID,
+										 ObjectIdGetDatum(object->objectId));
+				if (!HeapTupleIsValid(fmtTup))
+				{
+					if (!missing_ok)
+						elog(ERROR, "could not find tuple for formatter %u",
+							 object->objectId);
+					break;
+				}
+
+				fmtForm = (Form_pg_formatter) GETSTRUCT(fmtTup);
+
+				appendStringInfo(&buffer, _("formatter for cast from %s to %s"),
+								 format_type_be(fmtForm->fmtsource),
+								 format_type_be(fmtForm->fmttarget));
+
+				ReleaseSysCache(fmtTup);
+				break;
+			}
+
 		case CollationRelationId:
 			{
 				HeapTuple	collTup;
@@ -4762,6 +4826,10 @@ getObjectTypeDescription(const ObjectAddress *object, bool missing_ok)
 			appendStringInfoString(&buffer, "cast");
 			break;
 
+		case FormatterRelationId:
+			appendStringInfoString(&buffer, "formatter");
+			break;
+
 		case CollationRelationId:
 			appendStringInfoString(&buffer, "collation");
 			break;
@@ -5225,6 +5293,43 @@ getObjectIdentityParts(const ObjectAddress *object,
 				break;
 			}
 
+		case FormatterRelationId:
+			{
+				Relation	fmtRel;
+				HeapTuple	tup;
+				Form_pg_formatter fmtForm;
+
+				fmtRel = table_open(FormatterRelationId, AccessShareLock);
+
+				tup = get_catalog_object_by_oid(fmtRel, Anum_pg_formatter_oid,
+												object->objectId);
+
+				if (!HeapTupleIsValid(tup))
+				{
+					if (!missing_ok)
+						elog(ERROR, "could not find tuple for formatter %u",
+							 object->objectId);
+
+					table_close(fmtRel, AccessShareLock);
+					break;
+				}
+
+				fmtForm = (Form_pg_formatter) GETSTRUCT(tup);
+
+				appendStringInfo(&buffer, "for cast (%s AS %s)",
+								 format_type_be_qualified(fmtForm->fmtsource),
+								 format_type_be_qualified(fmtForm->fmttarget));
+
+				if (objname)
+				{
+					*objname = list_make1(format_type_be_qualified(fmtForm->fmtsource));
+					*objargs = list_make1(format_type_be_qualified(fmtForm->fmttarget));
+				}
+
+				table_close(fmtRel, AccessShareLock);
+				break;
+			}
+
 		case CollationRelationId:
 			{
 				HeapTuple	collTup;
diff --git a/src/backend/commands/Makefile b/src/backend/commands/Makefile
index 5b9d084977e..7a4066867e0 100644
--- a/src/backend/commands/Makefile
+++ b/src/backend/commands/Makefile
@@ -38,6 +38,7 @@ OBJS = \
 	explain_state.o \
 	extension.o \
 	foreigncmds.o \
+	formattercmds.o \
 	functioncmds.o \
 	indexcmds.o \
 	lockcmds.o \
diff --git a/src/backend/commands/dropcmds.c b/src/backend/commands/dropcmds.c
index 88a2df65c69..90e250f85ba 100644
--- a/src/backend/commands/dropcmds.c
+++ b/src/backend/commands/dropcmds.c
@@ -25,6 +25,7 @@
 #include "miscadmin.h"
 #include "parser/parse_type.h"
 #include "utils/acl.h"
+#include "utils/builtins.h"
 #include "utils/lsyscache.h"
 
 
@@ -401,6 +402,27 @@ does_not_exist_skipping(ObjectType objtype, Node *object)
 				}
 			}
 			break;
+		case OBJECT_FORMATTER:
+			{
+				if (!type_in_list_does_not_exist_skipping(list_make1(linitial(castNode(List, object))), &msg, &name) &&
+					!type_in_list_does_not_exist_skipping(list_make1(lsecond(castNode(List, object))), &msg, &name))
+				{
+					/*
+					 * Both types exist (else the checks above would have
+					 * produced a message), so resolve them to OIDs and report
+					 * them with format_type_be().  This keeps the wording
+					 * consistent with the duplicate-object and undefined-object
+					 * errors, which also use format_type_be().
+					 */
+					Oid			sourcetypeid = typenameTypeId(NULL, linitial_node(TypeName, castNode(List, object)));
+					Oid			targettypeid = typenameTypeId(NULL, lsecond_node(TypeName, castNode(List, object)));
+
+					msg = gettext_noop("formatter for cast from type %s to type %s does not exist, skipping");
+					name = format_type_be(sourcetypeid);
+					args = format_type_be(targettypeid);
+				}
+			}
+			break;
 		case OBJECT_TRANSFORM:
 			if (!type_in_list_does_not_exist_skipping(list_make1(linitial(castNode(List, object))), &msg, &name))
 			{
diff --git a/src/backend/commands/event_trigger.c b/src/backend/commands/event_trigger.c
index adc6eabc0f4..139bdf51c61 100644
--- a/src/backend/commands/event_trigger.c
+++ b/src/backend/commands/event_trigger.c
@@ -2301,6 +2301,7 @@ stringify_grant_objtype(ObjectType objtype)
 		case OBJECT_AMPROC:
 		case OBJECT_ATTRIBUTE:
 		case OBJECT_CAST:
+		case OBJECT_FORMATTER:
 		case OBJECT_COLLATION:
 		case OBJECT_CONVERSION:
 		case OBJECT_DEFAULT:
@@ -2385,6 +2386,7 @@ stringify_adefprivs_objtype(ObjectType objtype)
 		case OBJECT_AMPROC:
 		case OBJECT_ATTRIBUTE:
 		case OBJECT_CAST:
+		case OBJECT_FORMATTER:
 		case OBJECT_COLLATION:
 		case OBJECT_CONVERSION:
 		case OBJECT_DEFAULT:
diff --git a/src/backend/commands/formattercmds.c b/src/backend/commands/formattercmds.c
new file mode 100644
index 00000000000..2276519f562
--- /dev/null
+++ b/src/backend/commands/formattercmds.c
@@ -0,0 +1,245 @@
+/*-------------------------------------------------------------------------
+ *
+ * formattercmds.c
+ *	  Routines for SQL commands that manipulate formatters.
+ *
+ * A formatter associates a function with a (source type, target type) pair.
+ * The function implements CAST(expr AS target FORMAT format_expr): it
+ * receives the source value and the FORMAT expression (passed as text) and
+ * returns the target type.  Formatters are registered in the pg_formatter
+ * catalog and are intentionally separate from pg_cast (see pg_formatter.h).
+ *
+ * This file provides the catalog registration machinery (CREATE/DROP
+ * FORMATTER).  It does not perform expression transformation or execution of
+ * formatted casts.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *	  src/backend/commands/formattercmds.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/htup_details.h"
+#include "access/table.h"
+#include "catalog/catalog.h"
+#include "catalog/dependency.h"
+#include "catalog/indexing.h"
+#include "catalog/objectaccess.h"
+#include "catalog/objectaddress.h"
+#include "catalog/pg_formatter.h"
+#include "catalog/pg_proc.h"
+#include "catalog/pg_type.h"
+#include "commands/formatter.h"
+#include "miscadmin.h"
+#include "parser/parse_func.h"
+#include "parser/parse_type.h"
+#include "utils/acl.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
+#include "utils/rel.h"
+#include "utils/syscache.h"
+
+/*
+ * Validate the signature of a formatter function: it must be a normal
+ * function (not a procedure, aggregate or window function), must not return
+ * a set, and must have the signature
+ *
+ *		formatter(source_type, text) returns target_type
+ *
+ * NB: the formatter signature is fixed at two arguments and does not include
+ * the target type modifier; typmod-aware formatter signatures are not
+ * supported.
+ */
+static void
+check_formatter_function(Form_pg_proc procstruct,
+						 Oid sourcetypeid, Oid targettypeid)
+{
+	if (procstruct->prokind != PROKIND_FUNCTION)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
+				 errmsg("formatter function must be a normal function")));
+	if (procstruct->proretset)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
+				 errmsg("formatter function must not return a set")));
+	if (procstruct->pronargs != 2)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
+				 errmsg("formatter function must take exactly two arguments")));
+	if (procstruct->proargtypes.values[0] != sourcetypeid)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
+				 errmsg("first argument of formatter function must be type %s",
+						format_type_be(sourcetypeid))));
+	if (procstruct->proargtypes.values[1] != TEXTOID)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
+				 errmsg("second argument of formatter function must be type %s",
+						"text")));
+	if (procstruct->prorettype != targettypeid)
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
+				 errmsg("return data type of formatter function must be type %s",
+						format_type_be(targettypeid))));
+}
+
+/*
+ * CREATE FORMATTER
+ */
+ObjectAddress
+CreateFormatter(CreateFormatterStmt *stmt)
+{
+	Oid			sourcetypeid;
+	Oid			targettypeid;
+	char		sourcetyptype;
+	char		targettyptype;
+	Oid			funcid;
+	AclResult	aclresult;
+	Form_pg_proc procstruct;
+	HeapTuple	tuple;
+	HeapTuple	newtuple;
+	Datum		values[Natts_pg_formatter];
+	bool		nulls[Natts_pg_formatter] = {0};
+	Oid			formatterid;
+	Relation	relation;
+	ObjectAddress myself,
+				referenced;
+	ObjectAddresses *addrs;
+
+	sourcetypeid = typenameTypeId(NULL, stmt->sourcetype);
+	targettypeid = typenameTypeId(NULL, stmt->targettype);
+	sourcetyptype = get_typtype(sourcetypeid);
+	targettyptype = get_typtype(targettypeid);
+
+	/* No pseudo-types allowed */
+	if (sourcetyptype == TYPTYPE_PSEUDO)
+		ereport(ERROR,
+				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				 errmsg("source data type %s is a pseudo-type",
+						TypeNameToString(stmt->sourcetype))));
+	if (targettyptype == TYPTYPE_PSEUDO)
+		ereport(ERROR,
+				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				 errmsg("target data type %s is a pseudo-type",
+						TypeNameToString(stmt->targettype))));
+
+	/*
+	 * Permission check.  As for CREATE CAST, the caller must own at least one
+	 * of the two types involved; owning a type is what authorizes defining
+	 * conversion behavior for it.  In addition, and following CREATE OPERATOR
+	 * and CREATE AGGREGATE, we require EXECUTE permission on the formatter
+	 * function (this will also be checked when the formatter is used, but it
+	 * is a good idea to verify it up front).  We intentionally do not require
+	 * ownership of the function, unlike CREATE TRANSFORM, because a formatter
+	 * is a data conversion rather than a procedural-language binding.
+	 */
+	if (!object_ownercheck(TypeRelationId, sourcetypeid, GetUserId())
+		&& !object_ownercheck(TypeRelationId, targettypeid, GetUserId()))
+		ereport(ERROR,
+				(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				 errmsg("must be owner of type %s or type %s",
+						format_type_be(sourcetypeid),
+						format_type_be(targettypeid))));
+
+	/*
+	 * Look up and validate the formatter function.
+	 */
+	funcid = LookupFuncWithArgs(OBJECT_FUNCTION, stmt->func, false);
+
+	aclresult = object_aclcheck(ProcedureRelationId, funcid, GetUserId(), ACL_EXECUTE);
+	if (aclresult != ACLCHECK_OK)
+		aclcheck_error(aclresult, OBJECT_FUNCTION,
+					   NameListToString(stmt->func->objname));
+
+	tuple = SearchSysCache1(PROCOID, ObjectIdGetDatum(funcid));
+	if (!HeapTupleIsValid(tuple))
+		elog(ERROR, "cache lookup failed for function %u", funcid);
+	procstruct = (Form_pg_proc) GETSTRUCT(tuple);
+	check_formatter_function(procstruct, sourcetypeid, targettypeid);
+	ReleaseSysCache(tuple);
+
+	/*
+	 * Check for a pre-existing formatter for this (source, target) pair.  For
+	 * this version only one formatter per pair is allowed.
+	 */
+	relation = table_open(FormatterRelationId, RowExclusiveLock);
+
+	if (SearchSysCacheExists2(FORMATTERSOURCETARGET,
+							  ObjectIdGetDatum(sourcetypeid),
+							  ObjectIdGetDatum(targettypeid)))
+		ereport(ERROR,
+				(errcode(ERRCODE_DUPLICATE_OBJECT),
+				 errmsg("formatter for cast from type %s to type %s already exists",
+						format_type_be(sourcetypeid),
+						format_type_be(targettypeid))));
+
+	/*
+	 * Build and insert the catalog tuple.
+	 */
+	formatterid = GetNewOidWithIndex(relation, FormatterOidIndexId,
+									 Anum_pg_formatter_oid);
+	values[Anum_pg_formatter_oid - 1] = ObjectIdGetDatum(formatterid);
+	values[Anum_pg_formatter_fmtsource - 1] = ObjectIdGetDatum(sourcetypeid);
+	values[Anum_pg_formatter_fmttarget - 1] = ObjectIdGetDatum(targettypeid);
+	values[Anum_pg_formatter_fmtfunc - 1] = ObjectIdGetDatum(funcid);
+
+	newtuple = heap_form_tuple(RelationGetDescr(relation), values, nulls);
+	CatalogTupleInsert(relation, newtuple);
+
+	addrs = new_object_addresses();
+
+	ObjectAddressSet(myself, FormatterRelationId, formatterid);
+
+	/* dependency on source type */
+	ObjectAddressSet(referenced, TypeRelationId, sourcetypeid);
+	add_exact_object_address(&referenced, addrs);
+
+	/* dependency on target type */
+	ObjectAddressSet(referenced, TypeRelationId, targettypeid);
+	add_exact_object_address(&referenced, addrs);
+
+	/* dependency on the formatter function */
+	ObjectAddressSet(referenced, ProcedureRelationId, funcid);
+	add_exact_object_address(&referenced, addrs);
+
+	record_object_address_dependencies(&myself, addrs, DEPENDENCY_NORMAL);
+	free_object_addresses(addrs);
+
+	/* dependency on extension */
+	recordDependencyOnCurrentExtension(&myself, false);
+
+	/* Post creation hook for new formatter */
+	InvokeObjectPostCreateHook(FormatterRelationId, formatterid, 0);
+
+	heap_freetuple(newtuple);
+
+	table_close(relation, RowExclusiveLock);
+
+	return myself;
+}
+
+/*
+ * get_formatter_oid - given source and target type OIDs, look up a
+ * formatter OID
+ */
+Oid
+get_formatter_oid(Oid sourcetypeid, Oid targettypeid, bool missing_ok)
+{
+	Oid			oid;
+
+	oid = GetSysCacheOid2(FORMATTERSOURCETARGET, Anum_pg_formatter_oid,
+						  ObjectIdGetDatum(sourcetypeid),
+						  ObjectIdGetDatum(targettypeid));
+	if (!OidIsValid(oid) && !missing_ok)
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_OBJECT),
+				 errmsg("formatter for cast from type %s to type %s does not exist",
+						format_type_be(sourcetypeid),
+						format_type_be(targettypeid))));
+	return oid;
+}
diff --git a/src/backend/commands/meson.build b/src/backend/commands/meson.build
index 9f258d566eb..8c4ecb83c3a 100644
--- a/src/backend/commands/meson.build
+++ b/src/backend/commands/meson.build
@@ -26,6 +26,7 @@ backend_sources += files(
   'explain_state.c',
   'extension.c',
   'foreigncmds.c',
+  'formattercmds.c',
   'functioncmds.c',
   'indexcmds.c',
   'lockcmds.c',
diff --git a/src/backend/commands/seclabel.c b/src/backend/commands/seclabel.c
index 77542d04200..f7d1779dad8 100644
--- a/src/backend/commands/seclabel.c
+++ b/src/backend/commands/seclabel.c
@@ -66,6 +66,7 @@ SecLabelSupportsObjectType(ObjectType objtype)
 		case OBJECT_AMPROC:
 		case OBJECT_ATTRIBUTE:
 		case OBJECT_CAST:
+		case OBJECT_FORMATTER:
 		case OBJECT_COLLATION:
 		case OBJECT_CONVERSION:
 		case OBJECT_DEFAULT:
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ef4881efc81..6e82a4e090e 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -295,13 +295,14 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 		CreateSchemaStmt CreateSeqStmt CreateStmt CreateStatsStmt CreateTableSpaceStmt
 		CreateFdwStmt CreateForeignServerStmt CreateForeignTableStmt
 		CreateAssertionStmt CreateTransformStmt CreateTrigStmt CreateEventTrigStmt
+		CreateFormatterStmt
 		CreatePropGraphStmt AlterPropGraphStmt
 		CreateUserStmt CreateUserMappingStmt CreateRoleStmt CreatePolicyStmt
 		CreatedbStmt DeclareCursorStmt DefineStmt DeleteStmt DiscardStmt DoStmt
 		DropOpClassStmt DropOpFamilyStmt DropStmt
 		DropCastStmt DropRoleStmt
 		DropdbStmt DropTableSpaceStmt
-		DropTransformStmt
+		DropTransformStmt DropFormatterStmt
 		DropUserMappingStmt ExplainStmt FetchStmt
 		GrantStmt GrantRoleStmt ImportForeignSchemaStmt IndexStmt InsertStmt
 		ListenStmt LoadStmt LockStmt MergeStmt NotifyStmt ExplainableStmt PreparableStmt
@@ -769,7 +770,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	EXPLAIN EXPRESSION EXTENSION EXTERNAL EXTRACT
 
 	FALSE_P FAMILY FETCH FILTER FINALIZE FIRST_P FLOAT_P FOLLOWING FOR
-	FORCE FOREIGN FORMAT FORWARD FREEZE FROM FULL FUNCTION FUNCTIONS
+	FORCE FOREIGN FORMAT FORMATTER FORWARD FREEZE FROM FULL FUNCTION FUNCTIONS
 
 	GENERATED GLOBAL GRANT GRANTED GRAPH GRAPH_TABLE GREATEST GROUP_P GROUPING GROUPS
 
@@ -1105,6 +1106,7 @@ stmt:
 			| CreateStatsStmt
 			| CreateTableSpaceStmt
 			| CreateTransformStmt
+			| CreateFormatterStmt
 			| CreateTrigStmt
 			| CreateEventTrigStmt
 			| CreateRoleStmt
@@ -1125,6 +1127,7 @@ stmt:
 			| DropSubscriptionStmt
 			| DropTableSpaceStmt
 			| DropTransformStmt
+			| DropFormatterStmt
 			| DropRoleStmt
 			| DropUserMappingStmt
 			| DropdbStmt
@@ -9893,6 +9896,38 @@ DropTransformStmt: DROP TRANSFORM opt_if_exists FOR Typename LANGUAGE name opt_d
 		;
 
 
+/*****************************************************************************
+ *
+ *		CREATE FORMATTER / DROP FORMATTER
+ *
+ * A formatter registers the function implementing
+ * CAST(expr AS target FORMAT format_expr) for a (source, target) type pair.
+ *****************************************************************************/
+
+CreateFormatterStmt: CREATE FORMATTER FOR CAST '(' Typename AS Typename ')' WITH FUNCTION function_with_argtypes
+				{
+					CreateFormatterStmt *n = makeNode(CreateFormatterStmt);
+
+					n->sourcetype = $6;
+					n->targettype = $8;
+					n->func = $12;
+					$$ = (Node *) n;
+				}
+		;
+
+DropFormatterStmt: DROP FORMATTER opt_if_exists FOR CAST '(' Typename AS Typename ')' opt_drop_behavior
+				{
+					DropStmt *n = makeNode(DropStmt);
+
+					n->removeType = OBJECT_FORMATTER;
+					n->objects = list_make1(list_make2($7, $9));
+					n->behavior = $11;
+					n->missing_ok = $3;
+					$$ = (Node *) n;
+				}
+		;
+
+
 /*****************************************************************************
  *
  *		QUERY:
@@ -18933,6 +18968,7 @@ unreserved_keyword:
 			| FOLLOWING
 			| FORCE
 			| FORMAT
+			| FORMATTER
 			| FORWARD
 			| FUNCTION
 			| FUNCTIONS
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 73a56f1df1d..ee66296e0d0 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -37,6 +37,7 @@
 #include "commands/event_trigger.h"
 #include "commands/explain.h"
 #include "commands/extension.h"
+#include "commands/formatter.h"
 #include "commands/lockcmds.h"
 #include "commands/matview.h"
 #include "commands/policy.h"
@@ -193,6 +194,7 @@ ClassifyUtilityCommandAsReadOnly(Node *parsetree)
 		case T_CreateTableAsStmt:
 		case T_CreateTableSpaceStmt:
 		case T_CreateTransformStmt:
+		case T_CreateFormatterStmt:
 		case T_CreateTrigStmt:
 		case T_CreateUserMappingStmt:
 		case T_CreatedbStmt:
@@ -1755,6 +1757,10 @@ ProcessUtilitySlow(ParseState *pstate,
 				address = CreateTransform((CreateTransformStmt *) parsetree);
 				break;
 
+			case T_CreateFormatterStmt:
+				address = CreateFormatter((CreateFormatterStmt *) parsetree);
+				break;
+
 			case T_AlterOpFamilyStmt:
 				AlterOpFamily((AlterOpFamilyStmt *) parsetree);
 				/* commands are stashed in AlterOpFamily */
@@ -2666,6 +2672,9 @@ CreateCommandTag(Node *parsetree)
 				case OBJECT_TRANSFORM:
 					tag = CMDTAG_DROP_TRANSFORM;
 					break;
+				case OBJECT_FORMATTER:
+					tag = CMDTAG_DROP_FORMATTER;
+					break;
 				case OBJECT_ACCESS_METHOD:
 					tag = CMDTAG_DROP_ACCESS_METHOD;
 					break;
@@ -2981,6 +2990,10 @@ CreateCommandTag(Node *parsetree)
 			tag = CMDTAG_CREATE_TRANSFORM;
 			break;
 
+		case T_CreateFormatterStmt:
+			tag = CMDTAG_CREATE_FORMATTER;
+			break;
+
 		case T_CreateTrigStmt:
 			tag = CMDTAG_CREATE_TRIGGER;
 			break;
@@ -3690,6 +3703,10 @@ GetCommandLogLevel(Node *parsetree)
 			lev = LOGSTMT_DDL;
 			break;
 
+		case T_CreateFormatterStmt:
+			lev = LOGSTMT_DDL;
+			break;
+
 		case T_AlterOpFamilyStmt:
 			lev = LOGSTMT_DDL;
 			break;
diff --git a/src/bin/pg_dump/common.c b/src/bin/pg_dump/common.c
index dc98c5c5c09..d70753905eb 100644
--- a/src/bin/pg_dump/common.c
+++ b/src/bin/pg_dump/common.c
@@ -188,6 +188,9 @@ getSchemaData(Archive *fout, int *numTablesPtr)
 	pg_log_info("reading transforms");
 	getTransforms(fout);
 
+	pg_log_info("reading formatters");
+	getFormatters(fout);
+
 	pg_log_info("reading table inheritance information");
 	inhinfo = getInherits(fout, &numInherits);
 
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index c56437d6057..92883740743 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -300,6 +300,7 @@ static void dumpProcLang(Archive *fout, const ProcLangInfo *plang);
 static void dumpFunc(Archive *fout, const FuncInfo *finfo);
 static void dumpCast(Archive *fout, const CastInfo *cast);
 static void dumpTransform(Archive *fout, const TransformInfo *transform);
+static void dumpFormatter(Archive *fout, const FormatterInfo *formatter);
 static void dumpOpr(Archive *fout, const OprInfo *oprinfo);
 static void dumpAccessMethod(Archive *fout, const AccessMethodInfo *aminfo);
 static void dumpOpclass(Archive *fout, const OpclassInfo *opcinfo);
@@ -9320,6 +9321,83 @@ getTransforms(Archive *fout)
 	destroyPQExpBuffer(query);
 }
 
+/*
+ * getFormatters
+ *	  get basic information about every formatter in the system
+ */
+void
+getFormatters(Archive *fout)
+{
+	PGresult   *res;
+	int			ntups;
+	int			i;
+	PQExpBuffer query;
+	FormatterInfo *formatterinfo;
+	int			i_tableoid;
+	int			i_oid;
+	int			i_fmtsource;
+	int			i_fmttarget;
+	int			i_fmtfunc;
+
+	/* Formatters were introduced in v19 */
+	if (fout->remoteVersion < 190000)
+		return;
+
+	query = createPQExpBuffer();
+
+	appendPQExpBufferStr(query, "SELECT tableoid, oid, "
+						 "fmtsource, fmttarget, fmtfunc "
+						 "FROM pg_formatter "
+						 "ORDER BY 3,4");
+
+	res = ExecuteSqlQuery(fout, query->data, PGRES_TUPLES_OK);
+
+	ntups = PQntuples(res);
+
+	formatterinfo = pg_malloc_array(FormatterInfo, ntups);
+
+	i_tableoid = PQfnumber(res, "tableoid");
+	i_oid = PQfnumber(res, "oid");
+	i_fmtsource = PQfnumber(res, "fmtsource");
+	i_fmttarget = PQfnumber(res, "fmttarget");
+	i_fmtfunc = PQfnumber(res, "fmtfunc");
+
+	for (i = 0; i < ntups; i++)
+	{
+		PQExpBufferData namebuf;
+		TypeInfo   *sTypeInfo;
+		TypeInfo   *tTypeInfo;
+
+		formatterinfo[i].dobj.objType = DO_FORMATTER;
+		formatterinfo[i].dobj.catId.tableoid = atooid(PQgetvalue(res, i, i_tableoid));
+		formatterinfo[i].dobj.catId.oid = atooid(PQgetvalue(res, i, i_oid));
+		AssignDumpId(&formatterinfo[i].dobj);
+		formatterinfo[i].fmtsource = atooid(PQgetvalue(res, i, i_fmtsource));
+		formatterinfo[i].fmttarget = atooid(PQgetvalue(res, i, i_fmttarget));
+		formatterinfo[i].fmtfunc = atooid(PQgetvalue(res, i, i_fmtfunc));
+
+		/*
+		 * Try to name the formatter as a concatenation of the type names.
+		 * This is only used for purposes of sorting.  If we fail to find
+		 * either type, the name will be an empty string.
+		 */
+		initPQExpBuffer(&namebuf);
+		sTypeInfo = findTypeByOid(formatterinfo[i].fmtsource);
+		tTypeInfo = findTypeByOid(formatterinfo[i].fmttarget);
+		if (sTypeInfo && tTypeInfo)
+			appendPQExpBuffer(&namebuf, "%s %s",
+							  sTypeInfo->dobj.name, tTypeInfo->dobj.name);
+		formatterinfo[i].dobj.name = namebuf.data;
+
+		/* Decide whether we want to dump it */
+		selectDumpableObject(&(formatterinfo[i].dobj), fout);
+	}
+
+	PQclear(res);
+
+	destroyPQExpBuffer(query);
+}
+
 /*
  * getTableAttrs -
  *	  for each interesting table, read info about its attributes
@@ -11913,6 +11991,9 @@ dumpDumpableObject(Archive *fout, DumpableObject *dobj)
 		case DO_TRANSFORM:
 			dumpTransform(fout, (const TransformInfo *) dobj);
 			break;
+		case DO_FORMATTER:
+			dumpFormatter(fout, (const FormatterInfo *) dobj);
+			break;
 		case DO_SEQUENCE_SET:
 			dumpSequenceData(fout, (const TableDataInfo *) dobj);
 			break;
@@ -14255,6 +14336,90 @@ dumpTransform(Archive *fout, const TransformInfo *transform)
 	destroyPQExpBuffer(transformargs);
 }
 
+/*
+ * Dump a formatter
+ */
+static void
+dumpFormatter(Archive *fout, const FormatterInfo *formatter)
+{
+	DumpOptions *dopt = fout->dopt;
+	PQExpBuffer defqry;
+	PQExpBuffer delqry;
+	PQExpBuffer labelq;
+	PQExpBuffer formatterargs;
+	FuncInfo   *funcInfo;
+	const char *sourceType;
+	const char *targetType;
+
+	/* Do nothing if not dumping schema */
+	if (!dopt->dumpSchema)
+		return;
+
+	/* Cannot dump if we don't have the formatter function's info */
+	funcInfo = findFuncByOid(formatter->fmtfunc);
+	if (funcInfo == NULL)
+		pg_fatal("could not find function definition for function with OID %u",
+				 formatter->fmtfunc);
+
+	defqry = createPQExpBuffer();
+	delqry = createPQExpBuffer();
+	labelq = createPQExpBuffer();
+	formatterargs = createPQExpBuffer();
+
+	sourceType = getFormattedTypeName(fout, formatter->fmtsource, zeroAsNone);
+	targetType = getFormattedTypeName(fout, formatter->fmttarget, zeroAsNone);
+
+	appendPQExpBuffer(delqry, "DROP FORMATTER FOR CAST (%s AS %s);\n",
+					  sourceType, targetType);
+
+	appendPQExpBuffer(defqry, "CREATE FORMATTER FOR CAST (%s AS %s) ",
+					  sourceType, targetType);
+
+	{
+		char	   *fsig = format_function_signature(fout, funcInfo, true);
+
+		/*
+		 * Always qualify the function name (format_function_signature won't
+		 * qualify it).
+		 */
+		appendPQExpBuffer(defqry, "WITH FUNCTION %s.%s",
+						  fmtId(funcInfo->dobj.namespace->dobj.name), fsig);
+		free(fsig);
+	}
+	appendPQExpBufferStr(defqry, ";\n");
+
+	appendPQExpBuffer(labelq, "FORMATTER FOR CAST (%s AS %s)",
+					  sourceType, targetType);
+
+	appendPQExpBuffer(formatterargs, "FOR CAST (%s AS %s)",
+					  sourceType, targetType);
+
+	if (dopt->binary_upgrade)
+		binary_upgrade_extension_member(defqry, &formatter->dobj,
+										"FORMATTER", formatterargs->data, NULL);
+
+	if (formatter->dobj.dump & DUMP_COMPONENT_DEFINITION)
+		ArchiveEntry(fout, formatter->dobj.catId, formatter->dobj.dumpId,
+					 ARCHIVE_OPTS(.tag = labelq->data,
+								  .description = "FORMATTER",
+								  .section = SECTION_PRE_DATA,
+								  .createStmt = defqry->data,
+								  .dropStmt = delqry->data,
+								  .deps = formatter->dobj.dependencies,
+								  .nDeps = formatter->dobj.nDeps));
+
+	/* Dump Formatter Comments */
+	if (formatter->dobj.dump & DUMP_COMPONENT_COMMENT)
+		dumpComment(fout, "FORMATTER", formatterargs->data,
+					NULL, "",
+					formatter->dobj.catId, 0, formatter->dobj.dumpId);
+
+	destroyPQExpBuffer(defqry);
+	destroyPQExpBuffer(delqry);
+	destroyPQExpBuffer(labelq);
+	destroyPQExpBuffer(formatterargs);
+}
+
 
 /*
  * dumpOpr
@@ -20696,6 +20861,7 @@ addBoundaryDependencies(DumpableObject **dobjs, int numObjs,
 			case DO_FDW:
 			case DO_FOREIGN_SERVER:
 			case DO_TRANSFORM:
+			case DO_FORMATTER:
 				/* Pre-data objects: must come before the pre-data boundary */
 				addObjectDependency(preDataBound, dobj->dumpId);
 				break;
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 5a6726d8b12..b3adb97b5ba 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -73,6 +73,7 @@ typedef enum
 	DO_FOREIGN_SERVER,
 	DO_DEFAULT_ACL,
 	DO_TRANSFORM,
+	DO_FORMATTER,
 	DO_LARGE_OBJECT,
 	DO_LARGE_OBJECT_DATA,
 	DO_PRE_DATA_BOUNDARY,
@@ -559,6 +560,14 @@ typedef struct _transformInfo
 	Oid			trftosql;
 } TransformInfo;
 
+typedef struct _formatterInfo
+{
+	DumpableObject dobj;
+	Oid			fmtsource;
+	Oid			fmttarget;
+	Oid			fmtfunc;
+} FormatterInfo;
+
 /* InhInfo isn't a DumpableObject, just temporary state */
 typedef struct _inhInfo
 {
@@ -814,6 +823,7 @@ extern void getTriggers(Archive *fout, TableInfo tblinfo[], int numTables);
 extern void getProcLangs(Archive *fout);
 extern void getCasts(Archive *fout);
 extern void getTransforms(Archive *fout);
+extern void getFormatters(Archive *fout);
 extern void getTableAttrs(Archive *fout, TableInfo *tblinfo, int numTables);
 extern bool shouldPrintColumn(const DumpOptions *dopt, const TableInfo *tbinfo, int colno);
 extern void getTSParsers(Archive *fout);
diff --git a/src/bin/pg_dump/pg_dump_sort.c b/src/bin/pg_dump/pg_dump_sort.c
index 03e5c1c1116..65df81c9324 100644
--- a/src/bin/pg_dump/pg_dump_sort.c
+++ b/src/bin/pg_dump/pg_dump_sort.c
@@ -60,6 +60,7 @@ enum dbObjectTypePriorities
 	PRIO_EXTENSION,
 	PRIO_TYPE,					/* used for DO_TYPE and DO_SHELL_TYPE */
 	PRIO_CAST,
+	PRIO_FORMATTER,
 	PRIO_FUNC,
 	PRIO_AGG,
 	PRIO_ACCESS_METHOD,
@@ -128,6 +129,7 @@ static const int dbObjectTypePriority[] =
 	[DO_FK_CONSTRAINT] = PRIO_FK_CONSTRAINT,
 	[DO_PROCLANG] = PRIO_PROCLANG,
 	[DO_CAST] = PRIO_CAST,
+	[DO_FORMATTER] = PRIO_FORMATTER,
 	[DO_TABLE_DATA] = PRIO_TABLE_DATA,
 	[DO_SEQUENCE_SET] = PRIO_SEQUENCE_SET,
 	[DO_DUMMY_TYPE] = PRIO_DUMMY_TYPE,
@@ -1649,6 +1651,13 @@ describeDumpableObject(DumpableObject *obj, char *buf, int bufsize)
 					 ((CastInfo *) obj)->casttarget,
 					 obj->dumpId, obj->catId.oid);
 			return;
+		case DO_FORMATTER:
+			snprintf(buf, bufsize,
+					 "FORMATTER %u to %u  (ID %d OID %u)",
+					 ((FormatterInfo *) obj)->fmtsource,
+					 ((FormatterInfo *) obj)->fmttarget,
+					 obj->dumpId, obj->catId.oid);
+			return;
 		case DO_TRANSFORM:
 			snprintf(buf, bufsize,
 					 "TRANSFORM %u lang %u  (ID %d OID %u)",
diff --git a/src/include/catalog/Makefile b/src/include/catalog/Makefile
index bab57372b88..872f22b3910 100644
--- a/src/include/catalog/Makefile
+++ b/src/include/catalog/Makefile
@@ -75,6 +75,7 @@ CATALOG_HEADERS := \
 	pg_parameter_acl.h \
 	pg_partitioned_table.h \
 	pg_range.h \
+	pg_formatter.h \
 	pg_transform.h \
 	pg_sequence.h \
 	pg_publication.h \
diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h
index 635c0d9cb13..6402cc76c87 100644
--- a/src/include/catalog/catversion.h
+++ b/src/include/catalog/catversion.h
@@ -57,6 +57,6 @@
  */
 
 /*							yyyymmddN */
-#define CATALOG_VERSION_NO	202606281
+#define CATALOG_VERSION_NO	202606282
 
 #endif
diff --git a/src/include/catalog/meson.build b/src/include/catalog/meson.build
index fa836e4ee25..ea7fef5744d 100644
--- a/src/include/catalog/meson.build
+++ b/src/include/catalog/meson.build
@@ -62,6 +62,7 @@ catalog_headers = [
   'pg_parameter_acl.h',
   'pg_partitioned_table.h',
   'pg_range.h',
+  'pg_formatter.h',
   'pg_transform.h',
   'pg_sequence.h',
   'pg_publication.h',
diff --git a/src/include/catalog/pg_formatter.h b/src/include/catalog/pg_formatter.h
new file mode 100644
index 00000000000..e41a1de54ce
--- /dev/null
+++ b/src/include/catalog/pg_formatter.h
@@ -0,0 +1,62 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_formatter.h
+ *	  definition of the "formatter" system catalog (pg_formatter)
+ *
+ * A formatter registers a function that implements
+ * CAST(expr AS target FORMAT format_expr) for a particular
+ * (source type, target type) pair.  The formatter function receives the
+ * source value and the FORMAT expression (as text) and returns the target
+ * type.  This is intentionally separate from pg_cast: ordinary casts have
+ * implicit/assignment/explicit contexts and binary-coercible/WITH INOUT/
+ * WITHOUT FUNCTION methods, whereas a formatted cast is always explicit and
+ * always requires a function.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/catalog/pg_formatter.h
+ *
+ * NOTES
+ *	  The Catalog.pm module reads this file and derives schema
+ *	  information.
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_FORMATTER_H
+#define PG_FORMATTER_H
+
+#include "catalog/genbki.h"
+#include "catalog/pg_formatter_d.h"	/* IWYU pragma: export */
+
+/* ----------------
+ *		pg_formatter definition.  cpp turns this into
+ *		typedef struct FormData_pg_formatter
+ * ----------------
+ */
+BEGIN_CATALOG_STRUCT
+
+CATALOG(pg_formatter,8647,FormatterRelationId)
+{
+	Oid			oid;			/* oid */
+	Oid			fmtsource BKI_LOOKUP(pg_type);	/* source type */
+	Oid			fmttarget BKI_LOOKUP(pg_type);	/* target type */
+	Oid			fmtfunc BKI_LOOKUP(pg_proc);	/* formatter function */
+} FormData_pg_formatter;
+
+END_CATALOG_STRUCT
+
+/* ----------------
+ *		Form_pg_formatter corresponds to a pointer to a tuple with
+ *		the format of pg_formatter relation.
+ * ----------------
+ */
+typedef FormData_pg_formatter *Form_pg_formatter;
+
+DECLARE_UNIQUE_INDEX_PKEY(pg_formatter_oid_index, 8648, FormatterOidIndexId, pg_formatter, btree(oid oid_ops));
+DECLARE_UNIQUE_INDEX(pg_formatter_source_target_index, 8649, FormatterSourceTargetIndexId, pg_formatter, btree(fmtsource oid_ops, fmttarget oid_ops));
+
+MAKE_SYSCACHE(FORMATTEROID, pg_formatter_oid_index, 8);
+MAKE_SYSCACHE(FORMATTERSOURCETARGET, pg_formatter_source_target_index, 8);
+
+#endif							/* PG_FORMATTER_H */
diff --git a/src/include/commands/formatter.h b/src/include/commands/formatter.h
new file mode 100644
index 00000000000..c68a8745670
--- /dev/null
+++ b/src/include/commands/formatter.h
@@ -0,0 +1,24 @@
+/*-------------------------------------------------------------------------
+ *
+ * formatter.h
+ *	  prototypes for formattercmds.c.
+ *
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/commands/formatter.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef FORMATTER_H
+#define FORMATTER_H
+
+#include "catalog/objectaddress.h"
+#include "nodes/parsenodes.h"
+
+extern ObjectAddress CreateFormatter(CreateFormatterStmt *stmt);
+extern Oid	get_formatter_oid(Oid sourcetypeid, Oid targettypeid,
+							  bool missing_ok);
+
+#endif							/* FORMATTER_H */
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 759c6bfae54..0b0431f4b6a 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2445,6 +2445,7 @@ typedef enum ObjectType
 	OBJECT_FDW,
 	OBJECT_FOREIGN_SERVER,
 	OBJECT_FOREIGN_TABLE,
+	OBJECT_FORMATTER,
 	OBJECT_FUNCTION,
 	OBJECT_INDEX,
 	OBJECT_LANGUAGE,
@@ -4356,6 +4357,22 @@ typedef struct CreateTransformStmt
 	ObjectWithArgs *tosql;
 } CreateTransformStmt;
 
+/* ----------------------
+ *	CREATE FORMATTER Statement
+ *
+ * Registers a formatter function for CAST(... AS target FORMAT ...) on a
+ * (sourcetype, targettype) pair.  DROP FORMATTER reuses the generic
+ * DropStmt path with removeType == OBJECT_FORMATTER.
+ * ----------------------
+ */
+typedef struct CreateFormatterStmt
+{
+	NodeTag		type;
+	TypeName   *sourcetype;		/* source data type */
+	TypeName   *targettype;		/* target data type */
+	ObjectWithArgs *func;		/* formatter function */
+} CreateFormatterStmt;
+
 /* ----------------------
  *		PREPARE Statement
  * ----------------------
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index 51ead54f015..7ce539c79f9 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -183,6 +183,7 @@ PG_KEYWORD("for", FOR, RESERVED_KEYWORD, AS_LABEL)
 PG_KEYWORD("force", FORCE, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("foreign", FOREIGN, RESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("format", FORMAT, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("formatter", FORMATTER, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("forward", FORWARD, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("freeze", FREEZE, TYPE_FUNC_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("from", FROM, RESERVED_KEYWORD, AS_LABEL)
diff --git a/src/include/tcop/cmdtaglist.h b/src/include/tcop/cmdtaglist.h
index befae5f6b4f..c5701b4a8aa 100644
--- a/src/include/tcop/cmdtaglist.h
+++ b/src/include/tcop/cmdtaglist.h
@@ -95,6 +95,7 @@ PG_CMDTAG(CMDTAG_CREATE_EVENT_TRIGGER, "CREATE EVENT TRIGGER", false, false, fal
 PG_CMDTAG(CMDTAG_CREATE_EXTENSION, "CREATE EXTENSION", true, false, false)
 PG_CMDTAG(CMDTAG_CREATE_FOREIGN_DATA_WRAPPER, "CREATE FOREIGN DATA WRAPPER", true, false, false)
 PG_CMDTAG(CMDTAG_CREATE_FOREIGN_TABLE, "CREATE FOREIGN TABLE", true, false, false)
+PG_CMDTAG(CMDTAG_CREATE_FORMATTER, "CREATE FORMATTER", true, false, false)
 PG_CMDTAG(CMDTAG_CREATE_FUNCTION, "CREATE FUNCTION", true, false, false)
 PG_CMDTAG(CMDTAG_CREATE_INDEX, "CREATE INDEX", true, false, false)
 PG_CMDTAG(CMDTAG_CREATE_LANGUAGE, "CREATE LANGUAGE", true, false, false)
@@ -148,6 +149,7 @@ PG_CMDTAG(CMDTAG_DROP_EVENT_TRIGGER, "DROP EVENT TRIGGER", false, false, false)
 PG_CMDTAG(CMDTAG_DROP_EXTENSION, "DROP EXTENSION", true, false, false)
 PG_CMDTAG(CMDTAG_DROP_FOREIGN_DATA_WRAPPER, "DROP FOREIGN DATA WRAPPER", true, false, false)
 PG_CMDTAG(CMDTAG_DROP_FOREIGN_TABLE, "DROP FOREIGN TABLE", true, false, false)
+PG_CMDTAG(CMDTAG_DROP_FORMATTER, "DROP FORMATTER", true, false, false)
 PG_CMDTAG(CMDTAG_DROP_FUNCTION, "DROP FUNCTION", true, false, false)
 PG_CMDTAG(CMDTAG_DROP_INDEX, "DROP INDEX", true, false, false)
 PG_CMDTAG(CMDTAG_DROP_LANGUAGE, "DROP LANGUAGE", true, false, false)
diff --git a/src/test/regress/expected/formatters.out b/src/test/regress/expected/formatters.out
new file mode 100644
index 00000000000..bb1f091a1b8
--- /dev/null
+++ b/src/test/regress/expected/formatters.out
@@ -0,0 +1,116 @@
+--
+-- FORMATTERS
+--
+-- CREATE/DROP FORMATTER registers formatter metadata in pg_formatter,
+-- keyed by a (source type, target type) pair.  This is catalog and DDL
+-- infrastructure only; it does not transform or execute formatted casts.
+-- A simple formatter function with the required signature
+--   formatter(source_type, text) returns target_type
+CREATE FUNCTION int4_to_text_fmt(integer, text) RETURNS text
+	LANGUAGE sql IMMUTABLE RETURN $1::text || ':' || $2;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION int4_to_text_fmt(integer, text);
+-- It shows up in the catalog (use regtype/regprocedure, not raw OIDs)
+SELECT fmtsource::regtype, fmttarget::regtype, fmtfunc::regprocedure
+	FROM pg_formatter
+	WHERE fmtsource = 'integer'::regtype AND fmttarget = 'text'::regtype;
+ fmtsource | fmttarget |            fmtfunc             
+-----------+-----------+--------------------------------
+ integer   | text      | int4_to_text_fmt(integer,text)
+(1 row)
+
+-- ... and as a first-class object
+SELECT pg_describe_object('pg_formatter'::regclass, oid, 0)
+	FROM pg_formatter
+	WHERE fmtsource = 'integer'::regtype AND fmttarget = 'text'::regtype;
+           pg_describe_object            
+-----------------------------------------
+ formatter for cast from integer to text
+(1 row)
+
+-- Only one formatter per (source, target) pair
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION int4_to_text_fmt(integer, text);
+ERROR:  formatter for cast from type integer to type text already exists
+-- Function signature validation
+CREATE FUNCTION fmt_bad_nargs(integer) RETURNS text
+	LANGUAGE sql IMMUTABLE RETURN $1::text;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION fmt_bad_nargs(integer);			-- wrong # of arguments
+ERROR:  formatter function must take exactly two arguments
+CREATE FUNCTION fmt_bad_arg2(integer, integer) RETURNS text
+	LANGUAGE sql IMMUTABLE RETURN $1::text;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION fmt_bad_arg2(integer, integer);	-- second arg not text
+ERROR:  second argument of formatter function must be type text
+CREATE FUNCTION fmt_bad_arg1(bigint, text) RETURNS text
+	LANGUAGE sql IMMUTABLE RETURN $1::text;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION fmt_bad_arg1(bigint, text);		-- first arg mismatch
+ERROR:  first argument of formatter function must be type integer
+CREATE FUNCTION fmt_bad_ret(integer, text) RETURNS boolean
+	LANGUAGE sql IMMUTABLE RETURN true;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION fmt_bad_ret(integer, text);		-- return type mismatch
+ERROR:  return data type of formatter function must be type text
+CREATE FUNCTION fmt_bad_set(integer, text) RETURNS SETOF text
+	LANGUAGE sql IMMUTABLE AS $$ SELECT $1::text $$;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION fmt_bad_set(integer, text);		-- set-returning rejected
+ERROR:  formatter function must not return a set
+-- No pseudo-types
+CREATE FUNCTION fmt_anyel(anyelement, text) RETURNS text
+	LANGUAGE sql IMMUTABLE AS $$ SELECT $2 $$;
+CREATE FORMATTER FOR CAST (anyelement AS text)
+	WITH FUNCTION fmt_anyel(anyelement, text);
+ERROR:  source data type anyelement is a pseudo-type
+-- Registering a formatter does not enable a formatted cast: the FORMAT
+-- clause must not be silently ignored or rewritten to a built-in function,
+-- so CAST(... FORMAT ...) is rejected during parse analysis.
+SELECT CAST(5 AS text FORMAT 'YYYY');
+ERROR:  formatted casts are not implemented yet
+LINE 1: SELECT CAST(5 AS text FORMAT 'YYYY');
+                                     ^
+DETAIL:  No formatter resolution mechanism is available.
+-- Dependency behavior: the formatter depends on its function.
+DROP FUNCTION int4_to_text_fmt(integer, text);				-- fails (RESTRICT)
+ERROR:  cannot drop function int4_to_text_fmt(integer,text) because other objects depend on it
+DETAIL:  formatter for cast from integer to text depends on function int4_to_text_fmt(integer,text)
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+DROP FUNCTION int4_to_text_fmt(integer, text) CASCADE;		-- drops formatter
+NOTICE:  drop cascades to formatter for cast from integer to text
+SELECT count(*) FROM pg_formatter
+	WHERE fmtsource = 'integer'::regtype AND fmttarget = 'text'::regtype;
+ count 
+-------
+     0
+(1 row)
+
+-- Privileges: the creator must own the source or the target type.  (The
+-- ownership check happens before the function is looked up, so the function
+-- name here is irrelevant.)
+CREATE ROLE regress_formatter_user;
+SET ROLE regress_formatter_user;
+CREATE FORMATTER FOR CAST (text AS integer)
+	WITH FUNCTION pg_catalog.length(text);
+ERROR:  must be owner of type text or type integer
+RESET ROLE;
+DROP ROLE regress_formatter_user;
+-- DROP FORMATTER, including IF EXISTS
+CREATE FUNCTION int4_to_text_fmt(integer, text) RETURNS text
+	LANGUAGE sql IMMUTABLE RETURN $1::text || ':' || $2;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION int4_to_text_fmt(integer, text);
+DROP FORMATTER FOR CAST (integer AS text);
+DROP FORMATTER FOR CAST (integer AS text);					-- fails, gone
+ERROR:  formatter for cast from type integer to type text does not exist
+DROP FORMATTER IF EXISTS FOR CAST (integer AS text);		-- notice, no error
+NOTICE:  formatter for cast from type integer to type text does not exist, skipping
+-- Clean up
+DROP FUNCTION int4_to_text_fmt(integer, text);
+DROP FUNCTION fmt_bad_nargs(integer);
+DROP FUNCTION fmt_bad_arg2(integer, integer);
+DROP FUNCTION fmt_bad_arg1(bigint, text);
+DROP FUNCTION fmt_bad_ret(integer, text);
+DROP FUNCTION fmt_bad_set(integer, text);
+DROP FUNCTION fmt_anyel(anyelement, text);
diff --git a/src/test/regress/expected/oidjoins.out b/src/test/regress/expected/oidjoins.out
index d64169b7bf0..6b60b02aab3 100644
--- a/src/test/regress/expected/oidjoins.out
+++ b/src/test/regress/expected/oidjoins.out
@@ -257,6 +257,9 @@ NOTICE:  checking pg_range {rngmltconstruct1} => pg_proc {oid}
 NOTICE:  checking pg_range {rngmltconstruct2} => pg_proc {oid}
 NOTICE:  checking pg_range {rngcanonical} => pg_proc {oid}
 NOTICE:  checking pg_range {rngsubdiff} => pg_proc {oid}
+NOTICE:  checking pg_formatter {fmtsource} => pg_type {oid}
+NOTICE:  checking pg_formatter {fmttarget} => pg_type {oid}
+NOTICE:  checking pg_formatter {fmtfunc} => pg_proc {oid}
 NOTICE:  checking pg_transform {trftype} => pg_type {oid}
 NOTICE:  checking pg_transform {trflang} => pg_language {oid}
 NOTICE:  checking pg_transform {trffromsql} => pg_proc {oid}
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 8fa0a6c47fb..a0281f1fb2a 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -48,7 +48,7 @@ test: create_index create_index_spgist create_view index_including index_includi
 # ----------
 # Another group of parallel tests
 # ----------
-test: create_aggregate create_function_sql create_cast constraints triggers select inherit typed_table vacuum drop_if_exists updatable_views roleattributes create_am hash_func errors infinite_recurse create_property_graph for_portion_of
+test: create_aggregate create_function_sql create_cast formatters constraints triggers select inherit typed_table vacuum drop_if_exists updatable_views roleattributes create_am hash_func errors infinite_recurse create_property_graph for_portion_of
 
 # ----------
 # sanity_check does a vacuum, affecting the sort order of SELECT *
diff --git a/src/test/regress/sql/formatters.sql b/src/test/regress/sql/formatters.sql
new file mode 100644
index 00000000000..8050aee6f45
--- /dev/null
+++ b/src/test/regress/sql/formatters.sql
@@ -0,0 +1,99 @@
+--
+-- FORMATTERS
+--
+-- CREATE/DROP FORMATTER registers formatter metadata in pg_formatter,
+-- keyed by a (source type, target type) pair.  This is catalog and DDL
+-- infrastructure only; it does not transform or execute formatted casts.
+
+-- A simple formatter function with the required signature
+--   formatter(source_type, text) returns target_type
+CREATE FUNCTION int4_to_text_fmt(integer, text) RETURNS text
+	LANGUAGE sql IMMUTABLE RETURN $1::text || ':' || $2;
+
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION int4_to_text_fmt(integer, text);
+
+-- It shows up in the catalog (use regtype/regprocedure, not raw OIDs)
+SELECT fmtsource::regtype, fmttarget::regtype, fmtfunc::regprocedure
+	FROM pg_formatter
+	WHERE fmtsource = 'integer'::regtype AND fmttarget = 'text'::regtype;
+
+-- ... and as a first-class object
+SELECT pg_describe_object('pg_formatter'::regclass, oid, 0)
+	FROM pg_formatter
+	WHERE fmtsource = 'integer'::regtype AND fmttarget = 'text'::regtype;
+
+-- Only one formatter per (source, target) pair
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION int4_to_text_fmt(integer, text);
+
+-- Function signature validation
+CREATE FUNCTION fmt_bad_nargs(integer) RETURNS text
+	LANGUAGE sql IMMUTABLE RETURN $1::text;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION fmt_bad_nargs(integer);			-- wrong # of arguments
+
+CREATE FUNCTION fmt_bad_arg2(integer, integer) RETURNS text
+	LANGUAGE sql IMMUTABLE RETURN $1::text;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION fmt_bad_arg2(integer, integer);	-- second arg not text
+
+CREATE FUNCTION fmt_bad_arg1(bigint, text) RETURNS text
+	LANGUAGE sql IMMUTABLE RETURN $1::text;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION fmt_bad_arg1(bigint, text);		-- first arg mismatch
+
+CREATE FUNCTION fmt_bad_ret(integer, text) RETURNS boolean
+	LANGUAGE sql IMMUTABLE RETURN true;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION fmt_bad_ret(integer, text);		-- return type mismatch
+
+CREATE FUNCTION fmt_bad_set(integer, text) RETURNS SETOF text
+	LANGUAGE sql IMMUTABLE AS $$ SELECT $1::text $$;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION fmt_bad_set(integer, text);		-- set-returning rejected
+
+-- No pseudo-types
+CREATE FUNCTION fmt_anyel(anyelement, text) RETURNS text
+	LANGUAGE sql IMMUTABLE AS $$ SELECT $2 $$;
+CREATE FORMATTER FOR CAST (anyelement AS text)
+	WITH FUNCTION fmt_anyel(anyelement, text);
+
+-- Registering a formatter does not enable a formatted cast: the FORMAT
+-- clause must not be silently ignored or rewritten to a built-in function,
+-- so CAST(... FORMAT ...) is rejected during parse analysis.
+SELECT CAST(5 AS text FORMAT 'YYYY');
+
+-- Dependency behavior: the formatter depends on its function.
+DROP FUNCTION int4_to_text_fmt(integer, text);				-- fails (RESTRICT)
+DROP FUNCTION int4_to_text_fmt(integer, text) CASCADE;		-- drops formatter
+SELECT count(*) FROM pg_formatter
+	WHERE fmtsource = 'integer'::regtype AND fmttarget = 'text'::regtype;
+
+-- Privileges: the creator must own the source or the target type.  (The
+-- ownership check happens before the function is looked up, so the function
+-- name here is irrelevant.)
+CREATE ROLE regress_formatter_user;
+SET ROLE regress_formatter_user;
+CREATE FORMATTER FOR CAST (text AS integer)
+	WITH FUNCTION pg_catalog.length(text);
+RESET ROLE;
+DROP ROLE regress_formatter_user;
+
+-- DROP FORMATTER, including IF EXISTS
+CREATE FUNCTION int4_to_text_fmt(integer, text) RETURNS text
+	LANGUAGE sql IMMUTABLE RETURN $1::text || ':' || $2;
+CREATE FORMATTER FOR CAST (integer AS text)
+	WITH FUNCTION int4_to_text_fmt(integer, text);
+DROP FORMATTER FOR CAST (integer AS text);
+DROP FORMATTER FOR CAST (integer AS text);					-- fails, gone
+DROP FORMATTER IF EXISTS FOR CAST (integer AS text);		-- notice, no error
+
+-- Clean up
+DROP FUNCTION int4_to_text_fmt(integer, text);
+DROP FUNCTION fmt_bad_nargs(integer);
+DROP FUNCTION fmt_bad_arg2(integer, integer);
+DROP FUNCTION fmt_bad_arg1(bigint, text);
+DROP FUNCTION fmt_bad_ret(integer, text);
+DROP FUNCTION fmt_bad_set(integer, text);
+DROP FUNCTION fmt_anyel(anyelement, text);
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index c5db6ca6705..2feea74b3a5 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -575,6 +575,7 @@ CreateExtensionStmt
 CreateFdwStmt
 CreateForeignServerStmt
 CreateForeignTableStmt
+CreateFormatterStmt
 CreateFunctionStmt
 CreateOpClassItem
 CreateOpClassStmt
@@ -921,6 +922,7 @@ FormData_pg_extension
 FormData_pg_foreign_data_wrapper
 FormData_pg_foreign_server
 FormData_pg_foreign_table
+FormData_pg_formatter
 FormData_pg_index
 FormData_pg_inherits
 FormData_pg_language
@@ -986,6 +988,7 @@ Form_pg_extension
 Form_pg_foreign_data_wrapper
 Form_pg_foreign_server
 Form_pg_foreign_table
+Form_pg_formatter
 Form_pg_index
 Form_pg_inherits
 Form_pg_language
@@ -1028,6 +1031,7 @@ Form_pg_ts_template
 Form_pg_type
 Form_pg_user_mapping
 FormatNode
+FormatterInfo
 FreeBlockNumberArray
 FreeListData
 FreePageBtree
-- 
2.54.0

