Subject: Re: Expanding HOT updates for expression and partial indexes

From: Matthias van de Meent <[email-masked]>
Date: Fri, 7 Mar 2025 23:47:45 +0100

On Thu, 6 Mar 2025 at 13:40, Burd, Greg <[email-masked]> wrote:
>
> > On Mar 5, 2025, at 6:39 PM, Matthias van de Meent <[email-masked]> wrote:
> >
> > On Wed, 5 Mar 2025 at 18:21, Burd, Greg <[email-masked]> wrote:
> >> * augments IndexInfo only when needed for testing expressions and only once
> >
> > ExecExpressionIndexesUpdated seems to always loop over all indexes,
> > always calling AttributeIndexInfo which always updates the fields in
> > the IndexInfo when the index has only !byval attributes (e.g. text,
> > json, or other such varlena types). You say it happens only once, have
> > I missed something?
>
> There's a test that avoids doing it more than once, [...]

Is this that one?

+    if (indexInfo->ii_IndexAttrByVal)
+        return indexInfo;

I think that test doesn't work consistently: a bitmapset * is NULL
when no bits are set; and for some indexes no attribute will be byval,
thus failing this early-exit even after processing.

Another small issue with this approach is that it always calls and
tests in EEIU(), while it's quite likely we would do better if we
pre-processed _all_ indexes at once, so that we can have a path that
doesn't repeatedly get into EEIU only to exit immediately after. It'll
probably be hot enough to not matter much, but it's still cycles spent
on something that we can optimize for in code.

> >> * retains existing summarized index HOT update logic
> >
> > Great, thanks!
> >
> > Kind regards,
> >
> > Matthias van de Meent
> > Neon (https://neon.tech)
>
> I might widen this patch a bit to include support for testing equality of index tuples using custom operators when they exist for the index.  In the use case I'm solving for we use a custom operator for equality that is not the same as a memcmp().  Do you have thoughts on that?

I don't think that's a very great idea. From a certain point of view,
you can see HOT as "deduplicating multiple tuple versions behind a
single TID". Btree doesn't support deduplication for types that can
have more than one representation of the same value so that e.g.
'0.0'::numeric and '0'::numeric are both displayed correctly, even
when they compare as equal according to certain equality operators.

So, I don't think that's worth investing time into right now. Maybe in
the future if there are new discoveries about what we can and cannot
deduplicate, but I don't think it should be part of an MVP or 1.0.

Kind regards,

Matthias van de Meent

---

From: "Burd, Greg" <[email-masked]>
Date: Tue, 25 Mar 2025 11:47:21 +0000

Matthias, 

Rebased patch attached.

Changes in v14:
* UpdateContext now the location I've stored estate, resultRelInfo, etc.
* Reuse the result from the predicate on the partial index.

 -greg

> On Mar 7, 2025, at 5:47 PM, Matthias van de Meent <[email-masked]> wrote:
> 

I've changed this a bit, now in ExecOpenIndices() when there are expressions or predicates I augment the IndexInfo with information necessary to perform the tests in EEIU().  I've debated adding another bool to ExecOpenIndices() to indicate that we're opening indexes for the purpose of an update to avoid building that information in cases where we are not.  Similar to the bool `speculative` on ExecOpenIndices() today.  Thoughts?

> when they compare as equal according to certain equality operators.

Interesting, good point.  Seems like it would require a new index AM function:
bool indexed_tuple_would_change()

I'll drop this for now, it seems out of scope for this patch set.

---

From: "Burd, Greg" <[email-masked]>
Date: Tue, 25 Mar 2025 14:21:14 +0000

Apologies for the noise, I overlooked a compiler warning.

fixed. 

-greg

> On Mar 25, 2025, at 7:47 AM, Burd, Greg <[email-masked]> wrote:
> 
> Matthias, 
> 
> Rebased patch attached.
> 
> Changes in v14:
> <v14-0001-Expand-HOT-update-path-to-include-expression-and.patch>

---

From: "Greg Burd" <[email-masked]>
Date: Wed, 02 Jul 2025 14:10:19 -0400

I'm working again on expanding the conditions under which HOT updates are allowable, it is still a work-in-progress at this point.  It has been a while since my last update to this patch set so I'll refresh everyone's memories (including myself) with an overview.  Apologies in advance for a long email...

The goal is to allow HOT updates under two new conditions:
* when an indexed expression has not changed
* when possible for a partial index

Expression Indexes and HOT updates
==========================================================
Indexes on expressions such as:
 CREATE INDEX test1_lower_col1_idx ON test1 (lower(col1));
 CREATE INDEX people_names ON people ((first_name || ' ' || last_name));
 CREATE INDEX names ON people((docs->>'user'));

These are not currently candidates for HOT updates because the attributes they reference are added to the hotblockingattrs bitmapset that is compared for overlap with the modified_attrs bitmap in heap_update() as created by HeapDetermineColumnsInfo().  Logically this exclusion makes sense because expressions used to form indexes can only contain references to functions that are IMMUTABLE.  This then allows for a simple and efficient method for determining if the update is a candidate for the HOT path, a quick check for overlap of two bitmapsets and you have an answer.

But there is a common case that is overlooked.  The third example above is an expression index that references a JSONB attribute 'docs' field 'name'.  Imagine a simple bit of JSON stored in the 'docs' JSONB column changed by an UPDATE from:
  { "user": "scott", "password": "tiger" }
to:
  { "user": "scott", "password": "$ecret" }
this will not use the HOT path in today's code because the attribute for 'docs' will be referenced in the UPDATE statement and that attribute's content did change so it will be in the modified_attrs which will overlap with the hotblockingattrs set.  The result is that it is not possible to use the HOT update path in heap_update() if there is an indexed JSONB column in that statement.  This has a huge impact on performance and bloat.  It's not hard to see that the index doesn't require an update, the portion of the document used to form the index tuple isn't changing.

This patch addressed this problem by evaluating the expression on the index using the current and new tuple and then comparing them.  If there is no change to the index tuple then the HOT path is still an option for the update whereas before this was not the case.

There is at least one major challenge to this approach left to solve, it invokes UDFs while holding BUFFER_LOCK_EXCLUSIVE which we learn from heap_attr_equals() "we cannot safely invoke user-defined functions while holding exclusive buffer lock."

So, for performance an safety reasons it makes sense to try to limit the expressions that can be evaluated to those that can't self-deadlock trying to pin the same buffer and are roughly constant time and fast so we don't hold that lock for very long.  Additionally it would be nice to ensure that we really have to do the evaluation in the first place.

I looked for ways to limit the types of expressions to evaluate so as to only take this approach when necessary, but I wasn't able to identify a good way forward.  The problem is that it is possible to author a UDF similar to the json/jsonb getter functions json_extract_path() that extract a portion of a datum that is then used when creating an index.  There is currently no demarcation on functions similar to "IMMUTABLE" (for example "EXTRACTOR") that would indicate that's what the UDF does nor does it strictly fit within the category of volatility as I don't think of this as information for the optimizer to use.  Ideally you'd only want to evaluate the expression when any function in the expression is known to extract a portion of the datum.  Were that the case you could require for HOT updates in the presence of expression indexes that at least one function in the expression be an "EXTRACTOR".  I might still do this depending on reaction to it here, I'm seeking ideas.

Another idea was to change up the strategy a bit and not check expressions at all, but rather find a method to keep the attribute out of the modified_attrs set to begin with.  This would mean that HeapDeterminColumnsInfo() would have to change, any maybe that's a good idea to try out too.  It could even morph into something that can test equality using type-specific equality tests supporting custom types and invoke index access method-specific path extraction functions.  In some ways this feels more "correct" to me in that HeapDeterminColumnsInfo()'s goal is to reduce the set of attributes in play from the interesting set to the modified set and if what is interesting is an index on a field within a document that isn't modified then it shouldn't be in the set.  This comes with challenges related to that comment mentioned earlier in heap_attr_equals() and the fact that index access methods don't have a way to supply their equality op or provide a way to call a path operator should one exist.  But the benefit would be that we expand HOT updates a bit further than my initial goals.

I'm not wedded to the use of a reloption to disable expression checks, nor do I love the name I choose ("expression_checks").  I'm interested to hear if I should simple remove this.

Another benefit to changing HeapDeterminColumnsInfo() is that it is what provides information to the PHOT patch during heap pruning to record which attributes were changed.  As it stands now, this patch and the PHOT patch when combined result in some undesirable index scan results.

Partial Indexes and HOT updates
==========================================================

Partial indexes are a problem when it comes to HOT updates because they are part of the modified_attrs set returned by HeapDeterminColumnsInfo() even in cases where the value being set in the UPDATE does not satisfy the partial index predicate.  Take for example:

CREATE TABLE example (a int, b int);
CREATE INDEX idx_a ON example(a);
CREATE INDEX idx_b ON example(b) WHERE b > 100;
INSERT INTO example (a, b) VALUES (1, 50);
UPDATE example SET b = 60 WHERE a = 1;

What happened?  Was the update HOT?  No.  Why?  Because the predicate isn't taken into account at the time the decision for HOT (or not) is made, that happens later during ExecInsertIndexTuples().  Today this update would trigger new index entries for both idx_a and idx_b when neither were required.

This patch changes this behavior by evaluating the predicate for the partial index earlier during heap_update().  When both the existing and updated tuples fall outside of the predicate the HOT update path is still an option.  To limit scope creep I've not implemented the case when the existing was within the index's predicate but now the updated falls outside the predicate because that would require adding an optional delete function to the index access method (something that I think would be generally useful at some point).  When that happens this patch does not allow a HOT update forcing the new tuple in the heap to not be redirected so later on index scan the CTID from the index will be ignored.

Summary
==========================================================

Attached find v17 of this patch rebased to this morning's (EDT) HEAD passing make world and formatted.

any and all feedback welcome.

-greg

From 4057bfe4636de795bb14afa230d77cb7ead13b34 Mon Sep 17 00:00:00 2001
From: Greg Burd <[email-masked]>
Date: Mon, 27 Jan 2025 13:28:59 -0500
Subject: [PATCH v17] Expand HOT update path to include expression and partial
 indexes.

This patch extends the cases where HOT updates are possible in the
heapam.  Expression indexes have historically been considered "hot
blocking" because the functions used within an expression must be marked
IMMUTABLE and so any changed attribute referenced within the expression
logically requires a new index tuple.  However this logic doesn't hold
true in cases where only a portion of an attribute is used to form the
index tuple as is commonly the case with JSONB columns or other index
access methods that have custom operators which extract a subset of an
attribute when forming the index tuple. In these cases while an
attribute in an UPDATE statement may have changed as determined by
HeapDetermineColumnsInfo() and is part of the modified_attrs set the
result of the expression used to form the index tuple may be unchanged
from the previous version.  This oversight makes it impossible to have a
HOT update when an update includes a JSONB column.  This patch corrects
that. The index expression is evaluated to form the before and after
index datums and compares them to determine if the mutation to the
attribute(s) referenced in the expression is in fact a change that
requires inserting a new index tuple or not.

Secondly, updates to attributes referenced by partial indexes are
effectively "HOT blocking" even when they fall outside of the index's
predicate forcing all indexes on the relation to be updated.  This patch
changes that behavior allowing HOT updates only in cases where the
predicate on the index is not satisfied for either the current or the
new tuple.

It is important to note that this patch is an evolution of a previous
patch
https://postgr.es/m/4d9928ee-a9e6-15f9-9c82-5981f13ffca6%40postgrespro.ru
applied in c203d6cf81b4d7e43edb2b75ec1b741ba48e04e0 and later reverted
in 05f84605dbeb9cf8279a157234b24bbb706c5256.

Signed-off-by: Greg Burd <[email-masked]>
---
 doc/src/sgml/ref/create_table.sgml            |   18 +
 doc/src/sgml/storage.sgml                     |   29 +-
 src/backend/access/common/reloptions.c        |   12 +-
 src/backend/access/heap/README.HOT            |   45 +-
 src/backend/access/heap/heapam.c              |   61 +-
 src/backend/access/heap/heapam_handler.c      |   15 +-
 src/backend/access/table/tableam.c            |    5 +-
 src/backend/catalog/index.c                   |   48 +
 src/backend/catalog/indexing.c                |   18 +-
 src/backend/commands/copyfrom.c               |    2 +-
 src/backend/executor/execIndexing.c           |  212 +++-
 src/backend/executor/execPartition.c          |    2 +-
 src/backend/executor/execReplication.c        |   10 +-
 src/backend/executor/nodeModifyTable.c        |   31 +-
 src/backend/replication/logical/worker.c      |    6 +-
 src/backend/utils/cache/relcache.c            |   81 +-
 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               |    8 +-
 src/include/nodes/execnodes.h                 |   13 +
 src/include/utils/rel.h                       |   10 +
 src/include/utils/relcache.h                  |    1 +
 .../regress/expected/heap_hot_updates.out     | 1048 +++++++++++++++++
 src/test/regress/parallel_schedule            |    5 +
 src/test/regress/sql/heap_hot_updates.sql     |  718 +++++++++++
 src/tools/pgindent/typedefs.list              |    2 +-
 28 files changed, 2302 insertions(+), 140 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 dc000e913c1..bece90dc192 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -1997,6 +1997,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..6b340e885f2 100644
--- a/doc/src/sgml/storage.sgml
+++ b/doc/src/sgml/storage.sgml
@@ -1097,10 +1097,10 @@ data. Empty in ordinary tables.</entry>
   <itemizedlist>
    <listitem>
     <para>
-     The update does not modify any columns referenced by the table's indexes,
-     not including summarizing indexes.  The only summarizing index method in
-     the core <productname>PostgreSQL</productname> distribution is <link
-     linkend="brin">BRIN</link>.
+     The update does not modify index keys, or when using a summarized
+     index.  The only summarizing index method in the core
+     <productname>PostgreSQL</productname> distribution is
+     <link linkend="brin">BRIN</link>.
      </para>
    </listitem>
    <listitem>
@@ -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 determine 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 50747c16396..407d5dcf0c9 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}}
 };
@@ -1915,7 +1924,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 0dcd6ee817e..9edd18808f8 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -3238,13 +3238,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;
@@ -3323,6 +3324,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);
@@ -3384,10 +3387,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 */
@@ -3685,10 +3689,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);
@@ -4005,23 +4010,39 @@ l2:
 
 	if (newbuf == buffer)
 	{
+		ResultRelInfo *resultRelInfo = updateCxt->rri;
+		EState	   *estate = updateCxt->estate;
+
 		/*
-		 * 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.
+		 * hot_attrs includes indexes with expressions and indexes with
+		 * predicates that may not be impacted by this change.  If the
+		 * modified attributes in this update don't overlap with any
+		 * attributes referenced by indexes on the relation then we can use
+		 * the HOT update path.  If they do overlap, then check to see if the
+		 * overlap is exclusively due to attributes that are only referenced
+		 * within expressions.  If that is the case, the HOT update path may
+		 * be possible iff the expression indexes are unchanged by this update
+		 * or, with partial indexes, both the new and the old heap tuples
+		 * don't satisfy the partial index predicate expression (meaning they
+		 * are both outside of the scope of the index).
 		 */
-		if (!bms_overlap(modified_attrs, hot_attrs))
+		if (!bms_overlap(modified_attrs, hot_attrs) ||
+			(bms_is_subset(modified_attrs, exp_attrs) &&
+			 !ExecExprIndexesRequireUpdates(relation,
+											resultRelInfo,
+											modified_attrs,
+											estate,
+											resultRelInfo->ri_oldTupleSlot,
+											resultRelInfo->ri_newTupleSlot)))
 		{
 			use_hot_update = true;
 
 			/*
-			 * If none of the columns that are used in hot-blocking indexes
-			 * were updated, we can apply HOT, but we do still need to check
-			 * if we need to update the summarizing indexes, and update those
-			 * indexes if the columns were updated, or we may fail to detect
-			 * e.g. value bound changes in BRIN minmax indexes.
+			 * If all modified attributes were only referenced by summarizing
+			 * indexes then we remain HOT, but we need to update those indexes
+			 * to ensure that they are consistent with the new tuple.
 			 */
-			if (bms_overlap(modified_attrs, sum_attrs))
+			if (bms_is_subset(modified_attrs, sum_attrs))
 				summarized_update = true;
 		}
 	}
@@ -4192,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);
@@ -4481,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 cb4bc35c93e..ae03ed5e718 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 aa216683b74..cace52120af 100644
--- a/src/backend/catalog/index.c
+++ b/src/backend/catalog/index.c
@@ -2467,6 +2467,8 @@ BuildIndexInfo(Relation index)
 								 &ii->ii_ExclusionStrats);
 	}
 
+	ii->ii_IndexAttrByVal = NULL;
+
 	return ii;
 }
 
@@ -2707,6 +2709,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;
+
+	/*
+	 * Expressions are not allowed on non-key attributes, so we can skip them
+	 * as they should show up in the index HOT-blocking attributes.
+	 */
+	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_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_ExpressionsAttrs);
+
+	/* 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..c413c099da8 100644
--- a/src/backend/catalog/indexing.c
+++ b/src/backend/catalog/indexing.c
@@ -49,7 +49,7 @@ CatalogOpenIndexes(Relation heapRel)
 	resultRelInfo->ri_RelationDesc = heapRel;
 	resultRelInfo->ri_TrigDesc = NULL;	/* we don't fire triggers */
 
-	ExecOpenIndices(resultRelInfo, false);
+	ExecOpenIndices(resultRelInfo, false, false);
 
 	return resultRelInfo;
 }
@@ -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/commands/copyfrom.c b/src/backend/commands/copyfrom.c
index fbbbc09a97b..f8271aa8866 100644
--- a/src/backend/commands/copyfrom.c
+++ b/src/backend/commands/copyfrom.c
@@ -921,7 +921,7 @@ CopyFrom(CopyFromState cstate)
 	/* Verify the named relation is a valid target for INSERT */
 	CheckValidResultRel(resultRelInfo, CMD_INSERT, NIL);
 
-	ExecOpenIndices(resultRelInfo, false);
+	ExecOpenIndices(resultRelInfo, false, false);
 
 	/*
 	 * Set up a ModifyTableState so we can let FDW(s) init themselves for
diff --git a/src/backend/executor/execIndexing.c b/src/backend/executor/execIndexing.c
index ca33a854278..940311dc0d6 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
@@ -157,7 +160,7 @@ static void ExecWithoutOverlapsNotEmpty(Relation rel, NameData attname, Datum at
  * ----------------------------------------------------------------
  */
 void
-ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative)
+ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative, bool update)
 {
 	Relation	resultRelation = resultRelInfo->ri_RelationDesc;
 	List	   *indexoidlist;
@@ -220,6 +223,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 (update && (ii->ii_Expressions || ii->ii_Predicate))
+			BuildExpressionIndexInfo(indexDesc, ii);
+
 		relationDescs[i] = indexDesc;
 		indexInfoArray[i] = ii;
 		i++;
@@ -382,19 +392,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;
+			}
 		}
 
 		/*
@@ -1095,6 +1119,9 @@ index_unchanged_by_update(ResultRelInfo *resultRelInfo, EState *estate,
 
 	if (hasexpression)
 	{
+		if (indexInfo->ii_IndexUnchanged)
+			return true;
+
 		indexInfo->ii_IndexUnchanged = false;
 		return false;
 	}
@@ -1172,3 +1199,168 @@ 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))));
 }
+
+/*
+ *
+ * This will first determine if the index has a predicate and if so if the
+ * update satisfies that or not.  Then, if necessary, we compare old and
+ * new values of the indexed expression and help determine if it is possible
+ * to use a HOT update or not.
+ *
+ * 'resultRelInfo' is the table with the indexes we should examine.
+ * 'modified' is the set of attributes that are modified by the update.
+ * 'estate' is the executor state for the update.
+ * 'old_tts' is a slot with the old tuple.
+ * 'new_tts' is a slot with the new tuple.
+ *
+ * Returns true iff none of the indexes on this relation require updating.
+ *
+ * When the changes in new tuple impact a value stored in an index we must
+ * return true. When an index has a predicate that is not satisfied by either
+ * the new or old tuples then that index is unchanged. When an index has a
+ * predicate that is satisfied by both the old and new tuples then we can
+ * proceed and check to see if the indexed values were changed or not.
+ */
+bool
+ExecExprIndexesRequireUpdates(Relation relation,
+							  ResultRelInfo *resultRelInfo,
+							  Bitmapset *modifiedAttrs,
+							  EState *estate,
+							  TupleTableSlot *old_tts,
+							  TupleTableSlot *new_tts)
+{
+	bool		expression_checks = RelationGetExpressionChecks(relation);
+	bool		result = false;
+	IndexInfo  *indexInfo;
+	TupleTableSlot *save_scantuple;
+	ExprContext *econtext = NULL;
+
+	if (resultRelInfo == NULL || estate == NULL ||
+		old_tts == NULL || new_tts == NULL ||
+		!expression_checks || IsolationIsSerializable())
+		return true;
+
+	econtext = GetPerTupleExprContext(estate);
+
+	/*
+	 * Examine each index on this relation to see if it is affected by the
+	 * changes in newtup.  If any index is changed, we must not use a HOT
+	 * update.
+	 */
+	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".
+			 */
+			save_scantuple = econtext->ecxt_scantuple;
+			econtext->ecxt_scantuple = old_tts;
+			old_tuple_qualifies = ExecQual(pstate, econtext);
+
+			econtext->ecxt_scantuple = new_tts;
+			new_tuple_qualifies = ExecQual(pstate, econtext);
+			econtext->ecxt_scantuple = save_scantuple;
+
+			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 an index update.
+			 */
+			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_ExpressionsAttrs, modifiedAttrs))
+		{
+			Datum		old_values[INDEX_MAX_KEYS];
+			bool		old_isnull[INDEX_MAX_KEYS];
+			Datum		new_values[INDEX_MAX_KEYS];
+			bool		new_isnull[INDEX_MAX_KEYS];
+
+			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])
+				{
+					result = 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))
+					{
+						result = true;
+						break;
+					}
+				}
+			}
+
+			indexInfo->ii_CheckedUnchanged = true;
+			indexInfo->ii_IndexUnchanged = !result;
+
+			if (result)
+				break;
+		}
+	}
+
+	return result;
+}
diff --git a/src/backend/executor/execPartition.c b/src/backend/executor/execPartition.c
index 514eae1037d..2886705ed39 100644
--- a/src/backend/executor/execPartition.c
+++ b/src/backend/executor/execPartition.c
@@ -539,7 +539,7 @@ ExecInitPartitionInfo(ModifyTableState *mtstate, EState *estate,
 		leaf_part_rri->ri_IndexRelationDescs == NULL)
 		ExecOpenIndices(leaf_part_rri,
 						(node != NULL &&
-						 node->onConflictAction != ONCONFLICT_NONE));
+						 node->onConflictAction != ONCONFLICT_NONE), false);
 
 	/*
 	 * Build WITH CHECK OPTION constraints for the partition.  Note that we
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index 53ddd25c42d..3c97fce0100 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -677,7 +677,7 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo,
 	if (!skip_tuple)
 	{
 		List	   *recheckIndexes = NIL;
-		TU_UpdateIndexes update_indexes;
+		UpdateContext updateCxt = {0};
 		List	   *conflictindexes;
 		bool		conflict = false;
 
@@ -693,17 +693,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 54da8e7995b..b159ecb7b15 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -116,21 +116,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,
@@ -890,7 +875,7 @@ ExecInsert(ModifyTableContext *context,
 	 */
 	if (resultRelationDesc->rd_rel->relhasindex &&
 		resultRelInfo->ri_IndexRelationDescs == NULL)
-		ExecOpenIndices(resultRelInfo, onconflict != ONCONFLICT_NONE);
+		ExecOpenIndices(resultRelInfo, onconflict != ONCONFLICT_NONE, false);
 
 	/*
 	 * BEFORE ROW INSERT Triggers.
@@ -2105,7 +2090,7 @@ ExecUpdatePrologue(ModifyTableContext *context, ResultRelInfo *resultRelInfo,
 	 */
 	if (resultRelationDesc->rd_rel->relhasindex &&
 		resultRelInfo->ri_IndexRelationDescs == NULL)
-		ExecOpenIndices(resultRelInfo, false);
+		ExecOpenIndices(resultRelInfo, false, true);
 
 	/* BEFORE ROW UPDATE triggers */
 	if (resultRelInfo->ri_TrigDesc &&
@@ -2303,8 +2288,7 @@ lreplace:
 								estate->es_snapshot,
 								estate->es_crosscheck_snapshot,
 								true /* wait for commit */ ,
-								&context->tmfd, &updateCxt->lockmode,
-								&updateCxt->updateIndexes);
+								&context->tmfd, updateCxt);
 
 	return result;
 }
@@ -2322,6 +2306,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))
@@ -2329,7 +2314,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,
@@ -2465,6 +2450,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
 	 */
@@ -3153,6 +3141,9 @@ lmerge_matched:
 		TM_Result	result;
 		UpdateContext updateCxt = {0};
 
+		updateCxt.rri = resultRelInfo;
+		updateCxt.estate = estate;
+
 		/*
 		 * Test condition, if any.
 		 *
diff --git a/src/backend/replication/logical/worker.c b/src/backend/replication/logical/worker.c
index fd11805a44c..51054693f24 100644
--- a/src/backend/replication/logical/worker.c
+++ b/src/backend/replication/logical/worker.c
@@ -2452,7 +2452,7 @@ apply_handle_insert(StringInfo s)
 	{
 		ResultRelInfo *relinfo = edata->targetRelInfo;
 
-		ExecOpenIndices(relinfo, false);
+		ExecOpenIndices(relinfo, false, false);
 		apply_handle_insert_internal(edata, relinfo, remoteslot);
 		ExecCloseIndices(relinfo);
 	}
@@ -2675,7 +2675,7 @@ apply_handle_update_internal(ApplyExecutionData *edata,
 	MemoryContext oldctx;
 
 	EvalPlanQualInit(&epqstate, estate, NULL, NIL, -1, NIL);
-	ExecOpenIndices(relinfo, false);
+	ExecOpenIndices(relinfo, false, true);
 
 	found = FindReplTupleInLocalRel(edata, localrel,
 									&relmapentry->remoterel,
@@ -2819,7 +2819,7 @@ apply_handle_delete(StringInfo s)
 	{
 		ResultRelInfo *relinfo = edata->targetRelInfo;
 
-		ExecOpenIndices(relinfo, false);
+		ExecOpenIndices(relinfo, false, false);
 		apply_handle_delete_internal(edata, relinfo,
 									 remoteslot, rel->localindexoid);
 		ExecCloseIndices(relinfo);
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 559ba9cdb2c..b7e78fb3bf1 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"
@@ -2482,6 +2483,7 @@ RelationDestroyRelation(Relation relation, bool remember_tupdesc)
 	bms_free(relation->rd_idattr);
 	bms_free(relation->rd_hotblockingattr);
 	bms_free(relation->rd_summarizedattr);
+	bms_free(relation->rd_expressionattr);
 	if (relation->rd_pubdesc)
 		pfree(relation->rd_pubdesc);
 	if (relation->rd_options)
@@ -5283,6 +5285,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.
@@ -5305,8 +5308,9 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind)
 	Bitmapset  *uindexattrs;	/* columns in unique indexes */
 	Bitmapset  *pkindexattrs;	/* columns in the primary index */
 	Bitmapset  *idindexattrs;	/* columns in the replica identity */
-	Bitmapset  *hotblockingattrs;	/* columns with HOT blocking indexes */
-	Bitmapset  *summarizedattrs;	/* columns with summarizing indexes */
+	Bitmapset  *idx_attrs;		/* columns referenced by indexes */
+	Bitmapset  *expr_attrs;		/* columns referenced by index expressions */
+	Bitmapset  *sum_attrs;		/* columns with summarizing indexes */
 	List	   *indexoidlist;
 	List	   *newindexoidlist;
 	Oid			relpkindex;
@@ -5329,6 +5333,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);
 		}
@@ -5371,8 +5377,9 @@ restart:
 	uindexattrs = NULL;
 	pkindexattrs = NULL;
 	idindexattrs = NULL;
-	hotblockingattrs = NULL;
-	summarizedattrs = NULL;
+	idx_attrs = NULL;
+	expr_attrs = NULL;
+	sum_attrs = NULL;
 	foreach(l, indexoidlist)
 	{
 		Oid			indexOid = lfirst_oid(l);
@@ -5386,6 +5393,7 @@ restart:
 		bool		isPK;		/* primary key */
 		bool		isIDKey;	/* replica identity index */
 		Bitmapset **attrs;
+		Bitmapset **exprattrs;
 
 		indexDesc = index_open(indexOid, AccessShareLock);
 
@@ -5429,20 +5437,26 @@ restart:
 		 * decide which bitmap we'll update in the following loop.
 		 */
 		if (indexDesc->rd_indam->amsummarizing)
-			attrs = &summarizedattrs;
+		{
+			attrs = &sum_attrs;
+			exprattrs = &sum_attrs;
+		}
 		else
-			attrs = &hotblockingattrs;
+		{
+			attrs = &idx_attrs;
+			exprattrs = &expr_attrs;
+		}
 
 		/* 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
 			 * handle them accurately here. non-key columns must be added into
-			 * hotblockingattrs or summarizedattrs, since they are in index,
-			 * and update shouldn't miss them.
+			 * idx_attrs or sum_attrs, since they are in index, and update
+			 * shouldn't miss them.
 			 *
 			 * Summarizing indexes do not block HOT, but do need to be updated
 			 * when the column value changes, thus require a separate
@@ -5452,30 +5466,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);
 	}
@@ -5503,12 +5515,24 @@ restart:
 		bms_free(uindexattrs);
 		bms_free(pkindexattrs);
 		bms_free(idindexattrs);
-		bms_free(hotblockingattrs);
-		bms_free(summarizedattrs);
+		bms_free(idx_attrs);
+		bms_free(expr_attrs);
+		bms_free(sum_attrs);
 
 		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 attributes from the HOT-blocking columns bitmap as
+	 * those will be checked separately.
+	 */
+	expr_attrs = bms_del_members(expr_attrs, idx_attrs);
+	idx_attrs = bms_add_members(idx_attrs, expr_attrs);
+	expr_attrs = bms_add_members(expr_attrs, sum_attrs);
+
 	/* Don't leak the old values of these bitmaps, if any */
 	relation->rd_attrsvalid = false;
 	bms_free(relation->rd_keyattr);
@@ -5521,6 +5545,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
@@ -5533,8 +5559,9 @@ restart:
 	relation->rd_keyattr = bms_copy(uindexattrs);
 	relation->rd_pkattr = bms_copy(pkindexattrs);
 	relation->rd_idattr = bms_copy(idindexattrs);
-	relation->rd_hotblockingattr = bms_copy(hotblockingattrs);
-	relation->rd_summarizedattr = bms_copy(summarizedattrs);
+	relation->rd_hotblockingattr = bms_copy(idx_attrs);
+	relation->rd_summarizedattr = bms_copy(sum_attrs);
+	relation->rd_expressionattr = bms_copy(expr_attrs);
 	relation->rd_attrsvalid = true;
 	MemoryContextSwitchTo(oldcxt);
 
@@ -5548,9 +5575,11 @@ restart:
 		case INDEX_ATTR_BITMAP_IDENTITY_KEY:
 			return idindexattrs;
 		case INDEX_ATTR_BITMAP_HOT_BLOCKING:
-			return hotblockingattrs;
+			return idx_attrs;
 		case INDEX_ATTR_BITMAP_SUMMARIZED:
-			return summarizedattrs;
+			return sum_attrs;
+		case INDEX_ATTR_BITMAP_EXPRESSION:
+			return expr_attrs;
 		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 8c2ea0b9587..07d1cffdefa 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -3007,7 +3007,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 a2bd5a897f8..5dd0922f52f 100644
--- a/src/include/access/heapam.h
+++ b/src/include/access/heapam.h
@@ -22,6 +22,7 @@
 #include "access/table.h"		/* for backward compatibility */
 #include "access/tableam.h"
 #include "commands/vacuum.h"
+#include "executor/executor.h"
 #include "nodes/lockoptions.h"
 #include "nodes/primnodes.h"
 #include "storage/bufpage.h"
@@ -329,8 +330,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,
@@ -365,7 +365,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 1c9e802a6b1..932f8ff6c4c 100644
--- a/src/include/access/tableam.h
+++ b/src/include/access/tableam.h
@@ -118,6 +118,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
@@ -542,8 +564,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,
@@ -1494,13 +1515,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);
 }
 
 /*
@@ -2001,7 +2020,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 104b059544d..9cffdaa8561 100644
--- a/src/include/executor/executor.h
+++ b/src/include/executor/executor.h
@@ -731,7 +731,7 @@ extern Bitmapset *ExecGetAllUpdatedCols(ResultRelInfo *relinfo, EState *estate);
 /*
  * prototypes from functions in execIndexing.c
  */
-extern void ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative);
+extern void ExecOpenIndices(ResultRelInfo *resultRelInfo, bool speculative, bool update);
 extern void ExecCloseIndices(ResultRelInfo *resultRelInfo);
 extern List *ExecInsertIndexTuples(ResultRelInfo *resultRelInfo,
 								   TupleTableSlot *slot, EState *estate,
@@ -749,6 +749,12 @@ extern void check_exclusion_constraint(Relation heap, Relation index,
 									   ItemPointer tupleid,
 									   const Datum *values, const bool *isnull,
 									   EState *estate, bool newIndex);
+extern bool ExecExprIndexesRequireUpdates(Relation relation,
+										  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 e107d6e5f81..bc6d1919103 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -180,11 +180,20 @@ typedef struct IndexInfo
 	List	   *ii_Expressions; /* list of Expr */
 	/* exec state for expressions, or NIL if none */
 	List	   *ii_ExpressionsState;	/* list of ExprState */
+	/* attributes referenced by expressions, or NULL if none */
+	Bitmapset  *ii_ExpressionsAttrs;
+
+	/* index attribute length */
+	uint16		ii_IndexAttrLen[INDEX_MAX_KEYS];
+	/* is the index attribute by-value */
+	Bitmapset  *ii_IndexAttrByVal;
 
 	/* partial-index predicate, or NIL if none */
 	List	   *ii_Predicate;	/* list of Expr */
 	/* exec state for expressions, or NIL if none */
 	ExprState  *ii_PredicateState;
+	/* attributes referenced by the predicate, or NULL if none */
+	Bitmapset  *ii_PredicateAttrs;
 
 	/* Per-column exclusion operators, or NULL if none */
 	Oid		   *ii_ExclusionOps;	/* array with one entry per column */
@@ -208,6 +217,10 @@ typedef struct IndexInfo
 	bool		ii_CheckedUnchanged;
 	/* aminsert hint, cached for retail inserts */
 	bool		ii_IndexUnchanged;
+	/* partial index predicate determined yet? */
+	bool		ii_CheckedPredicate;
+	/* amupdate hint used to avoid rechecking predicate */
+	bool		ii_PredicateSatisfied;
 	/* are we doing a concurrent index build? */
 	bool		ii_Concurrent;
 	/* did we detect any broken HOT chains? */
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index b552359915f..27ed8db903d 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 */
 
@@ -348,6 +349,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
@@ -409,6 +411,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 3561c6bef0b..4b312bc8d06 100644
--- a/src/include/utils/relcache.h
+++ b/src/include/utils/relcache.h
@@ -71,6 +71,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..7d22befc34a
--- /dev/null
+++ b/src/test/regress/expected/heap_hot_updates.out
@@ -0,0 +1,1048 @@
+-- Create a function to measure HOT updates
+CREATE OR REPLACE FUNCTION check_hot_updates(
+    expected INT,
+    p_table_name TEXT DEFAULT 't',
+    p_schema_name TEXT DEFAULT current_schema()
+)
+RETURNS TABLE (
+    table_name TEXT,
+    total_updates BIGINT,
+    hot_updates BIGINT,
+    hot_update_percentage NUMERIC,
+    matches_expected BOOLEAN,
+    has_indexes BOOLEAN,
+    index_count INT,
+    fillfactor INT
+)
+LANGUAGE plpgsql
+AS $$
+DECLARE
+    v_relid oid;
+    v_qualified_name TEXT;
+    v_hot_updates BIGINT;
+    v_updates BIGINT;
+    v_xact_hot_updates BIGINT;
+    v_xact_updates BIGINT;
+BEGIN
+
+    -- We need to wait for statistics to update
+    PERFORM pg_stat_force_next_flush();
+
+    -- Construct qualified name
+    v_qualified_name := quote_ident(p_schema_name) || '.' || quote_ident(p_table_name);
+
+    -- Get the OID using regclass
+    v_relid := v_qualified_name::regclass;
+
+    IF v_relid IS NULL THEN
+        RAISE EXCEPTION 'Table %.% not found', p_schema_name, p_table_name;
+    END IF;
+
+    -- Get cumulative stats
+    v_hot_updates := COALESCE(pg_stat_get_tuples_hot_updated(v_relid), 0);
+    v_updates := COALESCE(pg_stat_get_tuples_updated(v_relid), 0);
+
+    -- Get current transaction stats
+    v_xact_hot_updates := COALESCE(pg_stat_get_xact_tuples_hot_updated(v_relid), 0);
+    v_xact_updates := COALESCE(pg_stat_get_xact_tuples_updated(v_relid), 0);
+
+    -- Combine stats
+    v_hot_updates := v_hot_updates + v_xact_hot_updates;
+    v_updates := v_updates + v_xact_updates;
+
+    RETURN QUERY
+    SELECT
+        p_table_name::TEXT,
+        v_updates::BIGINT as total_updates,
+        v_hot_updates::BIGINT as hot_updates,
+        CASE
+            WHEN v_updates > 0 THEN
+                ROUND((v_hot_updates::numeric / v_updates::numeric * 100)::numeric, 2)
+            ELSE 0
+        END as hot_update_percentage,
+        (v_hot_updates = expected)::BOOLEAN as matches_expected,
+        (EXISTS (
+            SELECT 1 FROM pg_index WHERE indrelid = v_relid
+        ))::BOOLEAN as has_indexes,
+        (
+            SELECT COUNT(*)::INT
+            FROM pg_index
+            WHERE indrelid = v_relid
+        ) as index_count,
+        COALESCE(
+            (
+                SELECT (regexp_match(array_to_string(reloptions, ','), 'fillfactor=(\d+)'))[1]::int
+                FROM pg_class
+                WHERE oid = v_relid
+            ),
+            100
+        ) as fillfactor;
+END;
+$$;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- This table will have two columns and two indexes, one on the primary key
+-- id and one on the expression (docs->>'name').  That means that the indexed
+-- attributes are 'id' and 'docs'.
+CREATE TABLE t(id INT PRIMARY KEY, docs JSONB) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_docs_idx ON t((docs->>'name'));
+INSERT INTO t VALUES (1, '{"name": "john", "data": "some data"}');
+-- Disable expression checks.
+ALTER TABLE t SET (expression_checks = false);
+SELECT reloptions FROM pg_class WHERE relname = 't';
+                           reloptions                           
+----------------------------------------------------------------
+ {autovacuum_enabled=off,fillfactor=70,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 t set docs='{"name": "john", "data": "something else"}' where id=1;
+SELECT * FROM check_hot_updates(0);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             1 |           0 |                  0.00 | t                | t           |           2 |         70
+(1 row)
+
+-- Re-enable expression checks.
+ALTER TABLE t SET (expression_checks = true);
+SELECT reloptions FROM pg_class WHERE relname = 't';
+                          reloptions                           
+---------------------------------------------------------------
+ {autovacuum_enabled=off,fillfactor=70,expression_checks=true}
+(1 row)
+
+-- The indexed attribute "name" with value "john" is unchanged, expect a HOT update.
+UPDATE t SET docs='{"name": "john", "data": "some other data"}' WHERE id=1;
+SELECT * FROM check_hot_updates(1);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             2 |           1 |                 50.00 | t                | t           |           2 |         70
+(1 row)
+
+-- The following update changes the indexed attribute "name", this should not be a HOT update.
+UPDATE t SET docs='{"name": "smith", "data": "some other data"}' WHERE id=1;
+SELECT * FROM check_hot_updates(1);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             3 |           1 |                 33.33 | t                | t           |           2 |         70
+(1 row)
+
+-- Now, this update does not change the indexed attribute "name" from "smith", this should be HOT.
+UPDATE t SET docs='{"name": "smith", "data": "some more data"}' WHERE id=1;
+SELECT * FROM check_hot_updates(2);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             4 |           2 |                 50.00 | t                | t           |           2 |         70
+(1 row)
+
+DROP TABLE t;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- 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 docs 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 docs column should prevent HOT updates for any updates to any
+-- portion of JSONB content in that column.
+CREATE TABLE t(id INT PRIMARY KEY, docs JSONB) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_docs_idx ON t((docs->>'name'));
+CREATE INDEX t_docs_col_idx ON t(docs);
+INSERT INTO t 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 docs column and so should not be HOT because the
+-- indexed value changed as a result of the update.
+UPDATE t SET docs='{"name": "john", "data": "some other data"}' WHERE id=1;
+SELECT * FROM check_hot_updates(0);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             1 |           0 |                  0.00 | t                | t           |           3 |         70
+(1 row)
+
+DROP TABLE t;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- 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 t (docs JSONB) WITH (autovacuum_enabled = off, fillfactor = 70);
+INSERT INTO t (docs) VALUES ('{"a": 0, "b": 0}');
+INSERT INTO t (docs) SELECT jsonb_build_object('b', n) FROM generate_series(100, 10000) as n;
+CREATE INDEX t_idx_a ON t ((docs->>'a'));
+CREATE INDEX t_idx_b ON t ((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 t SET docs = jsonb_build_object('a', 0, 'b', 1) WHERE (docs->>'a')::numeric = 0;
+SELECT * FROM check_hot_updates(1);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             1 |           1 |                100.00 | t                | t           |           2 |         70
+(1 row)
+
+-- Let's check to make sure that the index does not contain a value for 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Index Scan using t_idx_b on t
+   Filter: (((docs ->> 'b'::text))::numeric < '100'::numeric)
+(2 rows)
+
+SELECT * FROM t 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 t SET docs = jsonb_build_object('a', 0, 'b', 10) WHERE (docs->>'a')::numeric = 0;
+SELECT * FROM check_hot_updates(1);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             2 |           1 |                 50.00 | t                | t           |           2 |         70
+(1 row)
+
+-- Let's check to make sure that the index contains the new value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Index Scan using t_idx_b on t
+   Filter: (((docs ->> 'b'::text))::numeric < '100'::numeric)
+(2 rows)
+
+SELECT * FROM t 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 t SET docs = jsonb_build_object('a', 1, 'b', 10) WHERE (docs->>'b')::numeric = 10;
+SELECT * FROM check_hot_updates(1);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             3 |           1 |                 33.33 | t                | t           |           2 |         70
+(1 row)
+
+-- This update changes both 'a' and 'b' to new values that require index updates,
+-- this cannot use the HOT path.
+UPDATE t SET docs = jsonb_build_object('a', 2, 'b', 12) WHERE (docs->>'b')::numeric = 10;
+SELECT * FROM check_hot_updates(1);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             4 |           1 |                 25.00 | t                | t           |           2 |         70
+(1 row)
+
+-- Let's check to make sure that the index contains the new value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Index Scan using t_idx_b on t
+   Filter: (((docs ->> 'b'::text))::numeric < '100'::numeric)
+(2 rows)
+
+SELECT * FROM t 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 t SET docs = jsonb_build_object('a', 2, 'b', 1) WHERE (docs->>'b')::numeric = 12;
+SELECT * FROM check_hot_updates(1);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             5 |           1 |                 20.00 | t                | t           |           2 |         70
+(1 row)
+
+-- Let's check to make sure that the index no longer contains the value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+                          QUERY PLAN                          
+--------------------------------------------------------------
+ Index Scan using t_idx_b on t
+   Filter: (((docs ->> 'b'::text))::numeric < '100'::numeric)
+(2 rows)
+
+SELECT * FROM t WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+ docs 
+------
+(0 rows)
+
+DROP TABLE t;
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- Tests to ensure that HOT updates are not performed when multiple indexed
+-- attributes are updated.
+CREATE TABLE t(a INT, b INT) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_idx_a ON t(a);
+CREATE INDEX t_idx_b ON t(abs(b));
+INSERT INTO t VALUES (1, -1);
+-- Both are updated, the second is an expression index with an unchanged
+-- index value.  The change to the index on a should prevent HOT updates.
+UPDATE t SET a = 2, b = 1 WHERE a = 1;
+SELECT * FROM check_hot_updates(0);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             1 |           0 |                  0.00 | t                | t           |           2 |         70
+(1 row)
+
+DROP TABLE t;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- Tests to check the expression_checks reloption behavior.
+--
+CREATE TABLE t(a INT, b INT) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_idx_a ON t(abs(a)) WHERE abs(a) > 10;
+CREATE INDEX t_idx_b ON t(abs(b));
+INSERT INTO t VALUES (-1, -1), (-2, -2), (-3, -3), (-4, -4);
+SET SESSION enable_seqscan = OFF;
+SET SESSION enable_bitmapscan = OFF;
+-- Disable expression checks on indexes and partial index predicates.
+ALTER TABLE t SET (expression_checks = false);
+-- Before and after values of a are outside the predicate of the index and
+-- the indexed value of b hasn't changed however we've disabled expression
+-- checks so this should not be a HOT update.
+-- (-1, -1) -> (-5, -1)
+UPDATE t SET a = -5, b = -1 WHERE a = -1;
+SELECT * FROM check_hot_updates(0);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             1 |           0 |                  0.00 | t                | t           |           2 |         70
+(1 row)
+
+-- Enable expression checks on indexes, but not on predicates yet.
+ALTER TABLE t SET (expression_checks = true);
+-- The indexed value of b hasn't changed, this should be a HOT update.
+-- (-5, -1) -> (-5, 1)
+UPDATE t SET b = 1 WHERE a = -5;
+SELECT * FROM check_hot_updates(1);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             2 |           1 |                 50.00 | t                | t           |           2 |         70
+(1 row)
+
+-- Now that we're not checking the predicate of the partial index, this
+-- update of a from -5 to 5 should be HOT because we should ignore the
+-- predicate and check the expression and find it unchanged.
+-- (-5, 1) -> (5, 1)
+UPDATE t SET a = 5 WHERE a = -5;
+SELECT * FROM check_hot_updates(2);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             3 |           2 |                 66.67 | t                | t           |           2 |         70
+(1 row)
+
+-- This update meets the critera for the partial index and should not
+-- be HOT.  Let's make sure of that and check the index as well.
+-- (-4, -4) -> (-11, -4)
+UPDATE t SET a = -11 WHERE a = -4;
+SELECT * FROM check_hot_updates(2);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             4 |           2 |                 50.00 | t                | t           |           2 |         70
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE abs(a) > 10;
+          QUERY PLAN           
+-------------------------------
+ Index Scan using t_idx_a on t
+(1 row)
+
+SELECT * FROM t WHERE abs(a) > 10;
+  a  | b  
+-----+----
+ -11 | -4
+(1 row)
+
+-- (-11, -4) -> (11, -4)
+UPDATE t SET a = 11 WHERE a = -11;
+SELECT * FROM check_hot_updates(3);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             5 |           3 |                 60.00 | t                | t           |           2 |         70
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE abs(a) > 10;
+          QUERY PLAN           
+-------------------------------
+ Index Scan using t_idx_a on t
+(1 row)
+
+SELECT * FROM t WHERE abs(a) > 10;
+ a  | b  
+----+----
+ 11 | -4
+(1 row)
+
+-- (11, -4) -> (-4, -4)
+UPDATE t SET a = -4 WHERE a = 11;
+SELECT * FROM check_hot_updates(3);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             6 |           3 |                 50.00 | t                | t           |           2 |         70
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE abs(a) > 10;
+          QUERY PLAN           
+-------------------------------
+ Index Scan using t_idx_a on t
+(1 row)
+
+SELECT * FROM t WHERE abs(a) > 10;
+ a | b 
+---+---
+(0 rows)
+
+-- This update of a from 5 to -1 is HOT despite that attribute
+-- being indexed because the before and after values for the
+-- partial index predicate are outside the index definition.
+-- (5, 1) -> (-1, 1)
+UPDATE t SET a = -1 WHERE a = 5;
+SELECT * FROM check_hot_updates(4);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             7 |           4 |                 57.14 | t                | t           |           2 |         70
+(1 row)
+
+-- This update of a from -2 to -1 with predicate checks enabled should be
+-- HOT because the before/after values of a are both outside the predicate
+-- of the partial index.
+-- (-1, 1) -> (-2, 1)
+UPDATE t SET a = -2 WHERE a = -1;
+SELECT * FROM check_hot_updates(5);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             8 |           5 |                 62.50 | t                | t           |           2 |         70
+(1 row)
+
+-- The indexed value for b isn't changing, this should be HOT.
+-- (-2, -2) -> (-2, 2)
+UPDATE t SET b = 2 WHERE b = -2;
+SELECT * FROM check_hot_updates(6);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             9 |           6 |                 66.67 | t                | t           |           2 |         70
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT abs(b) FROM t;
+    QUERY PLAN    
+------------------
+ Seq Scan on t
+   Disabled: true
+(2 rows)
+
+SELECT abs(b) FROM t;
+ abs 
+-----
+   3
+   4
+   1
+   2
+(4 rows)
+
+-- Before and after values for a are outside the predicate of the index,
+-- and because we're checking this should be HOT.
+-- (-2, 1) -> (5, 1)
+-- (-2, -2) -> (5, -2)
+UPDATE t SET a = 5 WHERE a = -2;
+SELECT * FROM check_hot_updates(8);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |            11 |           8 |                 72.73 | t                | t           |           2 |         70
+(1 row)
+
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE abs(a) > 10;
+          QUERY PLAN           
+-------------------------------
+ Index Scan using t_idx_a on t
+(1 row)
+
+SELECT * FROM t WHERE abs(a) > 10;
+ a | b 
+---+---
+(0 rows)
+
+SELECT * FROM t;
+ a  | b  
+----+----
+ -3 | -3
+ -4 | -4
+  5 |  1
+  5 |  2
+(4 rows)
+
+DROP TABLE t;
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- The tests here examines the behavior of HOT updates when the relation
+-- has a JSONB column with an index on the field 'a' and the partial index
+-- expression on a different JSONB field 'b'.
+CREATE TABLE t(docs JSONB) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_docs_idx ON t((docs->'a')) WHERE (docs->'b')::integer = 1;
+INSERT INTO t VALUES ('{"a": 1, "b": 1}');
+EXPLAIN (COSTS OFF) SELECT * FROM t;
+  QUERY PLAN   
+---------------
+ Seq Scan on t
+(1 row)
+
+SELECT * FROM t;
+       docs       
+------------------
+ {"a": 1, "b": 1}
+(1 row)
+
+SET SESSION enable_seqscan = OFF;
+SET SESSION enable_bitmapscan = OFF;
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE (docs->'b')::integer = 1;
+            QUERY PLAN            
+----------------------------------
+ Index Scan using t_docs_idx on t
+(1 row)
+
+SELECT * FROM t WHERE (docs->'b')::integer = 1;
+       docs       
+------------------
+ {"a": 1, "b": 1}
+(1 row)
+
+SELECT * FROM check_hot_updates(0);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             0 |           0 |                     0 | t                | t           |           1 |         70
+(1 row)
+
+UPDATE t SET docs='{"a": 1, "b": 0}';
+SELECT * FROM check_hot_updates(0);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             1 |           0 |                  0.00 | t                | t           |           1 |         70
+(1 row)
+
+SELECT * FROM t WHERE (docs->'b')::integer = 1;
+ docs 
+------
+(0 rows)
+
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+DROP TABLE t;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- Tests for multi-column indexes
+--
+CREATE TABLE t(id INT, docs JSONB) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_docs_idx ON t(id, (docs->'a'));
+INSERT INTO t VALUES (1, '{"a": 1, "b": 1}');
+SET SESSION enable_seqscan = OFF;
+SET SESSION enable_bitmapscan = OFF;
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer > 0;
+                   QUERY PLAN                   
+------------------------------------------------
+ Index Scan using t_docs_idx on t
+   Index Cond: (id > 0)
+   Filter: (((docs -> 'a'::text))::integer > 0)
+(3 rows)
+
+SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer > 0;
+ id |       docs       
+----+------------------
+  1 | {"a": 1, "b": 1}
+(1 row)
+
+SELECT * FROM check_hot_updates(0);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             0 |           0 |                     0 | t                | t           |           1 |         70
+(1 row)
+
+-- Changing the id attribute which is an indexed attribute should
+-- prevent HOT updates.
+UPDATE t SET id = 2;
+SELECT * FROM check_hot_updates(0);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             1 |           0 |                  0.00 | t                | t           |           1 |         70
+(1 row)
+
+SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer > 0;
+ id |       docs       
+----+------------------
+  2 | {"a": 1, "b": 1}
+(1 row)
+
+-- Changing the docs->'a' field in the indexed attribute 'docs'
+-- should prevent HOT updates.
+UPDATE t SET docs='{"a": -2, "b": 1}';
+SELECT * FROM check_hot_updates(0);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             2 |           0 |                  0.00 | t                | t           |           1 |         70
+(1 row)
+
+SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer < 0;
+ id |       docs        
+----+-------------------
+  2 | {"a": -2, "b": 1}
+(1 row)
+
+-- Leaving the docs->'a' attribute unchanged means that the expression
+-- is unchanged and because the 'id' attribute isn't in the modified
+-- set the indexed tuple is unchanged, this can go HOT.
+UPDATE t SET docs='{"a": -2, "b": 2}';
+SELECT * FROM check_hot_updates(1);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             3 |           1 |                 33.33 | t                | t           |           1 |         70
+(1 row)
+
+SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer < 0;
+ id |       docs        
+----+-------------------
+  2 | {"a": -2, "b": 2}
+(1 row)
+
+-- Here we change the 'id' attribute and the 'docs' attribute setting
+-- the expression docs->'a' to a new value, this cannot be a HOT update.
+UPDATE t SET id = 3, docs='{"a": 3, "b": 3}';
+SELECT * FROM check_hot_updates(1);
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ t          |             4 |           1 |                 25.00 | t                | t           |           1 |         70
+(1 row)
+
+SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer > 0;
+ id |       docs       
+----+------------------
+  3 | {"a": 3, "b": 3}
+(1 row)
+
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+DROP TABLE t;
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- 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', [email-masked]'),
+('user2', [email-masked]'),
+('taken', [email-masked]'),
+('you', [email-masked]'),
+('taken', [email-masked]');
+-- Should fail because of the unique constraint on the email column.
+UPDATE users SET email = [email-masked]' WHERE email = [email-masked]';
+ERROR:  conflicting key value violates exclusion constraint "users_lower_excl"
+DETAIL:  Key (lower(email::text))=([email-masked]) conflicts with existing key (lower(email::text))=([email-masked]).
+SELECT * FROM check_hot_updates(0, 'users');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ users      |             1 |           0 |                  0.00 | t                | t           |           2 |        100
+(1 row)
+
+-- Should succeed because the email column is not being updated and should go HOT.
+UPDATE users SET name = 'foo' WHERE email = [email-masked]';
+SELECT * FROM check_hot_updates(1, 'users');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ users      |             2 |           1 |                 50.00 | t                | t           |           2 |        100
+(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 [email-masked]%';
+-- 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 = [email-masked]' WHERE name = 'you';
+SELECT * FROM check_hot_updates(1, 'users');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ users      |             3 |           1 |                 33.33 | t                | t           |           3 |        100
+(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 = [email-masked]' WHERE name = 'you';
+ERROR:  conflicting key value violates exclusion constraint "users_lower_excl"
+DETAIL:  Key (lower(email::text))=([email-masked]) conflicts with existing key (lower(email::text))=([email-masked]).
+SELECT * FROM check_hot_updates(1, 'users');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ users      |             4 |           1 |                 25.00 | t                | t           |           3 |        100
+(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 * FROM check_hot_updates(0, 'events');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ events     |             1 |           0 |                  0.00 | t                | t           |           2 |        100
+(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 * FROM check_hot_updates(0, 'events');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ events     |             2 |           0 |                  0.00 | t                | t           |           2 |        100
+(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 * FROM check_hot_updates(1, 'events');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ events     |             3 |           1 |                 33.33 | t                | t           |           2 |        100
+(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 * FROM check_hot_updates(1, 'ex');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ ex         |             1 |           1 |                100.00 | t                | t           |           9 |         60
+(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 * FROM check_hot_updates(1, 'ex');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ ex         |             2 |           1 |                 50.00 | t                | t           |           9 |         60
+(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 * FROM check_hot_updates(2, 'ex');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ ex         |             3 |           2 |                 66.67 | t                | t           |           9 |         60
+(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 * FROM check_hot_updates(3, 'ex');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ ex         |             4 |           3 |                 75.00 | t                | t           |           9 |         60
+(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 * FROM check_hot_updates(1, 'ex');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ ex         |             1 |           1 |                100.00 | t                | t           |           2 |         60
+(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 * FROM check_hot_updates(2, 'ex');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ ex         |             2 |           2 |                100.00 | t                | t           |           2 |         60
+(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 * FROM check_hot_updates(3, 'ex');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ ex         |             3 |           3 |                100.00 | t                | t           |           2 |         60
+(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 * FROM check_hot_updates(4, 'ex');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ ex         |             4 |           3 |                 75.00 | f                | t           |           2 |         60
+(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 * FROM check_hot_updates(0, 'my_table');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ my_table   |             1 |           0 |                  0.00 | t                | t           |           1 |        100
+(1 row)
+
+-- Update 2
+UPDATE my_table SET custom_val = ROW(0)::my_custom_type WHERE custom_val < ROW(3)::my_custom_type;
+SELECT * FROM check_hot_updates(0, 'my_table');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ my_table   |             3 |           0 |                  0.00 | t                | t           |           1 |        100
+(1 row)
+
+-- Update 3
+UPDATE my_table SET custom_val = ROW(6)::my_custom_type WHERE id = 3;
+SELECT * FROM check_hot_updates(0, 'my_table');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ my_table   |             4 |           0 |                  0.00 | t                | t           |           1 |        100
+(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)
+
+SELECT * FROM check_hot_updates(0, 'my_table');
+ table_name | total_updates | hot_updates | hot_update_percentage | matches_expected | has_indexes | index_count | fillfactor 
+------------+---------------+-------------+-----------------------+------------------+-------------+-------------+------------
+ my_table   |             5 |           1 |                 20.00 | f                | t           |           1 |        100
+(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;
+DROP FUNCTION check_hot_updates(int, text, text);
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index a424be2a6bf..f712acd9a20 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..7016e9eabd0
--- /dev/null
+++ b/src/test/regress/sql/heap_hot_updates.sql
@@ -0,0 +1,718 @@
+-- Create a function to measure HOT updates
+CREATE OR REPLACE FUNCTION check_hot_updates(
+    expected INT,
+    p_table_name TEXT DEFAULT 't',
+    p_schema_name TEXT DEFAULT current_schema()
+)
+RETURNS TABLE (
+    table_name TEXT,
+    total_updates BIGINT,
+    hot_updates BIGINT,
+    hot_update_percentage NUMERIC,
+    matches_expected BOOLEAN,
+    has_indexes BOOLEAN,
+    index_count INT,
+    fillfactor INT
+)
+LANGUAGE plpgsql
+AS $$
+DECLARE
+    v_relid oid;
+    v_qualified_name TEXT;
+    v_hot_updates BIGINT;
+    v_updates BIGINT;
+    v_xact_hot_updates BIGINT;
+    v_xact_updates BIGINT;
+BEGIN
+
+    -- We need to wait for statistics to update
+    PERFORM pg_stat_force_next_flush();
+
+    -- Construct qualified name
+    v_qualified_name := quote_ident(p_schema_name) || '.' || quote_ident(p_table_name);
+
+    -- Get the OID using regclass
+    v_relid := v_qualified_name::regclass;
+
+    IF v_relid IS NULL THEN
+        RAISE EXCEPTION 'Table %.% not found', p_schema_name, p_table_name;
+    END IF;
+
+    -- Get cumulative stats
+    v_hot_updates := COALESCE(pg_stat_get_tuples_hot_updated(v_relid), 0);
+    v_updates := COALESCE(pg_stat_get_tuples_updated(v_relid), 0);
+
+    -- Get current transaction stats
+    v_xact_hot_updates := COALESCE(pg_stat_get_xact_tuples_hot_updated(v_relid), 0);
+    v_xact_updates := COALESCE(pg_stat_get_xact_tuples_updated(v_relid), 0);
+
+    -- Combine stats
+    v_hot_updates := v_hot_updates + v_xact_hot_updates;
+    v_updates := v_updates + v_xact_updates;
+
+    RETURN QUERY
+    SELECT
+        p_table_name::TEXT,
+        v_updates::BIGINT as total_updates,
+        v_hot_updates::BIGINT as hot_updates,
+        CASE
+            WHEN v_updates > 0 THEN
+                ROUND((v_hot_updates::numeric / v_updates::numeric * 100)::numeric, 2)
+            ELSE 0
+        END as hot_update_percentage,
+        (v_hot_updates = expected)::BOOLEAN as matches_expected,
+        (EXISTS (
+            SELECT 1 FROM pg_index WHERE indrelid = v_relid
+        ))::BOOLEAN as has_indexes,
+        (
+            SELECT COUNT(*)::INT
+            FROM pg_index
+            WHERE indrelid = v_relid
+        ) as index_count,
+        COALESCE(
+            (
+                SELECT (regexp_match(array_to_string(reloptions, ','), 'fillfactor=(\d+)'))[1]::int
+                FROM pg_class
+                WHERE oid = v_relid
+            ),
+            100
+        ) as fillfactor;
+END;
+$$;
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- This table will have two columns and two indexes, one on the primary key
+-- id and one on the expression (docs->>'name').  That means that the indexed
+-- attributes are 'id' and 'docs'.
+CREATE TABLE t(id INT PRIMARY KEY, docs JSONB) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_docs_idx ON t((docs->>'name'));
+INSERT INTO t VALUES (1, '{"name": "john", "data": "some data"}');
+
+-- Disable expression checks.
+ALTER TABLE t SET (expression_checks = false);
+SELECT reloptions FROM pg_class WHERE relname = 't';
+
+-- 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 t set docs='{"name": "john", "data": "something else"}' where id=1;
+SELECT * FROM check_hot_updates(0);
+
+-- Re-enable expression checks.
+ALTER TABLE t SET (expression_checks = true);
+SELECT reloptions FROM pg_class WHERE relname = 't';
+
+-- The indexed attribute "name" with value "john" is unchanged, expect a HOT update.
+UPDATE t SET docs='{"name": "john", "data": "some other data"}' WHERE id=1;
+SELECT * FROM check_hot_updates(1);
+
+-- The following update changes the indexed attribute "name", this should not be a HOT update.
+UPDATE t SET docs='{"name": "smith", "data": "some other data"}' WHERE id=1;
+SELECT * FROM check_hot_updates(1);
+
+-- Now, this update does not change the indexed attribute "name" from "smith", this should be HOT.
+UPDATE t SET docs='{"name": "smith", "data": "some more data"}' WHERE id=1;
+SELECT * FROM check_hot_updates(2);
+
+DROP TABLE t;
+
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- 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 docs 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 docs column should prevent HOT updates for any updates to any
+-- portion of JSONB content in that column.
+CREATE TABLE t(id INT PRIMARY KEY, docs JSONB) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_docs_idx ON t((docs->>'name'));
+CREATE INDEX t_docs_col_idx ON t(docs);
+INSERT INTO t 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 docs column and so should not be HOT because the
+-- indexed value changed as a result of the update.
+UPDATE t SET docs='{"name": "john", "data": "some other data"}' WHERE id=1;
+SELECT * FROM check_hot_updates(0);
+DROP TABLE t;
+
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- 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 t (docs JSONB) WITH (autovacuum_enabled = off, fillfactor = 70);
+INSERT INTO t (docs) VALUES ('{"a": 0, "b": 0}');
+INSERT INTO t (docs) SELECT jsonb_build_object('b', n) FROM generate_series(100, 10000) as n;
+CREATE INDEX t_idx_a ON t ((docs->>'a'));
+CREATE INDEX t_idx_b ON t ((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 t SET docs = jsonb_build_object('a', 0, 'b', 1) WHERE (docs->>'a')::numeric = 0;
+SELECT * FROM check_hot_updates(1);
+-- Let's check to make sure that the index does not contain a value for 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+SELECT * FROM t 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 t SET docs = jsonb_build_object('a', 0, 'b', 10) WHERE (docs->>'a')::numeric = 0;
+SELECT * FROM check_hot_updates(1);
+-- Let's check to make sure that the index contains the new value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+SELECT * FROM t 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 t SET docs = jsonb_build_object('a', 1, 'b', 10) WHERE (docs->>'b')::numeric = 10;
+SELECT * FROM check_hot_updates(1);
+
+-- This update changes both 'a' and 'b' to new values that require index updates,
+-- this cannot use the HOT path.
+UPDATE t SET docs = jsonb_build_object('a', 2, 'b', 12) WHERE (docs->>'b')::numeric = 10;
+SELECT * FROM check_hot_updates(1);
+-- Let's check to make sure that the index contains the new value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+SELECT * FROM t 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 t SET docs = jsonb_build_object('a', 2, 'b', 1) WHERE (docs->>'b')::numeric = 12;
+SELECT * FROM check_hot_updates(1);
+-- Let's check to make sure that the index no longer contains the value of 'b'
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+SELECT * FROM t WHERE (docs->>'b')::numeric > 9 AND (docs->>'b')::numeric < 100;
+
+DROP TABLE t;
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- Tests to ensure that HOT updates are not performed when multiple indexed
+-- attributes are updated.
+CREATE TABLE t(a INT, b INT) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_idx_a ON t(a);
+CREATE INDEX t_idx_b ON t(abs(b));
+INSERT INTO t VALUES (1, -1);
+
+-- Both are updated, the second is an expression index with an unchanged
+-- index value.  The change to the index on a should prevent HOT updates.
+UPDATE t SET a = 2, b = 1 WHERE a = 1;
+SELECT * FROM check_hot_updates(0);
+
+DROP TABLE t;
+
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- Tests to check the expression_checks reloption behavior.
+--
+CREATE TABLE t(a INT, b INT) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_idx_a ON t(abs(a)) WHERE abs(a) > 10;
+CREATE INDEX t_idx_b ON t(abs(b));
+INSERT INTO t VALUES (-1, -1), (-2, -2), (-3, -3), (-4, -4);
+
+SET SESSION enable_seqscan = OFF;
+SET SESSION enable_bitmapscan = OFF;
+
+-- Disable expression checks on indexes and partial index predicates.
+ALTER TABLE t SET (expression_checks = false);
+
+-- Before and after values of a are outside the predicate of the index and
+-- the indexed value of b hasn't changed however we've disabled expression
+-- checks so this should not be a HOT update.
+-- (-1, -1) -> (-5, -1)
+UPDATE t SET a = -5, b = -1 WHERE a = -1;
+SELECT * FROM check_hot_updates(0);
+
+-- Enable expression checks on indexes, but not on predicates yet.
+ALTER TABLE t SET (expression_checks = true);
+
+-- The indexed value of b hasn't changed, this should be a HOT update.
+-- (-5, -1) -> (-5, 1)
+UPDATE t SET b = 1 WHERE a = -5;
+SELECT * FROM check_hot_updates(1);
+
+-- Now that we're not checking the predicate of the partial index, this
+-- update of a from -5 to 5 should be HOT because we should ignore the
+-- predicate and check the expression and find it unchanged.
+-- (-5, 1) -> (5, 1)
+UPDATE t SET a = 5 WHERE a = -5;
+SELECT * FROM check_hot_updates(2);
+
+-- This update meets the critera for the partial index and should not
+-- be HOT.  Let's make sure of that and check the index as well.
+-- (-4, -4) -> (-11, -4)
+UPDATE t SET a = -11 WHERE a = -4;
+SELECT * FROM check_hot_updates(2);
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE abs(a) > 10;
+SELECT * FROM t WHERE abs(a) > 10;
+
+-- (-11, -4) -> (11, -4)
+UPDATE t SET a = 11 WHERE a = -11;
+SELECT * FROM check_hot_updates(3);
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE abs(a) > 10;
+SELECT * FROM t WHERE abs(a) > 10;
+
+-- (11, -4) -> (-4, -4)
+UPDATE t SET a = -4 WHERE a = 11;
+SELECT * FROM check_hot_updates(3);
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE abs(a) > 10;
+SELECT * FROM t WHERE abs(a) > 10;
+
+-- This update of a from 5 to -1 is HOT despite that attribute
+-- being indexed because the before and after values for the
+-- partial index predicate are outside the index definition.
+-- (5, 1) -> (-1, 1)
+UPDATE t SET a = -1 WHERE a = 5;
+SELECT * FROM check_hot_updates(4);
+
+-- This update of a from -2 to -1 with predicate checks enabled should be
+-- HOT because the before/after values of a are both outside the predicate
+-- of the partial index.
+-- (-1, 1) -> (-2, 1)
+UPDATE t SET a = -2 WHERE a = -1;
+SELECT * FROM check_hot_updates(5);
+
+-- The indexed value for b isn't changing, this should be HOT.
+-- (-2, -2) -> (-2, 2)
+UPDATE t SET b = 2 WHERE b = -2;
+SELECT * FROM check_hot_updates(6);
+EXPLAIN (COSTS OFF) SELECT abs(b) FROM t;
+SELECT abs(b) FROM t;
+
+-- Before and after values for a are outside the predicate of the index,
+-- and because we're checking this should be HOT.
+-- (-2, 1) -> (5, 1)
+-- (-2, -2) -> (5, -2)
+UPDATE t SET a = 5 WHERE a = -2;
+SELECT * FROM check_hot_updates(8);
+
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE abs(a) > 10;
+SELECT * FROM t WHERE abs(a) > 10;
+
+SELECT * FROM t;
+
+DROP TABLE t;
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- The tests here examines the behavior of HOT updates when the relation
+-- has a JSONB column with an index on the field 'a' and the partial index
+-- expression on a different JSONB field 'b'.
+CREATE TABLE t(docs JSONB) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_docs_idx ON t((docs->'a')) WHERE (docs->'b')::integer = 1;
+INSERT INTO t VALUES ('{"a": 1, "b": 1}');
+
+EXPLAIN (COSTS OFF) SELECT * FROM t;
+SELECT * FROM t;
+
+SET SESSION enable_seqscan = OFF;
+SET SESSION enable_bitmapscan = OFF;
+
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE (docs->'b')::integer = 1;
+SELECT * FROM t WHERE (docs->'b')::integer = 1;
+
+SELECT * FROM check_hot_updates(0);
+
+UPDATE t SET docs='{"a": 1, "b": 0}';
+SELECT * FROM check_hot_updates(0);
+
+SELECT * FROM t WHERE (docs->'b')::integer = 1;
+
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+
+DROP TABLE t;
+
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- Tests for multi-column indexes
+--
+CREATE TABLE t(id INT, docs JSONB) WITH (autovacuum_enabled = off, fillfactor = 70);
+CREATE INDEX t_docs_idx ON t(id, (docs->'a'));
+INSERT INTO t VALUES (1, '{"a": 1, "b": 1}');
+
+SET SESSION enable_seqscan = OFF;
+SET SESSION enable_bitmapscan = OFF;
+
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer > 0;
+SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer > 0;
+
+SELECT * FROM check_hot_updates(0);
+
+-- Changing the id attribute which is an indexed attribute should
+-- prevent HOT updates.
+UPDATE t SET id = 2;
+SELECT * FROM check_hot_updates(0);
+
+SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer > 0;
+
+-- Changing the docs->'a' field in the indexed attribute 'docs'
+-- should prevent HOT updates.
+UPDATE t SET docs='{"a": -2, "b": 1}';
+SELECT * FROM check_hot_updates(0);
+
+SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer < 0;
+
+-- Leaving the docs->'a' attribute unchanged means that the expression
+-- is unchanged and because the 'id' attribute isn't in the modified
+-- set the indexed tuple is unchanged, this can go HOT.
+UPDATE t SET docs='{"a": -2, "b": 2}';
+SELECT * FROM check_hot_updates(1);
+
+SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer < 0;
+
+-- Here we change the 'id' attribute and the 'docs' attribute setting
+-- the expression docs->'a' to a new value, this cannot be a HOT update.
+UPDATE t SET id = 3, docs='{"a": 3, "b": 3}';
+SELECT * FROM check_hot_updates(1);
+
+SELECT * FROM t WHERE id > 0 AND (docs->'a')::integer > 0;
+
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+
+DROP TABLE t;
+
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+-- 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', [email-masked]'),
+('user2', [email-masked]'),
+('taken', [email-masked]'),
+('you', [email-masked]'),
+('taken', [email-masked]');
+
+-- Should fail because of the unique constraint on the email column.
+UPDATE users SET email = [email-masked]' WHERE email = [email-masked]';
+SELECT * FROM check_hot_updates(0, 'users');
+
+-- Should succeed because the email column is not being updated and should go HOT.
+UPDATE users SET name = 'foo' WHERE email = [email-masked]';
+SELECT * FROM check_hot_updates(1, 'users');
+
+-- Create a partial index on the email column, updates
+CREATE INDEX idx_users_email_no_example ON users (lower(email)) WHERE lower(email) LIKE [email-masked]%';
+
+-- 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 = [email-masked]' WHERE name = 'you';
+SELECT * FROM check_hot_updates(1, 'users');
+
+-- 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 = [email-masked]' WHERE name = 'you';
+SELECT * FROM check_hot_updates(1, 'users');
+
+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 * FROM check_hot_updates(0, 'events');
+
+-- 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 * FROM check_hot_updates(0, 'events');
+
+-- 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 * FROM check_hot_updates(1, 'events');
+
+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 * FROM check_hot_updates(1, 'ex');
+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 * FROM check_hot_updates(1, 'ex');
+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 * FROM check_hot_updates(2, 'ex');
+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 * FROM check_hot_updates(3, 'ex');
+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 * FROM check_hot_updates(1, 'ex');
+
+-- 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 * FROM check_hot_updates(2, 'ex');
+
+-- 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 * FROM check_hot_updates(3, 'ex');
+
+-- 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 * FROM check_hot_updates(4, 'ex');
+
+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 * FROM check_hot_updates(0, 'my_table');
+
+-- Update 2
+UPDATE my_table SET custom_val = ROW(0)::my_custom_type WHERE custom_val < ROW(3)::my_custom_type;
+SELECT * FROM check_hot_updates(0, 'my_table');
+
+-- Update 3
+UPDATE my_table SET custom_val = ROW(6)::my_custom_type WHERE id = 3;
+SELECT * FROM check_hot_updates(0, 'my_table');
+
+-- Update 4
+UPDATE my_table SET id = 5 WHERE id = 1;
+SELECT pg_stat_get_xact_tuples_hot_updated('my_table'::regclass);
+SELECT * FROM check_hot_updates(0, 'my_table');
+
+-- 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;
+
+DROP FUNCTION check_hot_updates(int, text, text);
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 220e5a4f6b3..b5cdf2ac89a 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2967,6 +2967,7 @@ TSVectorStat
 TState
 TStatus
 TStoreState
+UpdateContext
 TU_UpdateIndexes
 TXNEntryFile
 TYPCATEGORY
@@ -3154,7 +3155,6 @@ UniqueState
 UnlistenStmt
 UnresolvedTup
 UnresolvedTupData
-UpdateContext
 UpdateStmt
 UpgradeTask
 UpgradeTaskProcessCB
-- 
2.49.0

---

From: Greg Burd <[email-masked]>
Date: Tue, 7 Oct 2025 17:36:11 -0400

> * when possible for a partial index

This is still true. :)

This patch has languished for nearly 2+ months at v17.  Why?  Primarily
due to feedback that although the idea had merit, there was a critical
flaw in the approach that made it a non-starter.  The flaw was that I'd
been executing expressions while holding both the pin and a lock on the
buffer, which is not a great idea (self dead lock, etc.). This was
pointed out to me (thanks Robert Haas!) and so I needed to re-think my approach.

I put the patch aside for a while, then this past week at PGConf.dev/NYC
I heard interest from a few people (Jeff Davis, Nathan Bossart) who
encouraged me to move the code executing the expressions to just before
acquiring the lock but after pinning the buffer. The theory being that
my new code using the old/new tts to form and test the index tuples
resulting from executing expressions was using the resultsRelInfo struct
created during plan execution, not the information found on the page,
and so was safe without the lock.

This proved tricky because I had been using the modified_attrs and
expr_attrs as a test to avoid exercising expressions when unnecessary. 
Calling HeapDetermineColumnsInfo() outside the buffer lock to get
modified_attrs proved to be a problem as it examines an oldtup that is
cobbled together from the elements on the page, requiring the lock I was
trying to avoid.

After reviewing how updates work in the executor, I discovered that
during execution the new tuple slot is populated with the information
from ExecBuildUpdateProjection() and the old tuple, but that most
importantly for this use case that function created a bitmap of the
modified columns (the columns specified in the update).  This bitmap
isn't the same as the one produced by HeapDetermineColumnsInfo() as the
latter excludes attributes that are not changed after testing equality
with the helper function heap_attr_equals() where as the former will
include attributes that appear in the update but are the same value as
before.  This, happily, is immaterial for the purposes of my function
ExecExprIndexesRequireUpdates() which simply needs to check to see if
index tuples generated are unchanged.  So I had all I needed to run the
checks ahead of acquiring the lock on the buffer.

So, this led to v18 (attached), which passes test wold including a
number of new tests for the various corner cases relative to HOT updates
for expressions.

There is much room for improvement, and your suggestions are welcome.

I'll find time to quantify the benefit of this patch for the targeted
use cases and to ensure that all other cases see no regressions.

I need to review the tests I've added to ensure that they are the
minimal set required, that they communicate effectively their purpose,
etc. For now, it's more shotgun than scalpel, .... I'll get to it.

I added a reloption "expression_checks" to disable this new code path. 
Good idea or bad precedent?

In execIndexing I special case for IsolationIsSerializable() and I can't
remember why now but I do recall one isolation test failing... I'll
check on this and get back to the thread.  Or maybe you know why that

---

From: Nathan Bossart <[email-masked]>
Date: Wed, 8 Oct 2025 15:48:46 -0500

> and so was safe without the lock.

An open question (at least from me) is whether this is safe.  I'm not
familiar enough with this area of code yet to confidently determine that.

> checks ahead of acquiring the lock on the buffer.

Nice.

> There is much room for improvement, and your suggestions are welcome.

A general and predictable suggestion is to find ways to break this into
smaller pieces.  As-is, this patch would take me an enormous amount of time
to review in any depth.  If we can break off some smaller pieces that we
can scrutinize and commit independently, we can start making forward
progress sooner.  The UpdateContext and reloption stuff are examples of
things that might be possible to split into independent patches.

> use cases and to ensure that all other cases see no regressions.

Looking forward to these results.  This should also help us decide whether
to set expression_checks by default.

> Good idea or bad precedent?

If there are cases where the added overhead outweighs the benefits (which
seems like it must be true some of the time), then I think we must have a
way to opt-out (or maybe even opt-in).  In fact, I'd advise adding a GUC to
complement the reloption so that users can configure it at higher levels.

> check on this and get back to the thread.  Or maybe you know why that

I didn't follow this.

> I'd like not to build, then rebuild index tuples for these expressions
> but I can't think of a way to do that without a palloc(), this is
> avoided today.

Is the avoidance of palloc() a strict rule?  Is this discussed in the code
anywhere?

-- 
nathan

---

From: Jeff Davis <[email-masked]>
Date: Thu, 09 Oct 2025 12:08:30 -0700

On Wed, 2025-10-08 at 15:48 -0500, Nathan Bossart wrote:
> > The theory being that

The optimization requires that the expression evaluates to the same
thing on the old and new tuples. That determination doesn't have
anything to do with a lock on the buffer, so long as the old tuple
isn't pruned away or something. And clearly it won't be pruned, because
we're in the process of updating it, so we have a snapshot that can see
it.

There might be subtleties in other parts of the proposal, but the above
determination can be made safely without a buffer lock.

> complement the reloption so that users can configure it at higher

I'll push back against this. For now I'm fine with developer options to
make testing easier, but we should find a way to make this work well
without tuning.

Regards,
	Jeff Davis

---

From: Jeff Davis <[email-masked]>
Date: Thu, 09 Oct 2025 12:27:15 -0700

> checks ahead of acquiring the lock on the buffer.

You're still calling ExecExprIndexesRequireUpdates() from within
heap_update(). Can't you do that inside of ExecUpdatePrologue() or
thereabouts?

Regards,
	Jeff Davis

---

From: Greg Burd <[email-masked]>
Date: Thu, 9 Oct 2025 16:57:07 -0400

> On Oct 8, 2025, at 4:48 PM, Nathan Bossart <[email-masked]> wrote:
> 

Thanks for taking a look Nathan.
> familiar enough with this area of code yet to confidently determine that.

My read is that it is safe because we're testing the content of two
TupleTableSlots both formed in the executor.  The function uses only
that information and doesn't reference data on the page at all.

>> checks ahead of acquiring the lock on the buffer.

Handy indeed.  I'm not at all a fan of increasing the size of a plan node
but it's only by a little... and for a good cause.

> things that might be possible to split into independent patches.

Fair, I'll try.

> to set expression_checks by default.

In past my test results were very positive for cases where this helped avoid
heap and index bloat and almost immeasurably small even for cases where we
were doing the work to test but ultimately unable to take the HOT path.

This will require new tests as the code has changed quite a bit.

> complement the reloption so that users can configure it at higher levels.

This evolved from a GUC to a reloption and I'd rather it go away entirely.
I hear your concern, but I've yet to measure a perceptable impact and I'll
try hard to keep it that way as this matures.  Assuming that's the case,
I'd like to eliminate the potentially confusing tuning knob.

>> check on this and get back to the thread.  Or maybe you know why that

More later if/when I can reproduce it and understand it better myself.

> Is the avoidance of palloc() a strict rule?  Is this discussed in the code

Not that I know of, just my paranoid self trying to avoid it on a path that
didn't have it before.

best.

-greg

---

From: Greg Burd <[email-masked]>
Date: Thu, 9 Oct 2025 17:13:12 -0400

> On Oct 9, 2025, at 3:08 PM, Jeff Davis <[email-masked]> wrote:
> 

Hey Jeff,

Thanks for the nudge at PGConf.dev in NYC and for the follow-up here.

> we're in the process of updating it, so we have a snapshot that can see

Right, I test that the expression on the index evaluates to the same
value when forming an index tuple for old/new slots.

> make testing easier, but we should find a way to make this work well

I'm aligned with this, the reloption evolved from a GUC and I'm more of
the opinion that neither should exist and that the overhead of this be
minimized and so require no tuning or consideration by the end user.

best.

-greg

---

From: Greg Burd <[email-masked]>
Date: Tue, 14 Oct 2025 13:46:01 -0400

> On Oct 9, 2025, at 3:27 PM, Jeff Davis <[email-masked]> wrote:
> 

Hey Jeff,

I'm trying to knit this into the executor layer but that is tricky because
the concept of HOT is very heap-specific, so the executor should be
ignorant of the heap's specific needs (right?). Right now, I am considering
adding a step in ExecUpdatePrologue() just after opening the indexes.

The idea I'm toying with is to have a new function on all TupleTableSlots
that examines the before/after slots for an update and the set of updated
attributes and returns a Bitmapset of the changed attributes that overlap
with indexes and so should trigger index updates in ExecUpdateEpilogue().

That way for heap we'd have something like:
Bitmapset *tts_heap_getidxattr(ResultRelInfo *info,
			TupleTableSlot *updated,
			TupleTableSlot *existing,
			Bitmapset *updated_attrs)
{
	some combo of HeapDeterminColumnsInfo() and
	ExecExprIndexesRequireUpdates()

	returns the set of indexed attrs that this update changed
}

So, attributes only referenced by expressions where the expression
produces the same value for the updated and existing slots would be
removed from the set.

Interestingly, summarizing indexes that don't overlap with changed
attributes won't be updated (and that's a good thing).

Problem is we're not yet accounting for what is about to happen in
ExecUpdateAct() when calling into the heap_update().  That's where
heap tries to fit the new tuple onto the same page.  That might be
possible with large tuples thanks to TOAST, it's impossible to say
before getting into this function with the page locked.

So, for updates we include the modified_attrs in the UpdateContext
which is available to heap_update().  If the heap code decides to
go HOT, great unset all attributes in the modified_attrs except any
that are only summarizing.  If the heap can't go HOT, fine, add
the indexed attrs back into modified_attrs which should trigger all
indexes to be updated.

This gets rid of TU_UpdateIndexes enum and allows only modified
summarizing indexes to be updated on the HOT path.  Two additional
benefits IMO.

at least, that's what I'm trying out now,

-greg

---

From: Jeff Davis <[email-masked]>
Date: Tue, 14 Oct 2025 11:43:09 -0700

On Tue, 2025-10-14 at 13:46 -0400, Greg Burd wrote:
> ignorant of the heap's specific needs (right?).

It's wrong for the executor to say "do a HOT update" but it's OK for
the executor to say "this is the set of indexes that might have a new
key after the update". If that set is empty, then the heap can choose
to do a HOT update.

>  Right now, I am considering

Seems like a reasonable place.

> The idea I'm toying with is to have a new function on all
> TupleTableSlots...
> 

Why is this a generic method for all slots? Do we need to reuse it
somewhere else? I would have expected just a static method in
nodeModifyTable.c that does just what's needed.

And to be precise, it's the set of indexed attrs where the update might
have created a new key, right? The whole point is that we don't care if
the indexed attr has been changed, so long as it doesn't create a new
index key.

Nice.

> before getting into this function with the page locked.

I don't see why that's a problem. The executor can pass down the list
of indexed attrs that might have created new keys after the update,
then heap_update uses that information (along with other factors, like
if it fits on the same page) to determine whether to perform a HOT
update or not.

> which is available to heap_update().

It doesn't look like UpdateContext is currently available to
heap_update(). We might need to change the signature. But I think it's
fine to change the signature if it results in a cleaner design --
tableam extensions often need source changes when new major versions
are released.

>   If the heap code decides to

IIUC, that sounds like a good plan.

> summarizing indexes to be updated on the HOT path.  Two additional

I'm not sure that I understand, but I'll look at that after we sort out
some of the other details.

Regards,
	Jeff Davis

---

From: Greg Burd <[email-masked]>
Date: Sun, 16 Nov 2025 13:53:09 -0500

Hello again.

This idea started over a year ago for me while working on a project that
used JSONB and had horrible "bloat" with update times that were not
fantastic.  The root cause, expression indexes prevent HOT updates and
all indexes on JSONB are by definition, expressions.  That's the backstory.

The idea for the solution came from a patch [1] applied [2], then later
reverted [3], that basically evaluated the before/after tuple
expressions in heap_update() at the point where newbuff == buffer just
before deciding to use_hot_update or not.  When the evaluated
expressions produced equal results using a binary comparison the index
didn't need to be updated.  While this approach sorta worked, but it was
reverted for a few reasons, here's Tom's summary: "The problem here is
that [the code that checks for equality] thinks that the type of the
index column is identical to the type of the source datum for it, which
is not true for any opclass making use of the opckeytype property. [4]"

Still, expanding the domain of what might go HOT seems like a good goal
to me and it was at the heart of the issues I was facing on the project
using JSONB, so I kept at it.

The patches I've sent on this thread have evolved from that first idea. 
First they addressed the specific issue raised by Tom.  Then they
expanded to include partial indexes as well.  This worked, and I helped
to ship a fork of Postgres used this approach and solved customer issues
with the JSONB use case.  Customer experience was better, no unnecessary
index updates when indexed data wasn't modified meant no more heap/index
bloat and faster update times.  Vacuum could finally keep up and
performance and storage overhead was much better.

For this patch set v1 through v17 were essentially work that
refined/reworked that original approach, but there was a serious flaw
that I didn't fully appreciate until around v16/17.  I was evaluating
the expressions while holding a lock on the buffer page which a) expands
the time the lock is held, and b) opens the door to self-deadlock.  No bueno.

Then there was v18, a quick work-around for that.  I moved the call that
invokes the executor to the beginning of heap_update() before taking the
lock on the page.  To do this I had to find the set of updated
attributes, which I discovered was available in the executor as the
updated tuple is created.  This was a viable fix, but didn't really go
far enough and was a bit hackish IMO.  Jeff Davis and others challenged
me to move the work to identify what's changed into the executor and
clean it up.  I'm a sucker for a challenge.

More generally, this idea made sense.  While Postgres has many places
where logic is tightly coupled to the way the heap works this didn't
have to be one. To make this work I needed the update path in the
executor to be interested in:

a) knowing what columns were specified in the UPDATE statement and those
impacted by before/after triggers,
b) reducing that set to those attributes known to be both indexed and to
have changed value,
c) finding which of those (and possibly other) attributes that force new
index updates.

Why?  We'll, that code already exists in a few places and in some cases
is replicated; for (a) there is ExecGetAllUpdatedCols(), for (b)
HeapDetermineColumnsInfo() and index_unchanged_by_update().

An interesting thing to note is that HeapDetermineColumnsInfo() might
return a set that includes columns not returned by
ExecGetAllUpdatedCols() because HeapDetermineColumnsInfo() iterates over
all indexed attributes looking for changes and that might find an
indexed attribute that was changed by heap_modify_tuple() but not
knowable by ExecGetAllUpdatedCols().  This happens in tsvector code, see
tsvector_op.c tsvector_update_trigger() where if (update_needed)
heap_modify_tuple_by_cols().  That column isn't known to ExecGetAllUpdatedCols().

HeapDetermineColumnsInfo() is also critical when modifying catalog
tuples.  Catalog tuples are modified using either Form/GETSTRUCT or
values/nulls/replaces then using heap_modify_tuple() and calling into
CatalogTupleUpdate() which calls simple_heap_update() that calls
heap_update() where we find HeapDetermineColumnsInfo().  The interesting
thing here is that when modifying catalog tuples there is knowledge of
what attributes are changed, but that knowledge isn't preserved and
passed into CatalogTupleUpdate(), rather it is re-discovered in
HeapDetermineColumnsInfo().  That's how catalog tuples are able to take
the HOT path, they re-use that same logic.  There is a fix for that [5]
too (and I really hope that lands in master ASAP), but that's not the
subject of this thread.

HeapDetermineColumnsInfo() also helps inform a few other decisions in
heap_update(), but these have to happen after taking the buffer lock and
are very heap-specific, namely:

1. Do either the replica identity key attributes overlap with the
modified index attributes or do they need to be stored externally, this
is passed on to ExtractReplicaIdentity() to find out if we must augment
the WAL log or not.

2. Are there any modified indexed attributes that intersect with the
primary keys of the relation, if not lower the lock mode to enable
multixact to work.

HeapDetermineColumnsInfo() also takes a pragmatic approach to testing
for equality when looking for modified indexed attributes, it uses
datumIsEqual() which boils down to a simple memcmp() of the before/after
HeapTuple datum.  This is fine in most cases, but limits the scope of
what can be HOT.

Interestingly, this requirement of binary equality has leaked into other
parts of the code, namely nbtree's deduplication of TIDs on page split. 
That code uses binary equality as well.  A nbtree index with collation
for case insensitive must store both "A" and "a" despite those being
type-equal because they are not binary equivalent.  More on this later.

At this point, I had my sights set on HeapDetermineColumnsInfo().  I
felt that what it was doing should move into the executor, well as much
of that work as possible, and outside of the buffer lock.  This would
also open the door for removal of redundant code.  My thought was that
the table AM update API should have an additional argument, the
"modified indexed attributes" or "mix_attrs", passed in.

So, here we are at the door of v19... let's begin.

0001 - Reorganize heap update logic

This is preparatory work for the larger goal in that heap_update()
serves two masters: CatalogTupleUpdate()/simple_heap_update() and
heap_tuple_update() and in reality, they were different but needed most
of the same logic that happens at the start of heap_update().  This
patch splits that logic out and moves it into heap_tuple_update() and
simple_heap_update().  Functionally nothing changes.  That's the meat of
this patch.Reorganize heap update logic

0002 - Track changed indexed columns in the executor during UPDATEs

This is the first core set of changes, it doesn't expand HOT updates but
it does restructure where HeapDetermineColumnsInfo()'s core work happens.

A new function ExecCheckIndexedAttrsForChanges() in nodeModifyTable.c is
now responsible for checking for changes between Datum in the old/new
TupleTableSlots.  This is different from before in that we're not
checking the new HeapTuple Datum verses the HeapTuple we read from the
buffer page while holding the lock on that page.

An update starts off by reading the existing tuple using the table AM. 
Then a new updated tuple is created as the set of changes to the old. 
Then the new TupleTableSlot is the combination of the existing one we
just read and the changes we just recorded.  So, in the executor before
calling into the table AM's update function we have a pin on the buffer
and the before/after TupleTableSlots for this update.  So, I've put the
call to my new ExecCheckIndexedAttrsForChanges() function just before
calling table_tuple_update() and I've added the "mix_attrs" into that
call which get passed on to the heap in heap_tuple_update() and then
heap_update() and all is well.

Why is this safe?  The way I read heap_update() is that it has always
historically had code to deal with cases where the tuple is concurrently
updated and react accordingly thanks to HeapTupleSatisfiesUpdate() which
remains where it was in heap_update(). Visibility checks happened when
we first read the tuple to form the updated tuple and later in
heap_update() when we call HeapTupleSatisfiesVisibility() to check for
transaction-snapshot mode RI updates.

So, this new update path from the executor into the table AM seems to me
to be okay and almost functionally equivalent.  But there is one big
change to discuss before moving to the simple_heap_update() path.

In nodeModifyTable.c tts_attr_equal() replaces heap_attr_equal()
changing the test for equality when calling into heap_tuple_update(). 
In the past we used datumIsEqual(), essentially a binary comparison
using memcmp(), now the comparison code in tts_attr_equal uses
type-specific equality function when available and falls back to
datumIsEqual() when not.

The other parts of HeapDetermineColumnsInfo() remain in the code, but
they still happen within the simple_heap_update() and
heap_tuple_update() code.  That's where you'll find that after the
buffer is locked we do (1) and (2) from above.  This keeps the
heap-specific work in the heap, but we've moved some work up into the
executor and outside the buffer lock.

While this accomplishes the goal of removing HeapDetermineColumnsInfo()
from heap_update() on the path that uses the table AM API
heap_tuple_update() but it doesn't on the simple_heap_update() path. 
That remains the same as it was in the previous patch.  Ideally, my
patch to restructure how catalog tuples are updated [5] is committed and
we can fully remove HeapDetermineColumnsInfo() and likely speed up all
catalog updates in the process.  That's what motivated [5], please take
a look, it required a huge number of changes so I thought it deserved a
life/thread of its own.

Finally, there is the ExecSimpleRelationUpdate() path and
slot_modify_data().  On this path we know what attributes are being
updated, so we just check to see if they changed and then intersect that
with the set of indexed attributes and we have our modified indexed
attributes set to pass into simple_heap_update().

0003 - Replace index_unchanged_by_update with ri_ChangedIndexedCols

This patch removes the function index_unchanged_by_update() in
execIndexing.c and simply re-uses the modified indexed attributes that
we've stashed away in ResultRelInfo as ri_ChangedIndexedCols.  This
provides a hint when calling into the index AM's index_insert() function
indicating if the UPDATE was without logical change to the data or not. 
We've done that check, we don't need to do it again.

0004 - Enable HOT updates for expression and partial indexes

This finally gets us back to where this project started, but on much
more firm ground than before because we're not going to self-deadlock. 
The idea has grown from a small function into something larger, but only
out of necessity.

In this patch I add ExecWhichIndexesRequireUpdates() in execIndexing.c
which implements (c) finding the set of attributes that force new index
updates.  This set can be very different from the modified indexed
attributes.  We know that some attributes are not equal to their
previous versions, but does that mean that the index that references
that attribute needs a new index tuple?  It may, or it may not.  Here's
the comment on that function that explains:

/*
 * ExecWhichIndexesRequireUpdates
 *
 * Determine which indexes need updating given modified indexed attributes.
 * This function is a companion to ExecCheckIndexedAttrsForChanges(). 
On the
 * surface, they appear similar but they are doing two very different things.
 *
 * For a standard index on a set of attributes this is the intersection of
 * the mix_attrs and the index attrs (key, expression, but not predicate).
 *
 * For expression indexes and indexes which implement the amcomparedatums()
 * index AM API we'll need to form index datum and compare each
attribute to
 * see if any actually changed.
 *
 * For expression indexes the result of the expression might not change
at all,
 * this is common with JSONB columns which require expression indexes
and where
 * it is commonplace to index a field within a document and have updates that
 * generally don't update that field.
 *
 * Partial indexes won't trigger index tuples when the old/new tuples
are both
 * outside of the predicate range.
 *
 * For nbtree the amcomparedatums() API is critical as it requires that key
 * attributes are equal when they memcmp(), which might not be the case when
 * using type-specific comparison or factoring in collation which might make
 * an index case insensitive.
 *
 * All of this is to say that the goal is for the executor to know,
ahead of
 * calling into the table AM for the update and before calling into the index
 * AM for inserting new index tuples, which attributes at a minimum will
 * necessitate a new index tuple.
 *
...
 */

Whereas before we were comparing Datum in the table relation, now we're
comparing Datum in the index relation.  Index AMs are free to store what
they want, we need to know if what's changed and referenced by the index
means that the index needs a new tuple or not.

In the case of a JSONB expression index (or any expression index) the
expression is evaluated when calling FormIndexDatum().  The result of
the expression is the Datum in the values/isnull arrays.  Then we need
to compare them to see if they changed.  This can be done using the same
tts_attr_equal() function, but with the attribute desc of the index, not
table relation.

In some cases that's not enough, for instance nbtree and it's more
stringent equality requirements.  For that reason (and another coming up
in a second) we need a new optional index AM API, amcomparedatums(). 
Indexes implementing this function have the ability to compare two
Datums for equality in what ever way they want.  For nbtree, that's a
binary comparison.

The new case here that this also supports is for indexes like GIN/RUM
where there is an opclass that can extract zero or more pieces from the
attribute and form multiple index entries when needed.  Those extracted
pieces might have odd equality rules as well.  This opens the door for
index implementations to provide information that will help inform heap
when making HOT decisions that didn't exist before.

Take for example the Linux Foundation's DocumentDB [6] project [7] which
aims to be an open source alternative to MongoDB built on top of
PostgreSQL.  One of the pieces of that project is its "extended RUM"
index AM implementation.  This index extracts portions of the BSON
documents stored and forms index keys from that.  Here is an example:

CREATE INDEX documents_rum_index_14002
ON documentdb_data.documents_14001
USING documentdb_rum
  (document bson_rum_single_path_ops (path=a, iswildcard='true', tl='2699'))

An index on the "document" column which uses the
"bson_rum_single_path_ops" opclass to extract a portion of the BSON
document that matches "path=a".

For this to potentially be stored by heap as a HOT update we need to
know that what changed within that document didn't intersect with the
"path=a" and if it did that the new value(s) were all equal to the old
values.  Equality in DocumentDB isn't what you think, it's quite odd and
specific to BSON and rules defined by MongoDB so it's important to allow
the index AM to execute what's necessary for it's use case.

For the more common JSONB use case we can now have heap make an informed
decision  about HOT updates after evaluating the expression.  For
GIN/RUM/etc. implementation there is a path to HOT.  For nbtree there is
a way to maintain its requirements.

Is there a cost to all this?  Yes, of course.  There is net new work
being done on some paths.  IndexFormDatum() will be called more
frequently and sometimes twice for the same thing.  This could be
improved, there might be a way to cache that information.  But to have
tests of old/new we'll have to do that work at least twice.

Is there a benefit?  Yes, of course.  Some redundant code paths are gone
and in the end we've increased the number of cases where HOT updates are
a possibility.  This  especially helps out users of JSONB, but not only them.

What's left undone?

* I need to check code coverage so that I might
* create tests covering all the new cases
* update the README.HOT documentation, wiki, etc.
* performance...

For performance I'd like to examine some worst cases as in lots of
indexes that have a lot of new code to exercise and all but the last
index would allow for a HOT update.  That should represent the maximum
amount of new overhead for this code.  Then, the other side of the
equation, how much does this help JSONB?  I think that is something to
measure in terms of TPS as well as "bloat" avoided and time spent vacuuming.

I also don't like the TU_Updating enum, I think it's a leaky abstraction
and really pointless now.  I'd like to remove it in favor of the bitmap
of attributes known to force index tuples to be inserted.  Maybe I'll
layer that into the next set.

In the end, this is a lot of work and I believe that it moves the ball
forward.  I'll have more metrics on that soon I hope, but I wanted to
get the conversation re-started ASAP as we're late in the v19 cycle.

Finally, the "elephant" in the room (ha!) is PHOT[9]/WARM[10][11].  Yes,
some of this work does help make a solution to allow HOT updates when
only updating a subset of indexes closer to reality, I'd be lying not to
mention that here, it is a big part of my overall plan for my next year
and (I hope) v20 and I need this work to get there.  Specifically, PHOT
is in part based on HeapDetermineColumnsInfo() and I needed to make that
more truthful for it to work.

I hope you see the value in this work and will partner with me to
finalize it and get it into master.

best.

-greg

[1] https://www.postgresql.org/message-id/flat/4d9928ee-a9e6-15f9-9c82-5981f13ffca6%40postgrespro.ru
[2] https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=c203d6cf8
[3] https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=05f84605dbeb9cf8279a157234b24bbb706c5256
[4] https://www.postgresql.org/message-id/2877.1541538838%40sss.pgh.pa.us
[5] https://www.postgresql.org/message-id/flat/[email-masked]
[6] https://www.linuxfoundation.org/press/linux-foundation-welcomes-documentdb-to-advance-open-developer-first-nosql-innovation
[7] https://github.com/documentdb/documentdb
[8] https://github.com/documentdb/documentdb/tree/main/pg_documentdb_extended_rum
[9] https://www.postgresql.org/message-id/flat/2ECBBCA0-4D8D-4841-8872-4A5BBDC063D2%40amazon.com
[10] https://www.postgresql.org/message-id/flat/CABOikdMop5Rb_RnS2xFdAXMZGSqcJ-P-BY2ruMd%2BbuUkJ4iDPw%40mail.gmail.com
[11]
https://www.postgresql.org/message-id/flat/CABOikdMNy6yowA%2BwTGK9RVd8iw%2BCzqHeQSGpW7Yka_4RSZ_LOQ%40mail.gmail.com

---

From: Greg Burd <[email-masked]>
Date: Wed, 19 Nov 2025 13:00:29 -0500

> * necessitate a new index tuple.

Attached are rebased (d5b4f3a6d4e) patches with the only changes
happening in the last patch in the series.

I was never happy with the dual functions
ExecCheckIndexedAttrsForChanges() and ExecWhichIndexesRequireUpdates(),
it felt like too much overhead and duplication of effort.  While
updating my tests, adding a few cases, I found that there was also a
flaw in the logic.  So, time to rewrite and combine them.

What did I discover?  Before the logic was to find the set of modified
indexed attributes then review all the indexes for changed attributes
using FormIndexDatum() and comparing before/after to see if expressions
really changed the value to be indexed or not.  The first pass didn't
take into account expressions, the second did.  So, an expression index
over JSONB data wouldn't extract and test the field within the document,
it was just comparing the entire document before/after using the jsonb
comparison function, no bueno.

This approach wraps both functions into one somewhat simplified
function. The logic is basically, iterate over the indexes reviewing
indexed attributes for changes.  Along the way we call into the new
index AM's comparison function when present, otherwise we find and use
the proper type-specific comparison function for the datum.  At the end
of the function we have our Bitmapset of attributes that should trigger
new index tuples.

> * I need to check code coverage so that I might

I did this and it was quite good, I'll do it again for this new series
but it's nice to see that the tests are exercising the vast majority of
the code paths.

> * create tests covering all the new cases

I think the coverage is good, maybe even redundant or overly complex in places.

> * update the README.HOT documentation, wiki, etc.

Soon, I hope to have this approach solid and under review before
solidifying the docs.

> * performance...

Still as yet unmeasured, I know that there is more work per-update to
perform these checks, so some overhead, but I don't know if that
overhead is more than before with HeapDetermineColumnsInfo() and
index_unchanged_by_update().  Those two functions did essentially the
same thing, only with binary comparison (datumIsEqual()). I need to
measure that.  What about doing all this work outside of the buffer lock
in heap_update()?  Surely that'll give back a bit or at least add to
concurrency.  Forming index tuples a few extra times and evaluating the
expressions 3 times rather than 1 is going to hurt, I think I can come
up with a way to cache the formed datum and use it later on, but is that
worth it?  Complex expressions, yes.  Also, what about expressions that
expect to be executed once... and now are 3x?  That's what forced my
update to the insert-conflict-specconflict.out test, but AFAICT there is
no way to test if an expression's value is going to change on update
without exercising it once for the old tuple and once for the new tuple.
Even if it were possible for an index to provide the key it might have
changed after the expression evaluation (as is the case in hash), so I
don't think this is avoidable.  Maybe that's reason enough to add a
reloption to disable the expression evaluation piece of this?  Given
that it might create a logic or performance regression.  The flip side
is the potential to use the HOT path, that's a real savings.

One concerning thing is that nbtree's assumption that key attributes for
TIDs must use binary comparison for equality.  This means that for our
common case (heap/btree) there is more work per-update than before,
which is why I need to measure.  I could look into eliminating the
nbtree requirement, I don't understand it too well as yet by I believe
that on page split there is an attempt to deduplicate TIDs into a
TIDBitmap and the test for when that's possible is datumIsEqual().  If
that were the same as in this new code, possibly evening using
tts_attr_equal(), then... I don't know, I'll have to investigate.  Chime
in here if you can educate me on this one. :)

best.

-greg

---

From: Greg Burd <[email-masked]>
Date: Wed, 19 Nov 2025 13:21:51 -0500

> in here if you can educate me on this one. :)

Doh!

I forgot to commit the fixed regression test expected output before
formatting the patch set, here it is.

-greg

---

From: Matthias van de Meent <[email-masked]>
Date: Fri, 21 Nov 2025 16:25:06 +0100

> happening in the last patch in the series.

Here's a high-level review of the patchset, extending on what I shared
offline. I haven't looked too closely at the code changes.

Re: Perf testing

Apart from the workload we've discussed offline, there's another
workload to consider: Right now, we only really consider HOT when we
know there's space on the page. This patch, however, will front-load a
lot more checks before we have access to the page, and that will
~always impact update performance.
I'm a bit worried that the cost of off-page updates (when the page of
the old tuple can't fit the new tuple) will be too significantly
increased, especially considering that we have a default fillfactor of
100 -- if a table's tuples only grow, it's quite likely the table
frequently can't apply HOT regardless of the updated columns. So, a
workload that's tuned to only update tuples in a way that excercises
'can we HOT or not' code for already-full pages would be appreciated.
A solution to the issue might be needed if we lose too much
performance on expensive checks; a solution like passing page space of
the old tuple to the checks, and short-circuiting the non-HOT path if
that page space is too small for the new tuple.

0001:

I'm not sure I understand why the code is near completely duplicated
here. Maybe you can rename the resulting heap_update to
heap_update_ext, and keep a heap_update around which wraps this
heap_update_ext, allowing old callers to keep their signature until
they need to use _ext's features? Then you can introduce the
duplication if and when needed in later patches -- though I don't
expect a lot of this duplication to be strictly necessary, though that
may need some new helper functions.

I also see that for every update we're now copying, passing 5
bitmapsets individually and then freeing those bitmaps just a moment
later. I'd like to avoid that overhead and duplication if possible.
Maybe we can store these in an 'update context' struct, passed by
reference down to table_tuple_update() from the calling code, and then
onward to heap_update? That might then also be a prime candidate to
contain the EState * of ExecCheckIndexedAttrsForChanges.

0002:

This patch seems to have some formatting updates to changes you made
in 0001, without actually changing the code (e.g. at heap_update's
definition). When updating code, please put it in the expected
formatting in the same patch.

---
In the patch subject:
> For instance,
> indexes with collation information allowing more HOT updates when the
> index is specified to be case insensitive.

It is incorrect to assume that indexed "btree-equal" datums allow HOT
updates. The user can not be returned an old datum in index-only
scans, even if it's sorted the same as the new datum -- after all, a
function on the datum may return different results even if the datums
are otherwise equal. Think: count_uppercase(string). See also below at
"HOT, datum compare, etc".

---
With the addition of rd_indexedattr we now have 6 bitmaps in
RelationData, which generally only get accessed through
RelationGetIndexAttrBitmap by an enum value. Maybe it's now time to
bite the bullet and change that to a more general approach with
`Bitmapset  *rd_bitmaps[NUM_INDEX_ATTR_BITMAP]`? That way, the hot
path in RelationGetIndexAttrBitmap would not depend on the compiler to
determine that the fast path can do simple offset arithmatic to get
the requested bitmap.

0003:
This looks like it's a cleanup patch for 0002, and doesn't have much
standing on its own. Maybe the changes for ri_ChangedIndexedCols can
all be moved into 0003? I think that gives this patch more weight and
standing.

0004:
This does two things:
 1. Add index expression evaluation to the toolset to determine which
indexes were unchanged, and
 2. Allow index access methods to say "this value has not changed"
even if the datum itself may have changed.

Could that be split up into two different patches?

(aside: 2 makes a lot of sense in some cases, like trgm indexes
strings if no new trigrams are added/removed, so I really like the
idea behind this change)

HOT, datum compare, etc.:

Note that for index-only scans on an index to return correct results,
you _must_ update the index (and thus, do a non-HOT update) whenever a
value changes its binary datum, even if the value has the same btree
sort location as the old value. Even for non-IOS-supporting indexes,
the index may need more information than what's used in btree
comparisons when it has a btree opclass.
As SP/GIST have IOS support, they also need to compare the image of
the datum and not use ordinary equality as defined in nbtree's compare
function: the value must be exactly equal to what the table AM
would've provided.

Primary example: Btree compares the `numeric` datums of `1.0` and
`1.00` as equal, but for a user there is an observable difference; the
following SQL must return `1.0` in every valid plan:

BEGIN;
INSERT INTO mytab (mynumeric) VALUES ('1.00');
UPDATE mytab SET mynumeric = '1.0';
SELECT mynumeric FROM mytab;

So, IMO, the default datum compare in ExecCheckIndexedAttrsForChanges
and friends should just use datumIsEqual, and not this new
tts_attr_equal.
Indexes without IOS support might be able to opt into using a more lax
datum comparator, but 1.) it should never be the default, as it'd be a
loaded footgun for IndexAM implementers, and 2.) should not depend on
another AM's understanding of attributes, as that is a very leaky
abstraction.

Kind regards,

Matthias van de Meent
Databricks (https://www.databricks.com)

---

From: Greg Burd <[email-masked]>
Date: Sat, 22 Nov 2025 16:30:39 -0500

> offline. I haven't looked too closely at the code changes.

Matthias, thanks for spending a bit of time writing up your thoughts and
for chatting a bit with me before doing so.  I really appreciate your
point of view.

> know there's space on the page.

Yes, and no.  In master every UPDATE triggers a call into
HeapDetermineColumnsInfo() in heap_update(). That function's job is to
examine all the indexed attributes checking each attribute for changes. 
The set of indexed attributes is generated by combining a set of
Bitmapsets fetched (copied) from the relcache:

hot_attrs = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_HOT_BLOCKING);
sum_attrs = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_SUMMARIZED);
key_attrs = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_KEY);
id_attrs = RelationGetIndexAttrBitmap(relation, INDEX_ATTR_BITMAP_IDENTITY_KEY);

interesting_attrs = NULL;
interesting_attrs = bms_add_members(interesting_attrs, hot_attrs);
interesting_attrs = bms_add_members(interesting_attrs, sum_attrs);
interesting_attrs = bms_add_members(interesting_attrs, key_attrs);
interesting_attrs = bms_add_members(interesting_attrs, id_attrs);

These "interesting attributes" are each checked comparing the newly
formed updated HeapTuple passed in from the executor against a HeapTuple
read from the page.  This happens after taking the buffer lock on that
page.  The comparison is done with heap_attr_compare() which calls
datumIsEqual() which boils down to a memcmp().  The function returns the
"modified_attrs". 

This is the primary "work" that happens before considering HOT that I've
moved outside the buffer lock and into the executor.  The rest of the
HOT decision is 1) will it fit (possibly after being TOASTed), and b) do
the HOT blocking attributes overlap with the modified_attrs?  If they
do, unless they are all summarizing you can't go HOT.

Net new work with my approach is only related to the more complicated
checks that have to be done when there are expressions, partial indexes,
or an index implements the new index AM API for comparing datums.  In
those cases there is a bit more work, especially when it comes to
expression indexes.  Evaluating partial index predicate twice rather
than once is a bit more overhead.  Using type-specific comparison when
the index doesn't support index-only scans is a tad more.  Overall, not
a whole lot of new work.  Certainly a much more complex code path, and
maybe that'll show up in performance tests.  I don't know yet.

> This patch, however, will front-load a

Disagree, 90% of that work happens today on that same path after the
page lock.  Even in the case that the HeapTuple can't fit into the page
that work will happen, there's not much net new "wasted" effort.

Concurrency under load may be better because buffer locks will be held
for less time.

> that page space is too small for the new tuple.

Here's the problem, there's not much that can be known about what
ultimate size the HeapTuple will take or if the page can hold it until
after the lock.

Second, I feel that this signal would be specific to the heap, it's
particular MVCC implementation, and it's optimization (HOT).  I really
wanted this solution to be non-heap-specific, but heap-enabling.

For me that meant that in the general case the executor should be
concerned with updating only the set of indexes that it must and no
more.  So performing this work in the executor ahead of calling into the
table AM or index AM makes sense.

It is only due to heap's "special" model that we have other concerns
related to HOT. Sure, everyone/everything today uses heap so we should
pay attention to this, but I set out not to create yet another thing
that depends on heap's specific operational model.  I think I did that.

> may need some new helper functions.

This is just a split where I move the top portion of heap_update() into
the two paths that use it.  Sure I could have pulled that into another
function, but in this series the next step is to obliterate one half and
(if I get my other patch in cf-6221) then I can completely remove
HeapDetermineColumnsInfo() and vastly simplify simple_heap_update().

> later. I'd like to avoid that overhead and duplication if possible.

We do this today, nothing new here.  They are passed by reference, not
value into heap_update().

> contain the EState * of ExecCheckIndexedAttrsForChanges.

I've explored using UpdateContext before, not a bad idea but again this
is just a setup commit.  It could be cleaner on it's own, but it doesn't
really take a step backward on any dimension.

> formatting in the same patch.

I'll find/fix those after v23 attached, sorry for the noise.

> "HOT, datum compare, etc".

Thanks for pointing out the oversight for index-oriented scans (IOS),
you're right that the code in v22 doesn't handle that correctly.  I'll
fix that.  I still think that indexes that don't support IOS can and
should use the type-specific equality checks.  This opens the door to
HOT with custom types that have unusual equality rules (see BSON).

> determine that the fast path can do simple offset arithmatic to get

More than a few of those bitmaps in RelationData are purely for the HOT
tests, yes.  I'll review those and see if I can shrink the set
meaningfully.  It was something that had occurred to me too.  I'll
review and consider ways to consolidate them.

> all be moved into 0003? I think that gives this patch more weight and

The goal in this patch was to show that we could eliminate the redundant
set work of index_unchanged_by_update() in execIndexing's update path. 
Separating it out was done to make it more easily reviewed and to prove
that before/after tests passed making the change safe.

> even if the datum itself may have changed.

Yes, that's what it does. :)

> Could that be split up into two different patches?

Maybe, I might be able to add the index AM piece first and then the
expression piece.

> idea behind this change).

Nice, I appreciate that.

> sort location as the old value.

Yes, I get this now.  I also wasn't testing the INCLUDING (non-key)
columns.  I've fixed both of those in v23.

> Even for non-IOS-supporting indexes,

It's not always just the btree opclass for equality, but that is common.

> function: the value must be exactly equal to what the table AM

In v23 I've changed the logic to use datumIsEqual() for any index that
supports IOS and doesn't supply a custom amcomparedatums() function.

> SELECT mynumeric FROM mytab;

I'll try to reproduce this and add a test if I can.

> and friends should just use datumIsEqual, and not this new

Sure, and that's the case in v23.  tts_attr_equal() still has value in
other cases so it's not gone.

> another AM's understanding of attributes, as that is a very leaky

Agree, which is why the default for IOS indexes is datumIsEqual() now.

> Databricks (https://www.databricks.com)

Thanks again for the time and renewed interest in the patch!  I've also
added a lot more tests into the heap_hot_updates.sql regression suite,
likely too many, but for now it's good to be testing the corners.

v23 attached, changes are all in 0004, best.

-greg

---

From: Matthias van de Meent <[email-masked]>
Date: Mon, 24 Nov 2025 19:59:10 +0100

> HOT with custom types that have unusual equality rules (see BSON).

Do you have specific examples why it would be safe to default to
"unusual equality rules" for generally any index's data ingestion
needs? Why e.g. BSON must always be compared with their special
equality test (and not datumIsEqual), and why IOS-less indexes in
general are never going to distinguish between binary distinct but
btree-equal values, and why exact equality is the special case here?

I understand that you want to maximize optimization for specific
workloads that you have in mind, but lacking evidence to the contrary
I am really not convinced that your workloads are sufficiently
generalizable that they can (and should) be the baseline for these new
HOT rules: I have not yet seen good arguments why we could relax
"datum equality" to "type equality" without potentially breaking
existing indexes.

HOT was implemented quite conservatively to make sure that there are
no issues where changed values are not reflected in indexes: each
indexed TID represents a specific and unchanging set of indexed
values, in both the key and non-key attributes of indexes. If a value
changes however so slightly, that may be a cause for indexes to treat
it differently, and thus HOT must not be used.

Aside: The amsummarizing optimization gets around that check by
realizing the TID itself isn't really indexed, so the rules can be
relaxed around that, but it still needs to go through the effort to
update the summarizing indexes if the relevant attributes were ever so
slightly updated.

This patch right now wants to change these rules and behaviour of HOT
in two ways:

1.) Instead of only testing attributes mentioned by indexed
expressions for changes, it wants to test the output of the indexed
expressions.
I would consider this to be generally safe, as long as the expressions
comply with the rules we have for indexed expressions. [Which, if not
held, would break IOS and various other things, too, so relying on
these rules isn't new or special].

2.) Instead of datumIsEqual, it (by default) wants to do equality
checks as provided by the type's default btree opclass' = operator.
I have not seen evidence that this is safe. I have even explained with
an example that IOS will return distinctly wrong results if only
btree's = operator is used to determine if HOT can be applied, and
that doesn't even begin to cover the issues related to indexes that
may handle data differently from Btree.
I also don't want indexes that at some point in the future invent
support for IOS to return subtly incorrect results due to HOT checks
that depended on the output of a previous version's amcanreturn
output.

So, IMV, tts_attr_equal is just a rather expensive version of
datumIsEqual: the fast path cases would've been handled by
datumIsEqual at least as fast (without a switch() statement with 18
specific cases and a default branch); if there is no btree operator
it'll still default to btree compare, and if not then if the slow path
uses correctly implemented compare operators (for HOT, and potentially
all other possible indexes), then these would have an output that is
indistinguishable from datumIsEqual, with the only difference the
address and performance of the called function and a lot of added
catalog lookups.

All together, I think it's best to remove the second component of the
changes to the HOT rules (changing the type of matching done for
indexed values with tts_attr_compare) from this patchset.
If you believe this should be added regardless, I think it's best
discussed separately in its own thread and patchset -- it should be
relatively easy to introduce in both current and future versions of
this code, and (if you're correct and this is safe) it would have some
benefits even when committed on its own.

Kind regards,

Matthias van de Meent

---

From: Greg Burd <[email-masked]>
Date: Wed, 3 Dec 2025 17:06:06 -0500

On Nov 24 2025, at 1:59 pm, Matthias van de Meent
<[email-masked]> wrote:

> ... <awesome thoughtful questions, insights, etc> ...
> 
> Kind regards,
> 
> Matthias van de Meent

Hey Matthias,

I've updated the patch set to v25 and taken your suggested approach of
minimizing changes in hopes of getting the majority of this patch
series committed (I hope) soon.  By that I mean that $subject isn't
really accurate anymore.  This patch uses datumIsEqual(), but still
introduces the new index AM API and still moves
HeapDetermineColumnsInfo() into a function in nodeModifyTable.c called
ExecWhichIndexesRequireUpdates().  The "big idea" is that before calling
into the table AM to update a tuple the executor should know what the
impact of that update will be for the indexes on the relation. 
ExecWhichIndexesRequireUpdates() finds the set of attributes that were
a) modified and b) are referenced by an index and cause that index to
need a new index tuple.  This is left up to heap now, but really should
be generic across all table AMs (IMO), so that's what I've done.

At some point in the future maybe there's a way to switch from the
heap-specific model of all/none/summarized-only to something where we
only update indexes that really require the updates (HOT, WARM, etc.)
and I think this is a step in that direction, but for now the logic
remains the same as does the signal (TU_Updated).

I'll re-introduce $subject as a layer on v24 next week at which point
I'll try to address all the good points you raised in your email.  Those
additions (HOT expressions, HOT partial indexes, and type-specific
equality tests) are the most controversial.

I think it may be possible that these first few patches are less
controversial and could make the cut sooner while those other ideas
remain up for debate. I'm open to that, I think the work in the attached
set is good and valuable on it's own.

So, the attached patch set 

Benefits of the patch:
* the tests for what changed move outside of the buffer lock
* the redundant index_unchanged_by_update() is removed

Downsides of the patch:
* a bit of new overhead in some cases
* a bit more complicated logic than before

Recall that this patch set combines with another one of mine on the list
[1] which covers the simple_heap_update() path, this one doesn't cover
that case and you'll see that simple_heap_update() still depends on HeapDetermineColumnsInfo().

* 0001 - Prepare heapam_tuple_update() and simple_heap_update() for divergence

This splits off the top of heap_update() and places that logic in both
heapam_tuple_update() and simple_heap_update().  This patch is also
present in the other thread [1] and essentially the same.  That thread
addresses the changes to the catalog tuple updates.  No real effort was
made to make this patch "pretty" or "stand-alone" as it really is a
precursor to the work in 0002 and in [1].

* 0002 - Track changed indexed columns in the executor during UPDATEs

This is where the meat is, as described in earlier emails and the commit
message. HeapDetermineColumnsInfo() logic moves up into the executor
into ExecWhichIndexesRequireUpdates().  Some heap-specific logic related
to replica identity remains in heapam_tuple_update().

* 0003 - Replace index_unchanged_by_update() with ri_ChangedIndexedCols

This removes the now redundant index_unchanged_by_update() function and
instead uses the information gathered in
ExecWhichIndexesRequireUpdates() and recorded in ri_ChangedIndexCols for
the same outcome.

best.

-greg

[1] https://www.postgresql.org/message-id/flat/[email-masked]

---

From: Greg Burd <[email-masked]>
Date: Mon, 15 Dec 2025 16:46:11 -0500

I've updated the patch set a tad and I've got some benchmark results
(and questions).

PATCHES
===========================================================

Unchanged.

 Bug/oversight minor fix related to partial index attributes.

Also, I mistakenly said that v25 removed the $subject (ability to allow
expression indexes to be HOT).  That's not true, they can go HOT with
this patch provided that the result of the expression evaluated using
the before/after attribute values are equal using datumIsEqual().  When
that is the case, as can happen with updates to fields within JSONB
columns when indexes are on other fields, the update can be HOT should
the heap find room on the page to store the new tuple.

Unchanged.

* 0004 - Identify if partial indexes are impacted by an update

This is the new piece, it existed in the v24 patch set and now it is
back. This checks the before/after partial index expression and when
both are outside the predicate then it is possible that heap can use the
HOT path whereas in the past this couldn't happen.  In the past any
update to an attribute in an index, even if it was outside the
predicate, was (is) HOT blocking.

SUMMARY
===========================================================

I've just started to scratch the surface of performance testing for
this, attached is a very simple comparison of master/patch for a basic
update load that should always go HOT in either case.  It shows about 1%
variance between the two (-O0), tests run on my laptop so that's
essentially no difference despite more overhead of the new function and
that it seems to be called more frequently due to (guessing here) more
opportunity for TM_Updated to be the return from heapam_tuple_update. 
Your thoughts welcome here, or best/worst case ideas for tests to run.

Next up I plan to layer the controversial type-specific piece into this
patch set if nothing else just as a record of what's left over.  Then
I'll try to better isolate good/bad performance implications of this
patch set.

Ideally, this patch set and the one (under development) for catalog
tuples could combine to completely restructure the heap update process
and open the door to more HOT updates and faster catalog updates.  But,
I still have to demonstrate that.  For JSONB heavy applications this
should be a net win, for the rest it should be a minor or zero
regression.  For other custom implementations of indexes over
specialized types (as is the case for the new open sourced DocumentDB
work) this opens the door for HOT updates when possible.  All of that is
the the hope, it's time to measure hope against reality. :)

This patch set does start to move the executor away from a heap-specific
view of the world where updates are all/none/summarizing.  This
potentially eases the integration of WARM or PHOT-like solutions where
we only update those indexes that are materially impacted by an update. 
It should be clear by now, that's my ultimate goal.

best.

-greg