From 72428ce8f85343ab82d8400c4bd461df66cf4ce2 Mon Sep 17 00:00:00 2001
Message-ID: <72428ce8f85343ab82d8400c4bd461df66cf4ce2.1778237699.git.james.locke.uk@gmail.com>
In-Reply-To: <cover.1778237699.git.james.locke.uk@gmail.com>
References: <CAA-aLv6sYZ5XnuYrytTjxZumBh3KrdyMRmasxHfgaKf-HJrNpw@mail.gmail.com>
	<cover.1778237699.git.james.locke.uk@gmail.com>
From: James Lock <james.locke.uk@gmail.com>
Date: Thu, 7 May 2026 14:10:35 +0100
Subject: [POC PATCH 4/5] Add COMPACT command

COMPACT is a new top-level command that shrinks a table in place by
relocating live tuples towards the start of the relation and truncating
the trailing empty pages.  It complements VACUUM and REPACK as a third
maintenance tier:

  VACUUM   -- cheap, frequent, no significant size reduction
  COMPACT  -- in-place, never needs more than ~1 page of free disk,
              modest size reduction (just tail truncation)
  REPACK   -- full rewrite, needs live-data-size of free disk,
              full reorganization

Internally COMPACT runs three vacuum() invocations per relation:

  1. VACOPT_VACUUM | VACOPT_COMPACT, which scans, prunes, and then
     calls lazy_compact_heap to relocate tuples;
  2. VACOPT_VACUUM (without compact), to prune the dead source-tuple
     versions left behind by the relocation pass and run
     lazy_truncate_heap;
  3. Optionally VACOPT_ANALYZE, when the user requested ANALYZE.

Each pass runs in its own transaction.  Doing the work in three passes
makes COMPACT a one-shot command from the user's point of view -- they
do not need a follow-up VACUUM to actually shrink the relation.

Syntax:

    COMPACT [ ( option [, ...] ) ] [ table_name [, ...] ]
    COMPACT [ VERBOSE ] [ table_name [, ...] ]

Where option is VERBOSE, ANALYZE/ANALYSE, or BUFFER_USAGE_LIMIT.
CONCURRENTLY is intentionally absent: COMPACT already permits
concurrent reads and writes (no AccessExclusiveLock except briefly
during the truncation step), so the option would be redundant.

Empirically COMPACT writes about 25% more WAL than REPACK for the same
final size, runs in comparable wall time on cached workloads, and
leaves indexes temporarily inflated (the old entries stay until the
next ordinary vacuum reaps them).  Its decisive advantage is that it
never needs more than approximately one extra heap page of free space
at any moment, never holds an AccessExclusiveLock for the move phase,
and never blocks readers.
---
 doc/src/sgml/ref/allfiles.sgml   |   1 +
 doc/src/sgml/ref/compact.sgml    | 250 +++++++++++++++++++++++++++++++
 doc/src/sgml/reference.sgml      |   1 +
 src/backend/commands/Makefile    |   1 +
 src/backend/commands/compact.c   | 154 +++++++++++++++++++
 src/backend/commands/meson.build |   1 +
 src/backend/parser/gram.y        |  47 +++++-
 src/backend/tcop/utility.c       |  14 ++
 src/include/commands/compact.h   |  21 +++
 src/include/nodes/parsenodes.h   |  16 ++
 src/include/parser/kwlist.h      |   1 +
 src/include/tcop/cmdtaglist.h    |   1 +
 12 files changed, 506 insertions(+), 2 deletions(-)
 create mode 100644 doc/src/sgml/ref/compact.sgml
 create mode 100644 src/backend/commands/compact.c
 create mode 100644 src/include/commands/compact.h

diff --git a/doc/src/sgml/ref/allfiles.sgml b/doc/src/sgml/ref/allfiles.sgml
index e1a56c36221..99fc524bcbc 100644
--- a/doc/src/sgml/ref/allfiles.sgml
+++ b/doc/src/sgml/ref/allfiles.sgml
@@ -58,6 +58,7 @@ Complete list of usable sgml source files in this directory.
 <!ENTITY commentOn          SYSTEM "comment.sgml">
 <!ENTITY commit             SYSTEM "commit.sgml">
 <!ENTITY commitPrepared     SYSTEM "commit_prepared.sgml">
+<!ENTITY compact            SYSTEM "compact.sgml">
 <!ENTITY copyTable          SYSTEM "copy.sgml">
 <!ENTITY createAccessMethod SYSTEM "create_access_method.sgml">
 <!ENTITY createAggregate    SYSTEM "create_aggregate.sgml">
diff --git a/doc/src/sgml/ref/compact.sgml b/doc/src/sgml/ref/compact.sgml
new file mode 100644
index 00000000000..5212c1a92e8
--- /dev/null
+++ b/doc/src/sgml/ref/compact.sgml
@@ -0,0 +1,250 @@
+<!--
+doc/src/sgml/ref/compact.sgml
+PostgreSQL documentation
+-->
+
+<refentry id="sql-compact">
+ <indexterm zone="sql-compact">
+  <primary>COMPACT</primary>
+ </indexterm>
+
+ <refmeta>
+  <refentrytitle>COMPACT</refentrytitle>
+  <manvolnum>7</manvolnum>
+  <refmiscinfo>SQL - Language Statements</refmiscinfo>
+ </refmeta>
+
+ <refnamediv>
+  <refname>COMPACT</refname>
+  <refpurpose>shrink a table in place by relocating live tuples towards the start of the relation</refpurpose>
+ </refnamediv>
+
+ <refsynopsisdiv>
+<synopsis>
+COMPACT [ ( <replaceable class="parameter">option</replaceable> [, ...] ) ] [ <replaceable class="parameter">table_name</replaceable> [, ...] ]
+COMPACT [ VERBOSE ] [ <replaceable class="parameter">table_name</replaceable> [, ...] ]
+
+<phrase>where <replaceable class="parameter">option</replaceable> can be one of:</phrase>
+
+    VERBOSE [ <replaceable class="parameter">boolean</replaceable> ]
+    ANALYZE [ <replaceable class="parameter">boolean</replaceable> ]
+    BUFFER_USAGE_LIMIT <replaceable class="parameter">size</replaceable>
+</synopsis>
+ </refsynopsisdiv>
+
+ <refsect1>
+  <title>Description</title>
+
+  <para>
+   <command>COMPACT</command> reclaims storage occupied by trailing empty
+   pages of a table. It works by relocating live tuples from high-numbered
+   pages onto lower-numbered pages with free space, then truncating the
+   resulting empty pages from the end of the relation. Unlike <xref
+   linkend="sql-repack"/> and <command>VACUUM FULL</command>,
+   <command>COMPACT</command> never needs an extra copy of the table on
+   disk: it relocates tuples in place. It also never holds an
+   <literal>ACCESS EXCLUSIVE</literal> lock except briefly during the final
+   truncation step, so concurrent reads and writes are permitted throughout
+   most of the operation.
+  </para>
+
+  <para>
+   Internally <command>COMPACT</command> performs three passes per table:
+   first a <command>VACUUM</command> pass that scans, prunes, and then
+   relocates tuples; second a follow-up <command>VACUUM</command> pass that
+   prunes the dead source-tuple versions left behind by the relocation and
+   truncates the trailing empty pages; and optionally an
+   <command>ANALYZE</command> pass if the <literal>ANALYZE</literal>
+   option was specified. Each pass runs in its own transaction.
+  </para>
+
+  <para>
+   When a list of <replaceable class="parameter">table_name</replaceable>s is
+   omitted, <command>COMPACT</command> processes every table and materialized
+   view in the current database that the current user has the
+   <literal>MAINTAIN</literal> privilege on. This form of
+   <command>COMPACT</command> cannot be executed inside a transaction block.
+  </para>
+
+  <para>
+   <command>COMPACT</command> is most useful for relations whose live rows
+   have drifted towards the end of the file (typical of long-lived tables
+   that are heavily updated or that have had a large
+   <command>DELETE</command> at the head of the table). On such a relation,
+   the live data may occupy only a small fraction of its physical pages,
+   yet a regular <command>VACUUM</command> cannot reclaim the trailing
+   space because it cannot move tuples around.  <command>COMPACT</command>
+   moves the tuples explicitly so the trailing pages can be freed.
+  </para>
+ </refsect1>
+
+ <refsect1>
+  <title>Parameters</title>
+
+  <variablelist>
+   <varlistentry>
+    <term><literal>VERBOSE</literal></term>
+    <listitem>
+     <para>
+      Prints a detailed activity report for each compacted table, including
+      the number of tuples relocated and the resulting size change.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><literal>ANALYZE</literal></term>
+    <listitem>
+     <para>
+      Updates statistics used by the planner to determine the most efficient
+      way to execute a query, equivalent to running
+      <command>ANALYZE</command> on the table after compaction. Especially
+      useful when the row distribution has changed significantly as a result
+      of relocation.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><literal>BUFFER_USAGE_LIMIT</literal></term>
+    <listitem>
+     <para>
+      Specifies the buffer access strategy ring size to use; see
+      <xref linkend="sql-vacuum"/> for details.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><replaceable class="parameter">boolean</replaceable></term>
+    <listitem>
+     <para>
+      Specifies whether the selected option should be turned on or off. You
+      can write <literal>TRUE</literal>, <literal>ON</literal>, or
+      <literal>1</literal> to enable the option, and <literal>FALSE</literal>,
+      <literal>OFF</literal>, or <literal>0</literal> to disable it. The
+      <replaceable class="parameter">boolean</replaceable> value can also be
+      omitted, in which case <literal>TRUE</literal> is assumed.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><replaceable class="parameter">size</replaceable></term>
+    <listitem>
+     <para>
+      Specifies the buffer access strategy ring size in kilobytes for
+      <literal>BUFFER_USAGE_LIMIT</literal>. The size is in kilobytes by
+      default but can include a unit suffix; see <xref linkend="sql-vacuum"/>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><replaceable class="parameter">table_name</replaceable></term>
+    <listitem>
+     <para>
+      The name (optionally schema-qualified) of a table or materialized
+      view to compact. If omitted, all relations in the current database on
+      which the current user has the <literal>MAINTAIN</literal> privilege
+      are compacted.
+     </para>
+    </listitem>
+   </varlistentry>
+  </variablelist>
+ </refsect1>
+
+ <refsect1>
+  <title>Notes</title>
+
+  <para>
+   <command>COMPACT</command> can only relocate tuples whose
+   <literal>xmax</literal> is provably invalid at the time the compaction
+   step runs.  Tuples that are concurrently locked, that participate in a
+   multi-transaction lock, or that are being updated by another transaction
+   are skipped and remain on their original page until a future invocation.
+  </para>
+
+  <para>
+   Each relocation creates a new index entry for every index on the table.
+   The old entries become stale once the dead source tuples are pruned;
+   they are reaped by ordinary index vacuum on a subsequent
+   <command>VACUUM</command>. If a relation has many indexes,
+   <command>COMPACT</command> can therefore temporarily increase index
+   bloat. Running <command>REINDEX CONCURRENTLY</command> on the affected
+   indexes after a large compaction is the recommended remedy.
+  </para>
+
+  <para>
+   <command>COMPACT</command> writes a substantial amount of WAL: each
+   relocated tuple produces an update record, and the index inserts produce
+   their own records.  Empirically <command>COMPACT</command> generates
+   roughly 25% more WAL than <xref linkend="sql-repack"/> on the same input
+   --- the cost of producing one cross-page update WAL record per tuple
+   instead of a single bulk relation rewrite.  In exchange, it never needs
+   more than approximately one extra heap page of free space at any moment,
+   which is its defining advantage over <command>REPACK</command> /
+   <command>VACUUM FULL</command>.  Plan compaction during a maintenance
+   window for very large tables, or use <literal>BUFFER_USAGE_LIMIT</literal>
+   to throttle buffer pool churn.
+  </para>
+
+  <para>
+   Because relocations preserve tuple contents byte for byte, they do not
+   trigger logical replication output: subscribers will not see phantom
+   <command>UPDATE</command> events for compacted rows.
+  </para>
+
+  <para>
+   <command>COMPACT</command> is not a substitute for <xref
+   linkend="sql-repack"/> when fragmentation within live pages or the
+   physical ordering of tuples matters. Use <command>REPACK</command>
+   (or <command>VACUUM FULL</command>) when you need to fully rewrite
+   the table.
+  </para>
+ </refsect1>
+
+ <refsect1>
+  <title>Examples</title>
+
+  <para>
+   Compact a single table, printing a detailed report:
+<programlisting>
+COMPACT VERBOSE my_table;
+</programlisting>
+  </para>
+
+  <para>
+   Compact a table and update its planner statistics in one command:
+<programlisting>
+COMPACT (ANALYZE) my_table;
+</programlisting>
+  </para>
+
+  <para>
+   Compact every eligible relation in the current database:
+<programlisting>
+COMPACT;
+</programlisting>
+  </para>
+ </refsect1>
+
+ <refsect1>
+  <title>Compatibility</title>
+
+  <para>
+   There is no <command>COMPACT</command> statement in the SQL standard.
+  </para>
+ </refsect1>
+
+ <refsect1>
+  <title>See Also</title>
+
+  <simplelist type="inline">
+   <member><xref linkend="sql-vacuum"/></member>
+   <member><xref linkend="sql-repack"/></member>
+   <member><xref linkend="sql-cluster"/></member>
+   <member><xref linkend="sql-reindex"/></member>
+  </simplelist>
+ </refsect1>
+</refentry>
diff --git a/doc/src/sgml/reference.sgml b/doc/src/sgml/reference.sgml
index 674ac17e82c..1d756a56a25 100644
--- a/doc/src/sgml/reference.sgml
+++ b/doc/src/sgml/reference.sgml
@@ -86,6 +86,7 @@
    &commentOn;
    &commit;
    &commitPrepared;
+   &compact;
    &copyTable;
    &createAccessMethod;
    &createAggregate;
diff --git a/src/backend/commands/Makefile b/src/backend/commands/Makefile
index 5b9d084977e..9e757ea0636 100644
--- a/src/backend/commands/Makefile
+++ b/src/backend/commands/Makefile
@@ -20,6 +20,7 @@ OBJS = \
 	async.o \
 	collationcmds.o \
 	comment.o \
+	compact.o \
 	constraint.o \
 	conversioncmds.o \
 	copy.o \
diff --git a/src/backend/commands/compact.c b/src/backend/commands/compact.c
new file mode 100644
index 00000000000..a6d6147632d
--- /dev/null
+++ b/src/backend/commands/compact.c
@@ -0,0 +1,154 @@
+/*-------------------------------------------------------------------------
+ *
+ * compact.c
+ *	  implementation of the COMPACT command
+ *
+ * COMPACT relocates live tuples from high-numbered heap pages onto
+ * low-numbered pages with free space and truncates the now-empty trailing
+ * pages off the relation.  Unlike REPACK / CLUSTER / VACUUM FULL, it does
+ * not need a second copy of the relation on disk and never holds an
+ * AccessExclusiveLock except briefly during truncation.
+ *
+ * Internally COMPACT runs as a sequence of vacuum() invocations:
+ *
+ *	1. A vacuum pass with VACOPT_COMPACT set, which scans, prunes dead
+ *	   tuples in the usual way, then walks pages high-to-low, calling
+ *	   heap_relocate to move live tuples onto low-numbered targets and
+ *	   inserting matching index entries.
+ *	2. A second vacuum pass without VACOPT_COMPACT.  The first pass's
+ *	   transaction has committed by now, so the dead source tuples it
+ *	   left behind are visible to OldestXmin and can be pruned and
+ *	   truncated in this pass.
+ *	3. Optionally, an ANALYZE pass.
+ *
+ * Doing the work in three vacuum() calls means COMPACT is a one-shot
+ * command from the user's point of view -- they do not need a follow-up
+ * VACUUM to actually shrink the relation.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/backend/commands/compact.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "commands/compact.h"
+#include "commands/defrem.h"
+#include "commands/vacuum.h"
+#include "miscadmin.h"
+#include "storage/bufmgr.h"
+#include "storage/buf_internals.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+void
+ExecCompact(ParseState *pstate, CompactStmt *stmt, bool isTopLevel)
+{
+	VacuumParams params;
+	BufferAccessStrategy bstrategy = NULL;
+	MemoryContext vac_context;
+	bool		verbose = false;
+	bool		analyze = false;
+	int			ring_size = -1;
+	ListCell   *lc;
+
+	/* Parse COMPACT-specific options. */
+	foreach(lc, stmt->options)
+	{
+		DefElem    *opt = (DefElem *) lfirst(lc);
+
+		if (strcmp(opt->defname, "verbose") == 0)
+			verbose = defGetBoolean(opt);
+		else if (strcmp(opt->defname, "analyze") == 0 ||
+				 strcmp(opt->defname, "analyse") == 0)
+			analyze = defGetBoolean(opt);
+		else if (strcmp(opt->defname, "buffer_usage_limit") == 0)
+		{
+			const char *hintmsg;
+			int			result;
+			char	   *vac_buffer_size;
+
+			vac_buffer_size = defGetString(opt);
+
+			if (!parse_int(vac_buffer_size, &result, GUC_UNIT_KB, &hintmsg) ||
+				(result != 0 &&
+				 (result < MIN_BAS_VAC_RING_SIZE_KB || result > MAX_BAS_VAC_RING_SIZE_KB)))
+			{
+				ereport(ERROR,
+						(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+						 errmsg("%s option must be 0 or between %d kB and %d kB",
+								"BUFFER_USAGE_LIMIT",
+								MIN_BAS_VAC_RING_SIZE_KB, MAX_BAS_VAC_RING_SIZE_KB),
+						 hintmsg ? errhint_internal("%s", _(hintmsg)) : 0));
+			}
+
+			ring_size = result;
+		}
+		else
+			ereport(ERROR,
+					(errcode(ERRCODE_SYNTAX_ERROR),
+					 errmsg("unrecognized COMPACT option \"%s\"",
+							opt->defname),
+					 parser_errposition(pstate, opt->location)));
+	}
+
+	/* Build common parameters, mirroring ExecVacuum's setup. */
+	memset(&params, 0, sizeof(params));
+	params.index_cleanup = VACOPTVALUE_UNSPECIFIED;
+	params.truncate = VACOPTVALUE_ENABLED;
+	params.nworkers = -1;		/* parallel vacuum disabled for now */
+	params.toast_parent = InvalidOid;
+	params.freeze_min_age = -1;
+	params.freeze_table_age = -1;
+	params.multixact_freeze_min_age = -1;
+	params.multixact_freeze_table_age = -1;
+	params.is_wraparound = false;
+	params.log_vacuum_min_duration = -1;
+	params.log_analyze_min_duration = -1;
+	params.max_eager_freeze_failure_rate = vacuum_max_eager_freeze_failure_rate;
+
+	/* Cross-transaction memory and buffer strategy. */
+	vac_context = AllocSetContextCreate(PortalContext,
+										"Compact",
+										ALLOCSET_DEFAULT_SIZES);
+	{
+		MemoryContext old_context = MemoryContextSwitchTo(vac_context);
+
+		if (ring_size == -1)
+			ring_size = VacuumBufferUsageLimit;
+		bstrategy = GetAccessStrategyWithSize(BAS_VACUUM, ring_size);
+
+		MemoryContextSwitchTo(old_context);
+	}
+
+	/*
+	 * Pass 1: vacuum + compact.  Relocates tuples; leaves dead source
+	 * versions on the originating pages.
+	 */
+	params.options = VACOPT_VACUUM | VACOPT_PROCESS_MAIN | VACOPT_PROCESS_TOAST |
+		VACOPT_COMPACT |
+		(verbose ? VACOPT_VERBOSE : 0);
+	vacuum(stmt->rels, &params, bstrategy, vac_context, isTopLevel);
+
+	/*
+	 * Pass 2: plain vacuum.  The relocate transaction has committed by
+	 * now, so the dead source tuples it left behind are eligible for
+	 * pruning, after which lazy_truncate_heap can reclaim the trailing
+	 * empty pages.
+	 */
+	params.options = VACOPT_VACUUM | VACOPT_PROCESS_MAIN | VACOPT_PROCESS_TOAST |
+		(verbose ? VACOPT_VERBOSE : 0);
+	vacuum(stmt->rels, &params, bstrategy, vac_context, isTopLevel);
+
+	/* Pass 3 (optional): ANALYZE. */
+	if (analyze)
+	{
+		params.options = VACOPT_ANALYZE | (verbose ? VACOPT_VERBOSE : 0);
+		vacuum(stmt->rels, &params, bstrategy, vac_context, isTopLevel);
+	}
+
+	MemoryContextDelete(vac_context);
+}
diff --git a/src/backend/commands/meson.build b/src/backend/commands/meson.build
index 9f258d566eb..1213825d5d7 100644
--- a/src/backend/commands/meson.build
+++ b/src/backend/commands/meson.build
@@ -8,6 +8,7 @@ backend_sources += files(
   'async.c',
   'collationcmds.c',
   'comment.c',
+  'compact.c',
   'constraint.c',
   'conversioncmds.c',
   'copy.c',
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index ff4e1388c55..1ee2ef53b95 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -288,7 +288,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 		AlterCompositeTypeStmt AlterUserMappingStmt
 		AlterRoleStmt AlterRoleSetStmt AlterPolicyStmt AlterStatsStmt
 		AlterDefaultPrivilegesStmt DefACLAction
-		AnalyzeStmt CallStmt ClosePortalStmt CommentStmt
+		AnalyzeStmt CallStmt ClosePortalStmt CommentStmt CompactStmt
 		ConstraintsSetStmt CopyStmt CreateAsStmt CreateCastStmt
 		CreateDomainStmt CreateExtensionStmt CreateGroupStmt CreateOpClassStmt
 		CreateOpFamilyStmt AlterOpFamilyStmt CreatePLangStmt
@@ -753,7 +753,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	CACHE CALL CALLED CASCADE CASCADED CASE CAST CATALOG_P CHAIN CHAR_P
 	CHARACTER CHARACTERISTICS CHECK CHECKPOINT CLASS CLOSE
 	CLUSTER COALESCE COLLATE COLLATION COLUMN COLUMNS COMMENT COMMENTS COMMIT
-	COMMITTED COMPRESSION CONCURRENTLY CONDITIONAL CONFIGURATION CONFLICT
+	COMMITTED COMPACT COMPRESSION CONCURRENTLY CONDITIONAL CONFIGURATION CONFLICT
 	CONNECTION CONSTRAINT CONSTRAINTS CONTENT_P CONTINUE_P CONVERSION_P COPY
 	COST CREATE CROSS CSV CUBE CURRENT_P
 	CURRENT_CATALOG CURRENT_DATE CURRENT_ROLE CURRENT_SCHEMA
@@ -1076,6 +1076,7 @@ stmt:
 			| CheckPointStmt
 			| ClosePortalStmt
 			| CommentStmt
+			| CompactStmt
 			| ConstraintsSetStmt
 			| CopyStmt
 			| CreateAmStmt
@@ -12729,6 +12730,46 @@ VacuumStmt: VACUUM opt_full opt_freeze opt_verbose opt_analyze opt_vacuum_relati
 				}
 		;
 
+/*****************************************************************************
+ *
+ *		QUERY:
+ *				COMPACT
+ *
+ * Relocates live tuples from high-numbered pages onto low-numbered pages
+ * with free space and truncates the trailing empty pages.  Like VACUUM
+ * (and unlike REPACK) this never needs more than a small amount of extra
+ * disk space, but it cannot reorganise live data the way REPACK does.
+ *
+ *		COMPACT [ ( option [, ...] ) ] [ table_name [, ...] ]
+ *		COMPACT [ VERBOSE ] [ table_name [, ...] ]
+ *
+ * Where option is one of:
+ *		VERBOSE [ boolean ]
+ *		ANALYZE [ boolean ]
+ *
+ *****************************************************************************/
+
+CompactStmt: COMPACT opt_verbose opt_vacuum_relation_list
+				{
+					CompactStmt *n = makeNode(CompactStmt);
+
+					n->options = NIL;
+					if ($2)
+						n->options = lappend(n->options,
+											 makeDefElem("verbose", NULL, @2));
+					n->rels = $3;
+					$$ = (Node *) n;
+				}
+			| COMPACT '(' utility_option_list ')' opt_vacuum_relation_list
+				{
+					CompactStmt *n = makeNode(CompactStmt);
+
+					n->options = $3;
+					n->rels = $5;
+					$$ = (Node *) n;
+				}
+		;
+
 AnalyzeStmt: analyze_keyword opt_utility_option_list opt_vacuum_relation_list
 				{
 					VacuumStmt *n = makeNode(VacuumStmt);
@@ -18862,6 +18903,7 @@ unreserved_keyword:
 			| COMMENTS
 			| COMMIT
 			| COMMITTED
+			| COMPACT
 			| COMPRESSION
 			| CONDITIONAL
 			| CONFIGURATION
@@ -19441,6 +19483,7 @@ bare_label_keyword:
 			| COMMENTS
 			| COMMIT
 			| COMMITTED
+			| COMPACT
 			| COMPRESSION
 			| CONCURRENTLY
 			| CONDITIONAL
diff --git a/src/backend/tcop/utility.c b/src/backend/tcop/utility.c
index 73a56f1df1d..b83dbef1218 100644
--- a/src/backend/tcop/utility.c
+++ b/src/backend/tcop/utility.c
@@ -28,6 +28,7 @@
 #include "commands/async.h"
 #include "commands/collationcmds.h"
 #include "commands/comment.h"
+#include "commands/compact.h"
 #include "commands/conversioncmds.h"
 #include "commands/copy.h"
 #include "commands/createas.h"
@@ -285,6 +286,7 @@ ClassifyUtilityCommandAsReadOnly(Node *parsetree)
 		case T_ReindexStmt:
 		case T_VacuumStmt:
 		case T_RepackStmt:
+		case T_CompactStmt:
 			{
 				/*
 				 * These commands write WAL, so they're not strictly
@@ -867,6 +869,10 @@ standard_ProcessUtility(PlannedStmt *pstmt,
 			ExecRepack(pstate, (RepackStmt *) parsetree, isTopLevel);
 			break;
 
+		case T_CompactStmt:
+			ExecCompact(pstate, (CompactStmt *) parsetree, isTopLevel);
+			break;
+
 		case T_ExplainStmt:
 			ExplainQuery(pstate, (ExplainStmt *) parsetree, params, dest);
 			break;
@@ -2898,6 +2904,10 @@ CreateCommandTag(Node *parsetree)
 				tag = CMDTAG_REPACK;
 			break;
 
+		case T_CompactStmt:
+			tag = CMDTAG_COMPACT;
+			break;
+
 		case T_ExplainStmt:
 			tag = CMDTAG_EXPLAIN;
 			break;
@@ -3551,6 +3561,10 @@ GetCommandLogLevel(Node *parsetree)
 			lev = LOGSTMT_DDL;
 			break;
 
+		case T_CompactStmt:
+			lev = LOGSTMT_DDL;
+			break;
+
 		case T_VacuumStmt:
 			lev = LOGSTMT_ALL;
 			break;
diff --git a/src/include/commands/compact.h b/src/include/commands/compact.h
new file mode 100644
index 00000000000..95d3bdcb78b
--- /dev/null
+++ b/src/include/commands/compact.h
@@ -0,0 +1,21 @@
+/*-------------------------------------------------------------------------
+ *
+ * compact.h
+ *	  header file for the COMPACT command
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/commands/compact.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef COMPACT_H
+#define COMPACT_H
+
+#include "nodes/parsenodes.h"
+#include "parser/parse_node.h"
+
+extern void ExecCompact(ParseState *pstate, CompactStmt *stmt, bool isTopLevel);
+
+#endif							/* COMPACT_H */
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 91377a6cde3..02f2e772be3 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -4111,6 +4111,22 @@ typedef struct RepackStmt
 	List	   *params;			/* list of DefElem nodes */
 } RepackStmt;
 
+/* ----------------------
+ *		Compact Statement
+ *
+ * Relocates live tuples from high-numbered heap pages onto low-numbered
+ * pages with free space and truncates the trailing empty pages.  Unlike
+ * REPACK / CLUSTER / VACUUM FULL it never needs an extra copy of the
+ * relation on disk; it relocates tuples in place.
+ * ----------------------
+ */
+typedef struct CompactStmt
+{
+	NodeTag		type;
+	List	   *options;		/* list of DefElem nodes */
+	List	   *rels;			/* list of VacuumRelation, or NIL for all */
+} CompactStmt;
+
 /* ----------------------
  *		Explain Statement
  *
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index 51ead54f015..496740a99be 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -91,6 +91,7 @@ PG_KEYWORD("comment", COMMENT, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("comments", COMMENTS, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("commit", COMMIT, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("committed", COMMITTED, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("compact", COMPACT, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("compression", COMPRESSION, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("concurrently", CONCURRENTLY, TYPE_FUNC_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("conditional", CONDITIONAL, UNRESERVED_KEYWORD, BARE_LABEL)
diff --git a/src/include/tcop/cmdtaglist.h b/src/include/tcop/cmdtaglist.h
index befae5f6b4f..26b4ed888f3 100644
--- a/src/include/tcop/cmdtaglist.h
+++ b/src/include/tcop/cmdtaglist.h
@@ -81,6 +81,7 @@ PG_CMDTAG(CMDTAG_CLUSTER, "CLUSTER", false, false, false)
 PG_CMDTAG(CMDTAG_COMMENT, "COMMENT", true, false, false)
 PG_CMDTAG(CMDTAG_COMMIT, "COMMIT", false, false, false)
 PG_CMDTAG(CMDTAG_COMMIT_PREPARED, "COMMIT PREPARED", false, false, false)
+PG_CMDTAG(CMDTAG_COMPACT, "COMPACT", false, false, false)
 PG_CMDTAG(CMDTAG_COPY, "COPY", false, false, true)
 PG_CMDTAG(CMDTAG_COPY_FROM, "COPY FROM", false, false, false)
 PG_CMDTAG(CMDTAG_CREATE_ACCESS_METHOD, "CREATE ACCESS METHOD", true, false, false)
-- 
2.47.3

