From 989601d40a8a693631ae5194dfe59a95c98eb1b9 Mon Sep 17 00:00:00 2001
From: Gregory Burd <gregburd@amazon.com>
Date: Mon, 27 Jan 2025 13:28:59 -0500
Subject: [PATCH v15] Expand HOT update path to include expression and partial
 indexes.

This patch extends the cases where HOT updates are possible in the heapam by
examining expression indexes and determining if indexed values where mutated
or not.  Previously, any expression index on a column would disqualify it
from the HOT update path. Also examines partial indexes to see if the
values are within the predicate or not.

This is a modified application of a patch proposed on the pgsql-hackers list:
https://www.postgresql.org/message-id/flat/4d9928ee-a9e6-15f9-9c82-5981f13ffca6%40postgrespro.ru
applied: https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=c203d6cf8
reverted: https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=05f84605dbeb9cf8279a157234b24bbb706c5256

Signed-off-by: Greg Burd <gregburd@amazon.com>
---
 doc/src/sgml/ref/create_table.sgml            |  18 +
 doc/src/sgml/storage.sgml                     |  21 +
 src/backend/access/common/reloptions.c        |  12 +-
 src/backend/access/heap/README.HOT            |  45 +-
 src/backend/access/heap/heapam.c              |  43 +-
 src/backend/access/heap/heapam_handler.c      |  15 +-
 src/backend/access/table/tableam.c            |   5 +-
 src/backend/catalog/index.c                   |  46 ++
 src/backend/catalog/indexing.c                |  16 +-
 src/backend/executor/execIndexing.c           | 192 ++++-
 src/backend/executor/execReplication.c        |  10 +-
 src/backend/executor/nodeModifyTable.c        |  27 +-
 src/backend/utils/cache/relcache.c            |  70 +-
 src/bin/psql/tab-complete.in.c                |   2 +-
 src/include/access/heapam.h                   |   6 +-
 src/include/access/tableam.h                  |  33 +-
 src/include/catalog/index.h                   |   1 +
 src/include/executor/executor.h               |   5 +
 src/include/nodes/execnodes.h                 |  13 +
 src/include/utils/rel.h                       |  10 +
 src/include/utils/relcache.h                  |   1 +
 .../regress/expected/heap_hot_updates.out     | 665 ++++++++++++++++++
 src/test/regress/parallel_schedule            |   5 +
 src/test/regress/sql/heap_hot_updates.sql     | 481 +++++++++++++
 src/tools/pgindent/typedefs.list              |   2 +-
 25 files changed, 1639 insertions(+), 105 deletions(-)
 create mode 100644 src/test/regress/expected/heap_hot_updates.out
 create mode 100644 src/test/regress/sql/heap_hot_updates.sql

diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index e5c034d724e..569b2c3ee45 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -1980,6 +1980,24 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
     </listitem>
    </varlistentry>
 
+   <varlistentry id="reloption-expression-checks" xreflabel="expression_checks">
+    <term><literal>expression_checks</literal> (<type>boolean</type>)
+    <indexterm>
+     <primary><varname>expression_checks</varname> storage parameter</primary>
+    </indexterm>
+    </term>
+    <listitem>
+     <para>
+      Enables or disables evaulation of predicate expressions on partial
+      indexes or expressions used to define indexes during updates.
+      If <literal>true</literal>, then these expressions are evaluated during
+      updates to data within the heap relation against the old and new values
+      and then compared to determine if <acronym>HOT</acronym> updates are
+      allowable or not. The default value is <literal>true</literal>.
+     </para>
+    </listitem>
+   </varlistentry>
+
    </variablelist>
 
   </refsect2>
diff --git a/doc/src/sgml/storage.sgml b/doc/src/sgml/storage.sgml
index 61250799ec0..64a6264d8fd 100644
--- a/doc/src/sgml/storage.sgml
+++ b/doc/src/sgml/storage.sgml
@@ -1138,6 +1138,27 @@ data. Empty in ordinary tables.</entry>
   </itemizedlist>
  </para>
 
+ <para>
+  <acronym>HOT</acronym> updates can occur when the expression used to define
+  an index shows no changes to the indexed value. To determine this requires
+  that the expression be evaulated for the old and new values to be stored in
+  the index and then compared. This allows for <acronym>HOT</acronym> updates
+  when data indexed within JSONB columns is unchanged. To disable this
+  behavior and avoid the overhead of evaluating the expression during updates
+  set the <literal>expression_checks</literal> option to false for the table.
+ </para>
+
+ <para>
+  <acronym>HOT</acronym> updates can also occur when updated values are not
+  within the predicate of a partial index. However, <acronym>HOT</acronym>
+  updates are not possible when the updated value and the current value differ
+  with regards to the predicate. To determin this requires that the predicate
+  expression be evaluated for the old and new values to be stored in the index
+  and then compared. To disable this behavior and avoid the overhead of
+  evaluating the expression during updates set
+  the <literal>expression_checks</literal> option to false for the table.
+ </para>
+
  <para>
   You can increase the likelihood of sufficient page space for
   <acronym>HOT</acronym> updates by decreasing a table's <link
diff --git a/src/backend/access/common/reloptions.c b/src/backend/access/common/reloptions.c
index 645b5c00467..41c727eafcb 100644
--- a/src/backend/access/common/reloptions.c
+++ b/src/backend/access/common/reloptions.c
@@ -166,6 +166,15 @@ static relopt_bool boolRelOpts[] =
 		},
 		true
 	},
+	{
+		{
+			"expression_checks",
+			"When disabled prevents checking expressions on indexes and predicates on partial indexes for changes that might influence heap-only tuple (HOT) updates.",
+			RELOPT_KIND_HEAP,
+			ShareUpdateExclusiveLock
+		},
+		true
+	},
 	/* list terminator */
 	{{NULL}}
 };
@@ -1914,7 +1923,8 @@ default_reloptions(Datum reloptions, bool validate, relopt_kind kind)
 		{"vacuum_truncate", RELOPT_TYPE_BOOL,
 		offsetof(StdRdOptions, vacuum_truncate), offsetof(StdRdOptions, vacuum_truncate_set)},
 		{"vacuum_max_eager_freeze_failure_rate", RELOPT_TYPE_REAL,
-		offsetof(StdRdOptions, vacuum_max_eager_freeze_failure_rate)}
+		offsetof(StdRdOptions, vacuum_max_eager_freeze_failure_rate)},
+		{"expression_checks", RELOPT_TYPE_BOOL, offsetof(StdRdOptions, expression_checks)}
 	};
 
 	return (bytea *) build_reloptions(reloptions, validate, kind,
diff --git a/src/backend/access/heap/README.HOT b/src/backend/access/heap/README.HOT
index 74e407f375a..2340d82053f 100644
--- a/src/backend/access/heap/README.HOT
+++ b/src/backend/access/heap/README.HOT
@@ -36,7 +36,7 @@ HOT solves this problem for two restricted but useful special cases:
 First, where a tuple is repeatedly updated in ways that do not change
 its indexed columns.  (Here, "indexed column" means any column referenced
 at all in an index definition, including for example columns that are
-tested in a partial-index predicate but are not stored in the index.)
+tested in a partial-index predicate, that has materially changed.)
 
 Second, where the modified columns are only used in indexes that do not
 contain tuple IDs, but maintain summaries of the indexed data by block.
@@ -133,28 +133,34 @@ Note: we can use a "dead" line pointer for any DELETEd tuple,
 whether it was part of a HOT chain or not.  This allows space reclamation
 in advance of running VACUUM for plain DELETEs as well as HOT updates.
 
-The requirement for doing a HOT update is that indexes which point to
-the root line pointer (and thus need to be cleaned up by VACUUM when the
-tuple is dead) do not reference columns which are updated in that HOT
-chain.  Summarizing indexes (such as BRIN) are assumed to have no
-references to individual tuples and thus are ignored when checking HOT
-applicability.  The updated columns are checked at execution time by
-comparing the binary representation of the old and new values.  We insist
-on bitwise equality rather than using datatype-specific equality routines.
-The main reason to avoid the latter is that there might be multiple
-notions of equality for a datatype, and we don't know exactly which one
-is relevant for the indexes at hand.  We assume that bitwise equality
-guarantees equality for all purposes.
+The requirement for doing a HOT update is that indexes which point to the root
+line pointer (and thus need to be cleaned up by VACUUM when the tuple is dead)
+do not reference columns which are updated in that HOT chain.
+
+Summarizing indexes (such as BRIN) are assumed to have no references to
+individual tuples and thus are ignored when checking HOT applicability.
+
+Expressions on indexes are evaluated and the results used when checking for
+changes.  This allows for the JSONB datatype to have HOT updates when the
+indexed portion of the document are not modified.
+
+Partial index expressions are evaluated, HOT updates are allowed when the
+updated index values do not satisfy the predicate.
+
+The updated columns are checked at execution time by comparing the binary
+representation of the old and new values.  We insist on bitwise equality rather
+than using datatype-specific equality routines.  The main reason to avoid the
+latter is that there might be multiple notions of equality for a datatype, and
+we don't know exactly which one is relevant for the indexes at hand.  We assume
+that bitwise equality guarantees equality for all purposes.
 
 If any columns that are included by non-summarizing indexes are updated,
 the HOT optimization is not applied, and the new tuple is inserted into
 all indexes of the table.  If none of the updated columns are included in
 the table's indexes, the HOT optimization is applied and no indexes are
 updated.  If instead the updated columns are only indexed by summarizing
-indexes, the HOT optimization is applied, but the update is propagated to
-all summarizing indexes.  (Realistically, we only need to propagate the
-update to the indexes that contain the updated values, but that is yet to
-be implemented.)
+indexes, the HOT optimization is applied and the update is propagated to
+all of the summarizing indexes.
 
 Abort Cases
 -----------
@@ -477,8 +483,9 @@ Heap-only tuple
 HOT-safe
 
 	A proposed tuple update is said to be HOT-safe if it changes
-	none of the tuple's indexed columns.  It will only become an
-	actual HOT update if we can find room on the same page for
+	none of the tuple's indexed columns or if the changes remain
+	outside of a partial index's predicate.  It will only become
+	an actual HOT update if we can find room on the same page for
 	the new tuple version.
 
 HOT update
diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index b12b583c4d9..8b523d9cb82 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -3244,13 +3244,14 @@ simple_heap_delete(Relation relation, ItemPointer tid)
 TM_Result
 heap_update(Relation relation, ItemPointer otid, HeapTuple newtup,
 			CommandId cid, Snapshot crosscheck, bool wait,
-			TM_FailureData *tmfd, LockTupleMode *lockmode,
-			TU_UpdateIndexes *update_indexes)
+			TM_FailureData *tmfd, UpdateContext *updateCxt)
 {
 	TM_Result	result;
 	TransactionId xid = GetCurrentTransactionId();
+	LockTupleMode *lockmode = &updateCxt->lockmode;
 	Bitmapset  *hot_attrs;
 	Bitmapset  *sum_attrs;
+	Bitmapset  *exp_attrs;
 	Bitmapset  *key_attrs;
 	Bitmapset  *id_attrs;
 	Bitmapset  *interesting_attrs;
@@ -3327,6 +3328,8 @@ heap_update(Relation relation, ItemPointer otid, HeapTuple newtup,
 										   INDEX_ATTR_BITMAP_HOT_BLOCKING);
 	sum_attrs = RelationGetIndexAttrBitmap(relation,
 										   INDEX_ATTR_BITMAP_SUMMARIZED);
+	exp_attrs = RelationGetIndexAttrBitmap(relation,
+										   INDEX_ATTR_BITMAP_EXPRESSION);
 	key_attrs = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_KEY);
 	id_attrs = RelationGetIndexAttrBitmap(relation,
 										  INDEX_ATTR_BITMAP_IDENTITY_KEY);
@@ -3388,10 +3391,11 @@ heap_update(Relation relation, ItemPointer otid, HeapTuple newtup,
 		tmfd->ctid = *otid;
 		tmfd->xmax = InvalidTransactionId;
 		tmfd->cmax = InvalidCommandId;
-		*update_indexes = TU_None;
+		updateCxt->updateIndexes = TU_None;
 
 		bms_free(hot_attrs);
 		bms_free(sum_attrs);
+		bms_free(exp_attrs);
 		bms_free(key_attrs);
 		bms_free(id_attrs);
 		/* modified_attrs not yet initialized */
@@ -3689,10 +3693,11 @@ l2:
 			UnlockTupleTuplock(relation, &(oldtup.t_self), *lockmode);
 		if (vmbuffer != InvalidBuffer)
 			ReleaseBuffer(vmbuffer);
-		*update_indexes = TU_None;
+		updateCxt->updateIndexes = TU_None;
 
 		bms_free(hot_attrs);
 		bms_free(sum_attrs);
+		bms_free(exp_attrs);
 		bms_free(key_attrs);
 		bms_free(id_attrs);
 		bms_free(modified_attrs);
@@ -4009,12 +4014,24 @@ l2:
 
 	if (newbuf == buffer)
 	{
+		ResultRelInfo *resultRelInfo = updateCxt->rri;
+		EState	   *estate = updateCxt->estate;
+		bool		expression_checks = RelationGetExpressionChecks(relation);
+
 		/*
 		 * Since the new tuple is going into the same page, we might be able
-		 * to do a HOT update.  Check if any of the index columns have been
-		 * changed.
+		 * to do a HOT update.  As a reminder, hot_attrs includes attributes
+		 * used by indexes including within expressions and predicates, but
+		 * not attributes only used by summarizing indexes.
 		 */
-		if (!bms_overlap(modified_attrs, hot_attrs))
+		if (!bms_overlap(modified_attrs, hot_attrs) ||
+			(estate && resultRelInfo && expression_checks &&
+			 bms_overlap(modified_attrs, exp_attrs) &&
+			 !ExecExpressionIndexesUpdated(resultRelInfo,
+										   modified_attrs,
+										   estate,
+										   resultRelInfo->ri_oldTupleSlot,
+										   resultRelInfo->ri_newTupleSlot)))
 		{
 			use_hot_update = true;
 
@@ -4196,18 +4213,19 @@ l2:
 	if (use_hot_update)
 	{
 		if (summarized_update)
-			*update_indexes = TU_Summarizing;
+			updateCxt->updateIndexes = TU_Summarizing;
 		else
-			*update_indexes = TU_None;
+			updateCxt->updateIndexes = TU_None;
 	}
 	else
-		*update_indexes = TU_All;
+		updateCxt->updateIndexes = TU_All;
 
 	if (old_key_tuple != NULL && old_key_copied)
 		heap_freetuple(old_key_tuple);
 
 	bms_free(hot_attrs);
 	bms_free(sum_attrs);
+	bms_free(exp_attrs);
 	bms_free(key_attrs);
 	bms_free(id_attrs);
 	bms_free(modified_attrs);
@@ -4485,16 +4503,15 @@ HeapDetermineColumnsInfo(Relation relation,
  */
 void
 simple_heap_update(Relation relation, ItemPointer otid, HeapTuple tup,
-				   TU_UpdateIndexes *update_indexes)
+				   UpdateContext *updateCxt)
 {
 	TM_Result	result;
 	TM_FailureData tmfd;
-	LockTupleMode lockmode;
 
 	result = heap_update(relation, otid, tup,
 						 GetCurrentCommandId(true), InvalidSnapshot,
 						 true /* wait for commit */ ,
-						 &tmfd, &lockmode, update_indexes);
+						 &tmfd, updateCxt);
 	switch (result)
 	{
 		case TM_SelfModified:
diff --git a/src/backend/access/heap/heapam_handler.c b/src/backend/access/heap/heapam_handler.c
index 24d3765aa20..54f627c5b27 100644
--- a/src/backend/access/heap/heapam_handler.c
+++ b/src/backend/access/heap/heapam_handler.c
@@ -316,8 +316,7 @@ heapam_tuple_delete(Relation relation, ItemPointer tid, CommandId cid,
 static TM_Result
 heapam_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot,
 					CommandId cid, Snapshot snapshot, Snapshot crosscheck,
-					bool wait, TM_FailureData *tmfd,
-					LockTupleMode *lockmode, TU_UpdateIndexes *update_indexes)
+					bool wait, TM_FailureData *tmfd, UpdateContext *updateCxt)
 {
 	bool		shouldFree = true;
 	HeapTuple	tuple = ExecFetchSlotHeapTuple(slot, true, &shouldFree);
@@ -328,7 +327,7 @@ heapam_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot,
 	tuple->t_tableOid = slot->tts_tableOid;
 
 	result = heap_update(relation, otid, tuple, cid, crosscheck, wait,
-						 tmfd, lockmode, update_indexes);
+						 tmfd, updateCxt);
 	ItemPointerCopy(&tuple->t_self, &slot->tts_tid);
 
 	/*
@@ -343,14 +342,14 @@ heapam_tuple_update(Relation relation, ItemPointer otid, TupleTableSlot *slot,
 	 */
 	if (result != TM_Ok)
 	{
-		Assert(*update_indexes == TU_None);
-		*update_indexes = TU_None;
+		Assert(updateCxt->updateIndexes == TU_None);
+		updateCxt->updateIndexes = TU_None;
 	}
 	else if (!HeapTupleIsHeapOnly(tuple))
-		Assert(*update_indexes == TU_All);
+		Assert(updateCxt->updateIndexes == TU_All);
 	else
-		Assert((*update_indexes == TU_Summarizing) ||
-			   (*update_indexes == TU_None));
+		Assert((updateCxt->updateIndexes == TU_Summarizing) ||
+			   (updateCxt->updateIndexes == TU_None));
 
 	if (shouldFree)
 		pfree(tuple);
diff --git a/src/backend/access/table/tableam.c b/src/backend/access/table/tableam.c
index a56c5eceb14..1eeeebadc09 100644
--- a/src/backend/access/table/tableam.c
+++ b/src/backend/access/table/tableam.c
@@ -336,17 +336,16 @@ void
 simple_table_tuple_update(Relation rel, ItemPointer otid,
 						  TupleTableSlot *slot,
 						  Snapshot snapshot,
-						  TU_UpdateIndexes *update_indexes)
+						  UpdateContext *updateCxt)
 {
 	TM_Result	result;
 	TM_FailureData tmfd;
-	LockTupleMode lockmode;
 
 	result = table_tuple_update(rel, otid, slot,
 								GetCurrentCommandId(true),
 								snapshot, InvalidSnapshot,
 								true /* wait for commit */ ,
-								&tmfd, &lockmode, update_indexes);
+								&tmfd, updateCxt);
 
 	switch (result)
 	{
diff --git a/src/backend/catalog/index.c b/src/backend/catalog/index.c
index 739a92bdcc1..0d6a0235e79 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2707,6 +2707,52 @@ BuildSpeculativeIndexInfo(Relation index, IndexInfo *ii)
 	}
 }
 
+/* ----------------
+ *		BuildExpressionIndexInfo
+ *			Add extra state to IndexInfo record
+ *
+ * For expression indexes updates may not change the indexed value allowing
+ * for a HOT update.  Add information to the IndexInfo to allow for checking
+ * if the indexed value has changed.
+ *
+ * Do this processing here rather than in BuildIndexInfo() to not incur the
+ * overhead in the common non-expression cases.
+ * ----------------
+ */
+void
+BuildExpressionIndexInfo(Relation index, IndexInfo *ii)
+{
+	int			i;
+	int			indnkeyatts;
+
+	indnkeyatts = IndexRelationGetNumberOfKeyAttributes(index);
+
+	/*
+	 * Collect attributes used by the index, their len and if they are by
+	 * value.
+	 */
+	for (i = 0; i < indnkeyatts; i++)
+	{
+		CompactAttribute *attr = TupleDescCompactAttr(RelationGetDescr(index), i);
+
+		ii->ii_IndexAttrs =
+			bms_add_member(ii->ii_IndexAttrs,
+						   ii->ii_IndexAttrNumbers[i] - FirstLowInvalidHeapAttributeNumber);
+
+		ii->ii_IndexAttrLen[i] = attr->attlen;
+		if (attr->attbyval)
+			ii->ii_IndexAttrByVal = bms_add_member(ii->ii_IndexAttrByVal, i);
+	}
+
+	/* collect attributes used in the expression */
+	if (ii->ii_Expressions)
+		pull_varattnos((Node *) ii->ii_Expressions, 1, &ii->ii_ExpressionAttrs);
+
+	/* collect attributes used in the predicate */
+	if (ii->ii_Predicate)
+		pull_varattnos((Node *) ii->ii_Predicate, 1, &ii->ii_PredicateAttrs);
+}
+
 /* ----------------
  *		FormIndexDatum
  *			Construct values[] and isnull[] arrays for a new index tuple.
diff --git a/src/backend/catalog/indexing.c b/src/backend/catalog/indexing.c
index 25c4b6bdc87..91a6b455b14 100644
--- a/src/backend/catalog/indexing.c
+++ b/src/backend/catalog/indexing.c
@@ -313,15 +313,17 @@ void
 CatalogTupleUpdate(Relation heapRel, ItemPointer otid, HeapTuple tup)
 {
 	CatalogIndexState indstate;
-	TU_UpdateIndexes updateIndexes = TU_All;
+	UpdateContext updateCxt = {0};
+
+	updateCxt.updateIndexes = TU_All;
 
 	CatalogTupleCheckConstraints(heapRel, tup);
 
 	indstate = CatalogOpenIndexes(heapRel);
 
-	simple_heap_update(heapRel, otid, tup, &updateIndexes);
+	simple_heap_update(heapRel, otid, tup, &updateCxt);
 
-	CatalogIndexInsert(indstate, tup, updateIndexes);
+	CatalogIndexInsert(indstate, tup, updateCxt.updateIndexes);
 	CatalogCloseIndexes(indstate);
 }
 
@@ -337,13 +339,15 @@ void
 CatalogTupleUpdateWithInfo(Relation heapRel, ItemPointer otid, HeapTuple tup,
 						   CatalogIndexState indstate)
 {
-	TU_UpdateIndexes updateIndexes = TU_All;
+	UpdateContext updateCxt = {0};
+
+	updateCxt.updateIndexes = TU_All;
 
 	CatalogTupleCheckConstraints(heapRel, tup);
 
-	simple_heap_update(heapRel, otid, tup, &updateIndexes);
+	simple_heap_update(heapRel, otid, tup, &updateCxt);
 
-	CatalogIndexInsert(indstate, tup, updateIndexes);
+	CatalogIndexInsert(indstate, tup, updateCxt.updateIndexes);
 }
 
 /*
diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c
index e3fe9b78bb5..429e08f759c 100644
--- a/src/backend/executor/execIndexing.c
+++ b/src/backend/executor/execIndexing.c
@@ -113,10 +113,13 @@
 #include "catalog/index.h"
 #include "executor/executor.h"
 #include "nodes/nodeFuncs.h"
+#include "optimizer/optimizer.h"
 #include "storage/lmgr.h"
 #include "utils/multirangetypes.h"
 #include "utils/rangetypes.h"
 #include "utils/snapmgr.h"
+#include "utils/datum.h"
+#include "utils/lsyscache.h"
 
 /* waitMode argument to check_exclusion_or_unique_constraint() */
 typedef enum
@@ -221,6 +224,13 @@ ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative)
 		if (speculative && ii->ii_Unique && !indexDesc->rd_index->indisexclusion)
 			BuildSpeculativeIndexInfo(indexDesc, ii);
 
+		/*
+		 * If the index uses expressions then let's populate the additional
+		 * information nessaary to evaluate them for changes during updates.
+		 */
+		if (ii->ii_Expressions || ii->ii_Predicate)
+			BuildExpressionIndexInfo(indexDesc, ii);
+
 		relationDescs[i] = indexDesc;
 		indexInfoArray[i] = ii;
 		i++;
@@ -383,19 +393,33 @@ ExecInsertIndexTuples(ResultRelInfo *resultRelInfo,
 			ExprState  *predicate;
 
 			/*
-			 * If predicate state not set up yet, create it (in the estate's
-			 * per-query context)
+			 * It is possible that we've already checked the predicate, if so
+			 * then avoid the duplicate work.
 			 */
-			predicate = indexInfo->ii_PredicateState;
-			if (predicate == NULL)
+			if (indexInfo->ii_CheckedPredicate)
 			{
-				predicate = ExecPrepareQual(indexInfo->ii_Predicate, estate);
-				indexInfo->ii_PredicateState = predicate;
+				/* Skip this index-update if the predicate isn't satisfied */
+				if (!indexInfo->ii_PredicateSatisfied)
+					continue;
 			}
+			else
+			{
 
-			/* Skip this index-update if the predicate isn't satisfied */
-			if (!ExecQual(predicate, econtext))
-				continue;
+				/*
+				 * If predicate state not set up yet, create it (in the
+				 * estate's per-query context)
+				 */
+				predicate = indexInfo->ii_PredicateState;
+				if (predicate == NULL)
+				{
+					predicate = ExecPrepareQual(indexInfo->ii_Predicate, estate);
+					indexInfo->ii_PredicateState = predicate;
+				}
+
+				/* Skip this index-update if the predicate isn't satisfied */
+				if (!ExecQual(predicate, econtext))
+					continue;
+			}
 		}
 
 		/*
@@ -1096,6 +1120,9 @@ index_unchanged_by_update(ResultRelInfo *resultRelInfo, EState *estate,
 
 	if (hasexpression)
 	{
+		if (indexInfo->ii_IndexUnchanged)
+			return true;
+
 		indexInfo->ii_IndexUnchanged = false;
 		return false;
 	}
@@ -1173,3 +1200,150 @@ ExecWithoutOverlapsNotEmpty(Relation rel, NameData attname, Datum attval, char t
 				 errmsg("empty WITHOUT OVERLAPS value found in column \"%s\" in relation \"%s\"",
 						NameStr(attname), RelationGetRelationName(rel))));
 }
+
+/*
+ * Determine if indexes that have expressions or predicates require updates
+ * for the purposes of allowing HOT updates.  Returns false iff all indexed
+ * values are unchanged.
+ */
+bool
+ExecExpressionIndexesUpdated(ResultRelInfo *resultRelInfo,
+							 Bitmapset *modifiedAttrs,
+							 EState *estate,
+							 TupleTableSlot *old_tts,
+							 TupleTableSlot *new_tts)
+{
+	bool		result = false;
+	IndexInfo  *indexInfo;
+	ExprContext *econtext = NULL;
+
+	if (old_tts == NULL || new_tts == NULL)
+		return true;
+
+	econtext = GetPerTupleExprContext(estate);
+
+	/*
+	 * Examine each index on this relation relative to the changes between old
+	 * and new tuples.
+	 */
+	for (int i = 0; i < resultRelInfo->ri_NumIndices; i++)
+	{
+		indexInfo = resultRelInfo->ri_IndexRelationInfo[i];
+
+		/*
+		 * If this is a partial index it has a predicate, evaluate the
+		 * expression to determine if we need to include it or not.
+		 */
+		if (bms_overlap(indexInfo->ii_PredicateAttrs, modifiedAttrs))
+		{
+			ExprState  *pstate;
+			bool		old_tuple_qualifies,
+						new_tuple_qualifies;
+
+			pstate = ExecPrepareQual(indexInfo->ii_Predicate, estate);
+
+			/*
+			 * Here the term "qualifies" means "satisfies the predicate
+			 * condition of the partial index".
+			 */
+			econtext->ecxt_scantuple = old_tts;
+			old_tuple_qualifies = ExecQual(pstate, econtext);
+
+			econtext->ecxt_scantuple = new_tts;
+			new_tuple_qualifies = ExecQual(pstate, econtext);
+			indexInfo->ii_CheckedPredicate = true;
+			indexInfo->ii_PredicateSatisfied = new_tuple_qualifies;
+
+			/*
+			 * If neither the old nor the new tuples satisfy the predicate we
+			 * can be sure that this index doesn't need updating, continue to
+			 * the next index.
+			 */
+			if ((new_tuple_qualifies == false) && (old_tuple_qualifies == false))
+				continue;
+
+			/*
+			 * If there is a transition between indexed and not indexed,
+			 * that's enough to require that this index is updated. If any
+			 * single non-summarizing index requires updates then they all
+			 * should be updated.
+			 */
+			if (new_tuple_qualifies != old_tuple_qualifies)
+			{
+				result = true;
+				break;
+			}
+
+			/*
+			 * Otherwise the old and new values exist in the index, but did
+			 * they get updated?  We don't yet know, so proceed with the next
+			 * statement in the loop to find out.
+			 */
+		}
+
+		/*
+		 * Indexes with expressions may or may not have changed, it is
+		 * impossible to know without exercising their expression and
+		 * reviewing index tuple state for changes.  This is a lot of work,
+		 * but because all indexes on JSONB columns fall into this category it
+		 * can be worth it to avoid index updates and remain on the HOT update
+		 * path when possible.
+		 */
+		if (bms_overlap(indexInfo->ii_ExpressionAttrs, modifiedAttrs))
+		{
+			TupleTableSlot *save_scantuple;
+			Datum		old_values[INDEX_MAX_KEYS];
+			bool		old_isnull[INDEX_MAX_KEYS];
+			Datum		new_values[INDEX_MAX_KEYS];
+			bool		new_isnull[INDEX_MAX_KEYS];
+			bool		changed = false;
+
+			save_scantuple = econtext->ecxt_scantuple;
+			econtext->ecxt_scantuple = old_tts;
+			FormIndexDatum(indexInfo,
+						   old_tts,
+						   estate,
+						   old_values,
+						   old_isnull);
+			econtext->ecxt_scantuple = new_tts;
+			FormIndexDatum(indexInfo,
+						   new_tts,
+						   estate,
+						   new_values,
+						   new_isnull);
+			econtext->ecxt_scantuple = save_scantuple;
+
+			for (int j = 0; j < indexInfo->ii_NumIndexKeyAttrs; j++)
+			{
+				if (old_isnull[j] != new_isnull[j])
+				{
+					changed = true;
+					break;
+				}
+				else if (!old_isnull[j])
+				{
+					int16		elmlen = indexInfo->ii_IndexAttrLen[j];
+					bool		elmbyval = bms_is_member(j, indexInfo->ii_IndexAttrByVal);
+
+					if (!datum_image_eq(old_values[j], new_values[j],
+										elmbyval, elmlen))
+					{
+						changed = true;
+						break;
+					}
+				}
+			}
+
+			indexInfo->ii_CheckedUnchanged = true;
+			indexInfo->ii_IndexUnchanged = !changed;
+
+			if (changed)
+			{
+				result = true;
+				break;
+			}
+		}
+	}
+
+	return result;
+}
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index ede89ea3cf9..c13fb6befd5 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -647,7 +647,7 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo,
 	if (!skip_tuple)
 	{
 		List	   *recheckIndexes = NIL;
-		TU_UpdateIndexes update_indexes;
+		UpdateContext updateCxt = {0};
 		List	   *conflictindexes;
 		bool		conflict = false;
 
@@ -663,17 +663,19 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo,
 		if (rel->rd_rel->relispartition)
 			ExecPartitionCheck(resultRelInfo, slot, estate, true);
 
+		updateCxt.estate = estate;
+		updateCxt.rri = resultRelInfo;
 		simple_table_tuple_update(rel, tid, slot, estate->es_snapshot,
-								  &update_indexes);
+								  &updateCxt);
 
 		conflictindexes = resultRelInfo->ri_onConflictArbiterIndexes;
 
-		if (resultRelInfo->ri_NumIndices > 0 && (update_indexes != TU_None))
+		if (resultRelInfo->ri_NumIndices > 0 && (updateCxt.updateIndexes != TU_None))
 			recheckIndexes = ExecInsertIndexTuples(resultRelInfo,
 												   slot, estate, true,
 												   conflictindexes ? true : false,
 												   &conflict, conflictindexes,
-												   (update_indexes == TU_Summarizing));
+												   (updateCxt.updateIndexes == TU_Summarizing));
 
 		/*
 		 * Refer to the comments above the call to CheckAndReportConflict() in
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 87c820276a8..8650c2be096 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -115,21 +115,6 @@ typedef struct ModifyTableContext
 	TupleTableSlot *cpUpdateReturningSlot;
 } ModifyTableContext;
 
-/*
- * Context struct containing output data specific to UPDATE operations.
- */
-typedef struct UpdateContext
-{
-	bool		crossPartUpdate;	/* was it a cross-partition update? */
-	TU_UpdateIndexes updateIndexes; /* Which index updates are required? */
-
-	/*
-	 * Lock mode to acquire on the latest tuple version before performing
-	 * EvalPlanQual on it
-	 */
-	LockTupleMode lockmode;
-} UpdateContext;
-
 
 static void ExecBatchInsert(ModifyTableState *mtstate,
 							ResultRelInfo *resultRelInfo,
@@ -2282,8 +2267,7 @@ lreplace:
 								estate->es_snapshot,
 								estate->es_crosscheck_snapshot,
 								true /* wait for commit */ ,
-								&context->tmfd, &updateCxt->lockmode,
-								&updateCxt->updateIndexes);
+								&context->tmfd, updateCxt);
 
 	return result;
 }
@@ -2301,6 +2285,7 @@ ExecUpdateEpilogue(ModifyTableContext *context, UpdateContext *updateCxt,
 {
 	ModifyTableState *mtstate = context->mtstate;
 	List	   *recheckIndexes = NIL;
+	bool		onlySummarizing = updateCxt->updateIndexes == TU_Summarizing;
 
 	/* insert index entries for tuple if necessary */
 	if (resultRelInfo->ri_NumIndices > 0 && (updateCxt->updateIndexes != TU_None))
@@ -2308,7 +2293,7 @@ ExecUpdateEpilogue(ModifyTableContext *context, UpdateContext *updateCxt,
 											   slot, context->estate,
 											   true, false,
 											   NULL, NIL,
-											   (updateCxt->updateIndexes == TU_Summarizing));
+											   onlySummarizing);
 
 	/* AFTER ROW UPDATE Triggers */
 	ExecARUpdateTriggers(context->estate, resultRelInfo,
@@ -2444,6 +2429,9 @@ ExecUpdate(ModifyTableContext *context, ResultRelInfo *resultRelInfo,
 	UpdateContext updateCxt = {0};
 	TM_Result	result;
 
+	updateCxt.estate = estate;
+	updateCxt.rri = resultRelInfo;
+
 	/*
 	 * abort the operation if not running transactions
 	 */
@@ -3132,6 +3120,9 @@ lmerge_matched:
 		TM_Result	result;
 		UpdateContext updateCxt = {0};
 
+		updateCxt.rri = resultRelInfo;
+		updateCxt.estate = estate;
+
 		/*
 		 * Test condition, if any.
 		 *
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 9f54a9e72b7..29cef048a87 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -64,6 +64,7 @@
 #include "catalog/pg_type.h"
 #include "catalog/schemapg.h"
 #include "catalog/storage.h"
+#include "catalog/index.h"
 #include "commands/policy.h"
 #include "commands/publicationcmds.h"
 #include "commands/trigger.h"
@@ -5211,6 +5212,7 @@ RelationGetIndexPredicate(Relation relation)
  *									index (empty if FULL)
  *	INDEX_ATTR_BITMAP_HOT_BLOCKING	Columns that block updates from being HOT
  *	INDEX_ATTR_BITMAP_SUMMARIZED	Columns included in summarizing indexes
+ *	INDEX_ATTR_BITMAP_EXPRESSION	Columns included in expresion indexes
  *
  * Attribute numbers are offset by FirstLowInvalidHeapAttributeNumber so that
  * we can include system attributes (e.g., OID) in the bitmap representation.
@@ -5234,7 +5236,11 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind)
 	Bitmapset  *pkindexattrs;	/* columns in the primary index */
 	Bitmapset  *idindexattrs;	/* columns in the replica identity */
 	Bitmapset  *hotblockingattrs;	/* columns with HOT blocking indexes */
+	Bitmapset  *hotblockingexprattrs;	/* as above, but only those in
+										 * expressions */
 	Bitmapset  *summarizedattrs;	/* columns with summarizing indexes */
+	Bitmapset  *summarizedexprattrs;	/* as above, but only those in
+										 * expressions */
 	List	   *indexoidlist;
 	List	   *newindexoidlist;
 	Oid			relpkindex;
@@ -5257,6 +5263,8 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind)
 				return bms_copy(relation->rd_hotblockingattr);
 			case INDEX_ATTR_BITMAP_SUMMARIZED:
 				return bms_copy(relation->rd_summarizedattr);
+			case INDEX_ATTR_BITMAP_EXPRESSION:
+				return bms_copy(relation->rd_expressionattr);
 			default:
 				elog(ERROR, "unknown attrKind %u", attrKind);
 		}
@@ -5300,7 +5308,9 @@ restart:
 	pkindexattrs = NULL;
 	idindexattrs = NULL;
 	hotblockingattrs = NULL;
+	hotblockingexprattrs = NULL;
 	summarizedattrs = NULL;
+	summarizedexprattrs = NULL;
 	foreach(l, indexoidlist)
 	{
 		Oid			indexOid = lfirst_oid(l);
@@ -5314,6 +5324,7 @@ restart:
 		bool		isPK;		/* primary key */
 		bool		isIDKey;	/* replica identity index */
 		Bitmapset **attrs;
+		Bitmapset **exprattrs;
 
 		indexDesc = index_open(indexOid, AccessShareLock);
 
@@ -5357,14 +5368,20 @@ restart:
 		 * decide which bitmap we'll update in the following loop.
 		 */
 		if (indexDesc->rd_indam->amsummarizing)
+		{
 			attrs = &summarizedattrs;
+			exprattrs = &summarizedexprattrs;
+		}
 		else
+		{
 			attrs = &hotblockingattrs;
+			exprattrs = &hotblockingexprattrs;
+		}
 
 		/* Collect simple attribute references */
 		for (i = 0; i < indexDesc->rd_index->indnatts; i++)
 		{
-			int			attrnum = indexDesc->rd_index->indkey.values[i];
+			int			attridx = indexDesc->rd_index->indkey.values[i];
 
 			/*
 			 * Since we have covering indexes with non-key columns, we must
@@ -5380,30 +5397,28 @@ restart:
 			 * key or identity key. Hence we do not include them into
 			 * uindexattrs, pkindexattrs and idindexattrs bitmaps.
 			 */
-			if (attrnum != 0)
+			if (attridx != 0)
 			{
-				*attrs = bms_add_member(*attrs,
-										attrnum - FirstLowInvalidHeapAttributeNumber);
+				AttrNumber	attrnum = attridx - FirstLowInvalidHeapAttributeNumber;
+
+				*attrs = bms_add_member(*attrs, attrnum);
 
 				if (isKey && i < indexDesc->rd_index->indnkeyatts)
-					uindexattrs = bms_add_member(uindexattrs,
-												 attrnum - FirstLowInvalidHeapAttributeNumber);
+					uindexattrs = bms_add_member(uindexattrs, attrnum);
 
 				if (isPK && i < indexDesc->rd_index->indnkeyatts)
-					pkindexattrs = bms_add_member(pkindexattrs,
-												  attrnum - FirstLowInvalidHeapAttributeNumber);
+					pkindexattrs = bms_add_member(pkindexattrs, attrnum);
 
 				if (isIDKey && i < indexDesc->rd_index->indnkeyatts)
-					idindexattrs = bms_add_member(idindexattrs,
-												  attrnum - FirstLowInvalidHeapAttributeNumber);
+					idindexattrs = bms_add_member(idindexattrs, attrnum);
 			}
 		}
 
 		/* Collect all attributes used in expressions, too */
-		pull_varattnos(indexExpressions, 1, attrs);
+		pull_varattnos(indexExpressions, 1, exprattrs);
 
 		/* Collect all attributes in the index predicate, too */
-		pull_varattnos(indexPredicate, 1, attrs);
+		pull_varattnos(indexPredicate, 1, exprattrs);
 
 		index_close(indexDesc, AccessShareLock);
 	}
@@ -5432,11 +5447,37 @@ restart:
 		bms_free(pkindexattrs);
 		bms_free(idindexattrs);
 		bms_free(hotblockingattrs);
+		bms_free(hotblockingexprattrs);
 		bms_free(summarizedattrs);
+		bms_free(summarizedexprattrs);
 
 		goto restart;
 	}
 
+	/*
+	 * HOT-blocking attributes should include all columns that are part of the
+	 * index except attributes only referenced in expressions, including
+	 * expressions used to form partial indexes.  So, we need to remove the
+	 * expression-only columns from the HOT-blocking columns bitmap as those
+	 * will be checked separately.  This is true for both summarizing and
+	 * non-summarizing indexes which we've separated above, so we have to do
+	 * this for both bitmaps.
+	 */
+
+	/* {expression-only columns} = {expression columns} - {direct columns} */
+	hotblockingexprattrs = bms_del_members(hotblockingexprattrs,
+										   hotblockingattrs);
+	/* {hot-blocking columns} = {direct columns} + {expression-only columns} */
+	hotblockingattrs = bms_add_members(hotblockingattrs,
+									   hotblockingexprattrs);
+
+	/* {summarized-only columns} = {all summarized columns} - {direct columns} */
+	summarizedexprattrs = bms_del_members(summarizedexprattrs,
+										  summarizedattrs);
+	/* {summarized columns} = {all direct columns} + {summarized-only columns} */
+	summarizedattrs = bms_add_members(summarizedattrs,
+									  summarizedexprattrs);
+
 	/* Don't leak the old values of these bitmaps, if any */
 	relation->rd_attrsvalid = false;
 	bms_free(relation->rd_keyattr);
@@ -5449,6 +5490,8 @@ restart:
 	relation->rd_hotblockingattr = NULL;
 	bms_free(relation->rd_summarizedattr);
 	relation->rd_summarizedattr = NULL;
+	bms_free(relation->rd_expressionattr);
+	relation->rd_expressionattr = NULL;
 
 	/*
 	 * Now save copies of the bitmaps in the relcache entry.  We intentionally
@@ -5463,6 +5506,7 @@ restart:
 	relation->rd_idattr = bms_copy(idindexattrs);
 	relation->rd_hotblockingattr = bms_copy(hotblockingattrs);
 	relation->rd_summarizedattr = bms_copy(summarizedattrs);
+	relation->rd_expressionattr = bms_copy(hotblockingexprattrs);
 	relation->rd_attrsvalid = true;
 	MemoryContextSwitchTo(oldcxt);
 
@@ -5479,6 +5523,8 @@ restart:
 			return hotblockingattrs;
 		case INDEX_ATTR_BITMAP_SUMMARIZED:
 			return summarizedattrs;
+		case INDEX_ATTR_BITMAP_EXPRESSION:
+			return hotblockingexprattrs;
 		default:
 			elog(ERROR, "unknown attrKind %u", attrKind);
 			return NULL;
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 98951aef82c..3d4d50a95e9 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2993,7 +2993,7 @@ match_previous_words(int pattern_id,
 		COMPLETE_WITH("(");
 	/* ALTER TABLESPACE <foo> SET|RESET ( */
 	else if (Matches("ALTER", "TABLESPACE", MatchAny, "SET|RESET", "("))
-		COMPLETE_WITH("seq_page_cost", "random_page_cost",
+		COMPLETE_WITH("seq_page_cost", "random_page_cost", "expression_checks",
 					  "effective_io_concurrency", "maintenance_io_concurrency");
 
 	/* ALTER TEXT SEARCH */
diff --git a/src/include/access/heapam.h b/src/include/access/heapam.h
index 1640d9c32f7..a7eb7bd542d 100644
--- a/src/include/access/heapam.h
+++ b/src/include/access/heapam.h
@@ -21,6 +21,7 @@
 #include "access/skey.h"
 #include "access/table.h"		/* for backward compatibility */
 #include "access/tableam.h"
+#include "executor/executor.h"
 #include "nodes/lockoptions.h"
 #include "nodes/primnodes.h"
 #include "storage/bufpage.h"
@@ -338,8 +339,7 @@ extern void heap_abort_speculative(Relation relation, ItemPointer tid);
 extern TM_Result heap_update(Relation relation, ItemPointer otid,
 							 HeapTuple newtup,
 							 CommandId cid, Snapshot crosscheck, bool wait,
-							 struct TM_FailureData *tmfd, LockTupleMode *lockmode,
-							 TU_UpdateIndexes *update_indexes);
+							 struct TM_FailureData *tmfd, UpdateContext *updateCxt);
 extern TM_Result heap_lock_tuple(Relation relation, HeapTuple tuple,
 								 CommandId cid, LockTupleMode mode, LockWaitPolicy wait_policy,
 								 bool follow_updates,
@@ -374,7 +374,7 @@ extern bool heap_tuple_needs_eventual_freeze(HeapTupleHeader tuple);
 extern void simple_heap_insert(Relation relation, HeapTuple tup);
 extern void simple_heap_delete(Relation relation, ItemPointer tid);
 extern void simple_heap_update(Relation relation, ItemPointer otid,
-							   HeapTuple tup, TU_UpdateIndexes *update_indexes);
+							   HeapTuple tup, UpdateContext *updateCxt);
 
 extern TransactionId heap_index_delete_tuples(Relation rel,
 											  TM_IndexDeleteOp *delstate);
diff --git a/src/include/access/tableam.h b/src/include/access/tableam.h
index b8cb1e744ad..13a0bf1e114 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -125,6 +125,28 @@ typedef enum TU_UpdateIndexes
 	TU_Summarizing,
 } TU_UpdateIndexes;
 
+/*
+ * Data specific to processing UPDATE operations.
+ *
+ * When table_tuple_update is called some storage managers, notably heapam,
+ * can at times avoid index updates.  In the heapam this is known as a HOT
+ * update.  This struct is used to provide the state required to test for
+ * HOT updates and to communicate that decision on to the index AMs.
+ */
+typedef struct UpdateContext
+{
+	TU_UpdateIndexes updateIndexes; /* Which index updates are required? */
+	struct ResultRelInfo *rri;	/* ResultRelInfo for the updated table. */
+	struct EState *estate;		/* EState used within the update. */
+	bool		crossPartUpdate;	/* Was it a cross-partition update? */
+
+	/*
+	 * Lock mode to acquire on the latest tuple version before performing
+	 * EvalPlanQual on it
+	 */
+	LockTupleMode lockmode;
+} UpdateContext;
+
 /*
  * When table_tuple_update, table_tuple_delete, or table_tuple_lock fail
  * because the target tuple is already outdated, they fill in this struct to
@@ -549,8 +571,7 @@ typedef struct TableAmRoutine
 								 Snapshot crosscheck,
 								 bool wait,
 								 TM_FailureData *tmfd,
-								 LockTupleMode *lockmode,
-								 TU_UpdateIndexes *update_indexes);
+								 UpdateContext *updateCxt);
 
 	/* see table_tuple_lock() for reference about parameters */
 	TM_Result	(*tuple_lock) (Relation rel,
@@ -1504,13 +1525,11 @@ table_tuple_delete(Relation rel, ItemPointer tid, CommandId cid,
 static inline TM_Result
 table_tuple_update(Relation rel, ItemPointer otid, TupleTableSlot *slot,
 				   CommandId cid, Snapshot snapshot, Snapshot crosscheck,
-				   bool wait, TM_FailureData *tmfd, LockTupleMode *lockmode,
-				   TU_UpdateIndexes *update_indexes)
+				   bool wait, TM_FailureData *tmfd, UpdateContext *updateCxt)
 {
 	return rel->rd_tableam->tuple_update(rel, otid, slot,
 										 cid, snapshot, crosscheck,
-										 wait, tmfd,
-										 lockmode, update_indexes);
+										 wait, tmfd, updateCxt);
 }
 
 /*
@@ -2011,7 +2030,7 @@ extern void simple_table_tuple_delete(Relation rel, ItemPointer tid,
 									  Snapshot snapshot);
 extern void simple_table_tuple_update(Relation rel, ItemPointer otid,
 									  TupleTableSlot *slot, Snapshot snapshot,
-									  TU_UpdateIndexes *update_indexes);
+									  UpdateContext *updateCxt);
 
 
 /* ----------------------------------------------------------------------------
diff --git a/src/include/catalog/index.h b/src/include/catalog/index.h
index 4daa8bef5ee..fdbf47f607b 100644
--- a/src/include/catalog/index.h
+++ b/src/include/catalog/index.h
@@ -132,6 +132,7 @@ extern bool CompareIndexInfo(const IndexInfo *info1, const IndexInfo *info2,
 							 const AttrMap *attmap);
 
 extern void BuildSpeculativeIndexInfo(Relation index, IndexInfo *ii);
+extern void BuildExpressionIndexInfo(Relation index, IndexInfo *indexInfo);
 
 extern void FormIndexDatum(IndexInfo *indexInfo,
 						   TupleTableSlot *slot,
diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h
index 6a1fec88928..507e142a10e 100644
--- a/src/include/executor/executor.h
+++ b/src/include/executor/executor.h
@@ -773,6 +773,11 @@ extern void check_exclusion_constraint(Relation heap, Relation index,
 									   ItemPointer tupleid,
 									   const Datum *values, const bool *isnull,
 									   EState *estate, bool newIndex);
+extern bool ExecExpressionIndexesUpdated(ResultRelInfo *resultRelInfo,
+										 Bitmapset *modifiedAttrs,
+										 EState *estate,
+										 TupleTableSlot *old_tts,
+										 TupleTableSlot *new_tts);
 
 /*
  * prototypes from functions in execReplication.c
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index e42f9f9f957..f60b20671b9 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -160,12 +160,15 @@ typedef struct ExprState
  *
  *		NumIndexAttrs		total number of columns in this index
  *		NumIndexKeyAttrs	number of key columns in index
+ *		IndexAttrs			bitmap of index attributes
  *		IndexAttrNumbers	underlying-rel attribute numbers used as keys
  *							(zeroes indicate expressions). It also contains
  * 							info about included columns.
  *		Expressions			expr trees for expression entries, or NIL if none
+ *		ExpressionAttrs		bitmap of attributes used within the expression
  *		ExpressionsState	exec state for expressions, or NIL if none
  *		Predicate			partial-index predicate, or NIL if none
+ *		PredicateAttrs		bitmap of attributes used within the predicate
  *		PredicateState		exec state for predicate, or NIL if none
  *		ExclusionOps		Per-column exclusion operators, or NULL if none
  *		ExclusionProcs		Underlying function OIDs for ExclusionOps
@@ -184,6 +187,7 @@ typedef struct ExprState
  *		ParallelWorkers		# of workers requested (excludes leader)
  *		Am					Oid of index AM
  *		AmCache				private cache area for index AM
+ *		OpClassDataTypes	operator class data types
  *		Context				memory context holding this IndexInfo
  *
  * ii_Concurrent, ii_BrokenHotChain, and ii_ParallelWorkers are used only
@@ -195,10 +199,17 @@ typedef struct IndexInfo
 	NodeTag		type;
 	int			ii_NumIndexAttrs;	/* total number of columns in index */
 	int			ii_NumIndexKeyAttrs;	/* number of key columns in index */
+	Bitmapset  *ii_IndexAttrs;
 	AttrNumber	ii_IndexAttrNumbers[INDEX_MAX_KEYS];
+	uint16		ii_IndexAttrLen[INDEX_MAX_KEYS];
+	Bitmapset  *ii_IndexAttrByVal;
+	FmgrInfo   *ii_EqualityProc[INDEX_MAX_KEYS];
+	Oid			ii_Collation[INDEX_MAX_KEYS];
 	List	   *ii_Expressions; /* list of Expr */
+	Bitmapset  *ii_ExpressionAttrs;
 	List	   *ii_ExpressionsState;	/* list of ExprState */
 	List	   *ii_Predicate;	/* list of Expr */
+	Bitmapset  *ii_PredicateAttrs;
 	ExprState  *ii_PredicateState;
 	Oid		   *ii_ExclusionOps;	/* array with one entry per column */
 	Oid		   *ii_ExclusionProcs;	/* array with one entry per column */
@@ -211,6 +222,8 @@ typedef struct IndexInfo
 	bool		ii_ReadyForInserts;
 	bool		ii_CheckedUnchanged;
 	bool		ii_IndexUnchanged;
+	bool		ii_CheckedPredicate;
+	bool		ii_PredicateSatisfied;
 	bool		ii_Concurrent;
 	bool		ii_BrokenHotChain;
 	bool		ii_Summarizing;
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index d94fddd7cef..e44d9966778 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -164,6 +164,7 @@ typedef struct RelationData
 	Bitmapset  *rd_idattr;		/* included in replica identity index */
 	Bitmapset  *rd_hotblockingattr; /* cols blocking HOT update */
 	Bitmapset  *rd_summarizedattr;	/* cols indexed by summarizing indexes */
+	Bitmapset  *rd_expressionattr;	/* indexed cols referenced by expressions */
 
 	PublicationDesc *rd_pubdesc;	/* publication descriptor, or NULL */
 
@@ -345,6 +346,7 @@ typedef struct StdRdOptions
 	StdRdOptIndexCleanup vacuum_index_cleanup;	/* controls index vacuuming */
 	bool		vacuum_truncate;	/* enables vacuum to truncate a relation */
 	bool		vacuum_truncate_set;	/* whether vacuum_truncate is set */
+	bool		expression_checks;	/* use expression to checks for changes */
 
 	/*
 	 * Fraction of pages in a relation that vacuum can eagerly scan and fail
@@ -406,6 +408,14 @@ typedef struct StdRdOptions
 	((relation)->rd_options ? \
 	 ((StdRdOptions *) (relation)->rd_options)->parallel_workers : (defaultpw))
 
+/*
+ * RelationGetExpressionChecks
+ *		Returns the relation's expression_checks reloption setting.
+ */
+#define RelationGetExpressionChecks(relation) \
+	((relation)->rd_options ? \
+	 ((StdRdOptions *) (relation)->rd_options)->expression_checks : true)
+
 /* ViewOptions->check_option values */
 typedef enum ViewOptCheckOption
 {
diff --git a/src/include/utils/relcache.h b/src/include/utils/relcache.h
index a7c55db339e..0cc28cb97e2 100644
--- a/src/include/utils/relcache.h
+++ b/src/include/utils/relcache.h
@@ -63,6 +63,7 @@ typedef enum IndexAttrBitmapKind
 	INDEX_ATTR_BITMAP_IDENTITY_KEY,
 	INDEX_ATTR_BITMAP_HOT_BLOCKING,
 	INDEX_ATTR_BITMAP_SUMMARIZED,
+	INDEX_ATTR_BITMAP_EXPRESSION,
 } IndexAttrBitmapKind;
 
 extern Bitmapset *RelationGetIndexAttrBitmap(Relation relation,
diff --git a/src/test/regress/expected/heap_hot_updates.out b/src/test/regress/expected/heap_hot_updates.out
new file mode 100644
index 00000000000..027c8760d69
--- /dev/null
+++ b/src/test/regress/expected/heap_hot_updates.out
@@ -0,0 +1,665 @@
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- This table will have two columns and two indexes, one on the primary key
+-- id and one on the expression (info->>'name').  That means that the indexed
+-- attributes are 'id' and 'info'.
+create table keyvalue(id integer primary key, info jsonb);
+create index nameindex on keyvalue((info->>'name'));
+insert into keyvalue values (1, '{"name": "john", "data": "some data"}');
+-- Disable expression checks.
+ALTER TABLE keyvalue SET (expression_checks = false);
+SELECT reloptions FROM pg_class WHERE relname = 'keyvalue';
+        reloptions         
+---------------------------
+ {expression_checks=false}
+(1 row)
+
+-- While the indexed attribute "name" is unchanged we've disabled expression
+-- checks so this update should not go HOT as the system can't determine if
+-- the indexed attribute has changed without evaluating the expression.
+update keyvalue set info='{"name": "john", "data": "something else"}' where id=1;
+select pg_stat_get_xact_tuples_hot_updated('keyvalue'::regclass); -- expect: 0 row
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   0
+(1 row)
+
+-- Re-enable expression checks.
+ALTER TABLE keyvalue SET (expression_checks = true);
+SELECT reloptions FROM pg_class WHERE relname = 'keyvalue';
+        reloptions        
+--------------------------
+ {expression_checks=true}
+(1 row)
+
+-- The indexed attribute "name" with value "john" is unchanged, expect a HOT update.
+update keyvalue set info='{"name": "john", "data": "some other data"}' where id=1;
+select pg_stat_get_xact_tuples_hot_updated('keyvalue'::regclass); -- expect: 1 row
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- The following update changes the indexed attribute "name", this should not be a HOT update.
+update keyvalue set info='{"name": "smith", "data": "some other data"}' where id=1;
+select pg_stat_get_xact_tuples_hot_updated('keyvalue'::regclass); -- expect: 1 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- Now, this update does not change the indexed attribute "name" from "smith", this should be HOT.
+update keyvalue set info='{"name": "smith", "data": "some more data"}' where id=1;
+select pg_stat_get_xact_tuples_hot_updated('keyvalue'::regclass); -- expect: 2 rows now
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   2
+(1 row)
+
+drop table keyvalue;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- This table is the same as the previous one but it has a third index.  The
+-- index 'colindex' isn't an expression index, it indexes the entire value
+-- in the info column.  There are still only two indexed attributes for this
+-- relation, the same two as before.  The presence of an index on the entire
+-- value of the info column should prevent HOT updates for any updates to any
+-- portion of JSONB content in that column.
+create table keyvalue(id integer primary key, info jsonb);
+create index nameindex on keyvalue((info->>'name'));
+create index colindex on keyvalue(info);
+insert into keyvalue values (1, '{"name": "john", "data": "some data"}');
+-- This update doesn't change the value of the expression index, but it does
+-- change the content of the info column and so should not be HOT because the
+-- indexed value changed as a result of the update.
+update keyvalue set info='{"name": "john", "data": "some other data"}' where id=1;
+select pg_stat_get_xact_tuples_hot_updated('keyvalue'::regclass); -- expect: 0 rows
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   0
+(1 row)
+
+drop table keyvalue;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- The table has one column docs and two indexes.  They are both expression
+-- indexes referencing the same column attribute (docs) but one is a partial
+-- index.
+CREATE TABLE ex (docs JSONB) WITH (fillfactor = 60);
+INSERT INTO ex (docs) VALUES ('{"a": 0, "b": 0}');
+INSERT INTO ex (docs) SELECT jsonb_build_object('b', n) FROM generate_series(100, 10000) as n;
+CREATE INDEX idx_ex_a ON ex ((docs->>'a'));
+CREATE INDEX idx_ex_b ON ex ((docs->>'b')) WHERE (docs->>'b')::numeric > 9;
+-- We're using BTREE indexes and for this test we want to make sure that they remain
+-- in sync with changes to our relation.  Force the choice of index scans below so
+-- that we know we're checking the index's understanding of what values should be
+-- in the index or not.
+SET SESSION enable_seqscan = OFF;
+SET SESSION enable_bitmapscan = OFF;
+-- Leave 'a' unchanged but modify 'b' to a value outside of the index predicate.
+-- This should be a HOT update because neither index is changed.
+UPDATE ex SET docs = jsonb_build_object('a', 0, 'b', 1) WHERE (docs->>'a')::numeric = 0;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 row
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- Let's check to make sure that the index does not contain a value for 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Index Scan using idx_ex_b on ex
+   Filter: (((docs ->> 'b'::text))::numeric < '100'::numeric)
+(2 rows)
+
+SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+ docs 
+------
+(0 rows)
+
+-- Leave 'a' unchanged but modify 'b' to a value within the index predicate.
+-- This represents a change for field 'b' from unindexed to indexed and so
+-- this should not take the HOT path.
+UPDATE ex SET docs = jsonb_build_object('a', 0, 'b', 10) WHERE (docs->>'a')::numeric = 0;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- Let's check to make sure that the index contains the new value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Index Scan using idx_ex_b on ex
+   Filter: (((docs ->> 'b'::text))::numeric < '100'::numeric)
+(2 rows)
+
+SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+       docs        
+-------------------
+ {"a": 0, "b": 10}
+(1 row)
+
+-- This update modifies the value of 'a', an indexed field, so it also cannot
+-- be a HOT update.
+UPDATE ex SET docs = jsonb_build_object('a', 1, 'b', 10) WHERE (docs->>'b')::numeric = 10;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- This update changes both 'a' and 'b' to new values that require index updates,
+-- this cannot use the HOT path.
+UPDATE ex SET docs = jsonb_build_object('a', 2, 'b', 12) WHERE (docs->>'b')::numeric = 10;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- Let's check to make sure that the index contains the new value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Index Scan using idx_ex_b on ex
+   Filter: (((docs ->> 'b'::text))::numeric < '100'::numeric)
+(2 rows)
+
+SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+       docs        
+-------------------
+ {"a": 2, "b": 12}
+(1 row)
+
+-- This update changes 'b' to a value outside its predicate requiring that
+-- we remove it from the index.  That's a transition that can't be done
+-- during a HOT update.
+UPDATE ex SET docs = jsonb_build_object('a', 2, 'b', 1) WHERE (docs->>'b')::numeric = 12;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- Let's check to make sure that the index no longer contains the value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Index Scan using idx_ex_b on ex
+   Filter: (((docs ->> 'b'::text))::numeric < '100'::numeric)
+(2 rows)
+
+SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+ docs 
+------
+(0 rows)
+
+-- Disable expression checks.
+ALTER TABLE ex SET (expression_checks = false);
+SELECT reloptions FROM pg_class WHERE relname = 'ex';
+               reloptions                
+-----------------------------------------
+ {fillfactor=60,expression_checks=false}
+(1 row)
+
+-- This update changes 'b' to a value within its predicate just like it
+-- previous value, which would allow for a HOT update but with expression
+-- checks disabled we can't determine that so this should not be a HOT update.
+UPDATE ex SET docs = jsonb_build_object('a', 2, 'b', 2) WHERE (docs->>'b')::numeric = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- Let's make sure we're recording HOT updates for our 'ex' relation properly in the system
+-- table pg_stat_user_tables.  Note that statistics are stored within a transaction context
+-- first (xact) and then later into the global statistics for a relation, so first we need
+-- to ensure pending stats are flushed.
+SELECT pg_stat_force_next_flush();
+ pg_stat_force_next_flush 
+--------------------------
+ 
+(1 row)
+
+SELECT
+    c.relname AS table_name,
+    -- Transaction statistics
+    pg_stat_get_xact_tuples_updated(c.oid) AS xact_updates,
+    pg_stat_get_xact_tuples_hot_updated(c.oid) AS xact_hot_updates,
+    ROUND((
+        pg_stat_get_xact_tuples_hot_updated(c.oid)::float /
+        NULLIF(pg_stat_get_xact_tuples_updated(c.oid), 0) * 100
+    )::numeric, 2) AS xact_hot_update_percentage,
+    -- Cumulative statistics
+    s.n_tup_upd AS total_updates,
+    s.n_tup_hot_upd AS hot_updates,
+    ROUND((
+        s.n_tup_hot_upd::float /
+        NULLIF(s.n_tup_upd, 0) * 100
+    )::numeric, 2) AS total_hot_update_percentage
+FROM pg_class c
+LEFT JOIN pg_stat_user_tables s ON c.relname = s.relname
+WHERE c.relname = 'ex'
+AND c.relnamespace = 'public'::regnamespace;
+ table_name | xact_updates | xact_hot_updates | xact_hot_update_percentage | total_updates | hot_updates | total_hot_update_percentage 
+------------+--------------+------------------+----------------------------+---------------+-------------+-----------------------------
+ ex         |            0 |                0 |                            |             6 |           1 |                       16.67
+(1 row)
+
+-- expect: 5 xact updates with 1 xact hot update and no cumulative updates as yet
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+DROP TABLE ex;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- This table has a single column 'email' and a unique constraint on it that
+-- should preclude HOT updates.
+CREATE TABLE users (
+    user_id serial primary key,
+    name VARCHAR(255) NOT NULL,
+    email VARCHAR(255) NOT NULL,
+    EXCLUDE USING btree (lower(email) WITH =)
+);
+-- Add some data to the table and then update it in ways that should and should
+-- not be HOT updates.
+INSERT INTO users (name, email) VALUES
+('user1', 'user1@example.com'),
+('user2', 'user2@example.com'),
+('taken', 'taken@EXAMPLE.com'),
+('you', 'you@domain.com'),
+('taken', 'taken@domain.com');
+-- Should fail because of the unique constraint on the email column.
+UPDATE users SET email = 'user1@example.com' WHERE email = 'user2@example.com';
+ERROR:  conflicting key value violates exclusion constraint "users_lower_excl"
+DETAIL:  Key (lower(email::text))=(user1@example.com) conflicts with existing key (lower(email::text))=(user1@example.com).
+SELECT pg_stat_get_xact_tuples_hot_updated('users'::regclass); -- expect: 0 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   0
+(1 row)
+
+-- Should succeed because the email column is not being updated and should go HOT.
+UPDATE users SET name = 'foo' WHERE email = 'user1@example.com';
+SELECT pg_stat_get_xact_tuples_hot_updated('users'::regclass); -- expect: 1 a single new HOT update
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- Create a partial index on the email column, updates
+CREATE INDEX idx_users_email_no_example ON users (lower(email)) WHERE lower(email) LIKE '%@example.com%';
+-- An update that changes the email column but not the indexed portion of it and falls outside the constraint.
+-- Shouldn't be a HOT update because of the exclusion constraint.
+UPDATE users SET email = 'you+2@domain.com' WHERE name = 'you';
+SELECT pg_stat_get_xact_tuples_hot_updated('users'::regclass); -- expect: 1 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- An update that changes the email column but not the indexed portion of it and falls within the constraint.
+-- Again, should fail constraint and fail to be a HOT update.
+UPDATE users SET email = 'taken@domain.com' WHERE name = 'you';
+ERROR:  conflicting key value violates exclusion constraint "users_lower_excl"
+DETAIL:  Key (lower(email::text))=(taken@domain.com) conflicts with existing key (lower(email::text))=(taken@domain.com).
+SELECT pg_stat_get_xact_tuples_hot_updated('users'::regclass); -- expect: 1 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+DROP TABLE users;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- Another test of constraints spoiling HOT updates, this time with a range.
+CREATE TABLE events (
+    id serial primary key,
+    name VARCHAR(255) NOT NULL,
+    event_time tstzrange,
+    constraint no_screening_time_overlap exclude using gist (
+        event_time WITH &&
+    )
+);
+-- Add two non-overlapping events.
+INSERT INTO events (id, event_time, name)
+VALUES
+    (1, '["2023-01-01 19:00:00", "2023-01-01 20:45:00"]', 'event1'),
+    (2, '["2023-01-01 21:00:00", "2023-01-01 21:45:00"]', 'event2');
+-- Update the first event to overlap with the second, should fail the constraint and not be HOT.
+UPDATE events SET event_time = '["2023-01-01 20:00:00", "2023-01-01 21:45:00"]' WHERE id = 1;
+ERROR:  conflicting key value violates exclusion constraint "no_screening_time_overlap"
+DETAIL:  Key (event_time)=(["Sun Jan 01 20:00:00 2023 PST","Sun Jan 01 21:45:00 2023 PST"]) conflicts with existing key (event_time)=(["Sun Jan 01 21:00:00 2023 PST","Sun Jan 01 21:45:00 2023 PST"]).
+SELECT pg_stat_get_xact_tuples_hot_updated('events'::regclass); -- expect: 0 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   0
+(1 row)
+
+-- Update the first event to not overlap with the second, again not HOT due to the constraint.
+UPDATE events SET event_time = '["2023-01-01 22:00:00", "2023-01-01 22:45:00"]' WHERE id = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('events'::regclass); -- expect: 0 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   0
+(1 row)
+
+-- Update the first event to not overlap with the second, this time we're HOT because we don't overlap with the constraint.
+UPDATE events SET name = 'new name here' WHERE id = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('events'::regclass); -- expect: 1 one new HOT update
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+DROP TABLE events;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- A test to ensure that only modified summarizing indexes are updated, not
+-- all of them.
+CREATE TABLE ex (id SERIAL primary key, att1 JSONB, att2 text, att3 text, att4 text) WITH (fillfactor = 60);
+CREATE INDEX ex_expr1_idx ON ex USING btree((att1->'data'));
+CREATE INDEX ex_sumr1_idx ON ex USING BRIN(att2);
+CREATE INDEX ex_expr2_idx ON ex USING btree((att1->'a'));
+CREATE INDEX ex_expr3_idx ON ex USING btree((att1->'b'));
+CREATE INDEX ex_expr4_idx ON ex USING btree((att1->'c'));
+CREATE INDEX ex_sumr2_idx ON ex USING BRIN(att3);
+CREATE INDEX ex_sumr3_idx ON ex USING BRIN(att4);
+CREATE INDEX ex_expr5_idx ON ex USING btree((att1->'d'));
+INSERT INTO ex (att1, att2) VALUES ('{"data": []}'::json, 'nothing special');
+SELECT * FROM ex;
+ id |     att1     |      att2       | att3 | att4 
+----+--------------+-----------------+------+------
+  1 | {"data": []} | nothing special |      | 
+(1 row)
+
+-- Update att2 and att4 both are BRIN/summarizing indexes, this should be a HOT update and
+-- only update two of the three summarizing indexes.
+UPDATE ex SET att2 = 'special indeed', att4 = 'whatever';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 one new HOT update
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+SELECT * FROM ex;
+ id |     att1     |      att2      | att3 |   att4   
+----+--------------+----------------+------+----------
+  1 | {"data": []} | special indeed |      | whatever
+(1 row)
+
+-- Update att1 and att2, only one is BRIN/summarizing, this should NOT be a HOT update.
+UPDATE ex SET att1 = att1 || '{"data": "howdy"}', att2 = 'special, so special';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+SELECT * FROM ex;
+ id |       att1        |        att2         | att3 |   att4   
+----+-------------------+---------------------+------+----------
+  1 | {"data": "howdy"} | special, so special |      | whatever
+(1 row)
+
+-- Update att2, att3, and att4 all are BRIN/summarizing indexes, this should be a HOT update
+-- and yet still update all three summarizing indexes.
+UPDATE ex SET att2 = 'a', att3 = 'b', att4 = 'c';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 2 with one new HOT update
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   2
+(1 row)
+
+SELECT * FROM ex;
+ id |       att1        | att2 | att3 | att4 
+----+-------------------+------+------+------
+  1 | {"data": "howdy"} | a    | b    | c
+(1 row)
+
+-- Update att1, att2, and att3 all modified values are BRIN/summarizing indexes, this should be a HOT update
+-- and yet still update all three summarizing indexes.
+UPDATE ex SET att1 = '{"data": "howdy"}', att2 = 'd', att3 = 'e';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 3 with one new HOT update
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   3
+(1 row)
+
+SELECT * FROM ex;
+ id |       att1        | att2 | att3 | att4 
+----+-------------------+------+------+------
+  1 | {"data": "howdy"} | d    | e    | c
+(1 row)
+
+DROP TABLE ex;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- A test to ensure that summarizing indexes are not updated when they don't
+-- change, but are updated when they do while not prefent HOT updates.
+CREATE TABLE ex (att1 JSONB, att2 text) WITH (fillfactor = 60);
+CREATE INDEX ex_expr1_idx ON ex USING btree((att1->'data'));
+CREATE INDEX ex_sumr1_idx ON ex USING BRIN(att2);
+INSERT INTO ex VALUES ('{"data": []}', 'nothing special');
+-- Update the unindexed value of att1, this should be a HOT update and and should
+-- update the summarizing index.
+UPDATE ex SET att1 = att1 || '{"status": "stalemate"}';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 one new HOT update
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- Update the indexed value of att2, a summarized value, this is a summarized
+-- only update and should use the HOT path while still triggering an update to
+-- the summarizing BRIN index.
+UPDATE ex SET att2 = 'special indeed';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 2 one new HOT update
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   2
+(1 row)
+
+-- Update to att1 doesn't change the indexed value while the update to att2 does,
+-- this again is a summarized only update and should use the HOT path as well as
+-- trigger an update to the BRIN index.
+UPDATE ex SET att1 = att1 || '{"status": "checkmate"}', att2 = 'special, so special';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 3 one new HOT update
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   3
+(1 row)
+
+-- This updates both indexes, the expression index on att1 and the summarizing
+-- index on att2.  This should not be a HOT update because there are modified
+-- indexes and only some are summarized, not all.  This should force all
+-- indexes to be updated.
+UPDATE ex SET att1 = att1 || '{"data": [1,2,3]}', att2 = 'do you want to play a game?';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 4 both indexes updated
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   3
+(1 row)
+
+DROP TABLE ex;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- This test is for a table with a custom type and a custom operators on
+-- the BTREE index.  The question is, when comparing values for equality
+-- to determine if there are changes on the index or not... shouldn't we
+-- be using the custom operators?
+-- Create a type
+CREATE TYPE my_custom_type AS (val int);
+-- Comparison functions (returns boolean)
+CREATE FUNCTION my_custom_lt(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val < b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+CREATE FUNCTION my_custom_le(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val <= b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+CREATE FUNCTION my_custom_eq(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val = b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+CREATE FUNCTION my_custom_ge(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val >= b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+CREATE FUNCTION my_custom_gt(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val > b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+CREATE FUNCTION my_custom_ne(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val != b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+-- Comparison function (returns -1, 0, 1)
+CREATE FUNCTION my_custom_cmp(a my_custom_type, b my_custom_type) RETURNS int AS $$
+BEGIN
+    IF a.val < b.val THEN
+        RETURN -1;
+    ELSIF a.val > b.val THEN
+        RETURN 1;
+    ELSE
+        RETURN 0;
+    END IF;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+-- Create the operators
+CREATE OPERATOR < (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_lt,
+    COMMUTATOR = >,
+    NEGATOR = >=
+);
+CREATE OPERATOR <= (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_le,
+    COMMUTATOR = >=,
+    NEGATOR = >
+);
+CREATE OPERATOR = (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_eq,
+    COMMUTATOR = =,
+    NEGATOR = <>
+);
+CREATE OPERATOR >= (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_ge,
+    COMMUTATOR = <=,
+    NEGATOR = <
+);
+CREATE OPERATOR > (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_gt,
+    COMMUTATOR = <,
+    NEGATOR = <=
+);
+CREATE OPERATOR <> (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_ne,
+    COMMUTATOR = <>,
+    NEGATOR = =
+);
+-- Create the operator class (including the support function)
+CREATE OPERATOR CLASS my_custom_ops
+    DEFAULT FOR TYPE my_custom_type USING btree AS
+    OPERATOR 1 <,
+    OPERATOR 2 <=,
+    OPERATOR 3 =,
+    OPERATOR 4 >=,
+    OPERATOR 5 >,
+    FUNCTION 1 my_custom_cmp(my_custom_type, my_custom_type);
+-- Create the table
+CREATE TABLE my_table (
+    id int,
+    custom_val my_custom_type
+);
+-- Insert some data
+INSERT INTO my_table (id, custom_val) VALUES
+(1, ROW(3)::my_custom_type),
+(2, ROW(1)::my_custom_type),
+(3, ROW(4)::my_custom_type),
+(4, ROW(2)::my_custom_type);
+-- Create a function to use when indexing
+CREATE OR REPLACE FUNCTION abs_val(val my_custom_type) RETURNS int AS $$
+BEGIN
+  RETURN abs(val.val);
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+-- Create the index
+CREATE INDEX idx_custom_val_abs ON my_table (abs_val(custom_val));
+-- Update 1
+UPDATE my_table SET custom_val = ROW(5)::my_custom_type WHERE id = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('my_table'::regclass);
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   0
+(1 row)
+
+-- Update 2
+UPDATE my_table SET custom_val = ROW(0)::my_custom_type WHERE custom_val < ROW(3)::my_custom_type;
+SELECT pg_stat_get_xact_tuples_hot_updated('my_table'::regclass);
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   0
+(1 row)
+
+-- Update 3
+UPDATE my_table SET custom_val = ROW(6)::my_custom_type WHERE id = 3;
+SELECT pg_stat_get_xact_tuples_hot_updated('my_table'::regclass);
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   0
+(1 row)
+
+-- Update 4
+UPDATE my_table SET id = 5 WHERE id = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('my_table'::regclass);
+ pg_stat_get_xact_tuples_hot_updated 
+-------------------------------------
+                                   1
+(1 row)
+
+-- Query using the index
+EXPLAIN (COSTS OFF) SELECT * FROM my_table WHERE abs_val(custom_val) = 6;
+             QUERY PLAN              
+-------------------------------------
+ Seq Scan on my_table
+   Filter: (abs_val(custom_val) = 6)
+(2 rows)
+
+SELECT * FROM my_table WHERE abs_val(custom_val) = 6;
+ id | custom_val 
+----+------------
+  3 | (6)
+(1 row)
+
+-- Clean up
+DROP TABLE my_table CASCADE;
+DROP OPERATOR CLASS my_custom_ops USING btree CASCADE;
+DROP OPERATOR < (my_custom_type, my_custom_type);
+DROP OPERATOR <= (my_custom_type, my_custom_type);
+DROP OPERATOR = (my_custom_type, my_custom_type);
+DROP OPERATOR >= (my_custom_type, my_custom_type);
+DROP OPERATOR > (my_custom_type, my_custom_type);
+DROP OPERATOR <> (my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_lt(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_le(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_eq(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_ge(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_gt(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_ne(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_cmp(my_custom_type, my_custom_type);
+DROP FUNCTION abs_val(my_custom_type);
+DROP TYPE my_custom_type CASCADE;
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 0a35f2f8f6a..e2fd5fb74a5 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -68,6 +68,11 @@ test: select_into select_distinct select_distinct_on select_implicit select_havi
 # ----------
 test: brin gin gist spgist privileges init_privs security_label collate matview lock replica_identity rowsecurity object_address tablesample groupingsets drop_operator password identity generated_stored join_hash
 
+# ----------
+# Another group of parallel tests
+# ----------
+test: heap_hot_updates
+
 # ----------
 # Additional BRIN tests
 # ----------
diff --git a/src/test/regress/sql/heap_hot_updates.sql b/src/test/regress/sql/heap_hot_updates.sql
new file mode 100644
index 00000000000..56ec4f2f96c
--- /dev/null
+++ b/src/test/regress/sql/heap_hot_updates.sql
@@ -0,0 +1,481 @@
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- This table will have two columns and two indexes, one on the primary key
+-- id and one on the expression (info->>'name').  That means that the indexed
+-- attributes are 'id' and 'info'.
+create table keyvalue(id integer primary key, info jsonb);
+create index nameindex on keyvalue((info->>'name'));
+insert into keyvalue values (1, '{"name": "john", "data": "some data"}');
+
+-- Disable expression checks.
+ALTER TABLE keyvalue SET (expression_checks = false);
+SELECT reloptions FROM pg_class WHERE relname = 'keyvalue';
+
+-- While the indexed attribute "name" is unchanged we've disabled expression
+-- checks so this update should not go HOT as the system can't determine if
+-- the indexed attribute has changed without evaluating the expression.
+update keyvalue set info='{"name": "john", "data": "something else"}' where id=1;
+select pg_stat_get_xact_tuples_hot_updated('keyvalue'::regclass); -- expect: 0 row
+
+-- Re-enable expression checks.
+ALTER TABLE keyvalue SET (expression_checks = true);
+SELECT reloptions FROM pg_class WHERE relname = 'keyvalue';
+
+-- The indexed attribute "name" with value "john" is unchanged, expect a HOT update.
+update keyvalue set info='{"name": "john", "data": "some other data"}' where id=1;
+select pg_stat_get_xact_tuples_hot_updated('keyvalue'::regclass); -- expect: 1 row
+
+-- The following update changes the indexed attribute "name", this should not be a HOT update.
+update keyvalue set info='{"name": "smith", "data": "some other data"}' where id=1;
+select pg_stat_get_xact_tuples_hot_updated('keyvalue'::regclass); -- expect: 1 no new HOT updates
+
+-- Now, this update does not change the indexed attribute "name" from "smith", this should be HOT.
+update keyvalue set info='{"name": "smith", "data": "some more data"}' where id=1;
+select pg_stat_get_xact_tuples_hot_updated('keyvalue'::regclass); -- expect: 2 rows now
+drop table keyvalue;
+
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- This table is the same as the previous one but it has a third index.  The
+-- index 'colindex' isn't an expression index, it indexes the entire value
+-- in the info column.  There are still only two indexed attributes for this
+-- relation, the same two as before.  The presence of an index on the entire
+-- value of the info column should prevent HOT updates for any updates to any
+-- portion of JSONB content in that column.
+create table keyvalue(id integer primary key, info jsonb);
+create index nameindex on keyvalue((info->>'name'));
+create index colindex on keyvalue(info);
+insert into keyvalue values (1, '{"name": "john", "data": "some data"}');
+
+-- This update doesn't change the value of the expression index, but it does
+-- change the content of the info column and so should not be HOT because the
+-- indexed value changed as a result of the update.
+update keyvalue set info='{"name": "john", "data": "some other data"}' where id=1;
+select pg_stat_get_xact_tuples_hot_updated('keyvalue'::regclass); -- expect: 0 rows
+drop table keyvalue;
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- The table has one column docs and two indexes.  They are both expression
+-- indexes referencing the same column attribute (docs) but one is a partial
+-- index.
+CREATE TABLE ex (docs JSONB) WITH (fillfactor = 60);
+INSERT INTO ex (docs) VALUES ('{"a": 0, "b": 0}');
+INSERT INTO ex (docs) SELECT jsonb_build_object('b', n) FROM generate_series(100, 10000) as n;
+CREATE INDEX idx_ex_a ON ex ((docs->>'a'));
+CREATE INDEX idx_ex_b ON ex ((docs->>'b')) WHERE (docs->>'b')::numeric > 9;
+
+-- We're using BTREE indexes and for this test we want to make sure that they remain
+-- in sync with changes to our relation.  Force the choice of index scans below so
+-- that we know we're checking the index's understanding of what values should be
+-- in the index or not.
+SET SESSION enable_seqscan = OFF;
+SET SESSION enable_bitmapscan = OFF;
+
+-- Leave 'a' unchanged but modify 'b' to a value outside of the index predicate.
+-- This should be a HOT update because neither index is changed.
+UPDATE ex SET docs = jsonb_build_object('a', 0, 'b', 1) WHERE (docs->>'a')::numeric = 0;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 row
+-- Let's check to make sure that the index does not contain a value for 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+
+-- Leave 'a' unchanged but modify 'b' to a value within the index predicate.
+-- This represents a change for field 'b' from unindexed to indexed and so
+-- this should not take the HOT path.
+UPDATE ex SET docs = jsonb_build_object('a', 0, 'b', 10) WHERE (docs->>'a')::numeric = 0;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+-- Let's check to make sure that the index contains the new value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+
+-- This update modifies the value of 'a', an indexed field, so it also cannot
+-- be a HOT update.
+UPDATE ex SET docs = jsonb_build_object('a', 1, 'b', 10) WHERE (docs->>'b')::numeric = 10;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+
+-- This update changes both 'a' and 'b' to new values that require index updates,
+-- this cannot use the HOT path.
+UPDATE ex SET docs = jsonb_build_object('a', 2, 'b', 12) WHERE (docs->>'b')::numeric = 10;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+-- Let's check to make sure that the index contains the new value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+
+-- This update changes 'b' to a value outside its predicate requiring that
+-- we remove it from the index.  That's a transition that can't be done
+-- during a HOT update.
+UPDATE ex SET docs = jsonb_build_object('a', 2, 'b', 1) WHERE (docs->>'b')::numeric = 12;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+-- Let's check to make sure that the index no longer contains the value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+SELECT * FROM ex WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+
+-- Disable expression checks.
+ALTER TABLE ex SET (expression_checks = false);
+SELECT reloptions FROM pg_class WHERE relname = 'ex';
+
+-- This update changes 'b' to a value within its predicate just like it
+-- previous value, which would allow for a HOT update but with expression
+-- checks disabled we can't determine that so this should not be a HOT update.
+UPDATE ex SET docs = jsonb_build_object('a', 2, 'b', 2) WHERE (docs->>'b')::numeric = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+
+
+-- Let's make sure we're recording HOT updates for our 'ex' relation properly in the system
+-- table pg_stat_user_tables.  Note that statistics are stored within a transaction context
+-- first (xact) and then later into the global statistics for a relation, so first we need
+-- to ensure pending stats are flushed.
+SELECT pg_stat_force_next_flush();
+SELECT
+    c.relname AS table_name,
+    -- Transaction statistics
+    pg_stat_get_xact_tuples_updated(c.oid) AS xact_updates,
+    pg_stat_get_xact_tuples_hot_updated(c.oid) AS xact_hot_updates,
+    ROUND((
+        pg_stat_get_xact_tuples_hot_updated(c.oid)::float /
+        NULLIF(pg_stat_get_xact_tuples_updated(c.oid), 0) * 100
+    )::numeric, 2) AS xact_hot_update_percentage,
+    -- Cumulative statistics
+    s.n_tup_upd AS total_updates,
+    s.n_tup_hot_upd AS hot_updates,
+    ROUND((
+        s.n_tup_hot_upd::float /
+        NULLIF(s.n_tup_upd, 0) * 100
+    )::numeric, 2) AS total_hot_update_percentage
+FROM pg_class c
+LEFT JOIN pg_stat_user_tables s ON c.relname = s.relname
+WHERE c.relname = 'ex'
+AND c.relnamespace = 'public'::regnamespace;
+-- expect: 5 xact updates with 1 xact hot update and no cumulative updates as yet
+
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+
+DROP TABLE ex;
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- This table has a single column 'email' and a unique constraint on it that
+-- should preclude HOT updates.
+CREATE TABLE users (
+    user_id serial primary key,
+    name VARCHAR(255) NOT NULL,
+    email VARCHAR(255) NOT NULL,
+    EXCLUDE USING btree (lower(email) WITH =)
+);
+
+-- Add some data to the table and then update it in ways that should and should
+-- not be HOT updates.
+INSERT INTO users (name, email) VALUES
+('user1', 'user1@example.com'),
+('user2', 'user2@example.com'),
+('taken', 'taken@EXAMPLE.com'),
+('you', 'you@domain.com'),
+('taken', 'taken@domain.com');
+
+-- Should fail because of the unique constraint on the email column.
+UPDATE users SET email = 'user1@example.com' WHERE email = 'user2@example.com';
+SELECT pg_stat_get_xact_tuples_hot_updated('users'::regclass); -- expect: 0 no new HOT updates
+
+-- Should succeed because the email column is not being updated and should go HOT.
+UPDATE users SET name = 'foo' WHERE email = 'user1@example.com';
+SELECT pg_stat_get_xact_tuples_hot_updated('users'::regclass); -- expect: 1 a single new HOT update
+
+-- Create a partial index on the email column, updates
+CREATE INDEX idx_users_email_no_example ON users (lower(email)) WHERE lower(email) LIKE '%@example.com%';
+
+-- An update that changes the email column but not the indexed portion of it and falls outside the constraint.
+-- Shouldn't be a HOT update because of the exclusion constraint.
+UPDATE users SET email = 'you+2@domain.com' WHERE name = 'you';
+SELECT pg_stat_get_xact_tuples_hot_updated('users'::regclass); -- expect: 1 no new HOT updates
+
+-- An update that changes the email column but not the indexed portion of it and falls within the constraint.
+-- Again, should fail constraint and fail to be a HOT update.
+UPDATE users SET email = 'taken@domain.com' WHERE name = 'you';
+SELECT pg_stat_get_xact_tuples_hot_updated('users'::regclass); -- expect: 1 no new HOT updates
+
+DROP TABLE users;
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- Another test of constraints spoiling HOT updates, this time with a range.
+CREATE TABLE events (
+    id serial primary key,
+    name VARCHAR(255) NOT NULL,
+    event_time tstzrange,
+    constraint no_screening_time_overlap exclude using gist (
+        event_time WITH &&
+    )
+);
+
+-- Add two non-overlapping events.
+INSERT INTO events (id, event_time, name)
+VALUES
+    (1, '["2023-01-01 19:00:00", "2023-01-01 20:45:00"]', 'event1'),
+    (2, '["2023-01-01 21:00:00", "2023-01-01 21:45:00"]', 'event2');
+
+-- Update the first event to overlap with the second, should fail the constraint and not be HOT.
+UPDATE events SET event_time = '["2023-01-01 20:00:00", "2023-01-01 21:45:00"]' WHERE id = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('events'::regclass); -- expect: 0 no new HOT updates
+
+-- Update the first event to not overlap with the second, again not HOT due to the constraint.
+UPDATE events SET event_time = '["2023-01-01 22:00:00", "2023-01-01 22:45:00"]' WHERE id = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('events'::regclass); -- expect: 0 no new HOT updates
+
+-- Update the first event to not overlap with the second, this time we're HOT because we don't overlap with the constraint.
+UPDATE events SET name = 'new name here' WHERE id = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('events'::regclass); -- expect: 1 one new HOT update
+
+DROP TABLE events;
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- A test to ensure that only modified summarizing indexes are updated, not
+-- all of them.
+CREATE TABLE ex (id SERIAL primary key, att1 JSONB, att2 text, att3 text, att4 text) WITH (fillfactor = 60);
+CREATE INDEX ex_expr1_idx ON ex USING btree((att1->'data'));
+CREATE INDEX ex_sumr1_idx ON ex USING BRIN(att2);
+CREATE INDEX ex_expr2_idx ON ex USING btree((att1->'a'));
+CREATE INDEX ex_expr3_idx ON ex USING btree((att1->'b'));
+CREATE INDEX ex_expr4_idx ON ex USING btree((att1->'c'));
+CREATE INDEX ex_sumr2_idx ON ex USING BRIN(att3);
+CREATE INDEX ex_sumr3_idx ON ex USING BRIN(att4);
+CREATE INDEX ex_expr5_idx ON ex USING btree((att1->'d'));
+INSERT INTO ex (att1, att2) VALUES ('{"data": []}'::json, 'nothing special');
+
+SELECT * FROM ex;
+
+-- Update att2 and att4 both are BRIN/summarizing indexes, this should be a HOT update and
+-- only update two of the three summarizing indexes.
+UPDATE ex SET att2 = 'special indeed', att4 = 'whatever';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 one new HOT update
+SELECT * FROM ex;
+
+-- Update att1 and att2, only one is BRIN/summarizing, this should NOT be a HOT update.
+UPDATE ex SET att1 = att1 || '{"data": "howdy"}', att2 = 'special, so special';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 no new HOT updates
+SELECT * FROM ex;
+
+-- Update att2, att3, and att4 all are BRIN/summarizing indexes, this should be a HOT update
+-- and yet still update all three summarizing indexes.
+UPDATE ex SET att2 = 'a', att3 = 'b', att4 = 'c';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 2 with one new HOT update
+SELECT * FROM ex;
+
+-- Update att1, att2, and att3 all modified values are BRIN/summarizing indexes, this should be a HOT update
+-- and yet still update all three summarizing indexes.
+UPDATE ex SET att1 = '{"data": "howdy"}', att2 = 'd', att3 = 'e';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 3 with one new HOT update
+SELECT * FROM ex;
+
+DROP TABLE ex;
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- A test to ensure that summarizing indexes are not updated when they don't
+-- change, but are updated when they do while not prefent HOT updates.
+CREATE TABLE ex (att1 JSONB, att2 text) WITH (fillfactor = 60);
+CREATE INDEX ex_expr1_idx ON ex USING btree((att1->'data'));
+CREATE INDEX ex_sumr1_idx ON ex USING BRIN(att2);
+INSERT INTO ex VALUES ('{"data": []}', 'nothing special');
+
+-- Update the unindexed value of att1, this should be a HOT update and and should
+-- update the summarizing index.
+UPDATE ex SET att1 = att1 || '{"status": "stalemate"}';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 1 one new HOT update
+
+-- Update the indexed value of att2, a summarized value, this is a summarized
+-- only update and should use the HOT path while still triggering an update to
+-- the summarizing BRIN index.
+UPDATE ex SET att2 = 'special indeed';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 2 one new HOT update
+
+-- Update to att1 doesn't change the indexed value while the update to att2 does,
+-- this again is a summarized only update and should use the HOT path as well as
+-- trigger an update to the BRIN index.
+UPDATE ex SET att1 = att1 || '{"status": "checkmate"}', att2 = 'special, so special';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 3 one new HOT update
+
+-- This updates both indexes, the expression index on att1 and the summarizing
+-- index on att2.  This should not be a HOT update because there are modified
+-- indexes and only some are summarized, not all.  This should force all
+-- indexes to be updated.
+UPDATE ex SET att1 = att1 || '{"data": [1,2,3]}', att2 = 'do you want to play a game?';
+SELECT pg_stat_get_xact_tuples_hot_updated('ex'::regclass); -- expect: 4 both indexes updated
+
+DROP TABLE ex;
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- This test is for a table with a custom type and a custom operators on
+-- the BTREE index.  The question is, when comparing values for equality
+-- to determine if there are changes on the index or not... shouldn't we
+-- be using the custom operators?
+
+-- Create a type
+CREATE TYPE my_custom_type AS (val int);
+
+-- Comparison functions (returns boolean)
+CREATE FUNCTION my_custom_lt(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val < b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+
+CREATE FUNCTION my_custom_le(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val <= b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+
+CREATE FUNCTION my_custom_eq(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val = b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+
+CREATE FUNCTION my_custom_ge(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val >= b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+
+CREATE FUNCTION my_custom_gt(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val > b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+
+CREATE FUNCTION my_custom_ne(a my_custom_type, b my_custom_type) RETURNS boolean AS $$
+BEGIN
+    RETURN a.val != b.val;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+
+-- Comparison function (returns -1, 0, 1)
+CREATE FUNCTION my_custom_cmp(a my_custom_type, b my_custom_type) RETURNS int AS $$
+BEGIN
+    IF a.val < b.val THEN
+        RETURN -1;
+    ELSIF a.val > b.val THEN
+        RETURN 1;
+    ELSE
+        RETURN 0;
+    END IF;
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+
+-- Create the operators
+CREATE OPERATOR < (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_lt,
+    COMMUTATOR = >,
+    NEGATOR = >=
+);
+
+CREATE OPERATOR <= (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_le,
+    COMMUTATOR = >=,
+    NEGATOR = >
+);
+
+CREATE OPERATOR = (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_eq,
+    COMMUTATOR = =,
+    NEGATOR = <>
+);
+
+CREATE OPERATOR >= (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_ge,
+    COMMUTATOR = <=,
+    NEGATOR = <
+);
+
+CREATE OPERATOR > (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_gt,
+    COMMUTATOR = <,
+    NEGATOR = <=
+);
+
+CREATE OPERATOR <> (
+    LEFTARG = my_custom_type,
+    RIGHTARG = my_custom_type,
+    PROCEDURE = my_custom_ne,
+    COMMUTATOR = <>,
+    NEGATOR = =
+);
+
+-- Create the operator class (including the support function)
+CREATE OPERATOR CLASS my_custom_ops
+    DEFAULT FOR TYPE my_custom_type USING btree AS
+    OPERATOR 1 <,
+    OPERATOR 2 <=,
+    OPERATOR 3 =,
+    OPERATOR 4 >=,
+    OPERATOR 5 >,
+    FUNCTION 1 my_custom_cmp(my_custom_type, my_custom_type);
+
+-- Create the table
+CREATE TABLE my_table (
+    id int,
+    custom_val my_custom_type
+);
+
+-- Insert some data
+INSERT INTO my_table (id, custom_val) VALUES
+(1, ROW(3)::my_custom_type),
+(2, ROW(1)::my_custom_type),
+(3, ROW(4)::my_custom_type),
+(4, ROW(2)::my_custom_type);
+
+-- Create a function to use when indexing
+CREATE OR REPLACE FUNCTION abs_val(val my_custom_type) RETURNS int AS $$
+BEGIN
+  RETURN abs(val.val);
+END;
+$$ LANGUAGE plpgsql IMMUTABLE STRICT;
+
+-- Create the index
+CREATE INDEX idx_custom_val_abs ON my_table (abs_val(custom_val));
+
+-- Update 1
+UPDATE my_table SET custom_val = ROW(5)::my_custom_type WHERE id = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('my_table'::regclass);
+
+-- Update 2
+UPDATE my_table SET custom_val = ROW(0)::my_custom_type WHERE custom_val < ROW(3)::my_custom_type;
+SELECT pg_stat_get_xact_tuples_hot_updated('my_table'::regclass);
+
+-- Update 3
+UPDATE my_table SET custom_val = ROW(6)::my_custom_type WHERE id = 3;
+SELECT pg_stat_get_xact_tuples_hot_updated('my_table'::regclass);
+
+-- Update 4
+UPDATE my_table SET id = 5 WHERE id = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('my_table'::regclass);
+
+-- Query using the index
+EXPLAIN (COSTS OFF) SELECT * FROM my_table WHERE abs_val(custom_val) = 6;
+SELECT * FROM my_table WHERE abs_val(custom_val) = 6;
+
+-- Clean up
+DROP TABLE my_table CASCADE;
+DROP OPERATOR CLASS my_custom_ops USING btree CASCADE;
+DROP OPERATOR < (my_custom_type, my_custom_type);
+DROP OPERATOR <= (my_custom_type, my_custom_type);
+DROP OPERATOR = (my_custom_type, my_custom_type);
+DROP OPERATOR >= (my_custom_type, my_custom_type);
+DROP OPERATOR > (my_custom_type, my_custom_type);
+DROP OPERATOR <> (my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_lt(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_le(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_eq(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_ge(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_gt(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_ne(my_custom_type, my_custom_type);
+DROP FUNCTION my_custom_cmp(my_custom_type, my_custom_type);
+DROP FUNCTION abs_val(my_custom_type);
+DROP TYPE my_custom_type CASCADE;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 3fbf5a4c212..0b6587dbd56 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2934,6 +2934,7 @@ TSVectorStat
 TState
 TStatus
 TStoreState
+UpdateContext
 TU_UpdateIndexes
 TXNEntryFile
 TYPCATEGORY
@@ -3118,7 +3119,6 @@ UniqueState
 UnlistenStmt
 UnresolvedTup
 UnresolvedTupData
-UpdateContext
 UpdateStmt
 UpgradeTask
 UpgradeTaskProcessCB
-- 
2.42.0

