From 9951afa96fc2d1c0646f8717ed2cd4cfb5e2ed06 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Thu, 15 Oct 2026 12:51:22 -0400
Subject: [PATCH v39 4/8] Skip evaluating indexes that an UPDATE's SET clause
 cannot affect

The previous commit lets an UPDATE be HOT when it changes a column that
indexes use only through expressions or partial-index predicates, if
evaluating those expressions and predicates on the old and new rows gives
the same results.  For a large structured column, such as a jsonb
document, that means reading the document twice per index on every row,
even when the SET clause plainly changes a part no index reads:

    CREATE INDEX ON t ((doc->>'name'));
    UPDATE t SET doc = jsonb_set(doc, '{visits}', ...);

Work this out once per statement, when the update projection is set up,
from the plan tree alone.  For each SET target column that expression or
partial indexes use, find which parts of the column the SET expression may
change, and which parts each index reads; an index whose parts cannot
overlap the changed ones needs no evaluation for that column.  Nothing at
run time is involved, so the result is the same with or without JIT
compilation, and there is no per-row state to reset.

Parts are named by paths of object keys and array positions.  A function
describes its calls through its support function, with the new
SupportRequestSubattr request: which parts of which argument a call reads,
or which parts of its source argument it may change, given its constant
arguments.  Array and jsonb subscripting, composite field selection and
assignment, COALESCE and domain coercion are understood directly.  The
jsonb accessors (->, ->>, #>, #>>, jsonb_extract_path[_text]) and
modifiers (jsonb_set, jsonb_set_lax, jsonb_insert, -, #-, and || with a
constant object) get a support function, jsonb_subattr_support().
Extensions can describe their own document types and functions the same
way.  External Params of a generic plan are read as constants, so
prepared statements benefit as well.

The analysis gives up for a column unless the subplan is a plain scan of
the target relation (directly, or under an Append for inheritance and
partitioning), the statement is an UPDATE rather than a MERGE, there are
no BEFORE ROW UPDATE triggers, and every reference to the column in the
SET expression and the index is one it understands.  At run time, the
conclusion is not used for a row whose old or new value of the column is
null.  Two paths are taken to overlap unless they differ in a key that
cannot be read as an array position, so changes that move array elements,
negative positions and keys such as "01" or " 1" are all covered.
Assert-enabled builds evaluate the indexes anyway and raise an error if
the conclusion was wrong.

This only saves work: an update is HOT exactly when it was before.
---
 contrib/amcheck/Makefile                      |   2 +-
 contrib/amcheck/expected/check_subattr.out    | 545 ++++++++++++++++
 contrib/amcheck/meson.build                   |   1 +
 contrib/amcheck/sql/check_subattr.sql         | 178 ++++++
 doc/src/sgml/storage.sgml                     |   6 +-
 doc/src/sgml/xfunc.sgml                       |  15 +
 src/backend/executor/Makefile                 |   1 +
 src/backend/executor/execSubattr.c            | 597 ++++++++++++++++++
 src/backend/executor/meson.build              |   1 +
 src/backend/executor/nodeModifyTable.c        | 180 +++++-
 src/backend/utils/adt/jsonfuncs.c             | 273 ++++++++
 src/include/catalog/pg_proc.dat               |  63 +-
 src/include/executor/execSubattr.h            |  22 +
 src/include/nodes/execnodes.h                 |  10 +
 src/include/nodes/supportnodes.h              |  74 +++
 .../isolation/expected/update-subattr-epq.out |  65 ++
 src/test/isolation/isolation_schedule         |   1 +
 .../isolation/specs/update-subattr-epq.spec   |  44 ++
 src/test/regress/expected/misc_functions.out  |  39 ++
 src/test/regress/regress.c                    |  38 ++
 src/test/regress/sql/misc_functions.sql       |  29 +
 src/tools/pgindent/typedefs.list              |   5 +
 22 files changed, 2160 insertions(+), 29 deletions(-)
 create mode 100644 contrib/amcheck/expected/check_subattr.out
 create mode 100644 contrib/amcheck/sql/check_subattr.sql
 create mode 100644 src/backend/executor/execSubattr.c
 create mode 100644 src/include/executor/execSubattr.h
 create mode 100644 src/test/isolation/expected/update-subattr-epq.out
 create mode 100644 src/test/isolation/specs/update-subattr-epq.spec

diff --git a/contrib/amcheck/Makefile b/contrib/amcheck/Makefile
index 1b7a63cbaa4..1ca619e1777 100644
--- a/contrib/amcheck/Makefile
+++ b/contrib/amcheck/Makefile
@@ -13,7 +13,7 @@ DATA = amcheck--1.2--1.3.sql amcheck--1.1--1.2.sql amcheck--1.0--1.1.sql amcheck
 		amcheck--1.3--1.4.sql amcheck--1.4--1.5.sql
 PGFILEDESC = "amcheck - function for verifying relation integrity"
 
-REGRESS = check check_btree check_gin check_heap
+REGRESS = check check_btree check_gin check_heap check_subattr
 
 EXTRA_INSTALL = contrib/pg_walinspect
 TAP_TESTS = 1
diff --git a/contrib/amcheck/expected/check_subattr.out b/contrib/amcheck/expected/check_subattr.out
new file mode 100644
index 00000000000..8aacdef9804
--- /dev/null
+++ b/contrib/amcheck/expected/check_subattr.out
@@ -0,0 +1,545 @@
+--
+-- Updates of parts of jsonb, array and composite columns
+--
+-- The executor works out from an UPDATE's SET expressions which parts of a
+-- column it can change, and skips evaluating the expression and partial
+-- indexes that read only other parts.  A wrong conclusion would leave an
+-- index without an entry for a row, so each case checks every index against
+-- the heap; assert-enabled builds also evaluate the indexes anyway and fail
+-- if the conclusion was wrong.  HOT counts show which updates avoided new
+-- index entries, whether or not anything was skipped.
+--
+SET client_min_messages = warning;
+CREATE TABLE subattr_results (name text, hot bigint, upd bigint, amcheck text);
+CREATE FUNCTION subattr_probe(name text, upd text, arg jsonb DEFAULT NULL)
+RETURNS void LANGUAGE plpgsql AS $f$
+DECLARE h0 bigint; u0 bigint; ac text := 'ok'; i regclass;
+BEGIN
+  CREATE TABLE subattr (id int PRIMARY KEY, d jsonb, a int[], x int)
+    WITH (fillfactor = 50);
+  CREATE INDEX subattr_name ON subattr ((d->>'name'));
+  CREATE INDEX subattr_nested ON subattr ((d->'o'->>'k'));
+  CREATE INDEX subattr_path ON subattr ((d #>> '{p,q}'));
+  CREATE INDEX subattr_arr0 ON subattr ((d->'tags'->>0));
+  CREATE INDEX subattr_sub ON subattr ((d['s']));
+  CREATE INDEX subattr_part ON subattr (id) WHERE (d->>'flag') = 'on';
+  CREATE INDEX subattr_a2 ON subattr ((a[2]));
+  INSERT INTO subattr SELECT g,
+    jsonb_build_object('name', 'n' || g, 'age', g, 'tags', '[1,2,3]'::jsonb,
+                       'o', jsonb_build_object('k', 'k' || g, 'z', 1),
+                       'p', jsonb_build_object('q', 'q' || g), 's', 's' || g,
+                       'flag', CASE WHEN g % 2 = 0 THEN 'on' ELSE 'off' END,
+                       '1', 'one', 'arr', '[10,20,30]'::jsonb),
+    ARRAY[g, g + 1, g + 2], 0
+    FROM generate_series(1, 50) g;
+  IF name LIKE 'trigger%' THEN
+    CREATE FUNCTION subattr_trg() RETURNS trigger LANGUAGE plpgsql AS
+      $t$BEGIN NEW.d := jsonb_set(NEW.d, '{name}', '"renamed"'); RETURN NEW; END$t$;
+    CREATE TRIGGER subattr_trg BEFORE UPDATE ON subattr
+      FOR EACH ROW EXECUTE FUNCTION subattr_trg();
+  END IF;
+  h0 := pg_stat_get_xact_tuples_hot_updated('subattr'::regclass);
+  u0 := pg_stat_get_xact_tuples_updated('subattr'::regclass);
+  IF arg IS NULL THEN EXECUTE upd; ELSE EXECUTE upd USING arg; END IF;
+  FOR i IN SELECT indexrelid::regclass FROM pg_index
+           WHERE indrelid = 'subattr'::regclass ORDER BY 1 LOOP
+    BEGIN PERFORM bt_index_check(i, true);
+    EXCEPTION WHEN OTHERS THEN ac := i::text || ': ' || SQLERRM; END;
+  END LOOP;
+  INSERT INTO subattr_results VALUES (name,
+    pg_stat_get_xact_tuples_hot_updated('subattr'::regclass) - h0,
+    pg_stat_get_xact_tuples_updated('subattr'::regclass) - u0, ac);
+  DROP TABLE subattr;
+  DROP FUNCTION IF EXISTS subattr_trg();
+END $f$;
+-- Nothing indexed changes.
+SELECT subattr_probe('set-age',            $$UPDATE subattr SET d = jsonb_set(d, '{age}', '1')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-o-z',            $$UPDATE subattr SET d = jsonb_set(d, '{o,z}', '2')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-p-other',        $$UPDATE subattr SET d = jsonb_set(d, '{p,r}', '2')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('delete-age',         $$UPDATE subattr SET d = d - 'age'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('delete-keys',        $$UPDATE subattr SET d = d - '{age,zz}'::text[]$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('delete-path-age',    $$UPDATE subattr SET d = d #- '{age}'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('delete-arr-elem',    $$UPDATE subattr SET d = jsonb_set(d, '{tags}', (d->'tags') - 0)$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('concat-other',       $$UPDATE subattr SET d = d || '{"age": 5, "new": 1}'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('subscript-age',      $$UPDATE subattr SET d['age'] = '7'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('nested-mutators',    $$UPDATE subattr SET d = jsonb_set(jsonb_set(d, '{age}', '1'), '{new}', '2')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-lax-age',        $$UPDATE subattr SET d = jsonb_set_lax(d, '{age}', NULL, true, 'delete_key')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('insert-o-new',       $$UPDATE subattr SET d = jsonb_insert(d, '{o,new}', '1')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('array-a3',           $$UPDATE subattr SET a[3] = 99$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('array-a9-extend',    $$UPDATE subattr SET a[9] = 99$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-arr-elem',       $$UPDATE subattr SET d = jsonb_set(d, '{arr,1}', '5')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('param-path',         $$UPDATE subattr SET d = jsonb_set(d, ARRAY['age'], $1)$$, '3');
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('coalesce-wrap',      $$UPDATE subattr SET d = COALESCE(jsonb_set(d, '{age}', '1'), d)$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('flag-unchanged',     $$UPDATE subattr SET d = jsonb_set(d, '{age}', '1') WHERE id % 2 = 0$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+-- An indexed part changes, or might.
+SELECT subattr_probe('set-name',           $$UPDATE subattr SET d = jsonb_set(d, '{name}', '"x"')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-o',              $$UPDATE subattr SET d = jsonb_set(d, '{o}', '{}')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-o-k',            $$UPDATE subattr SET d = jsonb_set(d, '{o,k}', '"x"')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-p',              $$UPDATE subattr SET d = jsonb_set(d, '{p}', '1')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('delete-name',        $$UPDATE subattr SET d = d - 'name'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('delete-keys-name',   $$UPDATE subattr SET d = d - '{age,name}'::text[]$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('delete-path-name',   $$UPDATE subattr SET d = d #- '{name}'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('delete-path-o-k',    $$UPDATE subattr SET d = d #- '{o,k}'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('insert-tags-0',      $$UPDATE subattr SET d = jsonb_insert(d, '{tags,0}', '"x"')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('insert-tags-1',      $$UPDATE subattr SET d = jsonb_insert(d, '{tags,1}', '"x"')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('insert-tags-neg',    $$UPDATE subattr SET d = jsonb_insert(d, '{tags,-1}', '"x"')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-tags-0',         $$UPDATE subattr SET d = jsonb_set(d, '{tags,0}', '9')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-tags-neg3',      $$UPDATE subattr SET d = jsonb_set(d, '{tags,-3}', '9')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-tags-sp0',       $$UPDATE subattr SET d = jsonb_set(d, ARRAY['tags',' 0'], '9')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-tags-plus0',     $$UPDATE subattr SET d = jsonb_set(d, ARRAY['tags','+0'], '9')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-tags-00',        $$UPDATE subattr SET d = jsonb_set(d, ARRAY['tags','00'], '9')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('delete-path-tags-0', $$UPDATE subattr SET d = d #- '{tags,0}'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-lax-tags-0',     $$UPDATE subattr SET d = jsonb_set_lax(d, '{tags,0}', NULL, true, 'delete_key')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('concat-name',        $$UPDATE subattr SET d = d || '{"name": "x"}'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('concat-array',       $$UPDATE subattr SET d = d || '[1]'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('concat-left',        $$UPDATE subattr SET d = '{"name": "x"}' || d$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('whole-replace',      $$UPDATE subattr SET d = '{"name": "x"}'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('mutator-other-col',  $$UPDATE subattr SET d = jsonb_set(jsonb_build_object('name', 'z' || x), '{age}', '1')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('case-wrap',          $$UPDATE subattr SET d = CASE WHEN id > 0 THEN jsonb_set(d, '{name}', '"x"') ELSE d END$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-lax-name',       $$UPDATE subattr SET d = jsonb_set_lax(d, '{name}', NULL, true, 'delete_key')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('subscript-name',     $$UPDATE subattr SET d['name'] = '"x"'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('subscript-s',        $$UPDATE subattr SET d['s'] = '"x"'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('subscript-int',      $$UPDATE subattr SET d['tags'][0] = '9'$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-flag',           $$UPDATE subattr SET d = jsonb_set(d, '{flag}', '"on"')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('set-1-key',          $$UPDATE subattr SET d = jsonb_set(d, '{1}', '"two"')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('trigger-renames',    $$UPDATE subattr SET d = jsonb_set(d, '{age}', '1')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('array-a2',           $$UPDATE subattr SET a[2] = 99$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('array-a-neg',        $$UPDATE subattr SET a[-5] = 99$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('array-whole',        $$UPDATE subattr SET a = a || 99$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('param-path-name',    $$UPDATE subattr SET d = jsonb_set(d, ARRAY['name'], $1)$$, '"x"');
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('param-doc',          $$UPDATE subattr SET d = $1$$, '{"name":"x"}');
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('self-ref-value',     $$UPDATE subattr SET d = jsonb_set(d, '{age}', d->'name')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT subattr_probe('null-doc',           $$UPDATE subattr SET d = jsonb_set(NULL::jsonb, '{age}', '1')$$);
+ subattr_probe 
+---------------
+ 
+(1 row)
+
+SELECT name, upd, hot, amcheck FROM subattr_results ORDER BY name;
+        name        | upd | hot | amcheck 
+--------------------+-----+-----+---------
+ array-a2           |  50 |   0 | ok
+ array-a3           |  50 |  50 | ok
+ array-a9-extend    |  50 |  50 | ok
+ array-a-neg        |  50 |  50 | ok
+ array-whole        |  50 |  50 | ok
+ case-wrap          |  50 |   0 | ok
+ coalesce-wrap      |  50 |  50 | ok
+ concat-array       |  50 |   0 | ok
+ concat-left        |  50 |  50 | ok
+ concat-name        |  50 |   0 | ok
+ concat-other       |  50 |  50 | ok
+ delete-age         |  50 |  50 | ok
+ delete-arr-elem    |  50 |   0 | ok
+ delete-keys        |  50 |  50 | ok
+ delete-keys-name   |  50 |   0 | ok
+ delete-name        |  50 |   0 | ok
+ delete-path-age    |  50 |  50 | ok
+ delete-path-name   |  50 |   0 | ok
+ delete-path-o-k    |  50 |   0 | ok
+ delete-path-tags-0 |  50 |   0 | ok
+ flag-unchanged     |  25 |  25 | ok
+ insert-o-new       |  50 |  50 | ok
+ insert-tags-0      |  50 |   0 | ok
+ insert-tags-1      |  50 |  50 | ok
+ insert-tags-neg    |  50 |  50 | ok
+ mutator-other-col  |  50 |   0 | ok
+ nested-mutators    |  50 |  50 | ok
+ null-doc           |  50 |   0 | ok
+ param-doc          |  50 |   0 | ok
+ param-path         |  50 |  50 | ok
+ param-path-name    |  50 |   0 | ok
+ self-ref-value     |  50 |  50 | ok
+ set-1-key          |  50 |  50 | ok
+ set-age            |  50 |  50 | ok
+ set-arr-elem       |  50 |  50 | ok
+ set-flag           |  50 |  25 | ok
+ set-lax-age        |  50 |  50 | ok
+ set-lax-name       |  50 |   0 | ok
+ set-lax-tags-0     |  50 |   0 | ok
+ set-name           |  50 |   0 | ok
+ set-o              |  50 |   0 | ok
+ set-o-k            |  50 |   0 | ok
+ set-o-z            |  50 |  50 | ok
+ set-p              |  50 |   0 | ok
+ set-p-other        |  50 |  47 | ok
+ set-tags-0         |  50 |   0 | ok
+ set-tags-00        |  50 |   0 | ok
+ set-tags-neg3      |  50 |   0 | ok
+ set-tags-plus0     |  50 |   0 | ok
+ set-tags-sp0       |  50 |   0 | ok
+ subscript-age      |  50 |  50 | ok
+ subscript-int      |  50 |   0 | ok
+ subscript-name     |  50 |   0 | ok
+ subscript-s        |  50 |   0 | ok
+ trigger-renames    |  50 |   0 | ok
+ whole-replace      |  50 |   0 | ok
+(56 rows)
+
+DROP TABLE subattr_results;
+DROP FUNCTION subattr_probe;
+-- What was concluded, for a few statements.  Nothing is concluded for an
+-- UPDATE of a single row.
+CREATE TABLE subattr (id int PRIMARY KEY, d jsonb, a int[]);
+CREATE INDEX subattr_name ON subattr ((d->>'name'));
+CREATE INDEX subattr_tags ON subattr ((d->'tags'->>0));
+CREATE INDEX subattr_a2 ON subattr ((a[2]));
+INSERT INTO subattr VALUES (1, '{"name": "a", "tags": [1, 2]}', '{1,2,3}');
+SET client_min_messages = debug1;
+UPDATE subattr SET d = jsonb_set(d, '{age}', '1');
+INSERT INTO subattr VALUES (2, '{"name": "b", "tags": [1, 2]}', '{1,2,3}');
+UPDATE subattr SET d = jsonb_set(d, '{age}', '1');
+DEBUG:  UPDATE of column "d" cannot change index "subattr_name"
+DEBUG:  UPDATE of column "d" cannot change index "subattr_tags"
+UPDATE subattr SET d = jsonb_set(d, '{tags,1}', '9');
+DEBUG:  UPDATE of column "d" cannot change index "subattr_name"
+UPDATE subattr SET d = d - 'name', a[1] = 0;
+DEBUG:  UPDATE of column "d" cannot change index "subattr_tags"
+DEBUG:  UPDATE of column "a" cannot change index "subattr_a2"
+UPDATE subattr SET d = d || '{"age": 2}';
+DEBUG:  UPDATE of column "d" cannot change index "subattr_name"
+DEBUG:  UPDATE of column "d" cannot change index "subattr_tags"
+RESET client_min_messages;
+DROP TABLE subattr;
+-- Shapes that need their own tables: composite fields, domains, generic
+-- plans, a strict modifier that returns null, and references the analysis
+-- does not follow.
+CREATE TYPE subattr_pair AS (k int, v int);
+CREATE DOMAIN subattr_doc AS jsonb CHECK (VALUE ? 'name');
+CREATE TABLE subattr (id int PRIMARY KEY, d subattr_doc, p subattr_pair,
+                      r jsonb, n jsonb) WITH (fillfactor = 50);
+CREATE INDEX subattr_name ON subattr ((d->>'name'));
+CREATE INDEX subattr_pk ON subattr (((p).k));
+CREATE INDEX subattr_whole ON subattr ((r->>'name')) WHERE r IS NOT NULL;
+CREATE INDEX subattr_row ON subattr (id) WHERE subattr IS NOT NULL;
+CREATE INDEX subattr_text ON subattr ((n::text));
+CREATE INDEX subattr_ep ON subattr ((jsonb_extract_path(n, 'a', 'b')));
+INSERT INTO subattr SELECT g, jsonb_build_object('name', 'n' || g),
+    ROW(g, g)::subattr_pair, jsonb_build_object('name', 'r' || g),
+    '{"a": {"b": 1}}'
+  FROM generate_series(1, 4) g;
+SET client_min_messages = debug1;
+UPDATE subattr SET d = jsonb_set(d, '{age}', '1');
+DEBUG:  UPDATE of column "d" cannot change index "subattr_name"
+UPDATE subattr SET p.v = 0;
+DEBUG:  UPDATE of column "p" cannot change index "subattr_pk"
+UPDATE subattr SET p.k = (p).k + 10;
+UPDATE subattr SET r = jsonb_set(r, '{age}', '1');
+UPDATE subattr SET n = jsonb_set(n, '{c}', '1');
+DEBUG:  UPDATE of column "n" cannot change index "subattr_ep"
+UPDATE subattr SET n = jsonb_set(n, '{a,c}', '1');
+DEBUG:  UPDATE of column "n" cannot change index "subattr_ep"
+UPDATE subattr SET d = jsonb_set(d, ARRAY[NULL, 'x'], '1') WHERE false;
+UPDATE subattr SET d = jsonb_set(d, '{age}', (SELECT d->'name'));
+DEBUG:  UPDATE of column "d" cannot change index "subattr_name"
+PREPARE subattr_p(text[], jsonb) AS
+  UPDATE subattr SET d = jsonb_set(d, $1, $2);
+SET plan_cache_mode = force_generic_plan;
+EXECUTE subattr_p('{age}', '2');
+DEBUG:  UPDATE of column "d" cannot change index "subattr_name"
+EXECUTE subattr_p('{name}', '"x"');
+RESET plan_cache_mode;
+DEALLOCATE subattr_p;
+-- jsonb_set returns null for a null new value; such rows are evaluated
+UPDATE subattr SET r = jsonb_set(r, '{age}', NULL) WHERE id <= 2;
+RESET client_min_messages;
+SELECT id, d, p, r FROM subattr ORDER BY id;
+ id |            d            |   p    |            r             
+----+-------------------------+--------+--------------------------
+  1 | {"age": 2, "name": "x"} | (11,0) | 
+  2 | {"age": 2, "name": "x"} | (12,0) | 
+  3 | {"age": 2, "name": "x"} | (13,0) | {"age": 1, "name": "r3"}
+  4 | {"age": 2, "name": "x"} | (14,0) | {"age": 1, "name": "r4"}
+(4 rows)
+
+SELECT bt_index_check(indexrelid, true) FROM pg_index
+  WHERE indrelid = 'subattr'::regclass ORDER BY indexrelid::regclass::text;
+ bt_index_check 
+----------------
+ 
+ 
+ 
+ 
+ 
+ 
+ 
+(7 rows)
+
+DROP TABLE subattr;
+DROP DOMAIN subattr_doc;
+DROP TYPE subattr_pair;
diff --git a/contrib/amcheck/meson.build b/contrib/amcheck/meson.build
index d5137ef691d..cc4f6a50330 100644
--- a/contrib/amcheck/meson.build
+++ b/contrib/amcheck/meson.build
@@ -40,6 +40,7 @@ tests += {
       'check_btree',
       'check_gin',
       'check_heap',
+      'check_subattr',
     ],
   },
   'tap': {
diff --git a/contrib/amcheck/sql/check_subattr.sql b/contrib/amcheck/sql/check_subattr.sql
new file mode 100644
index 00000000000..e4a249d19b2
--- /dev/null
+++ b/contrib/amcheck/sql/check_subattr.sql
@@ -0,0 +1,178 @@
+--
+-- Updates of parts of jsonb, array and composite columns
+--
+-- The executor works out from an UPDATE's SET expressions which parts of a
+-- column it can change, and skips evaluating the expression and partial
+-- indexes that read only other parts.  A wrong conclusion would leave an
+-- index without an entry for a row, so each case checks every index against
+-- the heap; assert-enabled builds also evaluate the indexes anyway and fail
+-- if the conclusion was wrong.  HOT counts show which updates avoided new
+-- index entries, whether or not anything was skipped.
+--
+SET client_min_messages = warning;
+CREATE TABLE subattr_results (name text, hot bigint, upd bigint, amcheck text);
+CREATE FUNCTION subattr_probe(name text, upd text, arg jsonb DEFAULT NULL)
+RETURNS void LANGUAGE plpgsql AS $f$
+DECLARE h0 bigint; u0 bigint; ac text := 'ok'; i regclass;
+BEGIN
+  CREATE TABLE subattr (id int PRIMARY KEY, d jsonb, a int[], x int)
+    WITH (fillfactor = 50);
+  CREATE INDEX subattr_name ON subattr ((d->>'name'));
+  CREATE INDEX subattr_nested ON subattr ((d->'o'->>'k'));
+  CREATE INDEX subattr_path ON subattr ((d #>> '{p,q}'));
+  CREATE INDEX subattr_arr0 ON subattr ((d->'tags'->>0));
+  CREATE INDEX subattr_sub ON subattr ((d['s']));
+  CREATE INDEX subattr_part ON subattr (id) WHERE (d->>'flag') = 'on';
+  CREATE INDEX subattr_a2 ON subattr ((a[2]));
+  INSERT INTO subattr SELECT g,
+    jsonb_build_object('name', 'n' || g, 'age', g, 'tags', '[1,2,3]'::jsonb,
+                       'o', jsonb_build_object('k', 'k' || g, 'z', 1),
+                       'p', jsonb_build_object('q', 'q' || g), 's', 's' || g,
+                       'flag', CASE WHEN g % 2 = 0 THEN 'on' ELSE 'off' END,
+                       '1', 'one', 'arr', '[10,20,30]'::jsonb),
+    ARRAY[g, g + 1, g + 2], 0
+    FROM generate_series(1, 50) g;
+  IF name LIKE 'trigger%' THEN
+    CREATE FUNCTION subattr_trg() RETURNS trigger LANGUAGE plpgsql AS
+      $t$BEGIN NEW.d := jsonb_set(NEW.d, '{name}', '"renamed"'); RETURN NEW; END$t$;
+    CREATE TRIGGER subattr_trg BEFORE UPDATE ON subattr
+      FOR EACH ROW EXECUTE FUNCTION subattr_trg();
+  END IF;
+  h0 := pg_stat_get_xact_tuples_hot_updated('subattr'::regclass);
+  u0 := pg_stat_get_xact_tuples_updated('subattr'::regclass);
+  IF arg IS NULL THEN EXECUTE upd; ELSE EXECUTE upd USING arg; END IF;
+  FOR i IN SELECT indexrelid::regclass FROM pg_index
+           WHERE indrelid = 'subattr'::regclass ORDER BY 1 LOOP
+    BEGIN PERFORM bt_index_check(i, true);
+    EXCEPTION WHEN OTHERS THEN ac := i::text || ': ' || SQLERRM; END;
+  END LOOP;
+  INSERT INTO subattr_results VALUES (name,
+    pg_stat_get_xact_tuples_hot_updated('subattr'::regclass) - h0,
+    pg_stat_get_xact_tuples_updated('subattr'::regclass) - u0, ac);
+  DROP TABLE subattr;
+  DROP FUNCTION IF EXISTS subattr_trg();
+END $f$;
+
+-- Nothing indexed changes.
+SELECT subattr_probe('set-age',            $$UPDATE subattr SET d = jsonb_set(d, '{age}', '1')$$);
+SELECT subattr_probe('set-o-z',            $$UPDATE subattr SET d = jsonb_set(d, '{o,z}', '2')$$);
+SELECT subattr_probe('set-p-other',        $$UPDATE subattr SET d = jsonb_set(d, '{p,r}', '2')$$);
+SELECT subattr_probe('delete-age',         $$UPDATE subattr SET d = d - 'age'$$);
+SELECT subattr_probe('delete-keys',        $$UPDATE subattr SET d = d - '{age,zz}'::text[]$$);
+SELECT subattr_probe('delete-path-age',    $$UPDATE subattr SET d = d #- '{age}'$$);
+SELECT subattr_probe('delete-arr-elem',    $$UPDATE subattr SET d = jsonb_set(d, '{tags}', (d->'tags') - 0)$$);
+SELECT subattr_probe('concat-other',       $$UPDATE subattr SET d = d || '{"age": 5, "new": 1}'$$);
+SELECT subattr_probe('subscript-age',      $$UPDATE subattr SET d['age'] = '7'$$);
+SELECT subattr_probe('nested-mutators',    $$UPDATE subattr SET d = jsonb_set(jsonb_set(d, '{age}', '1'), '{new}', '2')$$);
+SELECT subattr_probe('set-lax-age',        $$UPDATE subattr SET d = jsonb_set_lax(d, '{age}', NULL, true, 'delete_key')$$);
+SELECT subattr_probe('insert-o-new',       $$UPDATE subattr SET d = jsonb_insert(d, '{o,new}', '1')$$);
+SELECT subattr_probe('array-a3',           $$UPDATE subattr SET a[3] = 99$$);
+SELECT subattr_probe('array-a9-extend',    $$UPDATE subattr SET a[9] = 99$$);
+SELECT subattr_probe('set-arr-elem',       $$UPDATE subattr SET d = jsonb_set(d, '{arr,1}', '5')$$);
+SELECT subattr_probe('param-path',         $$UPDATE subattr SET d = jsonb_set(d, ARRAY['age'], $1)$$, '3');
+SELECT subattr_probe('coalesce-wrap',      $$UPDATE subattr SET d = COALESCE(jsonb_set(d, '{age}', '1'), d)$$);
+SELECT subattr_probe('flag-unchanged',     $$UPDATE subattr SET d = jsonb_set(d, '{age}', '1') WHERE id % 2 = 0$$);
+
+-- An indexed part changes, or might.
+SELECT subattr_probe('set-name',           $$UPDATE subattr SET d = jsonb_set(d, '{name}', '"x"')$$);
+SELECT subattr_probe('set-o',              $$UPDATE subattr SET d = jsonb_set(d, '{o}', '{}')$$);
+SELECT subattr_probe('set-o-k',            $$UPDATE subattr SET d = jsonb_set(d, '{o,k}', '"x"')$$);
+SELECT subattr_probe('set-p',              $$UPDATE subattr SET d = jsonb_set(d, '{p}', '1')$$);
+SELECT subattr_probe('delete-name',        $$UPDATE subattr SET d = d - 'name'$$);
+SELECT subattr_probe('delete-keys-name',   $$UPDATE subattr SET d = d - '{age,name}'::text[]$$);
+SELECT subattr_probe('delete-path-name',   $$UPDATE subattr SET d = d #- '{name}'$$);
+SELECT subattr_probe('delete-path-o-k',    $$UPDATE subattr SET d = d #- '{o,k}'$$);
+SELECT subattr_probe('insert-tags-0',      $$UPDATE subattr SET d = jsonb_insert(d, '{tags,0}', '"x"')$$);
+SELECT subattr_probe('insert-tags-1',      $$UPDATE subattr SET d = jsonb_insert(d, '{tags,1}', '"x"')$$);
+SELECT subattr_probe('insert-tags-neg',    $$UPDATE subattr SET d = jsonb_insert(d, '{tags,-1}', '"x"')$$);
+SELECT subattr_probe('set-tags-0',         $$UPDATE subattr SET d = jsonb_set(d, '{tags,0}', '9')$$);
+SELECT subattr_probe('set-tags-neg3',      $$UPDATE subattr SET d = jsonb_set(d, '{tags,-3}', '9')$$);
+SELECT subattr_probe('set-tags-sp0',       $$UPDATE subattr SET d = jsonb_set(d, ARRAY['tags',' 0'], '9')$$);
+SELECT subattr_probe('set-tags-plus0',     $$UPDATE subattr SET d = jsonb_set(d, ARRAY['tags','+0'], '9')$$);
+SELECT subattr_probe('set-tags-00',        $$UPDATE subattr SET d = jsonb_set(d, ARRAY['tags','00'], '9')$$);
+SELECT subattr_probe('delete-path-tags-0', $$UPDATE subattr SET d = d #- '{tags,0}'$$);
+SELECT subattr_probe('set-lax-tags-0',     $$UPDATE subattr SET d = jsonb_set_lax(d, '{tags,0}', NULL, true, 'delete_key')$$);
+SELECT subattr_probe('concat-name',        $$UPDATE subattr SET d = d || '{"name": "x"}'$$);
+SELECT subattr_probe('concat-array',       $$UPDATE subattr SET d = d || '[1]'$$);
+SELECT subattr_probe('concat-left',        $$UPDATE subattr SET d = '{"name": "x"}' || d$$);
+SELECT subattr_probe('whole-replace',      $$UPDATE subattr SET d = '{"name": "x"}'$$);
+SELECT subattr_probe('mutator-other-col',  $$UPDATE subattr SET d = jsonb_set(jsonb_build_object('name', 'z' || x), '{age}', '1')$$);
+SELECT subattr_probe('case-wrap',          $$UPDATE subattr SET d = CASE WHEN id > 0 THEN jsonb_set(d, '{name}', '"x"') ELSE d END$$);
+SELECT subattr_probe('set-lax-name',       $$UPDATE subattr SET d = jsonb_set_lax(d, '{name}', NULL, true, 'delete_key')$$);
+SELECT subattr_probe('subscript-name',     $$UPDATE subattr SET d['name'] = '"x"'$$);
+SELECT subattr_probe('subscript-s',        $$UPDATE subattr SET d['s'] = '"x"'$$);
+SELECT subattr_probe('subscript-int',      $$UPDATE subattr SET d['tags'][0] = '9'$$);
+SELECT subattr_probe('set-flag',           $$UPDATE subattr SET d = jsonb_set(d, '{flag}', '"on"')$$);
+SELECT subattr_probe('set-1-key',          $$UPDATE subattr SET d = jsonb_set(d, '{1}', '"two"')$$);
+SELECT subattr_probe('trigger-renames',    $$UPDATE subattr SET d = jsonb_set(d, '{age}', '1')$$);
+SELECT subattr_probe('array-a2',           $$UPDATE subattr SET a[2] = 99$$);
+SELECT subattr_probe('array-a-neg',        $$UPDATE subattr SET a[-5] = 99$$);
+SELECT subattr_probe('array-whole',        $$UPDATE subattr SET a = a || 99$$);
+SELECT subattr_probe('param-path-name',    $$UPDATE subattr SET d = jsonb_set(d, ARRAY['name'], $1)$$, '"x"');
+SELECT subattr_probe('param-doc',          $$UPDATE subattr SET d = $1$$, '{"name":"x"}');
+SELECT subattr_probe('self-ref-value',     $$UPDATE subattr SET d = jsonb_set(d, '{age}', d->'name')$$);
+SELECT subattr_probe('null-doc',           $$UPDATE subattr SET d = jsonb_set(NULL::jsonb, '{age}', '1')$$);
+
+SELECT name, upd, hot, amcheck FROM subattr_results ORDER BY name;
+DROP TABLE subattr_results;
+DROP FUNCTION subattr_probe;
+
+-- What was concluded, for a few statements.  Nothing is concluded for an
+-- UPDATE of a single row.
+CREATE TABLE subattr (id int PRIMARY KEY, d jsonb, a int[]);
+CREATE INDEX subattr_name ON subattr ((d->>'name'));
+CREATE INDEX subattr_tags ON subattr ((d->'tags'->>0));
+CREATE INDEX subattr_a2 ON subattr ((a[2]));
+INSERT INTO subattr VALUES (1, '{"name": "a", "tags": [1, 2]}', '{1,2,3}');
+SET client_min_messages = debug1;
+UPDATE subattr SET d = jsonb_set(d, '{age}', '1');
+INSERT INTO subattr VALUES (2, '{"name": "b", "tags": [1, 2]}', '{1,2,3}');
+UPDATE subattr SET d = jsonb_set(d, '{age}', '1');
+UPDATE subattr SET d = jsonb_set(d, '{tags,1}', '9');
+UPDATE subattr SET d = d - 'name', a[1] = 0;
+UPDATE subattr SET d = d || '{"age": 2}';
+RESET client_min_messages;
+DROP TABLE subattr;
+
+-- Shapes that need their own tables: composite fields, domains, generic
+-- plans, a strict modifier that returns null, and references the analysis
+-- does not follow.
+CREATE TYPE subattr_pair AS (k int, v int);
+CREATE DOMAIN subattr_doc AS jsonb CHECK (VALUE ? 'name');
+CREATE TABLE subattr (id int PRIMARY KEY, d subattr_doc, p subattr_pair,
+                      r jsonb, n jsonb) WITH (fillfactor = 50);
+CREATE INDEX subattr_name ON subattr ((d->>'name'));
+CREATE INDEX subattr_pk ON subattr (((p).k));
+CREATE INDEX subattr_whole ON subattr ((r->>'name')) WHERE r IS NOT NULL;
+CREATE INDEX subattr_row ON subattr (id) WHERE subattr IS NOT NULL;
+CREATE INDEX subattr_text ON subattr ((n::text));
+CREATE INDEX subattr_ep ON subattr ((jsonb_extract_path(n, 'a', 'b')));
+INSERT INTO subattr SELECT g, jsonb_build_object('name', 'n' || g),
+    ROW(g, g)::subattr_pair, jsonb_build_object('name', 'r' || g),
+    '{"a": {"b": 1}}'
+  FROM generate_series(1, 4) g;
+SET client_min_messages = debug1;
+UPDATE subattr SET d = jsonb_set(d, '{age}', '1');
+UPDATE subattr SET p.v = 0;
+UPDATE subattr SET p.k = (p).k + 10;
+UPDATE subattr SET r = jsonb_set(r, '{age}', '1');
+UPDATE subattr SET n = jsonb_set(n, '{c}', '1');
+UPDATE subattr SET n = jsonb_set(n, '{a,c}', '1');
+UPDATE subattr SET d = jsonb_set(d, ARRAY[NULL, 'x'], '1') WHERE false;
+UPDATE subattr SET d = jsonb_set(d, '{age}', (SELECT d->'name'));
+PREPARE subattr_p(text[], jsonb) AS
+  UPDATE subattr SET d = jsonb_set(d, $1, $2);
+SET plan_cache_mode = force_generic_plan;
+EXECUTE subattr_p('{age}', '2');
+EXECUTE subattr_p('{name}', '"x"');
+RESET plan_cache_mode;
+DEALLOCATE subattr_p;
+-- jsonb_set returns null for a null new value; such rows are evaluated
+UPDATE subattr SET r = jsonb_set(r, '{age}', NULL) WHERE id <= 2;
+RESET client_min_messages;
+SELECT id, d, p, r FROM subattr ORDER BY id;
+SELECT bt_index_check(indexrelid, true) FROM pg_index
+  WHERE indrelid = 'subattr'::regclass ORDER BY indexrelid::regclass::text;
+DROP TABLE subattr;
+DROP DOMAIN subattr_doc;
+DROP TYPE subattr_pair;
diff --git a/doc/src/sgml/storage.sgml b/doc/src/sgml/storage.sgml
index 4d65523e7b9..4523d073a19 100644
--- a/doc/src/sgml/storage.sgml
+++ b/doc/src/sgml/storage.sgml
@@ -1116,7 +1116,11 @@ data. Empty in ordinary tables.</entry>
      expressions nor whether the row satisfies its predicate.  The values are
      compared as stored, so equal values with different representations, such
      as <literal>1.0</literal> and <literal>1.00</literal> of type
-     <type>numeric</type>, count as different.
+     <type>numeric</type>, count as different.  When the update's
+     <literal>SET</literal> clause changes only parts of the column that the
+     indexes do not read, for example through <function>jsonb_set</function>
+     or array subscripting, this is known without evaluating the indexes'
+     expressions.
      </para>
    </listitem>
    <listitem>
diff --git a/doc/src/sgml/xfunc.sgml b/doc/src/sgml/xfunc.sgml
index bc04d9dfc13..366bf9abd1c 100644
--- a/doc/src/sgml/xfunc.sgml
+++ b/doc/src/sgml/xfunc.sgml
@@ -4304,4 +4304,19 @@ supportfn(internal) returns internal
     To create such conditions, the support function must implement
     the <literal>SupportRequestIndexCondition</literal> request type.
    </para>
+
+   <para>
+    For functions that read part of a structured value, such as a member of a
+    document, or that return a copy of a structured value with some parts
+    changed, the executor can use a description of which parts are read or
+    changed.  When an <command>UPDATE</command> changes a column only through
+    such functions, and every expression index and partial-index predicate
+    that uses the column reads only other parts of it, the executor knows
+    without evaluating them that the update leaves those indexes unaffected
+    (see <xref linkend="storage-hot"/>).  To describe a call, the support
+    function must implement the <literal>SupportRequestSubattr</literal>
+    request type.  Its answer must be exact for every value the call could be
+    given: an answer that omits a part the call reads or changes can cause
+    index entries to be missing.
+   </para>
   </sect1>
diff --git a/src/backend/executor/Makefile b/src/backend/executor/Makefile
index 11118d0ce02..499e359882b 100644
--- a/src/backend/executor/Makefile
+++ b/src/backend/executor/Makefile
@@ -24,6 +24,7 @@ OBJS = \
 	execMain.o \
 	execParallel.o \
 	execPartition.o \
+	execSubattr.o \
 	execProcnode.o \
 	execReplication.o \
 	execSRF.o \
diff --git a/src/backend/executor/execSubattr.c b/src/backend/executor/execSubattr.c
new file mode 100644
index 00000000000..4871e30a9f6
--- /dev/null
+++ b/src/backend/executor/execSubattr.c
@@ -0,0 +1,597 @@
+/*-------------------------------------------------------------------------
+ *
+ * execSubattr.c
+ *	  Prove that an UPDATE cannot change what an expression or partial index
+ *	  stores, from the shape of the SET expressions and the index
+ *	  definitions alone.
+ *
+ * ExecUpdateModifiedIdxAttrs() finds the indexed columns an UPDATE changed
+ * by comparing the old and new rows.  For a column used only through index
+ * expressions or partial-index predicates it then evaluates those
+ * expressions and predicates on both rows.  That is always right, but for a
+ * large structured value (a jsonb document, an array, an extension's
+ * document type) it reads the value twice per index, and often the SET
+ * expression already tells us that the parts an index reads cannot have
+ * changed:
+ *
+ *		CREATE INDEX ON t ((doc->>'name'));
+ *		UPDATE t SET doc = jsonb_set(doc, '{visits}', ...);
+ *
+ * This module works that out once per statement, from the plan tree, so
+ * that nothing at run time (the expression interpreter, JIT-compiled code,
+ * the functions themselves) has to cooperate.  Functions
+ * take part through their support function by answering a
+ * SupportRequestSubattr: which parts of which argument a call reads, or
+ * which parts of its source argument it may change.  Array and jsonb
+ * subscripting are understood directly.  Anything this module does not
+ * understand makes it give up for that index, and the executor then
+ * evaluates it as before.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/backend/executor/execSubattr.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <ctype.h>
+
+#include "catalog/pg_type.h"
+#include "executor/execSubattr.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
+#include "nodes/params.h"
+#include "nodes/supportnodes.h"
+#include "utils/builtins.h"
+#include "utils/datum.h"
+#include "utils/fmgroids.h"
+#include "utils/lsyscache.h"
+#include "utils/rel.h"
+
+/* The column the walkers look for, and what they have found. */
+typedef struct SubattrWalk
+{
+	int			varno;
+	AttrNumber	attnum;
+	bool		known;			/* false: could not tell */
+	List	   *paths;			/* List of List of SubattrPathElem */
+} SubattrWalk;
+
+static bool read_walker(Node *node, SubattrWalk *walk);
+
+static Node *
+strip_relabel(Node *node)
+{
+	while (node && IsA(node, RelabelType))
+		node = (Node *) ((RelabelType *) node)->arg;
+	return node;
+}
+
+static bool
+is_target_var(Node *node, SubattrWalk *walk)
+{
+	node = strip_relabel(node);
+	return node && IsA(node, Var) &&
+		((Var *) node)->varno == walk->varno &&
+		((Var *) node)->varattno == walk->attnum &&
+		((Var *) node)->varlevelsup == 0;
+}
+
+static List *
+make_path_elem(SubattrPathElemKind kind, const char *key, int position)
+{
+	SubattrPathElem *e = palloc0_object(SubattrPathElem);
+
+	e->kind = kind;
+	e->key = key ? pstrdup(key) : NULL;
+	e->position = position;
+	return list_make1(e);
+}
+
+/* The function and arguments of a FuncExpr or OpExpr, else InvalidOid. */
+static Oid
+call_of(Node *node, List **args)
+{
+	if (IsA(node, FuncExpr))
+	{
+		*args = ((FuncExpr *) node)->args;
+		return ((FuncExpr *) node)->funcid;
+	}
+	if (IsA(node, OpExpr))
+	{
+		OpExpr	   *op = (OpExpr *) node;
+
+		*args = op->args;
+		return OidIsValid(op->opfuncid) ? op->opfuncid : get_opcode(op->opno);
+	}
+	return InvalidOid;
+}
+
+/*
+ * Ask the support function of funcid about a call with these arguments.
+ * Returns false if there is none or it declines.
+ */
+static bool
+ask_support(Oid funcid, List *args, SubattrRequestKind kind,
+			SupportRequestSubattr *req)
+{
+	RegProcedure prosupport = get_func_support(funcid);
+
+	if (!OidIsValid(prosupport))
+		return false;
+
+	memset(req, 0, sizeof(*req));
+	req->type = T_SupportRequestSubattr;
+	req->kind = kind;
+	req->funcid = funcid;
+	req->args = args;
+	req->source = -1;
+
+	if (DatumGetPointer(OidFunctionCall1(prosupport, PointerGetDatum(req))) !=
+		(Pointer) req)
+		return false;
+	return req->source >= 0 && req->source < list_length(args);
+}
+
+/*
+ * The path named by the subscripts of an array or jsonb subscripting
+ * expression, if they are all constants.
+ *
+ * jsonb subscripting turns an integer subscript into its text form and then
+ * reads each subscript as an object key or, in an array, as a position, so
+ * jsonb subscripts are always keys here; see paths_may_overlap() for how a
+ * key that reads as an integer is treated.
+ */
+static bool
+subscript_path(SubscriptingRef *sbsref, List **path)
+{
+	ListCell   *lc;
+	List	   *result = NIL;
+	RegProcedure handler = get_typsubscript(sbsref->refcontainertype, NULL);
+
+	if (handler != F_ARRAY_SUBSCRIPT_HANDLER &&
+		handler != F_JSONB_SUBSCRIPT_HANDLER)
+		return false;
+	if (sbsref->reflowerindexpr != NIL)
+		return false;			/* a slice */
+
+	foreach(lc, sbsref->refupperindexpr)
+	{
+		Node	   *sub = strip_relabel(lfirst(lc));
+		Const	   *c = (Const *) sub;
+
+		if (!sub || !IsA(sub, Const) || c->constisnull)
+			return false;
+		if (handler == F_ARRAY_SUBSCRIPT_HANDLER && c->consttype == INT4OID)
+			result = list_concat(result,
+								 make_path_elem(SUBATTR_ELEM_POSITION, NULL,
+												DatumGetInt32(c->constvalue)));
+		else if (handler == F_JSONB_SUBSCRIPT_HANDLER &&
+				 c->consttype == INT4OID)
+			result = list_concat(result,
+								 make_path_elem(SUBATTR_ELEM_KEY,
+												psprintf("%d", DatumGetInt32(c->constvalue)),
+												0));
+		else if (handler == F_JSONB_SUBSCRIPT_HANDLER &&
+				 c->consttype == TEXTOID)
+			result = list_concat(result,
+								 make_path_elem(SUBATTR_ELEM_KEY,
+												TextDatumGetCString(c->constvalue),
+												0));
+		else
+			return false;
+	}
+	*path = result;
+	return true;
+}
+
+/*
+ * If node is a chain of part-reading accessors ending at the target column,
+ * return the paths it reads in *paths.  *subvalue says whether the result of
+ * the chain is itself the part at the (single) path, so that an enclosing
+ * accessor can extend the path.
+ */
+static bool
+accessor_path(Node *node, SubattrWalk *walk, List **paths, bool *subvalue)
+{
+	List	   *args;
+	Oid			funcid;
+
+	node = strip_relabel(node);
+	if (is_target_var(node, walk))
+	{
+		*paths = list_make1(NIL);
+		*subvalue = true;
+		return true;
+	}
+
+	if (IsA(node, SubscriptingRef) &&
+		((SubscriptingRef *) node)->refassgnexpr == NULL)
+	{
+		SubscriptingRef *sbsref = (SubscriptingRef *) node;
+		List	   *inner;
+		List	   *mine;
+		bool		innersub;
+
+		if (!accessor_path((Node *) sbsref->refexpr, walk, &inner, &innersub) ||
+			!innersub || !subscript_path(sbsref, &mine))
+			return false;
+		*paths = list_make1(list_concat(linitial(inner), mine));
+		*subvalue = true;		/* fetching an element yields the element */
+		return true;
+	}
+
+	if (IsA(node, FieldSelect))
+	{
+		FieldSelect *fselect = (FieldSelect *) node;
+		List	   *inner;
+		bool		innersub;
+
+		if (!accessor_path((Node *) fselect->arg, walk, &inner, &innersub) ||
+			!innersub)
+			return false;
+		*paths = list_make1(list_concat(linitial(inner),
+										make_path_elem(SUBATTR_ELEM_POSITION,
+													   NULL,
+													   fselect->fieldnum)));
+		*subvalue = true;
+		return true;
+	}
+
+	funcid = call_of(node, &args);
+	if (OidIsValid(funcid))
+	{
+		SupportRequestSubattr req;
+		List	   *inner;
+		bool		innersub;
+		ListCell   *lc;
+
+		/*
+		 * A call that reads several paths cannot claim to return the part at
+		 * a path, so nothing can extend them.  An extended path is only
+		 * understood if the inner chain names one part.
+		 */
+		if (!ask_support(funcid, args, SUBATTR_READ, &req) ||
+			req.paths == NIL ||
+			(req.subvalue && list_length(req.paths) != 1) ||
+			!accessor_path(list_nth(args, req.source), walk, &inner, &innersub) ||
+			!innersub)
+			return false;
+
+		/* The other arguments must not read the column at all. */
+		for (int i = 0; i < list_length(args); i++)
+		{
+			SubattrWalk other = {walk->varno, walk->attnum, true, NIL};
+
+			if (i == req.source)
+				continue;
+			read_walker(list_nth(args, i), &other);
+			if (other.paths != NIL || !other.known)
+				return false;
+		}
+
+		*paths = NIL;
+		foreach(lc, req.paths)
+		{
+			List	   *path = list_concat_copy(linitial(inner), lfirst(lc));
+
+			*paths = lappend(*paths, path);
+		}
+		*subvalue = req.subvalue;
+		return true;
+	}
+
+	return false;
+}
+
+/*
+ * Collect, into walk->paths, every part of the target column that "node"
+ * reads.  A reference to the column that is not the start of an accessor
+ * chain we understand reads the whole value.
+ */
+static bool
+read_walker(Node *node, SubattrWalk *walk)
+{
+	List	   *paths;
+	bool		subvalue;
+
+	if (node == NULL)
+		return false;
+
+	if (accessor_path(node, walk, &paths, &subvalue))
+	{
+		walk->paths = list_concat(walk->paths, paths);
+		return false;
+	}
+
+	if (IsA(node, Var))
+	{
+		Var		   *var = (Var *) node;
+
+		/* a whole-row reference reads every column */
+		if (var->varno == walk->varno && var->varattno == InvalidAttrNumber &&
+			var->varlevelsup == 0)
+			walk->known = false;
+		return false;
+	}
+
+	if (IsA(node, SubLink) || IsA(node, SubPlan))
+	{
+		walk->known = false;	/* not attempting to see inside */
+		return true;
+	}
+
+	return expression_tree_walker(node, read_walker, walk);
+}
+
+/*
+ * Record the parts of the target column that the SET expression "expr" may
+ * change.  It must be the column itself, passed through modifications we
+ * understand; anything else is unknown.
+ */
+static void
+collect_modifies(Node *expr, SubattrWalk *walk)
+{
+	Oid			funcid;
+	List	   *args;
+
+	expr = strip_relabel(expr);
+	if (!walk->known)
+		return;
+
+	if (is_target_var(expr, walk))
+		return;					/* this part is unchanged */
+
+	/* a domain check passes the value through or fails */
+	if (IsA(expr, CoerceToDomain))
+	{
+		collect_modifies((Node *) ((CoerceToDomain *) expr)->arg, walk);
+		return;
+	}
+
+	if (IsA(expr, SubscriptingRef) &&
+		((SubscriptingRef *) expr)->refassgnexpr != NULL)
+	{
+		SubscriptingRef *sbsref = (SubscriptingRef *) expr;
+		List	   *path;
+
+		/*
+		 * col[subscripts] := value.  Assigning beyond the bounds of an array
+		 * fills the gap with nulls, which read the same as the missing
+		 * elements did; jsonb assignment may create containers on the way to
+		 * the path, but those are at a prefix of it, which overlaps.
+		 */
+		if (!subscript_path(sbsref, &path))
+		{
+			walk->known = false;
+			return;
+		}
+		walk->paths = lappend(walk->paths, path);
+		collect_modifies((Node *) sbsref->refexpr, walk);
+		return;
+	}
+
+	if (IsA(expr, FieldStore))
+	{
+		FieldStore *fstore = (FieldStore *) expr;
+		ListCell   *lc;
+
+		/* col.field := value, for one or more fields */
+		foreach(lc, fstore->fieldnums)
+			walk->paths = lappend(walk->paths,
+								  make_path_elem(SUBATTR_ELEM_POSITION, NULL,
+												 lfirst_int(lc)));
+		collect_modifies((Node *) fstore->arg, walk);
+		return;
+	}
+
+	funcid = call_of(expr, &args);
+	if (OidIsValid(funcid))
+	{
+		SupportRequestSubattr req;
+
+		if (ask_support(funcid, args, SUBATTR_MODIFY, &req))
+		{
+			walk->paths = list_concat(walk->paths, req.paths);
+			collect_modifies(list_nth(args, req.source), walk);
+			return;
+		}
+	}
+
+	/* COALESCE(f(col, ...), col): each branch is the column, modified */
+	if (IsA(expr, CoalesceExpr))
+	{
+		ListCell   *lc;
+
+		foreach(lc, ((CoalesceExpr *) expr)->args)
+			collect_modifies(lfirst(lc), walk);
+		return;
+	}
+
+	walk->known = false;
+}
+
+/*
+ * Could the key be read as an array position?  jsonb reads a path element
+ * as a position with strtoint(), which allows leading white space and a
+ * sign; we also allow trailing white space, to be safe.
+ */
+static bool
+key_may_be_position(const char *key)
+{
+	const char *p = key;
+
+	while (isspace((unsigned char) *p))
+		p++;
+	if (*p == '-' || *p == '+')
+		p++;
+	if (*p < '0' || *p > '9')
+		return false;
+	while (*p >= '0' && *p <= '9')
+		p++;
+	while (isspace((unsigned char) *p))
+		p++;
+	return *p == '\0';
+}
+
+/*
+ * Can paths a and b name overlapping parts?  See SupportRequestSubattr.
+ *
+ * Two keys that both read as integers are taken to overlap even if they
+ * differ: "01" and "1" select the same array element, and inserting into or
+ * deleting from an array, or appending past its end, moves or creates
+ * elements at positions other than the one named.
+ */
+static bool
+paths_may_overlap(List *a, List *b)
+{
+	ListCell   *la,
+			   *lb;
+
+	forboth(la, a, lb, b)
+	{
+		SubattrPathElem *ea = lfirst(la);
+		SubattrPathElem *eb = lfirst(lb);
+
+		if (ea->kind == SUBATTR_ELEM_ANY || eb->kind == SUBATTR_ELEM_ANY)
+			continue;
+
+		if (ea->kind == SUBATTR_ELEM_KEY && eb->kind == SUBATTR_ELEM_KEY)
+		{
+			if (strcmp(ea->key, eb->key) != 0 &&
+				!(key_may_be_position(ea->key) &&
+				  key_may_be_position(eb->key)))
+				return false;
+			continue;
+		}
+
+		if (ea->kind == SUBATTR_ELEM_KEY || eb->kind == SUBATTR_ELEM_KEY)
+		{
+			/* a key against a position or any position */
+			SubattrPathElem *key = ea->kind == SUBATTR_ELEM_KEY ? ea : eb;
+
+			if (!key_may_be_position(key->key))
+				return false;
+			continue;
+		}
+
+		if (ea->kind == SUBATTR_ELEM_POSITION &&
+			eb->kind == SUBATTR_ELEM_POSITION &&
+			ea->position != eb->position)
+			return false;
+	}
+	return true;
+}
+
+/*
+ * Replace external Params whose values are fixed for the execution by
+ * Consts, as eval_const_expressions() does, so that support functions see
+ * the values of the arguments of a generic plan.  Nothing is evaluated.
+ */
+static Node *
+substitute_params(Node *node, ParamListInfo params)
+{
+	if (node == NULL)
+		return NULL;
+	if (IsA(node, Param) && ((Param *) node)->paramkind == PARAM_EXTERN)
+	{
+		Param	   *param = (Param *) node;
+		ParamExternData prmdata;
+		ParamExternData *prm;
+
+		if (param->paramid <= 0 || param->paramid > params->numParams)
+			return node;
+		if (params->paramFetch != NULL)
+			prm = params->paramFetch(params, param->paramid, true, &prmdata);
+		else
+			prm = &params->params[param->paramid - 1];
+
+		if (prm != NULL && prm->ptype == param->paramtype &&
+			OidIsValid(prm->ptype) &&
+			(prm->pflags & PARAM_FLAG_CONST))
+		{
+			int16		typlen;
+			bool		typbyval;
+
+			get_typlenbyval(param->paramtype, &typlen, &typbyval);
+			return (Node *) makeConst(param->paramtype, param->paramtypmod,
+									  param->paramcollid, (int) typlen,
+									  prm->isnull ? (Datum) 0 :
+									  datumCopy(prm->value, typbyval, typlen),
+									  prm->isnull, typbyval);
+		}
+		return node;
+	}
+	return expression_tree_mutator(node, substitute_params, params);
+}
+
+/*
+ * ExecSubattrProveUntouched
+ *
+ * setexpr computes column attnum of the result relation in an UPDATE, with
+ * Vars of varno referring to the row being updated, and Params whose values
+ * are in params.  Return the indexes,
+ * as positions in resultRelInfo's index arrays, that provably read nothing
+ * of the column that setexpr may change, provided the column is not null in
+ * the old or the new row.  An index that uses the column in any way we do
+ * not understand, or as a plain key or INCLUDE column, is never included.
+ */
+Bitmapset *
+ExecSubattrProveUntouched(ResultRelInfo *resultRelInfo, Node *setexpr,
+						  int varno, AttrNumber attnum, ParamListInfo params)
+{
+	SubattrWalk mods = {varno, attnum, true, NIL};
+	Bitmapset  *result = NULL;
+
+	if (params != NULL && params->numParams > 0)
+		setexpr = substitute_params(setexpr, params);
+	collect_modifies(setexpr, &mods);
+	if (!mods.known || mods.paths == NIL)
+		return NULL;
+
+	for (int i = 0; i < resultRelInfo->ri_NumIndices; i++)
+	{
+		IndexInfo  *ii = resultRelInfo->ri_IndexRelationInfo[i];
+		SubattrWalk reads = {1, attnum, true, NIL};
+		bool		disjoint = true;
+		ListCell   *lr;
+
+		if (resultRelInfo->ri_IndexRelationDescs[i] == NULL)
+			continue;
+		for (int k = 0; k < ii->ii_NumIndexAttrs; k++)
+		{
+			if (ii->ii_IndexAttrNumbers[k] == attnum)
+				reads.known = false;
+		}
+		if (!reads.known)
+			continue;
+
+		/* index expressions and predicates refer to the table as varno 1 */
+		read_walker((Node *) ii->ii_Expressions, &reads);
+		read_walker((Node *) ii->ii_Predicate, &reads);
+		if (!reads.known || reads.paths == NIL)
+			continue;
+
+		foreach(lr, reads.paths)
+		{
+			ListCell   *lm;
+
+			foreach(lm, mods.paths)
+			{
+				if (paths_may_overlap(lfirst(lr), lfirst(lm)))
+				{
+					disjoint = false;
+					break;
+				}
+			}
+			if (!disjoint)
+				break;
+		}
+		if (disjoint)
+			result = bms_add_member(result, i);
+	}
+
+	return result;
+}
diff --git a/src/backend/executor/meson.build b/src/backend/executor/meson.build
index dc45be0b2ce..909ac6f9717 100644
--- a/src/backend/executor/meson.build
+++ b/src/backend/executor/meson.build
@@ -12,6 +12,7 @@ backend_sources += files(
   'execMain.c',
   'execParallel.c',
   'execPartition.c',
+  'execSubattr.c',
   'execProcnode.c',
   'execReplication.c',
   'execSRF.c',
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 2c51b638b9b..8911e052cd2 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -63,6 +63,7 @@
 #include "commands/trigger.h"
 #include "executor/execPartition.h"
 #include "executor/executor.h"
+#include "executor/execSubattr.h"
 #include "executor/instrument.h"
 #include "executor/nodeModifyTable.h"
 #include "foreign/fdwapi.h"
@@ -336,6 +337,58 @@ IndexExprAttrs(ResultRelInfo *resultRelInfo, int i, EState *estate)
 	return resultRelInfo->ri_IndexExprAttrs[i];
 }
 
+/*
+ * Is the i'th index unaffected by the update from old_tts to new_tts, where
+ * "mine" are the changed attributes it uses?  Use the statement-level proof
+ * (ri_IndexUntouchedAttrs) when it covers them all and none is null in
+ * either row; otherwise evaluate.  Assert builds evaluate anyway and check
+ * that the proof agrees.
+ */
+static bool
+IndexUnaffected(ResultRelInfo *resultRelInfo, int i, Bitmapset *mine,
+				TupleTableSlot *old_tts, TupleTableSlot *new_tts,
+				EState *estate)
+{
+	IndexInfo  *indexInfo = resultRelInfo->ri_IndexRelationInfo[i];
+	bool		proven = false;
+
+	if (resultRelInfo->ri_IndexUntouchedAttrs != NULL &&
+		bms_is_subset(mine, resultRelInfo->ri_IndexUntouchedAttrs[i]))
+	{
+		int			attidx = -1;
+
+		proven = true;
+		while ((attidx = bms_next_member(mine, attidx)) >= 0)
+		{
+			AttrNumber	attnum = attidx + FirstLowInvalidHeapAttributeNumber;
+
+			if (slot_attisnull(old_tts, attnum) ||
+				slot_attisnull(new_tts, attnum))
+			{
+				proven = false;
+				break;
+			}
+		}
+	}
+
+#ifdef USE_ASSERT_CHECKING
+	if (proven)
+	{
+		if (IndexEntryChanges(resultRelInfo, indexInfo, old_tts, new_tts,
+							  estate))
+			elog(ERROR, "index \"%s\" proven unaffected by UPDATE, but its entry changed",
+				 RelationGetRelationName(resultRelInfo->ri_IndexRelationDescs[i]));
+		return true;
+	}
+#else
+	if (proven)
+		return true;
+#endif
+
+	return !IndexEntryChanges(resultRelInfo, indexInfo, old_tts, new_tts,
+							  estate);
+}
+
 /*
  * ExecUpdateModifiedIdxAttrs
  *
@@ -486,8 +539,8 @@ ExecUpdateModifiedIdxAttrs(ResultRelInfo *resultRelInfo,
 
 			if (!indexInfo->ii_ReadyForInserts ||
 				!resultRelInfo->ri_IndexRelationDescs[i]->rd_index->indisvalid ||
-				IndexEntryChanges(resultRelInfo, indexInfo,
-								  old_tts, new_tts, estate))
+				!IndexUnaffected(resultRelInfo, i, mine, old_tts, new_tts,
+								 estate))
 			{
 				keep = bms_add_members(keep, mine);
 				if (bms_is_subset(candidates, keep))
@@ -1033,6 +1086,118 @@ ExecInitInsertProjection(ModifyTableState *mtstate,
 	resultRelInfo->ri_projectNewInfoValid = true;
 }
 
+/*
+ * ExecInitUpdateUntouched
+ *		Work out, per index, which SET target columns the statement cannot
+ *		change in any part the index reads.  See execSubattr.c.
+ *
+ * Only for a plain UPDATE whose subplan is a scan of the result relation
+ * itself, so that its target list computes each new value from Vars of the
+ * row being updated.  (MERGE keeps its SET expressions elsewhere.)  Joins,
+ * subqueries and the like bring other rows' values, and are left to
+ * ExecUpdateModifiedIdxAttrs() to evaluate.
+ */
+static void
+ExecInitUpdateUntouched(ModifyTableState *mtstate,
+						ResultRelInfo *resultRelInfo)
+{
+	EState	   *estate = mtstate->ps.state;
+	Plan	   *subplan = outerPlan(mtstate->ps.plan);
+	int			whichrel = resultRelInfo - mtstate->resultRelInfo;
+	List	   *updateColnos;
+	Relation	rel = resultRelInfo->ri_RelationDesc;
+	Index		rti = resultRelInfo->ri_RangeTableIndex;
+	Bitmapset  *exprattrs;
+	ListCell   *lt,
+			   *lc;
+	MemoryContext oldcxt;
+
+	Assert(whichrel >= 0 && whichrel < mtstate->mt_nrels);
+	updateColnos = (List *) list_nth(mtstate->mt_updateColnosLists, whichrel);
+
+	resultRelInfo->ri_IndexUntouchedAttrs = NULL;
+	if (resultRelInfo->ri_NumIndices == 0 || rti == 0)
+		return;
+
+	/*
+	 * For an inheritance tree or partitioned table, each child's rows come
+	 * from its own scan under an Append, laid out the same way.
+	 */
+	if (IsA(subplan, Append))
+	{
+		Plan	   *found = NULL;
+
+		foreach(lc, ((Append *) subplan)->appendplans)
+		{
+			Plan	   *child = lfirst(lc);
+
+			if (IsA(child, SeqScan) || IsA(child, IndexScan) ||
+				IsA(child, BitmapHeapScan) || IsA(child, TidScan) ||
+				IsA(child, TidRangeScan))
+			{
+				if (((Scan *) child)->scanrelid == rti)
+					found = child;
+			}
+		}
+		if (found == NULL)
+			return;
+		subplan = found;
+	}
+
+	if (!(IsA(subplan, SeqScan) || IsA(subplan, IndexScan) ||
+		  IsA(subplan, BitmapHeapScan) || IsA(subplan, TidScan) ||
+		  IsA(subplan, TidRangeScan)) ||
+		((Scan *) subplan)->scanrelid != rti)
+		return;
+
+	/*
+	 * A BEFORE ROW trigger may change any column of the new row, which the
+	 * SET expressions do not show.
+	 */
+	if (resultRelInfo->ri_TrigDesc &&
+		resultRelInfo->ri_TrigDesc->trig_update_before_row)
+		return;
+
+	exprattrs = RelationGetIndexAttrBitmap(rel, INDEX_ATTR_BITMAP_EXPRESSION);
+	if (bms_is_empty(exprattrs))
+		return;
+
+	oldcxt = MemoryContextSwitchTo(estate->es_query_cxt);
+	forboth(lt, subplan->targetlist, lc, updateColnos)
+	{
+		TargetEntry *tle = lfirst_node(TargetEntry, lt);
+		AttrNumber	attnum = lfirst_int(lc);
+		Bitmapset  *untouched;
+		int			i;
+
+		if (!bms_is_member(attnum - FirstLowInvalidHeapAttributeNumber,
+						   exprattrs))
+			continue;
+
+		untouched = ExecSubattrProveUntouched(resultRelInfo,
+											  (Node *) tle->expr,
+											  rti, attnum,
+											  estate->es_param_list_info);
+		i = -1;
+		while ((i = bms_next_member(untouched, i)) >= 0)
+		{
+			ereport(DEBUG1,
+					errmsg_internal("UPDATE of column \"%s\" cannot change index \"%s\"",
+									NameStr(TupleDescAttr(RelationGetDescr(rel),
+														  attnum - 1)->attname),
+									RelationGetRelationName(resultRelInfo->ri_IndexRelationDescs[i])));
+			if (resultRelInfo->ri_IndexUntouchedAttrs == NULL)
+				resultRelInfo->ri_IndexUntouchedAttrs =
+					palloc0_array(Bitmapset *, resultRelInfo->ri_NumIndices);
+			resultRelInfo->ri_IndexUntouchedAttrs[i] =
+				bms_add_member(resultRelInfo->ri_IndexUntouchedAttrs[i],
+							   attnum - FirstLowInvalidHeapAttributeNumber);
+		}
+	}
+	MemoryContextSwitchTo(oldcxt);
+	bms_free(exprattrs);
+}
+
 /*
  * ExecInitUpdateProjection
  *		Do one-time initialization of projection data for UPDATE tuples.
@@ -2666,6 +2831,17 @@ lreplace:
 	 * we will overlook attributes directly modified by heap_modify_tuple()
 	 * which are not known to ExecGetUpdatedCols().
 	 */
+
+	/*
+	 * From the second row on, use what the SET clause shows about which
+	 * indexes it cannot affect.  For a single row, working that out costs
+	 * more than evaluating the indexes it would save.
+	 */
+	if (unlikely(resultRelInfo->ri_UpdateCount < 2) &&
+		++resultRelInfo->ri_UpdateCount == 2 &&
+		context->mtstate->operation == CMD_UPDATE)
+		ExecInitUpdateUntouched(context->mtstate, resultRelInfo);
+
 	modified_idx_attrs = ExecUpdateModifiedIdxAttrs(resultRelInfo, oldSlot, slot,
 													estate);
 
diff --git a/src/backend/utils/adt/jsonfuncs.c b/src/backend/utils/adt/jsonfuncs.c
index adbafd0ecd3..e6948a58a81 100644
--- a/src/backend/utils/adt/jsonfuncs.c
+++ b/src/backend/utils/adt/jsonfuncs.c
@@ -29,6 +29,7 @@
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
 #include "nodes/miscnodes.h"
+#include "nodes/supportnodes.h"
 #include "parser/parse_coerce.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
@@ -6167,3 +6168,275 @@ json_check_mutability(Oid typoid, bool is_jsonb, bool *has_mutable)
 			break;
 	}
 }
+
+/*
+ * Turn a text[] Const into a SubattrPathElem path, or return false.
+ */
+static bool
+jsonb_subattr_text_path(Node *arg, List **path)
+{
+	Const	   *c = (Const *) arg;
+	Datum	   *elems;
+	bool	   *nulls;
+	int			nelems;
+	List	   *result = NIL;
+
+	if (!arg || !IsA(arg, Const) || c->constisnull ||
+		c->consttype != TEXTARRAYOID)
+		return false;
+
+	deconstruct_array_builtin(DatumGetArrayTypeP(c->constvalue), TEXTOID,
+							  &elems, &nulls, &nelems);
+	for (int i = 0; i < nelems; i++)
+	{
+		SubattrPathElem *e;
+
+		if (nulls[i])
+			return false;		/* an error at run time; claim nothing */
+		e = palloc0_object(SubattrPathElem);
+		e->kind = SUBATTR_ELEM_KEY;
+		e->key = TextDatumGetCString(elems[i]);
+		result = lappend(result, e);
+	}
+	*path = result;
+	return true;
+}
+
+/* A one-key path from a text Const, or an int4 Const in text form. */
+static bool
+jsonb_subattr_one_elem(Node *arg, List **path)
+{
+	Const	   *c = (Const *) arg;
+	SubattrPathElem *e;
+
+	if (!arg || !IsA(arg, Const) || c->constisnull)
+		return false;
+	e = palloc0_object(SubattrPathElem);
+	e->kind = SUBATTR_ELEM_KEY;
+	if (c->consttype == TEXTOID)
+		e->key = TextDatumGetCString(c->constvalue);
+	else if (c->consttype == INT4OID)
+		e->key = psprintf("%d", DatumGetInt32(c->constvalue));
+	else
+		return false;
+	*path = list_make1(e);
+	return true;
+}
+
+/* path || [any position] */
+static List *
+jsonb_subattr_any_position(List *prefix)
+{
+	SubattrPathElem *e = palloc0_object(SubattrPathElem);
+
+	e->kind = SUBATTR_ELEM_ANY_POSITION;
+	return lappend(prefix, e);
+}
+
+/*
+ * jsonb_subattr_support
+ *		Support function for the jsonb accessors and modifiers: tells the
+ *		executor which parts of the document a call reads or may change.
+ *		See SupportRequestSubattr.
+ *
+ * Paths are reported as keys throughout, also where a function takes an
+ * integer: jsonb reads every path element as an object key or, inside an
+ * array, as a position, and the executor treats any key that reads as an
+ * integer as possibly every position.
+ *
+ * Every path an accessor reads includes the containers on the way to it:
+ * "a"->"b" reads "a" too, and so overlaps a change to "a", as a prefix.
+ */
+Datum
+jsonb_subattr_support(PG_FUNCTION_ARGS)
+{
+	Node	   *rawreq = (Node *) PG_GETARG_POINTER(0);
+	SupportRequestSubattr *req;
+	List	   *args;
+	List	   *path;
+
+	if (!IsA(rawreq, SupportRequestSubattr))
+		PG_RETURN_POINTER(NULL);
+	req = (SupportRequestSubattr *) rawreq;
+	args = req->args;
+
+	if (req->kind == SUBATTR_READ)
+	{
+		switch (req->funcid)
+		{
+			case F_JSONB_OBJECT_FIELD:
+			case F_JSONB_ARRAY_ELEMENT:
+				if (!jsonb_subattr_one_elem(lsecond(args), &path))
+					PG_RETURN_POINTER(NULL);
+				req->subvalue = true;
+				break;
+			case F_JSONB_OBJECT_FIELD_TEXT:
+			case F_JSONB_ARRAY_ELEMENT_TEXT:
+				if (!jsonb_subattr_one_elem(lsecond(args), &path))
+					PG_RETURN_POINTER(NULL);
+				req->subvalue = false;
+				break;
+			case F_JSONB_EXTRACT_PATH:
+				if (!jsonb_subattr_text_path(lsecond(args), &path))
+					PG_RETURN_POINTER(NULL);
+				req->subvalue = true;
+				break;
+			case F_JSONB_EXTRACT_PATH_TEXT:
+				if (!jsonb_subattr_text_path(lsecond(args), &path))
+					PG_RETURN_POINTER(NULL);
+				req->subvalue = false;
+				break;
+			default:
+				PG_RETURN_POINTER(NULL);
+		}
+		req->source = 0;
+		req->paths = list_make1(path);
+		PG_RETURN_POINTER(req);
+	}
+
+	Assert(req->kind == SUBATTR_MODIFY);
+	req->source = 0;
+	switch (req->funcid)
+	{
+		case F_JSONB_SET:
+		case F_JSONB_SET_LAX:
+
+			/*
+			 * jsonb_set(target, path, new, create_if_missing) replaces or
+			 * creates the part at path.  In an array it may instead add an
+			 * element at either end, and jsonb_set_lax() with a null new
+			 * value may delete the part, moving later elements; but an array
+			 * position is named by a key that reads as an integer, which the
+			 * executor takes to overlap every position.
+			 */
+			if (!jsonb_subattr_text_path(lsecond(args), &path))
+				PG_RETURN_POINTER(NULL);
+			req->paths = list_make1(path);
+			break;
+
+		case F_JSONB_DELETE_PATH:
+			/* jsonb #- path deletes the part at path; as above */
+			if (!jsonb_subattr_text_path(lsecond(args), &path))
+				PG_RETURN_POINTER(NULL);
+			req->paths = list_make1(path);
+			break;
+
+		case F_JSONB_INSERT:
+
+			/*
+			 * jsonb_insert(target, path, new, after) adds a member at path to
+			 * an object, or an element next to the one at path in an array,
+			 * moving the later elements: the container may change in a key at
+			 * path or in any of its positions.
+			 */
+			if (!jsonb_subattr_text_path(lsecond(args), &path) ||
+				path == NIL)
+				PG_RETURN_POINTER(NULL);
+			req->paths = list_make2(path,
+									jsonb_subattr_any_position(list_copy_head(path,
+																			  list_length(path) - 1)));
+			break;
+
+		case F_JSONB_DELETE_JSONB_TEXT:
+		case F_JSONB_DELETE_JSONB__TEXT:
+			{
+				/*
+				 * jsonb - key and jsonb - keys[] delete top-level members of
+				 * an object, or matching string elements of an array, moving
+				 * the others.
+				 */
+				Const	   *c = (Const *) lsecond(args);
+				Datum	   *elems;
+				bool	   *nulls;
+				int			nelems;
+
+				if (!c || !IsA(c, Const) || c->constisnull)
+					PG_RETURN_POINTER(NULL);
+				if (req->funcid == F_JSONB_DELETE_JSONB_TEXT)
+				{
+					elems = &c->constvalue;
+					nulls = &c->constisnull;
+					nelems = 1;
+				}
+				else
+					deconstruct_array_builtin(DatumGetArrayTypeP(c->constvalue),
+											  TEXTOID, &elems, &nulls,
+											  &nelems);
+				req->paths = list_make1(jsonb_subattr_any_position(NIL));
+				for (int i = 0; i < nelems; i++)
+				{
+					SubattrPathElem *e;
+
+					if (nulls[i])
+						continue;	/* deletes nothing */
+					e = palloc0_object(SubattrPathElem);
+					e->kind = SUBATTR_ELEM_KEY;
+					e->key = TextDatumGetCString(elems[i]);
+					req->paths = lappend(req->paths, list_make1(e));
+				}
+				break;
+			}
+
+		case F_JSONB_DELETE_JSONB_INT4:
+			/* jsonb - int deletes an array element, moving later ones */
+			req->paths = list_make1(jsonb_subattr_any_position(NIL));
+			break;
+
+		case F_JSONB_CONCAT:
+			{
+				/*
+				 * doc || '{"k": v, ...}' with a constant object, or the other
+				 * way around.  For an object doc, only the constant's
+				 * top-level keys change.  For an array or scalar doc, the
+				 * result is an array of its elements (or the scalar) and the
+				 * object, in some order: positions change, and no key that
+				 * does not read as an integer reads anything before or after.
+				 * Any other constant (an array, a scalar) turns an object doc
+				 * into an array, which changes every key; give up.
+				 */
+				Const	   *c;
+				Jsonb	   *jb;
+				JsonbIterator *it;
+				JsonbValue	v;
+				JsonbIteratorToken r;
+
+				if (IsA(lsecond(args), Const))
+				{
+					c = (Const *) lsecond(args);
+					req->source = 0;
+				}
+				else if (IsA(linitial(args), Const))
+				{
+					c = (Const *) linitial(args);
+					req->source = 1;
+				}
+				else
+					PG_RETURN_POINTER(NULL);
+				if (c->constisnull)
+					PG_RETURN_POINTER(NULL);
+				jb = DatumGetJsonbP(c->constvalue);
+				if (!JB_ROOT_IS_OBJECT(jb))
+					PG_RETURN_POINTER(NULL);
+
+				req->paths = list_make1(jsonb_subattr_any_position(NIL));
+				it = JsonbIteratorInit(&jb->root);
+				while ((r = JsonbIteratorNext(&it, &v, true)) != WJB_DONE)
+				{
+					if (r == WJB_KEY)
+					{
+						SubattrPathElem *e = palloc0_object(SubattrPathElem);
+
+						e->kind = SUBATTR_ELEM_KEY;
+						e->key = pnstrdup(v.val.string.val, v.val.string.len);
+						req->paths = lappend(req->paths, list_make1(e));
+					}
+				}
+				break;
+			}
+
+		default:
+			PG_RETURN_POINTER(NULL);
+	}
+
+	PG_RETURN_POINTER(req);
+}
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 5e4c9e503f8..6d7256d5eb8 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -10541,31 +10541,35 @@
   proargdefaults => '{false}', prosrc => 'jsonb_strip_nulls' },
 
 { oid => '3478',
-  proname => 'jsonb_object_field', prorettype => 'jsonb',
-  proargtypes => 'jsonb text', proargnames => '{from_json, field_name}',
-  prosrc => 'jsonb_object_field' },
+  proname => 'jsonb_object_field', prosupport => 'jsonb_subattr_support',
+  prorettype => 'jsonb', proargtypes => 'jsonb text',
+  proargnames => '{from_json, field_name}', prosrc => 'jsonb_object_field' },
 { oid => '3214',
-  proname => 'jsonb_object_field_text', prorettype => 'text',
-  proargtypes => 'jsonb text', proargnames => '{from_json, field_name}',
+  proname => 'jsonb_object_field_text', prosupport => 'jsonb_subattr_support',
+  prorettype => 'text', proargtypes => 'jsonb text',
+  proargnames => '{from_json, field_name}',
   prosrc => 'jsonb_object_field_text' },
 { oid => '3215',
-  proname => 'jsonb_array_element', prorettype => 'jsonb',
-  proargtypes => 'jsonb int4', proargnames => '{from_json, element_index}',
+  proname => 'jsonb_array_element', prosupport => 'jsonb_subattr_support',
+  prorettype => 'jsonb', proargtypes => 'jsonb int4',
+  proargnames => '{from_json, element_index}',
   prosrc => 'jsonb_array_element' },
 { oid => '3216',
-  proname => 'jsonb_array_element_text', prorettype => 'text',
-  proargtypes => 'jsonb int4', proargnames => '{from_json, element_index}',
+  proname => 'jsonb_array_element_text', prosupport => 'jsonb_subattr_support',
+  prorettype => 'text', proargtypes => 'jsonb int4',
+  proargnames => '{from_json, element_index}',
   prosrc => 'jsonb_array_element_text' },
 { oid => '3217', descr => 'get value from jsonb with path elements',
-  proname => 'jsonb_extract_path', provariadic => 'text', prorettype => 'jsonb',
+  proname => 'jsonb_extract_path', provariadic => 'text',
+  prosupport => 'jsonb_subattr_support', prorettype => 'jsonb',
   proargtypes => 'jsonb _text', proallargtypes => '{jsonb,_text}',
   proargmodes => '{i,v}', proargnames => '{from_json,path_elems}',
   prosrc => 'jsonb_extract_path' },
 { oid => '3940', descr => 'get value from jsonb as text with path elements',
   proname => 'jsonb_extract_path_text', provariadic => 'text',
-  prorettype => 'text', proargtypes => 'jsonb _text',
-  proallargtypes => '{jsonb,_text}', proargmodes => '{i,v}',
-  proargnames => '{from_json,path_elems}',
+  prosupport => 'jsonb_subattr_support', prorettype => 'text',
+  proargtypes => 'jsonb _text', proallargtypes => '{jsonb,_text}',
+  proargmodes => '{i,v}', proargnames => '{from_json,path_elems}',
   prosrc => 'jsonb_extract_path_text' },
 { oid => '3219', descr => 'elements of a jsonb array',
   proname => 'jsonb_array_elements', prorows => '100', proretset => 't',
@@ -10694,38 +10698,44 @@
   proargtypes => 'internal int2 jsonb int4 internal internal internal',
   prosrc => 'gin_triconsistent_jsonb_path' },
 { oid => '3301',
-  proname => 'jsonb_concat', prorettype => 'jsonb',
-  proargtypes => 'jsonb jsonb', prosrc => 'jsonb_concat' },
+  proname => 'jsonb_concat', prosupport => 'jsonb_subattr_support',
+  prorettype => 'jsonb', proargtypes => 'jsonb jsonb',
+  prosrc => 'jsonb_concat' },
 { oid => '3302',
-  proname => 'jsonb_delete', prorettype => 'jsonb', proargtypes => 'jsonb text',
+  proname => 'jsonb_delete', prosupport => 'jsonb_subattr_support',
+  prorettype => 'jsonb', proargtypes => 'jsonb text',
   prosrc => 'jsonb_delete' },
 { oid => '3303',
-  proname => 'jsonb_delete', prorettype => 'jsonb', proargtypes => 'jsonb int4',
+  proname => 'jsonb_delete', prosupport => 'jsonb_subattr_support',
+  prorettype => 'jsonb', proargtypes => 'jsonb int4',
   prosrc => 'jsonb_delete_idx' },
 { oid => '3343',
-  proname => 'jsonb_delete', provariadic => 'text', prorettype => 'jsonb',
+  proname => 'jsonb_delete', provariadic => 'text',
+  prosupport => 'jsonb_subattr_support', prorettype => 'jsonb',
   proargtypes => 'jsonb _text', proallargtypes => '{jsonb,_text}',
   proargmodes => '{i,v}', proargnames => '{from_json,path_elems}',
   prosrc => 'jsonb_delete_array' },
 { oid => '3304',
-  proname => 'jsonb_delete_path', prorettype => 'jsonb',
-  proargtypes => 'jsonb _text', prosrc => 'jsonb_delete_path' },
+  proname => 'jsonb_delete_path', prosupport => 'jsonb_subattr_support',
+  prorettype => 'jsonb', proargtypes => 'jsonb _text',
+  prosrc => 'jsonb_delete_path' },
 { oid => '5054', descr => 'Set part of a jsonb, handle NULL value',
-  proname => 'jsonb_set_lax', proisstrict => 'f', prorettype => 'jsonb',
+  proname => 'jsonb_set_lax', prosupport => 'jsonb_subattr_support',
+  proisstrict => 'f', prorettype => 'jsonb',
   proargtypes => 'jsonb _text jsonb bool text',
   proargnames => '{jsonb_in,path,replacement,create_if_missing,null_value_treatment}',
   proargdefaults => '{true,use_json_null}', prosrc => 'jsonb_set_lax' },
 { oid => '3305', descr => 'Set part of a jsonb',
-  proname => 'jsonb_set', prorettype => 'jsonb',
-  proargtypes => 'jsonb _text jsonb bool',
+  proname => 'jsonb_set', prosupport => 'jsonb_subattr_support',
+  prorettype => 'jsonb', proargtypes => 'jsonb _text jsonb bool',
   proargnames => '{jsonb_in,path,replacement,create_if_missing}',
   proargdefaults => '{true}', prosrc => 'jsonb_set' },
 { oid => '3306', descr => 'Indented text from jsonb',
   proname => 'jsonb_pretty', prorettype => 'text', proargtypes => 'jsonb',
   prosrc => 'jsonb_pretty' },
 { oid => '3579', descr => 'Insert value into a jsonb',
-  proname => 'jsonb_insert', prorettype => 'jsonb',
-  proargtypes => 'jsonb _text jsonb bool',
+  proname => 'jsonb_insert', prosupport => 'jsonb_subattr_support',
+  prorettype => 'jsonb', proargtypes => 'jsonb _text jsonb bool',
   proargnames => '{jsonb_in,path,replacement,insert_after}',
   proargdefaults => '{false}', prosrc => 'jsonb_insert' },
 
@@ -12469,6 +12479,9 @@
   proname => 'raw_array_subscript_handler', prorettype => 'internal',
   proargtypes => 'internal', prosrc => 'raw_array_subscript_handler' },
 # type subscripting support
+{ oid => '8008', descr => 'support for jsonb accessors and modifiers',
+  proname => 'jsonb_subattr_support', prorettype => 'internal',
+  proargtypes => 'internal', prosrc => 'jsonb_subattr_support' },
 { oid => '6098', descr => 'jsonb subscripting logic',
   proname => 'jsonb_subscript_handler', prorettype => 'internal',
   proargtypes => 'internal', prosrc => 'jsonb_subscript_handler' },
diff --git a/src/include/executor/execSubattr.h b/src/include/executor/execSubattr.h
new file mode 100644
index 00000000000..61c0fb58404
--- /dev/null
+++ b/src/include/executor/execSubattr.h
@@ -0,0 +1,22 @@
+/*-------------------------------------------------------------------------
+ *
+ * execSubattr.h
+ *	  Prove that an UPDATE cannot change what an index reads of a column.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/include/executor/execSubattr.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef EXECSUBATTR_H
+#define EXECSUBATTR_H
+
+#include "nodes/execnodes.h"
+
+extern Bitmapset *ExecSubattrProveUntouched(ResultRelInfo *resultRelInfo,
+											Node *setexpr, int varno,
+											AttrNumber attnum,
+											ParamListInfo params);
+
+#endif							/* EXECSUBATTR_H */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 220aaa8b965..bf1cfc2aeee 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -510,6 +510,16 @@ typedef struct ResultRelInfo
 	 */
 	Bitmapset **ri_IndexExprAttrs;
 
+	/*
+	 * For UPDATE, per index, the table attributes (offset as above) that the
+	 * statement's SET expressions provably cannot change any part of that the
+	 * index reads, as long as the attribute is not null before or after.
+	 * Worked out when ri_UpdateCount, which counts updated rows up to then,
+	 * reaches 2; NULL if nothing was proven.
+	 */
+	Bitmapset **ri_IndexUntouchedAttrs;
+	int			ri_UpdateCount;
+
 	/*
 	 * For UPDATE/DELETE/MERGE result relations, the attribute number of the
 	 * row identity junk attribute in the source plan's output tuples
diff --git a/src/include/nodes/supportnodes.h b/src/include/nodes/supportnodes.h
index 132a2bcd8af..d148b15ae41 100644
--- a/src/include/nodes/supportnodes.h
+++ b/src/include/nodes/supportnodes.h
@@ -445,4 +445,78 @@ typedef struct SupportRequestModifyInPlace
 	int			paramid;		/* ID of Param(s) representing variable */
 } SupportRequestModifyInPlace;
 
+/*
+ * The SupportRequestSubattr request lets the executor learn, without running
+ * anything, which parts of a structured value (a jsonb document, an array,
+ * an extension's own document type) a function call reads or may change.
+ * The executor uses the answers to skip work when an UPDATE cannot change
+ * what an index stores.  A wrong answer can leave an index without an entry
+ * for a row, so a support function must answer only what it can guarantee,
+ * and otherwise decline by returning NULL.
+ *
+ * A part is named by a path: a List of SubattrPathElem, from the outermost
+ * level in.  An empty path (NIL) names the whole value.
+ *
+ * The support function sets "source" to the index in "args" of the argument
+ * the call reads (SUBATTR_READ) or copies from (SUBATTR_MODIFY).  It may
+ * rely on the values of other arguments only if they are Consts; the
+ * executor replaces external Params by Consts holding their values before
+ * asking.  If the answer depends on anything else, decline.
+ *
+ * SUBATTR_READ: the call's result is computed only from the parts of
+ * args[source] named in "paths", and from its other arguments.  A call may
+ * name several paths.  Set "subvalue" if the call has a single path and
+ * returns exactly the part that path names, as a value of a type whose parts
+ * are named the same way (jsonb's -> does; ->> returns text and does not),
+ * so that reading a part of the result reads the part at the combined path.
+ *
+ * SUBATTR_MODIFY: the call returns args[source] changed at most at the
+ * parts named in "paths": every other part reads the same, byte for byte,
+ * through any SUBATTR_READ call.  A change that moves other parts, as
+ * inserting into or deleting from an array moves later elements, must name
+ * them: SUBATTR_ELEM_ANY_POSITION names every element of an array.  If the
+ * source argument is null, or the result is null, the executor does not
+ * rely on the answer.
+ *
+ * Two paths are taken to name overlapping parts unless, at some depth, both
+ * have an element and those elements cannot name the same part: two
+ * different keys, two different non-negative positions, a position or
+ * SUBATTR_ELEM_ANY_POSITION against a key that does not read as an integer.
+ * A path overlaps every path it is a prefix of.
+ */
+typedef enum SubattrPathElemKind
+{
+	SUBATTR_ELEM_KEY,			/* object member by name */
+	SUBATTR_ELEM_POSITION,		/* array element by position */
+	SUBATTR_ELEM_ANY_POSITION,	/* any array element */
+	SUBATTR_ELEM_ANY,			/* any member or element */
+} SubattrPathElemKind;
+
+typedef struct SubattrPathElem
+{
+	SubattrPathElemKind kind;
+	char	   *key;			/* SUBATTR_ELEM_KEY: null-terminated name */
+	int			position;		/* SUBATTR_ELEM_POSITION */
+} SubattrPathElem;
+
+typedef enum SubattrRequestKind
+{
+	SUBATTR_READ,
+	SUBATTR_MODIFY,
+} SubattrRequestKind;
+
+typedef struct SupportRequestSubattr
+{
+	NodeTag		type;
+
+	SubattrRequestKind kind;	/* what the executor asks */
+	Oid			funcid;			/* PG_PROC OID of the target function */
+	List	   *args;			/* arguments to the function */
+
+	/* Set by the support function before returning the request: */
+	int			source;			/* index into args of the value read/copied */
+	List	   *paths;			/* List of List of SubattrPathElem */
+	bool		subvalue;		/* SUBATTR_READ: returns the part at paths */
+} SupportRequestSubattr;
+
 #endif							/* SUPPORTNODES_H */
diff --git a/src/test/isolation/expected/update-subattr-epq.out b/src/test/isolation/expected/update-subattr-epq.out
new file mode 100644
index 00000000000..d54e266e40e
--- /dev/null
+++ b/src/test/isolation/expected/update-subattr-epq.out
@@ -0,0 +1,65 @@
+Parsed test spec with 2 sessions
+
+starting permutation: s2_begin s2_status s1_n s2_commit s1_check
+step s2_begin: BEGIN;
+step s2_status: UPDATE subattr_epq SET doc = jsonb_set(doc, ARRAY['status'], to_jsonb('b'::text)) WHERE id = 2;
+step s1_n: UPDATE subattr_epq SET doc = jsonb_set(doc, ARRAY['n'], '1'); <waiting ...>
+step s2_commit: COMMIT;
+step s1_n: <... completed>
+step s1_check: 
+	SET enable_seqscan = off;
+	SET enable_bitmapscan = off;
+	SELECT id FROM subattr_epq WHERE doc->>'status' = 'b';
+	SELECT id FROM subattr_epq WHERE (doc->>'vip') = 'yes' AND id IN (1, 2);
+	RESET enable_seqscan;
+	RESET enable_bitmapscan;
+	SELECT id, doc FROM subattr_epq ORDER BY id;
+
+id
+--
+ 2
+(1 row)
+
+id
+--
+ 1
+ 2
+(2 rows)
+
+id|doc                                  
+--+-------------------------------------
+ 1|{"n": 1, "vip": "yes", "status": "a"}
+ 2|{"n": 1, "vip": "yes", "status": "b"}
+(2 rows)
+
+
+starting permutation: s2_begin s2_vip s1_n s2_commit s1_check
+step s2_begin: BEGIN;
+step s2_vip: UPDATE subattr_epq SET doc = jsonb_set(doc, ARRAY['vip'], to_jsonb('no'::text)) WHERE id = 2;
+step s1_n: UPDATE subattr_epq SET doc = jsonb_set(doc, ARRAY['n'], '1'); <waiting ...>
+step s2_commit: COMMIT;
+step s1_n: <... completed>
+step s1_check: 
+	SET enable_seqscan = off;
+	SET enable_bitmapscan = off;
+	SELECT id FROM subattr_epq WHERE doc->>'status' = 'b';
+	SELECT id FROM subattr_epq WHERE (doc->>'vip') = 'yes' AND id IN (1, 2);
+	RESET enable_seqscan;
+	RESET enable_bitmapscan;
+	SELECT id, doc FROM subattr_epq ORDER BY id;
+
+id
+--
+(0 rows)
+
+id
+--
+ 1
+(1 row)
+
+id|doc                                  
+--+-------------------------------------
+ 1|{"n": 1, "vip": "yes", "status": "a"}
+ 2|{"n": 1, "vip": "no", "status": "a"} 
+(2 rows)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index 2e99ca04305..b76a884d646 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -44,6 +44,7 @@ test: subxid-overflow
 test: eval-plan-qual
 test: eval-plan-qual-trigger
 test: update-epq-indexes
+test: update-subattr-epq
 test: eval-plan-qual-partition-prune
 test: inplace-inval
 test: intra-grant-inplace
diff --git a/src/test/isolation/specs/update-subattr-epq.spec b/src/test/isolation/specs/update-subattr-epq.spec
new file mode 100644
index 00000000000..c611c2cad45
--- /dev/null
+++ b/src/test/isolation/specs/update-subattr-epq.spec
@@ -0,0 +1,44 @@
+# An UPDATE whose SET clause cannot change what an index reads, waiting on
+# a concurrent UPDATE that does change it.  The waiting UPDATE recomputes
+# its new row from the version the other one wrote, and must leave the
+# index entry for that version in place.  The waiting row is the second
+# row of the UPDATE, so that the conclusion drawn from the SET clause is
+# used for it.
+
+setup
+{
+	CREATE TABLE subattr_epq (id int PRIMARY KEY, doc jsonb)
+		WITH (fillfactor = 50);
+	CREATE INDEX subattr_epq_status ON subattr_epq ((doc->>'status'));
+	CREATE INDEX subattr_epq_vip ON subattr_epq (id) WHERE (doc->>'vip') = 'yes';
+	INSERT INTO subattr_epq
+		SELECT g, jsonb_build_object('status', 'a', 'vip', 'yes', 'n', 0)
+		FROM generate_series(1, 2) g;
+}
+
+teardown
+{
+	DROP TABLE subattr_epq;
+}
+
+session s1
+step s1_n		{ UPDATE subattr_epq SET doc = jsonb_set(doc, ARRAY['n'], '1'); }
+step s1_check
+{
+	SET enable_seqscan = off;
+	SET enable_bitmapscan = off;
+	SELECT id FROM subattr_epq WHERE doc->>'status' = 'b';
+	SELECT id FROM subattr_epq WHERE (doc->>'vip') = 'yes' AND id IN (1, 2);
+	RESET enable_seqscan;
+	RESET enable_bitmapscan;
+	SELECT id, doc FROM subattr_epq ORDER BY id;
+}
+
+session s2
+step s2_begin	{ BEGIN; }
+step s2_status	{ UPDATE subattr_epq SET doc = jsonb_set(doc, ARRAY['status'], to_jsonb('b'::text)) WHERE id = 2; }
+step s2_vip		{ UPDATE subattr_epq SET doc = jsonb_set(doc, ARRAY['vip'], to_jsonb('no'::text)) WHERE id = 2; }
+step s2_commit	{ COMMIT; }
+
+permutation s2_begin s2_status s1_n s2_commit s1_check
+permutation s2_begin s2_vip s1_n s2_commit s1_check
diff --git a/src/test/regress/expected/misc_functions.out b/src/test/regress/expected/misc_functions.out
index 35d6ee2ce9e..62532d1fe33 100644
--- a/src/test/regress/expected/misc_functions.out
+++ b/src/test/regress/expected/misc_functions.out
@@ -631,6 +631,45 @@ SELECT * FROM tenk1 a JOIN my_gen_series(1,10) g ON a.unique1 = g;
          Index Cond: (unique1 = g.g)
 (4 rows)
 
+--
+-- Test SupportRequestSubattr with a function that reads two parts of a
+-- document.  An UPDATE that changes neither need not evaluate an index on
+-- it; one that changes either must, and must give the index a new entry.
+-- Assert-enabled builds also check that no index skipped this way changes.
+--
+CREATE FUNCTION test_subattr_support(internal)
+    RETURNS internal
+    AS :'regresslib', 'test_subattr_support'
+    LANGUAGE C STRICT;
+-- (plpgsql, so that it is not inlined and the support function is asked)
+CREATE FUNCTION two_keys(jsonb, text, text) RETURNS text
+    LANGUAGE plpgsql IMMUTABLE STRICT
+    AS $$ BEGIN RETURN ($1->>$2) || '/' || ($1->>$3); END $$
+    SUPPORT test_subattr_support;
+CREATE TABLE subattr_two (id int, d jsonb) WITH (fillfactor = 50);
+CREATE INDEX subattr_two_idx ON subattr_two (two_keys(d, 'a', 'b'));
+INSERT INTO subattr_two SELECT g, jsonb_build_object('a', g, 'b', g, 'c', g)
+  FROM generate_series(1, 2) g;
+UPDATE subattr_two SET d = jsonb_set(d, '{c}', '0');
+UPDATE subattr_two SET d = jsonb_set(d, '{b}', '0');
+SET enable_seqscan = off;
+SELECT id FROM subattr_two WHERE two_keys(d, 'a', 'b') = '2/0';
+ id 
+----
+  2
+(1 row)
+
+RESET enable_seqscan;
+SELECT id, two_keys(d, 'a', 'b') FROM subattr_two ORDER BY id;
+ id | two_keys 
+----+----------
+  1 | 1/0
+  2 | 2/0
+(2 rows)
+
+DROP TABLE subattr_two;
+DROP FUNCTION two_keys;
+DROP FUNCTION test_subattr_support;
 --
 -- Test SupportRequestInlineInFrom request
 --
diff --git a/src/test/regress/regress.c b/src/test/regress/regress.c
index 3deb4ed7203..060ac9544a5 100644
--- a/src/test/regress/regress.c
+++ b/src/test/regress/regress.c
@@ -750,6 +750,44 @@ is_catalog_text_unique_index_oid(PG_FUNCTION_ARGS)
 	return BoolGetDatum(IsCatalogTextUniqueIndexOid(PG_GETARG_OID(0)));
 }
 
+/*
+ * test_subattr_support: SupportRequestSubattr for a function of
+ * (jsonb, text, text) that reads the two top-level keys its constant
+ * arguments name, and returns something that is not a part of the document
+ * (so subvalue is false).
+ */
+PG_FUNCTION_INFO_V1(test_subattr_support);
+Datum
+test_subattr_support(PG_FUNCTION_ARGS)
+{
+	Node	   *rawreq = (Node *) PG_GETARG_POINTER(0);
+	SupportRequestSubattr *req;
+	List	   *paths = NIL;
+
+	if (!IsA(rawreq, SupportRequestSubattr))
+		PG_RETURN_POINTER(NULL);
+	req = (SupportRequestSubattr *) rawreq;
+	if (req->kind != SUBATTR_READ || list_length(req->args) != 3)
+		PG_RETURN_POINTER(NULL);
+
+	for (int i = 1; i <= 2; i++)
+	{
+		Const	   *c = (Const *) list_nth(req->args, i);
+		SubattrPathElem *e;
+
+		if (!IsA(c, Const) || c->constisnull || c->consttype != TEXTOID)
+			PG_RETURN_POINTER(NULL);
+		e = palloc0_object(SubattrPathElem);
+		e->kind = SUBATTR_ELEM_KEY;
+		e->key = TextDatumGetCString(c->constvalue);
+		paths = lappend(paths, list_make1(e));
+	}
+	req->source = 0;
+	req->paths = paths;
+	req->subvalue = false;
+	PG_RETURN_POINTER(req);
+}
+
 PG_FUNCTION_INFO_V1(test_support_func);
 Datum
 test_support_func(PG_FUNCTION_ARGS)
diff --git a/src/test/regress/sql/misc_functions.sql b/src/test/regress/sql/misc_functions.sql
index 94b91964c04..7b76900f0f1 100644
--- a/src/test/regress/sql/misc_functions.sql
+++ b/src/test/regress/sql/misc_functions.sql
@@ -236,6 +236,35 @@ SELECT * FROM tenk1 a JOIN my_gen_series(1,1000) g ON a.unique1 = g;
 EXPLAIN (COSTS OFF)
 SELECT * FROM tenk1 a JOIN my_gen_series(1,10) g ON a.unique1 = g;
 
+--
+-- Test SupportRequestSubattr with a function that reads two parts of a
+-- document.  An UPDATE that changes neither need not evaluate an index on
+-- it; one that changes either must, and must give the index a new entry.
+-- Assert-enabled builds also check that no index skipped this way changes.
+--
+CREATE FUNCTION test_subattr_support(internal)
+    RETURNS internal
+    AS :'regresslib', 'test_subattr_support'
+    LANGUAGE C STRICT;
+-- (plpgsql, so that it is not inlined and the support function is asked)
+CREATE FUNCTION two_keys(jsonb, text, text) RETURNS text
+    LANGUAGE plpgsql IMMUTABLE STRICT
+    AS $$ BEGIN RETURN ($1->>$2) || '/' || ($1->>$3); END $$
+    SUPPORT test_subattr_support;
+CREATE TABLE subattr_two (id int, d jsonb) WITH (fillfactor = 50);
+CREATE INDEX subattr_two_idx ON subattr_two (two_keys(d, 'a', 'b'));
+INSERT INTO subattr_two SELECT g, jsonb_build_object('a', g, 'b', g, 'c', g)
+  FROM generate_series(1, 2) g;
+UPDATE subattr_two SET d = jsonb_set(d, '{c}', '0');
+UPDATE subattr_two SET d = jsonb_set(d, '{b}', '0');
+SET enable_seqscan = off;
+SELECT id FROM subattr_two WHERE two_keys(d, 'a', 'b') = '2/0';
+RESET enable_seqscan;
+SELECT id, two_keys(d, 'a', 'b') FROM subattr_two ORDER BY id;
+DROP TABLE subattr_two;
+DROP FUNCTION two_keys;
+DROP FUNCTION test_subattr_support;
+
 --
 -- Test SupportRequestInlineInFrom request
 --
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index a7a8dbdd23f..bccd3cac465 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3021,6 +3021,10 @@ SubXactCallback
 SubXactCallbackItem
 SubXactEvent
 SubXactInfo
+SubattrPathElem
+SubattrPathElemKind
+SubattrRequestKind
+SubattrWalk
 SubqueryScan
 SubqueryScanPath
 SubqueryScanState
@@ -3044,6 +3048,7 @@ SupportRequestRows
 SupportRequestSelectivity
 SupportRequestSimplify
 SupportRequestSimplifyAggref
+SupportRequestSubattr
 SupportRequestWFuncMonotonic
 Syn
 SyncOps
-- 
2.50.1

