From c09fd7d1825965db3698fad8b8b32b625155e45a Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Tue, 10 Mar 2026 09:28:15 -0400
Subject: [PATCH v35 1/3] Add tests to cover a variety of heap HOT update
 behaviors

This commit introduces test infrastructure for verifying Heap-Only Tuple
(HOT) update functionality in PostgreSQL. It provides a baseline for
demonstrating and validating HOT update behavior.

Regression tests:
- Basic HOT vs non-HOT update decisions
- All-or-none property for multiple indexes
- Partial indexes and predicate handling
- BRIN (summarizing) indexes allowing HOT updates
- TOAST column handling with HOT
- Unique constraints behavior
- Multi-column indexes
- Partitioned table HOT updates

Isolation tests:
- HOT chain formation and maintenance
- Concurrent HOT update scenarios
- Index scan behavior with HOT chains
---
 .../isolation/expected/hot_updates_chain.out  | 144 +++
 .../expected/hot_updates_concurrent.out       | 143 +++
 .../expected/hot_updates_index_scan.out       | 132 +++
 src/test/isolation/isolation_schedule         |   3 +
 .../isolation/specs/hot_updates_chain.spec    | 110 ++
 .../specs/hot_updates_concurrent.spec         | 107 ++
 .../specs/hot_updates_index_scan.spec         |  94 ++
 src/test/regress/expected/hot_updates.out     | 950 ++++++++++++++++++
 src/test/regress/parallel_schedule            |   5 +
 src/test/regress/sql/hot_updates.sql          | 692 +++++++++++++
 10 files changed, 2380 insertions(+)
 create mode 100644 src/test/isolation/expected/hot_updates_chain.out
 create mode 100644 src/test/isolation/expected/hot_updates_concurrent.out
 create mode 100644 src/test/isolation/expected/hot_updates_index_scan.out
 create mode 100644 src/test/isolation/specs/hot_updates_chain.spec
 create mode 100644 src/test/isolation/specs/hot_updates_concurrent.spec
 create mode 100644 src/test/isolation/specs/hot_updates_index_scan.spec
 create mode 100644 src/test/regress/expected/hot_updates.out
 create mode 100644 src/test/regress/sql/hot_updates.sql

diff --git a/src/test/isolation/expected/hot_updates_chain.out b/src/test/isolation/expected/hot_updates_chain.out
new file mode 100644
index 00000000000..503252009ea
--- /dev/null
+++ b/src/test/isolation/expected/hot_updates_chain.out
@@ -0,0 +1,144 @@
+Parsed test spec with 5 sessions
+
+starting permutation: s1_begin s1_hot_update1 s1_hot_update2 s1_hot_update3 s1_commit s1_select s1_verify_hot
+step s1_begin: BEGIN;
+step s1_hot_update1: UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1;
+step s1_hot_update2: UPDATE hot_test SET non_indexed_col = 'update2' WHERE id = 1;
+step s1_hot_update3: UPDATE hot_test SET non_indexed_col = 'update3' WHERE id = 1;
+step s1_commit: COMMIT;
+step s1_select: SELECT * FROM hot_test WHERE id = 1;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+ 1|        100|update3        
+(1 row)
+
+step s1_verify_hot: 
+    -- Check for HOT chain: LP_REDIRECT or tuple with t_ctid pointing to same page
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2  -- LP_REDIRECT indicates HOT chain
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0  -- same page
+           AND t_ctid != ('(0,' || lp || ')')::tid);    -- different offset
+
+has_hot_chain
+-------------
+t            
+(1 row)
+
+
+starting permutation: s2_begin s2_select_before s1_begin s1_hot_update1 s1_hot_update2 s1_commit s2_select_after s2_commit
+step s2_begin: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2_select_before: SELECT non_indexed_col FROM hot_test WHERE id = 1;
+non_indexed_col
+---------------
+initial        
+(1 row)
+
+step s1_begin: BEGIN;
+step s1_hot_update1: UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1;
+step s1_hot_update2: UPDATE hot_test SET non_indexed_col = 'update2' WHERE id = 1;
+step s1_commit: COMMIT;
+step s2_select_after: SELECT non_indexed_col FROM hot_test WHERE id = 1;
+non_indexed_col
+---------------
+initial        
+(1 row)
+
+step s2_commit: COMMIT;
+
+starting permutation: s1_begin s1_hot_update1 s1_hot_update2 s1_commit s3_begin s3_non_hot_update s3_commit s1_select
+step s1_begin: BEGIN;
+step s1_hot_update1: UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1;
+step s1_hot_update2: UPDATE hot_test SET non_indexed_col = 'update2' WHERE id = 1;
+step s1_commit: COMMIT;
+step s3_begin: BEGIN;
+step s3_non_hot_update: UPDATE hot_test SET indexed_col = 150 WHERE id = 1;
+step s3_commit: COMMIT;
+step s1_select: SELECT * FROM hot_test WHERE id = 1;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+ 1|        150|update2        
+(1 row)
+
+
+starting permutation: s1_begin s1_hot_update1 s1_commit s3_begin s3_non_hot_update s3_commit s4_begin s4_hot_after_non_hot s4_commit s4_select s4_verify_hot
+step s1_begin: BEGIN;
+step s1_hot_update1: UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1;
+step s1_commit: COMMIT;
+step s3_begin: BEGIN;
+step s3_non_hot_update: UPDATE hot_test SET indexed_col = 150 WHERE id = 1;
+step s3_commit: COMMIT;
+step s4_begin: BEGIN;
+step s4_hot_after_non_hot: UPDATE hot_test SET non_indexed_col = 'after_non_hot' WHERE id = 1;
+step s4_commit: COMMIT;
+step s4_select: SELECT * FROM hot_test WHERE id = 1;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+ 1|        150|after_non_hot  
+(1 row)
+
+step s4_verify_hot: 
+    -- Check for new HOT chain after non-HOT update broke the previous chain
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0
+           AND t_ctid != ('(0,' || lp || ')')::tid);
+
+has_hot_chain
+-------------
+t            
+(1 row)
+
+
+starting permutation: s1_begin s1_hot_update1 s1_hot_update2 s5_begin s5_hot_update_row2_1 s5_hot_update_row2_2 s1_commit s5_commit s1_select s5_select s1_verify_hot s5_verify_hot
+step s1_begin: BEGIN;
+step s1_hot_update1: UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1;
+step s1_hot_update2: UPDATE hot_test SET non_indexed_col = 'update2' WHERE id = 1;
+step s5_begin: BEGIN;
+step s5_hot_update_row2_1: UPDATE hot_test SET non_indexed_col = 'row2_update1' WHERE id = 2;
+step s5_hot_update_row2_2: UPDATE hot_test SET non_indexed_col = 'row2_update2' WHERE id = 2;
+step s1_commit: COMMIT;
+step s5_commit: COMMIT;
+step s1_select: SELECT * FROM hot_test WHERE id = 1;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+ 1|        100|update2        
+(1 row)
+
+step s5_select: SELECT * FROM hot_test WHERE id = 2;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+ 2|        200|row2_update2   
+(1 row)
+
+step s1_verify_hot: 
+    -- Check for HOT chain: LP_REDIRECT or tuple with t_ctid pointing to same page
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2  -- LP_REDIRECT indicates HOT chain
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0  -- same page
+           AND t_ctid != ('(0,' || lp || ')')::tid);    -- different offset
+
+has_hot_chain
+-------------
+t            
+(1 row)
+
+step s5_verify_hot: 
+    -- Check for HOT chain on page 0
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0
+           AND t_ctid != ('(0,' || lp || ')')::tid);
+
+has_hot_chain
+-------------
+t            
+(1 row)
+
diff --git a/src/test/isolation/expected/hot_updates_concurrent.out b/src/test/isolation/expected/hot_updates_concurrent.out
new file mode 100644
index 00000000000..b1a8b0cb7b2
--- /dev/null
+++ b/src/test/isolation/expected/hot_updates_concurrent.out
@@ -0,0 +1,143 @@
+Parsed test spec with 4 sessions
+
+starting permutation: s1_begin s1_hot_update s2_begin s2_hot_update s1_commit s2_commit s1_select s2_select s2_verify_hot
+step s1_begin: BEGIN;
+step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'updated_s1' WHERE id = 1;
+step s2_begin: BEGIN;
+step s2_hot_update: UPDATE hot_test SET non_indexed_col = 'updated_s2' WHERE id = 1; <waiting ...>
+step s1_commit: COMMIT;
+step s2_hot_update: <... completed>
+step s2_commit: COMMIT;
+step s1_select: SELECT * FROM hot_test WHERE id = 1;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+ 1|        100|updated_s2     
+(1 row)
+
+step s2_select: SELECT * FROM hot_test WHERE id = 1;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+ 1|        100|updated_s2     
+(1 row)
+
+step s2_verify_hot: 
+    -- Check for HOT chain: look for LP_REDIRECT (lp_flags=2) or tuple with t_ctid pointing to same page
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2  -- LP_REDIRECT indicates HOT chain
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0  -- same page
+           AND t_ctid != ('(0,' || lp || ')')::tid);    -- different offset
+
+has_hot_chain
+-------------
+t            
+(1 row)
+
+
+starting permutation: s1_begin s1_hot_update s3_begin s3_non_hot_update s1_commit s3_commit s3_select s3_verify_index
+step s1_begin: BEGIN;
+step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'updated_s1' WHERE id = 1;
+step s3_begin: BEGIN;
+step s3_non_hot_update: UPDATE hot_test SET indexed_col = 150 WHERE id = 1; <waiting ...>
+step s1_commit: COMMIT;
+step s3_non_hot_update: <... completed>
+step s3_commit: COMMIT;
+step s3_select: SELECT * FROM hot_test WHERE id = 1;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+ 1|        150|updated_s1     
+(1 row)
+
+step s3_verify_index: 
+    -- Verify index was updated (proves non-HOT)
+    SELECT COUNT(*) = 1 AS index_updated FROM hot_test WHERE indexed_col = 150;
+    SELECT COUNT(*) = 0 AS old_value_gone FROM hot_test WHERE indexed_col = 100;
+
+index_updated
+-------------
+t            
+(1 row)
+
+old_value_gone
+--------------
+t             
+(1 row)
+
+
+starting permutation: s3_begin s3_non_hot_update s1_begin s1_hot_update s3_commit s1_commit s1_select s1_verify_hot
+step s3_begin: BEGIN;
+step s3_non_hot_update: UPDATE hot_test SET indexed_col = 150 WHERE id = 1;
+step s1_begin: BEGIN;
+step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'updated_s1' WHERE id = 1; <waiting ...>
+step s3_commit: COMMIT;
+step s1_hot_update: <... completed>
+step s1_commit: COMMIT;
+step s1_select: SELECT * FROM hot_test WHERE id = 1;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+ 1|        150|updated_s1     
+(1 row)
+
+step s1_verify_hot: 
+    -- Check for HOT chain: look for LP_REDIRECT (lp_flags=2) or tuple with t_ctid pointing to same page
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2  -- LP_REDIRECT indicates HOT chain
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0  -- same page
+           AND t_ctid != ('(0,' || lp || ')')::tid);    -- different offset
+
+has_hot_chain
+-------------
+t            
+(1 row)
+
+
+starting permutation: s1_begin s1_hot_update s4_begin s4_hot_update_row2 s1_commit s4_commit s1_select s4_select s1_verify_hot s4_verify_hot
+step s1_begin: BEGIN;
+step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'updated_s1' WHERE id = 1;
+step s4_begin: BEGIN;
+step s4_hot_update_row2: UPDATE hot_test SET non_indexed_col = 'updated_s4' WHERE id = 2;
+step s1_commit: COMMIT;
+step s4_commit: COMMIT;
+step s1_select: SELECT * FROM hot_test WHERE id = 1;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+ 1|        100|updated_s1     
+(1 row)
+
+step s4_select: SELECT * FROM hot_test WHERE id = 2;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+ 2|        200|updated_s4     
+(1 row)
+
+step s1_verify_hot: 
+    -- Check for HOT chain: look for LP_REDIRECT (lp_flags=2) or tuple with t_ctid pointing to same page
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2  -- LP_REDIRECT indicates HOT chain
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0  -- same page
+           AND t_ctid != ('(0,' || lp || ')')::tid);    -- different offset
+
+has_hot_chain
+-------------
+t            
+(1 row)
+
+step s4_verify_hot: 
+    -- Check for HOT chain on page 0
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0
+           AND t_ctid != ('(0,' || lp || ')')::tid);
+
+has_hot_chain
+-------------
+t            
+(1 row)
+
diff --git a/src/test/isolation/expected/hot_updates_index_scan.out b/src/test/isolation/expected/hot_updates_index_scan.out
new file mode 100644
index 00000000000..7d8e9ff8857
--- /dev/null
+++ b/src/test/isolation/expected/hot_updates_index_scan.out
@@ -0,0 +1,132 @@
+Parsed test spec with 4 sessions
+
+starting permutation: s1_begin s1_hot_update s2_begin s2_index_scan s1_commit s2_commit
+step s1_begin: BEGIN;
+step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'hot_updated' WHERE id = 50;
+step s2_begin: BEGIN;
+step s2_index_scan: SELECT * FROM hot_test WHERE indexed_col = 500;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+50|        500|initial50      
+(1 row)
+
+step s1_commit: COMMIT;
+step s2_commit: COMMIT;
+
+starting permutation: s1_begin s1_non_hot_update s1_commit s2_begin s2_index_scan_new s2_commit s2_verify_index
+step s1_begin: BEGIN;
+step s1_non_hot_update: UPDATE hot_test SET indexed_col = 555 WHERE id = 50;
+step s1_commit: COMMIT;
+step s2_begin: BEGIN;
+step s2_index_scan_new: SELECT * FROM hot_test WHERE indexed_col = 555;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+50|        555|initial50      
+(1 row)
+
+step s2_commit: COMMIT;
+step s2_verify_index: 
+    -- After non-HOT update, verify index reflects the change
+    SELECT COUNT(*) = 1 AS found_new_value FROM hot_test WHERE indexed_col = 555;
+    SELECT COUNT(*) = 0 AS old_value_gone FROM hot_test WHERE indexed_col = 500;
+
+found_new_value
+---------------
+t              
+(1 row)
+
+old_value_gone
+--------------
+t             
+(1 row)
+
+
+starting permutation: s3_begin s3_select_for_update s1_begin s1_hot_update s3_commit s1_commit s1_verify_hot
+step s3_begin: BEGIN;
+step s3_select_for_update: SELECT * FROM hot_test WHERE id = 50 FOR UPDATE;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+50|        500|initial50      
+(1 row)
+
+step s1_begin: BEGIN;
+step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'hot_updated' WHERE id = 50; <waiting ...>
+step s3_commit: COMMIT;
+step s1_hot_update: <... completed>
+step s1_commit: COMMIT;
+step s1_verify_hot: 
+    -- Verify HOT chain exists for row with id=50
+    -- Use actual ctid to find the correct page
+    SELECT EXISTS (
+        SELECT 1 FROM heap_page_items(
+            get_raw_page('hot_test', (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50))
+        )
+        WHERE lp_flags = 2
+           OR (t_ctid IS NOT NULL
+               AND t_ctid != ('(' || (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50) || ',' || lp || ')')::tid
+               AND (t_ctid::text::point)[0]::int = (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50))
+    ) AS has_hot_chain;
+
+has_hot_chain
+-------------
+t            
+(1 row)
+
+
+starting permutation: s1_begin s1_hot_update s3_begin s3_select_for_update s1_commit s3_commit
+step s1_begin: BEGIN;
+step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'hot_updated' WHERE id = 50;
+step s3_begin: BEGIN;
+step s3_select_for_update: SELECT * FROM hot_test WHERE id = 50 FOR UPDATE; <waiting ...>
+step s1_commit: COMMIT;
+step s3_select_for_update: <... completed>
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+50|        500|hot_updated    
+(1 row)
+
+step s3_commit: COMMIT;
+
+starting permutation: s4_begin s4_select_for_key_share s1_begin s1_hot_update s4_commit s1_commit s1_verify_hot
+step s4_begin: BEGIN;
+step s4_select_for_key_share: SELECT * FROM hot_test WHERE id = 50 FOR KEY SHARE;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+50|        500|initial50      
+(1 row)
+
+step s1_begin: BEGIN;
+step s1_hot_update: UPDATE hot_test SET non_indexed_col = 'hot_updated' WHERE id = 50;
+step s4_commit: COMMIT;
+step s1_commit: COMMIT;
+step s1_verify_hot: 
+    -- Verify HOT chain exists for row with id=50
+    -- Use actual ctid to find the correct page
+    SELECT EXISTS (
+        SELECT 1 FROM heap_page_items(
+            get_raw_page('hot_test', (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50))
+        )
+        WHERE lp_flags = 2
+           OR (t_ctid IS NOT NULL
+               AND t_ctid != ('(' || (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50) || ',' || lp || ')')::tid
+               AND (t_ctid::text::point)[0]::int = (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50))
+    ) AS has_hot_chain;
+
+has_hot_chain
+-------------
+t            
+(1 row)
+
+
+starting permutation: s4_begin s4_select_for_key_share s1_begin s1_non_hot_update s4_commit s1_commit
+step s4_begin: BEGIN;
+step s4_select_for_key_share: SELECT * FROM hot_test WHERE id = 50 FOR KEY SHARE;
+id|indexed_col|non_indexed_col
+--+-----------+---------------
+50|        500|initial50      
+(1 row)
+
+step s1_begin: BEGIN;
+step s1_non_hot_update: UPDATE hot_test SET indexed_col = 555 WHERE id = 50;
+step s4_commit: COMMIT;
+step s1_commit: COMMIT;
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index 4e466580cd4..46525b0a62a 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -19,6 +19,9 @@ test: multiple-row-versions
 test: index-only-scan
 test: index-only-bitmapscan
 test: predicate-lock-hot-tuple
+test: hot_updates_concurrent
+test: hot_updates_index_scan
+test: hot_updates_chain
 test: update-conflict-out
 test: deadlock-simple
 test: deadlock-hard
diff --git a/src/test/isolation/specs/hot_updates_chain.spec b/src/test/isolation/specs/hot_updates_chain.spec
new file mode 100644
index 00000000000..85cd2176133
--- /dev/null
+++ b/src/test/isolation/specs/hot_updates_chain.spec
@@ -0,0 +1,110 @@
+# Test HOT update chains and their interaction with VACUUM and page pruning
+#
+# This test verifies that HOT update chains are correctly maintained when
+# multiple HOT updates occur on the same row, and that VACUUM correctly
+# handles HOT chains.
+
+setup
+{
+    CREATE EXTENSION IF NOT EXISTS pageinspect;
+
+    CREATE TABLE hot_test (
+        id int PRIMARY KEY,
+        indexed_col int,
+        non_indexed_col text
+    );
+
+    CREATE INDEX hot_test_indexed_idx ON hot_test(indexed_col);
+
+    INSERT INTO hot_test VALUES (1, 100, 'initial');
+    INSERT INTO hot_test VALUES (2, 200, 'initial');
+}
+
+teardown
+{
+    DROP TABLE hot_test;
+    DROP EXTENSION pageinspect;
+}
+
+# Session 1: Create HOT chain with multiple updates
+session s1
+step s1_begin { BEGIN; }
+step s1_hot_update1 { UPDATE hot_test SET non_indexed_col = 'update1' WHERE id = 1; }
+step s1_hot_update2 { UPDATE hot_test SET non_indexed_col = 'update2' WHERE id = 1; }
+step s1_hot_update3 { UPDATE hot_test SET non_indexed_col = 'update3' WHERE id = 1; }
+step s1_commit { COMMIT; }
+step s1_select { SELECT * FROM hot_test WHERE id = 1; }
+step s1_verify_hot {
+    -- Check for HOT chain: LP_REDIRECT or tuple with t_ctid pointing to same page
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2  -- LP_REDIRECT indicates HOT chain
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0  -- same page
+           AND t_ctid != ('(0,' || lp || ')')::tid);    -- different offset
+}
+
+# Session 2: Read while HOT chain is being built
+session s2
+step s2_begin { BEGIN ISOLATION LEVEL REPEATABLE READ; }
+step s2_select_before { SELECT non_indexed_col FROM hot_test WHERE id = 1; }
+step s2_select_after { SELECT non_indexed_col FROM hot_test WHERE id = 1; }
+step s2_commit { COMMIT; }
+
+# Session 3: Break HOT chain with non-HOT update
+session s3
+step s3_begin { BEGIN; }
+step s3_non_hot_update { UPDATE hot_test SET indexed_col = 150 WHERE id = 1; }
+step s3_commit { COMMIT; }
+
+# Session 4: Try to build HOT chain after non-HOT update
+session s4
+step s4_begin { BEGIN; }
+step s4_hot_after_non_hot { UPDATE hot_test SET non_indexed_col = 'after_non_hot' WHERE id = 1; }
+step s4_commit { COMMIT; }
+step s4_select { SELECT * FROM hot_test WHERE id = 1; }
+step s4_verify_hot {
+    -- Check for new HOT chain after non-HOT update broke the previous chain
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0
+           AND t_ctid != ('(0,' || lp || ')')::tid);
+}
+
+# Session 5: Multiple sessions building separate HOT chains on different rows
+session s5
+step s5_begin { BEGIN; }
+step s5_hot_update_row2_1 { UPDATE hot_test SET non_indexed_col = 'row2_update1' WHERE id = 2; }
+step s5_hot_update_row2_2 { UPDATE hot_test SET non_indexed_col = 'row2_update2' WHERE id = 2; }
+step s5_commit { COMMIT; }
+step s5_select { SELECT * FROM hot_test WHERE id = 2; }
+step s5_verify_hot {
+    -- Check for HOT chain on page 0
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0
+           AND t_ctid != ('(0,' || lp || ')')::tid);
+}
+
+# Build HOT chain within single transaction
+# All updates should form a HOT chain
+permutation s1_begin s1_hot_update1 s1_hot_update2 s1_hot_update3 s1_commit s1_select s1_verify_hot
+
+# REPEATABLE READ should see consistent snapshot across HOT chain updates
+# Session 2 starts before updates, should see 'initial' throughout
+permutation s2_begin s2_select_before s1_begin s1_hot_update1 s1_hot_update2 s1_commit s2_select_after s2_commit
+
+# HOT chain followed by non-HOT update
+# Non-HOT update breaks the HOT chain
+permutation s1_begin s1_hot_update1 s1_hot_update2 s1_commit s3_begin s3_non_hot_update s3_commit s1_select
+
+# HOT update after non-HOT update can start new HOT chain
+# After breaking chain with indexed column update, new HOT updates can start fresh chain
+permutation s1_begin s1_hot_update1 s1_commit s3_begin s3_non_hot_update s3_commit s4_begin s4_hot_after_non_hot s4_commit s4_select s4_verify_hot
+
+# Multiple sessions building separate HOT chains on different rows
+permutation s1_begin s1_hot_update1 s1_hot_update2 s5_begin s5_hot_update_row2_1 s5_hot_update_row2_2 s1_commit s5_commit s1_select s5_select s1_verify_hot s5_verify_hot
diff --git a/src/test/isolation/specs/hot_updates_concurrent.spec b/src/test/isolation/specs/hot_updates_concurrent.spec
new file mode 100644
index 00000000000..eac78d62ac5
--- /dev/null
+++ b/src/test/isolation/specs/hot_updates_concurrent.spec
@@ -0,0 +1,107 @@
+# Test concurrent HOT updates and validate HOT chains
+#
+# This test verifies that HOT updates work correctly when multiple sessions
+# are updating the same table concurrently, and validates that HOT chains
+# are actually created using heap_page_items().
+
+setup
+{
+    CREATE EXTENSION IF NOT EXISTS pageinspect;
+
+    CREATE TABLE hot_test (
+        id int PRIMARY KEY,
+        indexed_col int,
+        non_indexed_col text
+    );
+
+    CREATE INDEX hot_test_indexed_idx ON hot_test(indexed_col);
+
+    INSERT INTO hot_test VALUES (1, 100, 'initial1');
+    INSERT INTO hot_test VALUES (2, 200, 'initial2');
+    INSERT INTO hot_test VALUES (3, 300, 'initial3');
+}
+
+teardown
+{
+    DROP TABLE hot_test;
+    DROP EXTENSION pageinspect;
+}
+
+# Session 1: HOT update (modify non-indexed column)
+session s1
+step s1_begin { BEGIN; }
+step s1_hot_update { UPDATE hot_test SET non_indexed_col = 'updated_s1' WHERE id = 1; }
+step s1_commit { COMMIT; }
+step s1_select { SELECT * FROM hot_test WHERE id = 1; }
+step s1_verify_hot {
+    -- Check for HOT chain: look for LP_REDIRECT (lp_flags=2) or tuple with t_ctid pointing to same page
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2  -- LP_REDIRECT indicates HOT chain
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0  -- same page
+           AND t_ctid != ('(0,' || lp || ')')::tid);    -- different offset
+}
+
+# Session 2: HOT update (modify non-indexed column on same row)
+session s2
+step s2_begin { BEGIN; }
+step s2_hot_update { UPDATE hot_test SET non_indexed_col = 'updated_s2' WHERE id = 1; }
+step s2_commit { COMMIT; }
+step s2_select { SELECT * FROM hot_test WHERE id = 1; }
+step s2_verify_hot {
+    -- Check for HOT chain: look for LP_REDIRECT (lp_flags=2) or tuple with t_ctid pointing to same page
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2  -- LP_REDIRECT indicates HOT chain
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0  -- same page
+           AND t_ctid != ('(0,' || lp || ')')::tid);    -- different offset
+}
+
+# Session 3: Non-HOT update (modify indexed column)
+session s3
+step s3_begin { BEGIN; }
+step s3_non_hot_update { UPDATE hot_test SET indexed_col = 150 WHERE id = 1; }
+step s3_commit { COMMIT; }
+step s3_select { SELECT * FROM hot_test WHERE id = 1; }
+step s3_verify_index {
+    -- Verify index was updated (proves non-HOT)
+    SELECT COUNT(*) = 1 AS index_updated FROM hot_test WHERE indexed_col = 150;
+    SELECT COUNT(*) = 0 AS old_value_gone FROM hot_test WHERE indexed_col = 100;
+}
+
+# Session 4: Concurrent HOT updates on different rows
+session s4
+step s4_begin { BEGIN; }
+step s4_hot_update_row2 { UPDATE hot_test SET non_indexed_col = 'updated_s4' WHERE id = 2; }
+step s4_commit { COMMIT; }
+step s4_select { SELECT * FROM hot_test WHERE id = 2; }
+step s4_verify_hot {
+    -- Check for HOT chain on page 0
+    SELECT COUNT(*) > 0 AS has_hot_chain
+    FROM heap_page_items(get_raw_page('hot_test', 0))
+    WHERE lp_flags = 2
+       OR (t_ctid IS NOT NULL
+           AND (t_ctid::text::point)[0]::int = 0
+           AND t_ctid != ('(0,' || lp || ')')::tid);
+}
+
+# Two sessions both doing HOT updates on same row
+# Second session should block until first commits
+# Both should create HOT chains
+permutation s1_begin s1_hot_update s2_begin s2_hot_update s1_commit s2_commit s1_select s2_select s2_verify_hot
+
+# HOT update followed by non-HOT update
+# Non-HOT update should wait for HOT update to commit
+# First update is HOT, second is non-HOT (index updated)
+permutation s1_begin s1_hot_update s3_begin s3_non_hot_update s1_commit s3_commit s3_select s3_verify_index
+
+# Non-HOT update followed by HOT update
+# HOT update should wait for non-HOT update to commit
+# First update is non-HOT (index), second is HOT
+permutation s3_begin s3_non_hot_update s1_begin s1_hot_update s3_commit s1_commit s1_select s1_verify_hot
+
+# Concurrent HOT updates on different rows (should not block)
+# Both sessions should be able to create HOT chains independently
+permutation s1_begin s1_hot_update s4_begin s4_hot_update_row2 s1_commit s4_commit s1_select s4_select s1_verify_hot s4_verify_hot
diff --git a/src/test/isolation/specs/hot_updates_index_scan.spec b/src/test/isolation/specs/hot_updates_index_scan.spec
new file mode 100644
index 00000000000..70c3dae5166
--- /dev/null
+++ b/src/test/isolation/specs/hot_updates_index_scan.spec
@@ -0,0 +1,94 @@
+# Test HOT updates interaction with index scans and SELECT FOR UPDATE
+#
+# This test verifies that HOT updates are correctly handled when concurrent
+# sessions are performing index scans, using SELECT FOR UPDATE, and validates
+# HOT chains using heap_page_items().
+
+setup
+{
+    CREATE EXTENSION IF NOT EXISTS pageinspect;
+
+    CREATE TABLE hot_test (
+        id int PRIMARY KEY,
+        indexed_col int,
+        non_indexed_col text
+    );
+
+    CREATE INDEX hot_test_indexed_idx ON hot_test(indexed_col);
+
+    INSERT INTO hot_test SELECT i, i * 10, 'initial' || i FROM generate_series(1, 100) i;
+}
+
+teardown
+{
+    DROP TABLE hot_test;
+    DROP EXTENSION pageinspect;
+}
+
+# Session 1: Perform HOT update
+session s1
+step s1_begin { BEGIN; }
+step s1_hot_update { UPDATE hot_test SET non_indexed_col = 'hot_updated' WHERE id = 50; }
+step s1_non_hot_update { UPDATE hot_test SET indexed_col = 555 WHERE id = 50; }
+step s1_commit { COMMIT; }
+step s1_verify_hot {
+    -- Verify HOT chain exists for row with id=50
+    -- Use actual ctid to find the correct page
+    SELECT EXISTS (
+        SELECT 1 FROM heap_page_items(
+            get_raw_page('hot_test', (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50))
+        )
+        WHERE lp_flags = 2
+           OR (t_ctid IS NOT NULL
+               AND t_ctid != ('(' || (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50) || ',' || lp || ')')::tid
+               AND (t_ctid::text::point)[0]::int = (SELECT (ctid::text::point)[0]::int FROM hot_test WHERE id = 50))
+    ) AS has_hot_chain;
+}
+
+# Session 2: Index scan while HOT update in progress
+session s2
+step s2_begin { BEGIN; }
+step s2_index_scan { SELECT * FROM hot_test WHERE indexed_col = 500; }
+step s2_index_scan_new { SELECT * FROM hot_test WHERE indexed_col = 555; }
+step s2_commit { COMMIT; }
+step s2_verify_index {
+    -- After non-HOT update, verify index reflects the change
+    SELECT COUNT(*) = 1 AS found_new_value FROM hot_test WHERE indexed_col = 555;
+    SELECT COUNT(*) = 0 AS old_value_gone FROM hot_test WHERE indexed_col = 500;
+}
+
+# Session 3: SELECT FOR UPDATE
+session s3
+step s3_begin { BEGIN; }
+step s3_select_for_update { SELECT * FROM hot_test WHERE id = 50 FOR UPDATE; }
+step s3_commit { COMMIT; }
+
+# Session 4: SELECT FOR KEY SHARE (should not block HOT update of non-key column)
+session s4
+step s4_begin { BEGIN; }
+step s4_select_for_key_share { SELECT * FROM hot_test WHERE id = 50 FOR KEY SHARE; }
+step s4_commit { COMMIT; }
+
+# Index scan should see consistent snapshot during HOT update
+# Index scan starts before HOT update commits
+permutation s1_begin s1_hot_update s2_begin s2_index_scan s1_commit s2_commit
+
+# Index scan after non-HOT update should see new index entry
+# Index scan starts after non-HOT update commits
+permutation s1_begin s1_non_hot_update s1_commit s2_begin s2_index_scan_new s2_commit s2_verify_index
+
+# SELECT FOR UPDATE blocks HOT update
+# FOR UPDATE should block the UPDATE until SELECT commits
+permutation s3_begin s3_select_for_update s1_begin s1_hot_update s3_commit s1_commit s1_verify_hot
+
+# HOT update blocks SELECT FOR UPDATE
+# SELECT FOR UPDATE should wait for HOT update to commit
+permutation s1_begin s1_hot_update s3_begin s3_select_for_update s1_commit s3_commit
+
+# SELECT FOR KEY SHARE should not block HOT update (non-key column)
+# HOT update of non-indexed column should not conflict with FOR KEY SHARE
+permutation s4_begin s4_select_for_key_share s1_begin s1_hot_update s4_commit s1_commit s1_verify_hot
+
+# Non-HOT update (key column) should block after FOR KEY SHARE
+# Non-HOT update of indexed column should wait for FOR KEY SHARE
+permutation s4_begin s4_select_for_key_share s1_begin s1_non_hot_update s4_commit s1_commit
diff --git a/src/test/regress/expected/hot_updates.out b/src/test/regress/expected/hot_updates.out
new file mode 100644
index 00000000000..e99a51966ce
--- /dev/null
+++ b/src/test/regress/expected/hot_updates.out
@@ -0,0 +1,950 @@
+-- Load required extensions
+CREATE EXTENSION IF NOT EXISTS pageinspect;
+-- Function to get HOT update count
+CREATE OR REPLACE FUNCTION get_hot_count(rel_name text)
+RETURNS TABLE (
+    updates BIGINT,
+    hot BIGINT
+) AS $$
+DECLARE
+  rel_oid oid;
+BEGIN
+  rel_oid := rel_name::regclass::oid;
+
+  -- Read both committed and transaction-local stats
+  -- In autocommit mode (default for regression tests), this works correctly
+  -- Note: In explicit transactions (BEGIN/COMMIT), committed stats already
+  -- include flushed updates, so this would double-count. For explicit
+  -- transaction testing, call pg_stat_force_next_flush() before this function.
+  updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
+             COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
+  hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
+         COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 0);
+
+  RETURN NEXT;
+END;
+$$ LANGUAGE plpgsql;
+-- Check if a tuple is part of a HOT chain (has a predecessor on same page)
+CREATE OR REPLACE FUNCTION has_hot_chain(rel_name text, target_ctid tid)
+RETURNS boolean AS $$
+DECLARE
+  block_num int;
+  page_item record;
+BEGIN
+  block_num := (target_ctid::text::point)[0]::int;
+
+  -- Look for a different tuple on the same page that points to our target tuple
+  FOR page_item IN
+    SELECT lp, lp_flags, t_ctid
+    FROM heap_page_items(get_raw_page(rel_name, block_num))
+    WHERE lp_flags = 1
+      AND t_ctid IS NOT NULL
+      AND t_ctid = target_ctid
+      AND ('(' || block_num::text || ',' || lp::text || ')')::tid != target_ctid
+  LOOP
+    RETURN true;
+  END LOOP;
+
+  RETURN false;
+END;
+$$ LANGUAGE plpgsql;
+-- Print the HOT chain starting from a given tuple
+CREATE OR REPLACE FUNCTION print_hot_chain(rel_name text, start_ctid tid)
+RETURNS TABLE(chain_position int, ctid tid, lp_flags text, t_ctid tid, chain_end boolean) AS
+$$
+#variable_conflict use_column
+DECLARE
+  block_num int;
+  line_ptr int;
+  current_ctid tid := start_ctid;
+  next_ctid tid;
+  position int := 0;
+  max_iterations int := 100;
+  page_item record;
+  found_predecessor boolean := false;
+  flags_name text;
+BEGIN
+  block_num := (start_ctid::text::point)[0]::int;
+
+  -- Find the predecessor (old tuple pointing to our start_ctid)
+  FOR page_item IN
+    SELECT lp, lp_flags, t_ctid
+    FROM heap_page_items(get_raw_page(rel_name, block_num))
+    WHERE lp_flags = 1
+      AND t_ctid = start_ctid
+  LOOP
+    current_ctid := ('(' || block_num::text || ',' || page_item.lp::text || ')')::tid;
+    found_predecessor := true;
+    EXIT;
+  END LOOP;
+
+  -- If no predecessor found, start with the given ctid
+  IF NOT found_predecessor THEN
+    current_ctid := start_ctid;
+  END IF;
+
+  -- Follow the chain forward
+  WHILE position < max_iterations LOOP
+    line_ptr := (current_ctid::text::point)[1]::int;
+
+    FOR page_item IN
+      SELECT lp, lp_flags, t_ctid
+      FROM heap_page_items(get_raw_page(rel_name, block_num))
+      WHERE lp = line_ptr
+    LOOP
+      -- Map lp_flags to names
+      flags_name := CASE page_item.lp_flags
+        WHEN 0 THEN 'unused (0)'
+        WHEN 1 THEN 'normal (1)'
+        WHEN 2 THEN 'redirect (2)'
+        WHEN 3 THEN 'dead (3)'
+        ELSE 'unknown (' || page_item.lp_flags::text || ')'
+      END;
+
+      RETURN QUERY SELECT
+        position,
+        current_ctid,
+        flags_name,
+        page_item.t_ctid,
+        (page_item.t_ctid IS NULL OR page_item.t_ctid = current_ctid)::boolean
+      ;
+
+      IF page_item.t_ctid IS NULL OR page_item.t_ctid = current_ctid THEN
+        RETURN;
+      END IF;
+
+      next_ctid := page_item.t_ctid;
+
+      IF (next_ctid::text::point)[0]::int != block_num THEN
+        RETURN;
+      END IF;
+
+      current_ctid := next_ctid;
+      position := position + 1;
+    END LOOP;
+
+    IF position = 0 THEN
+      RETURN;
+    END IF;
+  END LOOP;
+END;
+$$ LANGUAGE plpgsql;
+-- Basic HOT update functionality
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    indexed_col int,
+    non_indexed_col text
+) USING heap WITH (fillfactor = 50);
+CREATE INDEX hot_test_indexed_idx ON hot_test(indexed_col);
+INSERT INTO hot_test VALUES (1, 100, 'initial');
+INSERT INTO hot_test VALUES (2, 200, 'initial');
+INSERT INTO hot_test VALUES (3, 300, 'initial');
+-- Get baseline
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       0 |   0
+(1 row)
+
+-- Should be HOT updates (only non-indexed column modified)
+UPDATE hot_test SET non_indexed_col = 'updated1' WHERE id = 1;
+UPDATE hot_test SET non_indexed_col = 'updated2' WHERE id = 2;
+UPDATE hot_test SET non_indexed_col = 'updated3' WHERE id = 3;
+-- Verify HOT updates occurred
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       3 |   3
+(1 row)
+
+-- Dump the HOT chain for tuple with id == 1
+WITH current_tuple AS (
+  SELECT ctid FROM hot_test WHERE id = 1
+)
+SELECT
+  has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
+  chain_position,
+  print_hot_chain.ctid,
+  lp_flags,
+  t_ctid
+FROM current_tuple,
+LATERAL print_hot_chain('hot_test', current_tuple.ctid);
+ has_chain | chain_position | ctid  |  lp_flags  | t_ctid 
+-----------+----------------+-------+------------+--------
+ t         |              0 | (0,1) | normal (1) | (0,4)
+ t         |              1 | (0,4) | normal (1) | (0,4)
+(2 rows)
+
+-- Trigger optimistic heap page pruning
+SELECT ctid, * FROM hot_test;
+ ctid  | id | indexed_col | non_indexed_col 
+-------+----+-------------+-----------------
+ (0,4) |  1 |         100 | updated1
+ (0,5) |  2 |         200 | updated2
+ (0,6) |  3 |         300 | updated3
+(3 rows)
+
+-- Dump the HOT chain after prune
+WITH current_tuple AS (
+  SELECT ctid FROM hot_test WHERE id = 1
+)
+SELECT
+  has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
+  chain_position,
+  print_hot_chain.ctid,
+  lp_flags,
+  t_ctid
+FROM current_tuple,
+LATERAL print_hot_chain('hot_test', current_tuple.ctid);
+ has_chain | chain_position | ctid  |  lp_flags  | t_ctid 
+-----------+----------------+-------+------------+--------
+ t         |              0 | (0,1) | normal (1) | (0,4)
+ t         |              1 | (0,4) | normal (1) | (0,4)
+(2 rows)
+
+SET SESSION enable_seqscan = OFF;
+SET SESSION enable_bitmapscan = OFF;
+-- Verify indexes still work
+EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hot_test WHERE indexed_col = 100;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Index Scan using hot_test_indexed_idx on hot_test
+   Index Cond: (indexed_col = 100)
+(2 rows)
+
+SELECT id, indexed_col FROM hot_test WHERE indexed_col = 100;
+ id | indexed_col 
+----+-------------
+  1 |         100
+(1 row)
+
+-- Vacuum the relation, expect the HOT chain to collapse
+VACUUM hot_test;
+-- Show that there is no chain after vacuum
+WITH current_tuple AS (
+  SELECT ctid FROM hot_test WHERE id = 1
+)
+SELECT
+  has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
+  chain_position,
+  print_hot_chain.ctid,
+  lp_flags,
+  t_ctid
+FROM current_tuple,
+LATERAL print_hot_chain('hot_test', current_tuple.ctid);
+ has_chain | chain_position | ctid  |  lp_flags  | t_ctid 
+-----------+----------------+-------+------------+--------
+ f         |              0 | (0,4) | normal (1) | (0,4)
+(1 row)
+
+-- Non-HOT update (update indexed column)
+UPDATE hot_test SET indexed_col = 150 WHERE id = 1;
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       4 |   3
+(1 row)
+
+-- Verify index was updated (new value findable)
+EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hot_test WHERE indexed_col = 150;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Index Scan using hot_test_indexed_idx on hot_test
+   Index Cond: (indexed_col = 150)
+(2 rows)
+
+SELECT id, indexed_col FROM hot_test WHERE indexed_col = 150;
+ id | indexed_col 
+----+-------------
+  1 |         150
+(1 row)
+
+-- Verify old value no longer in index
+EXPLAIN (COSTS OFF) SELECT id FROM hot_test WHERE indexed_col = 100;
+                    QUERY PLAN                     
+---------------------------------------------------
+ Index Scan using hot_test_indexed_idx on hot_test
+   Index Cond: (indexed_col = 100)
+(2 rows)
+
+SELECT id FROM hot_test WHERE indexed_col = 100;
+ id 
+----
+(0 rows)
+
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+-- All-or-none property: updating one indexed column requires ALL index updates
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    col_a int,
+    col_b int,
+    col_c int,
+    non_indexed text
+) USING heap WITH (fillfactor = 50);
+CREATE INDEX hot_test_a_idx ON hot_test(col_a);
+CREATE INDEX hot_test_b_idx ON hot_test(col_b);
+CREATE INDEX hot_test_c_idx ON hot_test(col_c);
+INSERT INTO hot_test VALUES (1, 10, 20, 30, 'initial');
+-- Update only col_a - should NOT be HOT because an indexed column changed
+-- This means ALL indexes must be updated (all-or-none property)
+UPDATE hot_test SET col_a = 15 WHERE id = 1;
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       1 |   0
+(1 row)
+
+-- Verify all three indexes still work correctly
+SELECT id, col_a FROM hot_test WHERE col_a = 15;  -- updated index
+ id | col_a 
+----+-------
+  1 |    15
+(1 row)
+
+SELECT id, col_b FROM hot_test WHERE col_b = 20;  -- unchanged index
+ id | col_b 
+----+-------
+  1 |    20
+(1 row)
+
+SELECT id, col_c FROM hot_test WHERE col_c = 30;  -- unchanged index
+ id | col_c 
+----+-------
+  1 |    30
+(1 row)
+
+-- Now update only non-indexed column - should be HOT
+UPDATE hot_test SET non_indexed = 'updated';
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       2 |   1
+(1 row)
+
+-- Verify all indexes still work
+SELECT id FROM hot_test WHERE col_a = 15 AND col_b = 20 AND col_c = 30;
+ id 
+----
+  1
+(1 row)
+
+-- Partial index: both old and new outside predicate (conservative = non-HOT)
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    status text,
+    data text
+) WITH (fillfactor = 50);
+-- Partial index only covers status = 'active'
+CREATE INDEX hot_test_active_idx ON hot_test(status) WHERE status = 'active';
+INSERT INTO hot_test VALUES (1, 'active', 'data1');
+INSERT INTO hot_test VALUES (2, 'inactive', 'data2');
+INSERT INTO hot_test VALUES (3, 'deleted', 'data3');
+-- Update non-indexed column on 'active' row (in predicate, status unchanged)
+-- Should be HOT
+UPDATE hot_test SET data = 'updated1' WHERE id = 1;
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       1 |   1
+(1 row)
+
+-- Update non-indexed column on 'inactive' row (outside predicate)
+-- Should be HOT
+UPDATE hot_test SET data = 'updated2' WHERE id = 2;
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       2 |   2
+(1 row)
+
+-- Update status from 'inactive' to 'deleted' (both outside predicate)
+-- PostgreSQL is conservative: heap insert happens before predicate check
+-- So this is NON-HOT even though both values are outside predicate
+UPDATE hot_test SET status = 'deleted' WHERE id = 2;
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       3 |   2
+(1 row)
+
+-- Verify index still works for 'active' rows
+SELECT id, status FROM hot_test WHERE status = 'active';
+ id | status 
+----+--------
+  1 | active
+(1 row)
+
+-- Only BRIN (summarizing) indexes on non-PK columns
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    ts timestamp,
+    value int,
+    brin_col int
+) WITH (fillfactor = 50);
+CREATE INDEX hot_test_ts_brin ON hot_test USING brin(ts);
+CREATE INDEX hot_test_brin_col_brin ON hot_test USING brin(brin_col);
+INSERT INTO hot_test VALUES (1, '2024-01-01', 100, 1000);
+-- Update both BRIN columns - should still be HOT (only summarizing indexes)
+UPDATE hot_test SET ts = '2024-01-02', brin_col = 2000 WHERE id = 1;
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       1 |   1
+(1 row)
+
+-- Verify BRIN indexes work
+SELECT id FROM hot_test WHERE ts >= '2024-01-02';
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM hot_test WHERE brin_col >= 2000;
+ id 
+----
+  1
+(1 row)
+
+-- Update non-indexed column - should also be HOT
+UPDATE hot_test SET value = 200 WHERE id = 1;
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       2 |   2
+(1 row)
+
+-- Unique constraint (unique index) behaves like regular index
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    unique_col int UNIQUE,
+    data text
+) WITH (fillfactor = 50);
+INSERT INTO hot_test VALUES (1, 100, 'data1');
+INSERT INTO hot_test VALUES (2, 200, 'data2');
+-- Update data (non-indexed) - should be HOT
+UPDATE hot_test SET data = 'updated';
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       2 |   2
+(1 row)
+
+-- Verify unique constraint still enforced
+SELECT id, unique_col, data FROM hot_test ORDER BY id;
+ id | unique_col |  data   
+----+------------+---------
+  1 |        100 | updated
+  2 |        200 | updated
+(2 rows)
+
+-- This should fail (unique violation)
+UPDATE hot_test SET unique_col = 100 WHERE id = 2;
+ERROR:  duplicate key value violates unique constraint "hot_test_unique_col_key"
+DETAIL:  Key (unique_col)=(100) already exists.
+-- Multi-column index: any column change = non-HOT
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    col_a int,
+    col_b int,
+    col_c int,
+    col_d int
+) WITH (fillfactor = 50);
+CREATE INDEX hot_test_ab_idx ON hot_test(col_a, col_b);
+CREATE INDEX hot_test_ab_inc_c_idx ON hot_test(col_a, col_b) INCLUDE(col_c);
+INSERT INTO hot_test VALUES (1, 10, 20, 30, 40);
+-- Update col_a (part of multi-column index) - should NOT be HOT
+UPDATE hot_test SET col_a = 15;
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       1 |   0
+(1 row)
+
+-- Update col_b (part of multi-column index) - should NOT be HOT
+UPDATE hot_test SET col_b = 25;
+SELECT * FROM get_hot_count('hot_test');
+ updates | hot 
+---------+-----
+       2 |   0
+(1 row)
+
+-- Update col_c (not indexed, but included) - should NOT be HOT
+UPDATE hot_test SET col_c = 35;
+-- Verify multi-column index-only scan for included columns works
+EXPLAIN (COSTS OFF) SELECT col_c FROM hot_test WHERE col_a = 15 AND col_b = 25;
+                       QUERY PLAN                        
+---------------------------------------------------------
+ Index Only Scan using hot_test_ab_inc_c_idx on hot_test
+   Index Cond: ((col_a = 15) AND (col_b = 25))
+(2 rows)
+
+SELECT col_c FROM hot_test WHERE col_a = 15 AND col_b = 25;
+ col_c 
+-------
+    35
+(1 row)
+
+-- ============================================================================
+-- Expression indexes with JSONB
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+-- Indexes on specific JSONB paths
+CREATE INDEX hot_test_status_idx ON hot_test((data->'status'));
+CREATE INDEX hot_test_user_id_idx ON hot_test((data->'user'->'id'));
+INSERT INTO hot_test VALUES (
+    1,
+    '{"status": "active", "user": {"id": 123, "name": "Alice"}, "count": 0}'::jsonb
+);
+-- Baseline
+SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test');
+   test   | updates | hot 
+----------+---------+-----
+ Baseline |       0 |   0
+(1 row)
+
+-- Update non-indexed path {count} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 1;
+SELECT 'After updating count (non-indexed)' AS test, * FROM get_hot_count('hot_test');
+                test                | updates | hot 
+------------------------------------+---------+-----
+ After updating count (non-indexed) |       1 |   0
+(1 row)
+
+-- Update different non-indexed path {user,name} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{user,name}', '"Bob"') WHERE id = 1;
+SELECT 'After updating user.name (non-indexed)' AS test, * FROM get_hot_count('hot_test');
+                  test                  | updates | hot 
+----------------------------------------+---------+-----
+ After updating user.name (non-indexed) |       2 |   0
+(1 row)
+
+-- Update indexed path {status} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 1;
+SELECT 'After updating status (indexed)' AS test, * FROM get_hot_count('hot_test');
+              test               | updates | hot 
+---------------------------------+---------+-----
+ After updating status (indexed) |       3 |   0
+(1 row)
+
+-- Update indexed path {user,id} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{user,id}', '456') WHERE id = 1;
+SELECT 'After updating user.id (indexed)' AS test, * FROM get_hot_count('hot_test');
+               test               | updates | hot 
+----------------------------------+---------+-----
+ After updating user.id (indexed) |       4 |   0
+(1 row)
+
+-- Verify indexes still work correctly
+SELECT id FROM hot_test WHERE data->'status' = '"inactive"'::jsonb;
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM hot_test WHERE data->'user'->'id' = '456'::jsonb;
+ id 
+----
+  1
+(1 row)
+
+-- ============================================================================
+-- Nested paths and path intersection
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+CREATE INDEX hot_test_deep_idx ON hot_test((data->'a'->'b'->'c'));
+INSERT INTO hot_test VALUES (
+    1,
+    '{"a": {"b": {"c": "indexed", "d": "not-indexed"}}, "x": "other"}'::jsonb
+);
+SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test');
+   test   | updates | hot 
+----------+---------+-----
+ Baseline |       0 |   0
+(1 row)
+
+-- Update sibling of indexed path {a,b,d} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{a,b,d}', '"updated"') WHERE id = 1;
+SELECT 'After updating a.b.d (sibling, non-indexed)' AS test, * FROM get_hot_count('hot_test');
+                    test                     | updates | hot 
+---------------------------------------------+---------+-----
+ After updating a.b.d (sibling, non-indexed) |       1 |   0
+(1 row)
+
+-- Update unrelated path {x} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{x}', '"modified"') WHERE id = 1;
+SELECT 'After updating x (unrelated path)' AS test, * FROM get_hot_count('hot_test');
+               test                | updates | hot 
+-----------------------------------+---------+-----
+ After updating x (unrelated path) |       2 |   0
+(1 row)
+
+-- Update parent of indexed path {a,b} - should NOT be HOT (affects child)
+UPDATE hot_test SET data = jsonb_set(data, '{a,b}', '{"c": "new", "d": "data"}') WHERE id = 1;
+SELECT 'After updating a.b (parent of indexed)' AS test, * FROM get_hot_count('hot_test');
+                  test                  | updates | hot 
+----------------------------------------+---------+-----
+ After updating a.b (parent of indexed) |       3 |   0
+(1 row)
+
+-- ============================================================================
+-- Multiple JSONB mutation functions
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+CREATE INDEX hot_test_keep_idx ON hot_test((data->'keep'));
+INSERT INTO hot_test VALUES (
+    1,
+    '{"keep": "important", "remove": "unimportant", "extra": "data"}'::jsonb
+);
+SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test');
+   test   | updates | hot 
+----------+---------+-----
+ Baseline |       0 |   0
+(1 row)
+
+-- jsonb_delete on non-indexed key - should NOT be HOT
+UPDATE hot_test SET data = data - 'remove' WHERE id = 1;
+SELECT 'After deleting non-indexed key' AS test, * FROM get_hot_count('hot_test');
+              test              | updates | hot 
+--------------------------------+---------+-----
+ After deleting non-indexed key |       1 |   0
+(1 row)
+
+-- jsonb_set on non-indexed key - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{extra}', '"modified"') WHERE id = 1;
+SELECT 'After modifying non-indexed key' AS test, * FROM get_hot_count('hot_test');
+              test               | updates | hot 
+---------------------------------+---------+-----
+ After modifying non-indexed key |       2 |   0
+(1 row)
+
+-- jsonb_delete on indexed key - should NOT be HOT
+UPDATE hot_test SET data = data - 'keep' WHERE id = 1;
+SELECT 'After deleting indexed key' AS test, * FROM get_hot_count('hot_test');
+            test            | updates | hot 
+----------------------------+---------+-----
+ After deleting indexed key |       3 |   0
+(1 row)
+
+-- ============================================================================
+-- Array operations
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+-- Index on array element
+CREATE INDEX hot_test_tags_idx ON hot_test((data->'tags'->0));
+INSERT INTO hot_test VALUES (
+    1,
+    '{"tags": ["indexed", "second", "third"], "other": "data"}'::jsonb
+);
+SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test');
+   test   | updates | hot 
+----------+---------+-----
+ Baseline |       0 |   0
+(1 row)
+
+-- Update non-indexed array element - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{tags,1}', '"modified"') WHERE id = 1;
+SELECT 'After updating tags[1]' AS test, * FROM get_hot_count('hot_test');
+          test          | updates | hot 
+------------------------+---------+-----
+ After updating tags[1] |       1 |   0
+(1 row)
+
+-- Update indexed array element - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{tags,0}', '"changed"') WHERE id = 1;
+SELECT 'After updating tags[0] (indexed)' AS test, * FROM get_hot_count('hot_test');
+               test               | updates | hot 
+----------------------------------+---------+-----
+ After updating tags[0] (indexed) |       2 |   0
+(1 row)
+
+-- ============================================================================
+-- Whole column index
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+-- Index on entire JSONB column, and a path extraction
+CREATE INDEX hot_test_whole_idx ON hot_test(data);
+CREATE INDEX hot_test_tags_idx ON hot_test((data->'a'));
+INSERT INTO hot_test VALUES (1, '{"a": 1, "b": 1}'::jsonb);
+SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test');
+   test   | updates | hot 
+----------+---------+-----
+ Baseline |       0 |   0
+(1 row)
+
+-- Any modification to data - should NOT be HOT (whole column indexed)
+UPDATE hot_test SET data = jsonb_set(data, '{b}', '2') WHERE id = 1;
+SELECT 'After modifying any field (whole column indexed)' AS test, * FROM get_hot_count('hot_test');
+                       test                       | updates | hot 
+--------------------------------------------------+---------+-----
+ After modifying any field (whole column indexed) |       1 |   0
+(1 row)
+
+-- ============================================================================
+-- Performance at scale
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor=50);
+CREATE INDEX hot_test_status_idx ON hot_test((data->'status'));
+CREATE INDEX hot_test_priority_idx ON hot_test((data->'priority'));
+-- Insert 10000 rows
+INSERT INTO hot_test
+SELECT i, jsonb_build_object(
+    'status', 'active',
+    'priority', 1,
+    'count', 0,
+    'data', 'value_' || i
+)
+FROM generate_series(1, 10000) i;
+SELECT 'Baseline (10000 rows)' AS test, * FROM get_hot_count('hot_test');
+         test          | updates | hot 
+-----------------------+---------+-----
+ Baseline (10000 rows) |       0 |   0
+(1 row)
+
+-- Update non-indexed fields on all rows - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{count}', to_jsonb((data->>'count')::int + 1));
+SELECT 'After updating 10000 rows (non-indexed)' AS test, * FROM get_hot_count('hot_test');
+                  test                   | updates | hot 
+-----------------------------------------+---------+-----
+ After updating 10000 rows (non-indexed) |   10000 |   0
+(1 row)
+
+-- Verify correctness
+SELECT COUNT(*) AS rows_with_count_1 FROM hot_test WHERE (data->>'count')::int = 1;
+ rows_with_count_1 
+-------------------
+             10000
+(1 row)
+
+-- Update indexed field on subset - should NOT be HOT for those rows
+UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"')
+WHERE id <= 10;
+SELECT 'After updating 10 rows (indexed)' AS test, * FROM get_hot_count('hot_test');
+               test               | updates | hot 
+----------------------------------+---------+-----
+ After updating 10 rows (indexed) |   10010 |   0
+(1 row)
+
+-- Verify indexes work
+SELECT COUNT(*) FROM hot_test WHERE data->>'status' = 'inactive';
+ count 
+-------
+    10
+(1 row)
+
+SELECT COUNT(*) FROM hot_test WHERE data->>'status' = 'active';
+ count 
+-------
+  9990
+(1 row)
+
+-- Only BRIN (summarizing) indexes on non-PK columns
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    ts timestamp,
+    value int,
+    brin_col int
+) USING heap WITH(fillfactor = 50);
+CREATE INDEX hot_test_ts_brin ON hot_test USING brin(ts);
+CREATE INDEX hot_test_brin_col_brin ON hot_test USING brin(brin_col);
+INSERT INTO hot_test VALUES (1, '2024-01-01', 100, 1000);
+-- Update both BRIN columns - should still be HOT (only summarizing indexes)
+UPDATE hot_test SET ts = '2024-01-02', brin_col = 2000 WHERE id = 1;
+SELECT 'After updating ts, brin_col (summarizing-only)' AS test, * FROM get_hot_count('hot_test');
+                      test                      | updates | hot 
+------------------------------------------------+---------+-----
+ After updating ts, brin_col (summarizing-only) |       1 |   1
+(1 row)
+
+-- Verify BRIN indexes work
+SELECT id FROM hot_test WHERE ts >= '2024-01-02';
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM hot_test WHERE brin_col >= 2000;
+ id 
+----
+  1
+(1 row)
+
+-- TOASTed columns can participate in HOT
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    large_text text
+) USING heap WITH(fillfactor = 50);
+CREATE INDEX hot_test_idx ON hot_test(large_text);
+-- Insert row with TOASTed column (> 2KB)
+INSERT INTO hot_test VALUES (1, repeat('x', 3000));
+-- Update TOASTed column - should NOT be HOT
+UPDATE hot_test SET large_text = repeat('y', 3000);
+SELECT 'After updating large_text (TOASTed)' AS test, * FROM get_hot_count('hot_test');
+                test                 | updates | hot 
+-------------------------------------+---------+-----
+ After updating large_text (TOASTed) |       1 |   0
+(1 row)
+
+-- Partitioned tables: HOT works within partitions
+CREATE TABLE hot_test_partitioned (
+    id int,
+    partition_key int,
+    indexed_col int,
+    data text,
+    PRIMARY KEY (id, partition_key)
+) PARTITION BY RANGE (partition_key);
+CREATE TABLE hot_test_part1 PARTITION OF hot_test_partitioned
+    FOR VALUES FROM (1) TO (100);
+CREATE TABLE hot_test_part2 PARTITION OF hot_test_partitioned
+    FOR VALUES FROM (100) TO (200);
+CREATE INDEX hot_test_partitioned_idx ON hot_test_partitioned(indexed_col);
+CREATE INDEX hot_test_part2_data ON hot_test_part2(data);
+INSERT INTO hot_test_partitioned VALUES (1, 50, 100, 'initial1');
+INSERT INTO hot_test_partitioned VALUES (2, 150, 200, 'initial2');
+-- Update in partition 1 (non-indexed column) - should be HOT
+UPDATE hot_test_partitioned SET data = 'UPDATED' WHERE id = 1;
+SELECT 'After updating partition 1 data' AS test, * FROM get_hot_count('hot_test_part1');
+              test               | updates | hot 
+---------------------------------+---------+-----
+ After updating partition 1 data |       1 |   1
+(1 row)
+
+-- Update in partition 2 (indexed column) - should NOT be HOT
+UPDATE hot_test_partitioned SET data = 'UPDATED' WHERE id = 2;
+SELECT 'After updating large_text (TOASTed)' AS test, * FROM get_hot_count('hot_test_part2');
+                test                 | updates | hot 
+-------------------------------------+---------+-----
+ After updating large_text (TOASTed) |       1 |   0
+(1 row)
+
+-- Verify indexes work on partitions
+SELECT id FROM hot_test_partitioned WHERE indexed_col = 100;
+ id 
+----
+  1
+(1 row)
+
+SELECT id FROM hot_test_partitioned WHERE indexed_col = 200;
+ id 
+----
+  2
+(1 row)
+
+-- Update indexed column in partition - should NOT be HOT
+-- Partition 1 previously had 1 update and 1 HOT update, this should
+-- change that to 2 updates and 1 HOT update.
+UPDATE hot_test_partitioned SET indexed_col = 150 WHERE id = 1;
+SELECT 'After updating indexed_col' AS test, * FROM get_hot_count('hot_test_part1');
+            test            | updates | hot 
+----------------------------+---------+-----
+ After updating indexed_col |       2 |   1
+(1 row)
+
+-- ============================================================================
+-- Partial indexes with complex predicates on JSONB
+-- ============================================================================
+-- Test partial indexes with WHERE clauses on JSONB expressions.
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+-- Partial index: only index status when priority > 5
+CREATE INDEX hot_test_partial_idx ON hot_test((data->'status'))
+    WHERE (data->>'priority')::int > 5;
+INSERT INTO hot_test VALUES (
+    1,
+    '{"status": "active", "priority": 10, "count": 0}'::jsonb
+);
+INSERT INTO hot_test VALUES (
+    2,
+    '{"status": "active", "priority": 3, "count": 0}'::jsonb
+);
+SELECT 'Partial Index Test: Baseline' AS test, * FROM get_hot_count('hot_test');
+             test             | updates | hot 
+------------------------------+---------+-----
+ Partial Index Test: Baseline |       0 |   0
+(1 row)
+
+-- Update non-indexed path on row inside predicate (priority=10 > 5)
+-- Should NOT be HOT despite {count} is not indexed
+UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 1;
+SELECT 'Partial Index Test: count update, inside predicate' AS test, * FROM get_hot_count('hot_test');
+                        test                        | updates | hot 
+----------------------------------------------------+---------+-----
+ Partial Index Test: count update, inside predicate |       1 |   0
+(1 row)
+
+-- Update non-indexed path on row outside predicate (priority=3 <= 5)
+-- Should NOT be HOT dispite {count} is not indexed
+UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 2;
+SELECT 'Partial Index Test: count update, outside predicate' AS test, * FROM get_hot_count('hot_test');
+                        test                         | updates | hot 
+-----------------------------------------------------+---------+-----
+ Partial Index Test: count update, outside predicate |       2 |   0
+(1 row)
+
+-- Update indexed path on row inside predicate (priority=10 > 5)
+-- Should NOT be HOT indexed portion is updated
+UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 1;
+SELECT 'Partial Index Test: status update, inside predicate' AS test, * FROM get_hot_count('hot_test');
+                        test                         | updates | hot 
+-----------------------------------------------------+---------+-----
+ Partial Index Test: status update, inside predicate |       3 |   0
+(1 row)
+
+-- Update indexed path on row outside predicate (priority=3 <= 5)
+-- PostgreSQL makes a conservative choice and treats it as non-HOT because the
+-- indexed column changed, even though the before/after rows are outside the predicate
+UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 2;
+SELECT 'Partial Index Test: status update, outside predicate' AS test, * FROM get_hot_count('hot_test');
+                         test                         | updates | hot 
+------------------------------------------------------+---------+-----
+ Partial Index Test: status update, outside predicate |       4 |   0
+(1 row)
+
+-- Verify index works
+SELECT id FROM hot_test WHERE data->'status' = '"inactive"'::jsonb AND (data->>'priority')::int > 5;
+ id 
+----
+  1
+(1 row)
+
+-- ============================================================================
+DROP TABLE IF EXISTS hot_test;
+DROP TABLE IF EXISTS hot_test_partitioned CASCADE;
+DROP FUNCTION IF EXISTS has_hot_chain(text, tid);
+DROP FUNCTION IF EXISTS print_hot_chain(text, tid);
+DROP FUNCTION IF EXISTS get_hot_count(text);
+DROP EXTENSION pageinspect;
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 549e9b2d7be..e06247ef7ea 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -137,6 +137,11 @@ test: event_trigger_login
 # this test also uses event triggers, so likewise run it by itself
 test: fast_default
 
+# ----------
+# HOT updates tests
+# ----------
+test: hot_updates
+
 # run tablespace test at the end because it drops the tablespace created during
 # setup that other tests may use.
 test: tablespace
diff --git a/src/test/regress/sql/hot_updates.sql b/src/test/regress/sql/hot_updates.sql
new file mode 100644
index 00000000000..34da4552d4f
--- /dev/null
+++ b/src/test/regress/sql/hot_updates.sql
@@ -0,0 +1,692 @@
+-- Load required extensions
+CREATE EXTENSION IF NOT EXISTS pageinspect;
+
+-- Function to get HOT update count
+CREATE OR REPLACE FUNCTION get_hot_count(rel_name text)
+RETURNS TABLE (
+    updates BIGINT,
+    hot BIGINT
+) AS $$
+DECLARE
+  rel_oid oid;
+BEGIN
+  rel_oid := rel_name::regclass::oid;
+
+  -- Read both committed and transaction-local stats
+  -- In autocommit mode (default for regression tests), this works correctly
+  -- Note: In explicit transactions (BEGIN/COMMIT), committed stats already
+  -- include flushed updates, so this would double-count. For explicit
+  -- transaction testing, call pg_stat_force_next_flush() before this function.
+  updates := COALESCE(pg_stat_get_tuples_updated(rel_oid), 0) +
+             COALESCE(pg_stat_get_xact_tuples_updated(rel_oid), 0);
+  hot := COALESCE(pg_stat_get_tuples_hot_updated(rel_oid), 0) +
+         COALESCE(pg_stat_get_xact_tuples_hot_updated(rel_oid), 0);
+
+  RETURN NEXT;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Check if a tuple is part of a HOT chain (has a predecessor on same page)
+CREATE OR REPLACE FUNCTION has_hot_chain(rel_name text, target_ctid tid)
+RETURNS boolean AS $$
+DECLARE
+  block_num int;
+  page_item record;
+BEGIN
+  block_num := (target_ctid::text::point)[0]::int;
+
+  -- Look for a different tuple on the same page that points to our target tuple
+  FOR page_item IN
+    SELECT lp, lp_flags, t_ctid
+    FROM heap_page_items(get_raw_page(rel_name, block_num))
+    WHERE lp_flags = 1
+      AND t_ctid IS NOT NULL
+      AND t_ctid = target_ctid
+      AND ('(' || block_num::text || ',' || lp::text || ')')::tid != target_ctid
+  LOOP
+    RETURN true;
+  END LOOP;
+
+  RETURN false;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Print the HOT chain starting from a given tuple
+CREATE OR REPLACE FUNCTION print_hot_chain(rel_name text, start_ctid tid)
+RETURNS TABLE(chain_position int, ctid tid, lp_flags text, t_ctid tid, chain_end boolean) AS
+$$
+#variable_conflict use_column
+DECLARE
+  block_num int;
+  line_ptr int;
+  current_ctid tid := start_ctid;
+  next_ctid tid;
+  position int := 0;
+  max_iterations int := 100;
+  page_item record;
+  found_predecessor boolean := false;
+  flags_name text;
+BEGIN
+  block_num := (start_ctid::text::point)[0]::int;
+
+  -- Find the predecessor (old tuple pointing to our start_ctid)
+  FOR page_item IN
+    SELECT lp, lp_flags, t_ctid
+    FROM heap_page_items(get_raw_page(rel_name, block_num))
+    WHERE lp_flags = 1
+      AND t_ctid = start_ctid
+  LOOP
+    current_ctid := ('(' || block_num::text || ',' || page_item.lp::text || ')')::tid;
+    found_predecessor := true;
+    EXIT;
+  END LOOP;
+
+  -- If no predecessor found, start with the given ctid
+  IF NOT found_predecessor THEN
+    current_ctid := start_ctid;
+  END IF;
+
+  -- Follow the chain forward
+  WHILE position < max_iterations LOOP
+    line_ptr := (current_ctid::text::point)[1]::int;
+
+    FOR page_item IN
+      SELECT lp, lp_flags, t_ctid
+      FROM heap_page_items(get_raw_page(rel_name, block_num))
+      WHERE lp = line_ptr
+    LOOP
+      -- Map lp_flags to names
+      flags_name := CASE page_item.lp_flags
+        WHEN 0 THEN 'unused (0)'
+        WHEN 1 THEN 'normal (1)'
+        WHEN 2 THEN 'redirect (2)'
+        WHEN 3 THEN 'dead (3)'
+        ELSE 'unknown (' || page_item.lp_flags::text || ')'
+      END;
+
+      RETURN QUERY SELECT
+        position,
+        current_ctid,
+        flags_name,
+        page_item.t_ctid,
+        (page_item.t_ctid IS NULL OR page_item.t_ctid = current_ctid)::boolean
+      ;
+
+      IF page_item.t_ctid IS NULL OR page_item.t_ctid = current_ctid THEN
+        RETURN;
+      END IF;
+
+      next_ctid := page_item.t_ctid;
+
+      IF (next_ctid::text::point)[0]::int != block_num THEN
+        RETURN;
+      END IF;
+
+      current_ctid := next_ctid;
+      position := position + 1;
+    END LOOP;
+
+    IF position = 0 THEN
+      RETURN;
+    END IF;
+  END LOOP;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Basic HOT update functionality
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    indexed_col int,
+    non_indexed_col text
+) USING heap WITH (fillfactor = 50);
+
+CREATE INDEX hot_test_indexed_idx ON hot_test(indexed_col);
+
+INSERT INTO hot_test VALUES (1, 100, 'initial');
+INSERT INTO hot_test VALUES (2, 200, 'initial');
+INSERT INTO hot_test VALUES (3, 300, 'initial');
+
+-- Get baseline
+SELECT * FROM get_hot_count('hot_test');
+
+-- Should be HOT updates (only non-indexed column modified)
+UPDATE hot_test SET non_indexed_col = 'updated1' WHERE id = 1;
+UPDATE hot_test SET non_indexed_col = 'updated2' WHERE id = 2;
+UPDATE hot_test SET non_indexed_col = 'updated3' WHERE id = 3;
+
+-- Verify HOT updates occurred
+SELECT * FROM get_hot_count('hot_test');
+
+-- Dump the HOT chain for tuple with id == 1
+WITH current_tuple AS (
+  SELECT ctid FROM hot_test WHERE id = 1
+)
+SELECT
+  has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
+  chain_position,
+  print_hot_chain.ctid,
+  lp_flags,
+  t_ctid
+FROM current_tuple,
+LATERAL print_hot_chain('hot_test', current_tuple.ctid);
+
+-- Trigger optimistic heap page pruning
+SELECT ctid, * FROM hot_test;
+
+-- Dump the HOT chain after prune
+WITH current_tuple AS (
+  SELECT ctid FROM hot_test WHERE id = 1
+)
+SELECT
+  has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
+  chain_position,
+  print_hot_chain.ctid,
+  lp_flags,
+  t_ctid
+FROM current_tuple,
+LATERAL print_hot_chain('hot_test', current_tuple.ctid);
+
+SET SESSION enable_seqscan = OFF;
+SET SESSION enable_bitmapscan = OFF;
+
+-- Verify indexes still work
+EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hot_test WHERE indexed_col = 100;
+SELECT id, indexed_col FROM hot_test WHERE indexed_col = 100;
+
+-- Vacuum the relation, expect the HOT chain to collapse
+VACUUM hot_test;
+
+-- Show that there is no chain after vacuum
+WITH current_tuple AS (
+  SELECT ctid FROM hot_test WHERE id = 1
+)
+SELECT
+  has_hot_chain('hot_test', current_tuple.ctid) AS has_chain,
+  chain_position,
+  print_hot_chain.ctid,
+  lp_flags,
+  t_ctid
+FROM current_tuple,
+LATERAL print_hot_chain('hot_test', current_tuple.ctid);
+
+-- Non-HOT update (update indexed column)
+UPDATE hot_test SET indexed_col = 150 WHERE id = 1;
+SELECT * FROM get_hot_count('hot_test');
+
+-- Verify index was updated (new value findable)
+EXPLAIN (COSTS OFF) SELECT id, indexed_col FROM hot_test WHERE indexed_col = 150;
+SELECT id, indexed_col FROM hot_test WHERE indexed_col = 150;
+
+-- Verify old value no longer in index
+EXPLAIN (COSTS OFF) SELECT id FROM hot_test WHERE indexed_col = 100;
+SELECT id FROM hot_test WHERE indexed_col = 100;
+
+SET SESSION enable_seqscan = ON;
+SET SESSION enable_bitmapscan = ON;
+
+-- All-or-none property: updating one indexed column requires ALL index updates
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    col_a int,
+    col_b int,
+    col_c int,
+    non_indexed text
+) USING heap WITH (fillfactor = 50);
+
+CREATE INDEX hot_test_a_idx ON hot_test(col_a);
+CREATE INDEX hot_test_b_idx ON hot_test(col_b);
+CREATE INDEX hot_test_c_idx ON hot_test(col_c);
+
+INSERT INTO hot_test VALUES (1, 10, 20, 30, 'initial');
+
+-- Update only col_a - should NOT be HOT because an indexed column changed
+-- This means ALL indexes must be updated (all-or-none property)
+UPDATE hot_test SET col_a = 15 WHERE id = 1;
+SELECT * FROM get_hot_count('hot_test');
+
+-- Verify all three indexes still work correctly
+SELECT id, col_a FROM hot_test WHERE col_a = 15;  -- updated index
+SELECT id, col_b FROM hot_test WHERE col_b = 20;  -- unchanged index
+SELECT id, col_c FROM hot_test WHERE col_c = 30;  -- unchanged index
+
+-- Now update only non-indexed column - should be HOT
+UPDATE hot_test SET non_indexed = 'updated';
+SELECT * FROM get_hot_count('hot_test');
+
+-- Verify all indexes still work
+SELECT id FROM hot_test WHERE col_a = 15 AND col_b = 20 AND col_c = 30;
+
+-- Partial index: both old and new outside predicate (conservative = non-HOT)
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    status text,
+    data text
+) WITH (fillfactor = 50);
+
+-- Partial index only covers status = 'active'
+CREATE INDEX hot_test_active_idx ON hot_test(status) WHERE status = 'active';
+
+INSERT INTO hot_test VALUES (1, 'active', 'data1');
+INSERT INTO hot_test VALUES (2, 'inactive', 'data2');
+INSERT INTO hot_test VALUES (3, 'deleted', 'data3');
+
+-- Update non-indexed column on 'active' row (in predicate, status unchanged)
+-- Should be HOT
+UPDATE hot_test SET data = 'updated1' WHERE id = 1;
+SELECT * FROM get_hot_count('hot_test');
+
+-- Update non-indexed column on 'inactive' row (outside predicate)
+-- Should be HOT
+UPDATE hot_test SET data = 'updated2' WHERE id = 2;
+SELECT * FROM get_hot_count('hot_test');
+
+-- Update status from 'inactive' to 'deleted' (both outside predicate)
+-- PostgreSQL is conservative: heap insert happens before predicate check
+-- So this is NON-HOT even though both values are outside predicate
+UPDATE hot_test SET status = 'deleted' WHERE id = 2;
+SELECT * FROM get_hot_count('hot_test');
+
+-- Verify index still works for 'active' rows
+SELECT id, status FROM hot_test WHERE status = 'active';
+
+-- Only BRIN (summarizing) indexes on non-PK columns
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    ts timestamp,
+    value int,
+    brin_col int
+) WITH (fillfactor = 50);
+
+CREATE INDEX hot_test_ts_brin ON hot_test USING brin(ts);
+CREATE INDEX hot_test_brin_col_brin ON hot_test USING brin(brin_col);
+
+INSERT INTO hot_test VALUES (1, '2024-01-01', 100, 1000);
+
+-- Update both BRIN columns - should still be HOT (only summarizing indexes)
+UPDATE hot_test SET ts = '2024-01-02', brin_col = 2000 WHERE id = 1;
+SELECT * FROM get_hot_count('hot_test');
+
+-- Verify BRIN indexes work
+SELECT id FROM hot_test WHERE ts >= '2024-01-02';
+SELECT id FROM hot_test WHERE brin_col >= 2000;
+
+-- Update non-indexed column - should also be HOT
+UPDATE hot_test SET value = 200 WHERE id = 1;
+SELECT * FROM get_hot_count('hot_test');
+
+-- Unique constraint (unique index) behaves like regular index
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    unique_col int UNIQUE,
+    data text
+) WITH (fillfactor = 50);
+
+INSERT INTO hot_test VALUES (1, 100, 'data1');
+INSERT INTO hot_test VALUES (2, 200, 'data2');
+
+-- Update data (non-indexed) - should be HOT
+UPDATE hot_test SET data = 'updated';
+SELECT * FROM get_hot_count('hot_test');
+
+-- Verify unique constraint still enforced
+SELECT id, unique_col, data FROM hot_test ORDER BY id;
+
+-- This should fail (unique violation)
+UPDATE hot_test SET unique_col = 100 WHERE id = 2;
+
+-- Multi-column index: any column change = non-HOT
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    col_a int,
+    col_b int,
+    col_c int,
+    col_d int
+) WITH (fillfactor = 50);
+
+CREATE INDEX hot_test_ab_idx ON hot_test(col_a, col_b);
+CREATE INDEX hot_test_ab_inc_c_idx ON hot_test(col_a, col_b) INCLUDE(col_c);
+
+INSERT INTO hot_test VALUES (1, 10, 20, 30, 40);
+
+-- Update col_a (part of multi-column index) - should NOT be HOT
+UPDATE hot_test SET col_a = 15;
+SELECT * FROM get_hot_count('hot_test');
+
+-- Update col_b (part of multi-column index) - should NOT be HOT
+UPDATE hot_test SET col_b = 25;
+SELECT * FROM get_hot_count('hot_test');
+
+-- Update col_c (not indexed, but included) - should NOT be HOT
+UPDATE hot_test SET col_c = 35;
+
+-- Verify multi-column index-only scan for included columns works
+EXPLAIN (COSTS OFF) SELECT col_c FROM hot_test WHERE col_a = 15 AND col_b = 25;
+SELECT col_c FROM hot_test WHERE col_a = 15 AND col_b = 25;
+
+-- ============================================================================
+-- Expression indexes with JSONB
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+
+-- Indexes on specific JSONB paths
+CREATE INDEX hot_test_status_idx ON hot_test((data->'status'));
+CREATE INDEX hot_test_user_id_idx ON hot_test((data->'user'->'id'));
+
+INSERT INTO hot_test VALUES (
+    1,
+    '{"status": "active", "user": {"id": 123, "name": "Alice"}, "count": 0}'::jsonb
+);
+
+-- Baseline
+SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test');
+
+-- Update non-indexed path {count} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 1;
+SELECT 'After updating count (non-indexed)' AS test, * FROM get_hot_count('hot_test');
+
+-- Update different non-indexed path {user,name} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{user,name}', '"Bob"') WHERE id = 1;
+SELECT 'After updating user.name (non-indexed)' AS test, * FROM get_hot_count('hot_test');
+
+-- Update indexed path {status} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 1;
+SELECT 'After updating status (indexed)' AS test, * FROM get_hot_count('hot_test');
+
+-- Update indexed path {user,id} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{user,id}', '456') WHERE id = 1;
+SELECT 'After updating user.id (indexed)' AS test, * FROM get_hot_count('hot_test');
+
+-- Verify indexes still work correctly
+SELECT id FROM hot_test WHERE data->'status' = '"inactive"'::jsonb;
+SELECT id FROM hot_test WHERE data->'user'->'id' = '456'::jsonb;
+
+-- ============================================================================
+-- Nested paths and path intersection
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+
+CREATE INDEX hot_test_deep_idx ON hot_test((data->'a'->'b'->'c'));
+
+INSERT INTO hot_test VALUES (
+    1,
+    '{"a": {"b": {"c": "indexed", "d": "not-indexed"}}, "x": "other"}'::jsonb
+);
+
+SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test');
+
+-- Update sibling of indexed path {a,b,d} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{a,b,d}', '"updated"') WHERE id = 1;
+SELECT 'After updating a.b.d (sibling, non-indexed)' AS test, * FROM get_hot_count('hot_test');
+
+-- Update unrelated path {x} - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{x}', '"modified"') WHERE id = 1;
+SELECT 'After updating x (unrelated path)' AS test, * FROM get_hot_count('hot_test');
+
+-- Update parent of indexed path {a,b} - should NOT be HOT (affects child)
+UPDATE hot_test SET data = jsonb_set(data, '{a,b}', '{"c": "new", "d": "data"}') WHERE id = 1;
+SELECT 'After updating a.b (parent of indexed)' AS test, * FROM get_hot_count('hot_test');
+
+-- ============================================================================
+-- Multiple JSONB mutation functions
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+
+CREATE INDEX hot_test_keep_idx ON hot_test((data->'keep'));
+
+INSERT INTO hot_test VALUES (
+    1,
+    '{"keep": "important", "remove": "unimportant", "extra": "data"}'::jsonb
+);
+
+SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test');
+
+-- jsonb_delete on non-indexed key - should NOT be HOT
+UPDATE hot_test SET data = data - 'remove' WHERE id = 1;
+SELECT 'After deleting non-indexed key' AS test, * FROM get_hot_count('hot_test');
+
+-- jsonb_set on non-indexed key - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{extra}', '"modified"') WHERE id = 1;
+SELECT 'After modifying non-indexed key' AS test, * FROM get_hot_count('hot_test');
+
+-- jsonb_delete on indexed key - should NOT be HOT
+UPDATE hot_test SET data = data - 'keep' WHERE id = 1;
+SELECT 'After deleting indexed key' AS test, * FROM get_hot_count('hot_test');
+
+-- ============================================================================
+-- Array operations
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+
+-- Index on array element
+CREATE INDEX hot_test_tags_idx ON hot_test((data->'tags'->0));
+
+INSERT INTO hot_test VALUES (
+    1,
+    '{"tags": ["indexed", "second", "third"], "other": "data"}'::jsonb
+);
+
+SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test');
+
+-- Update non-indexed array element - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{tags,1}', '"modified"') WHERE id = 1;
+SELECT 'After updating tags[1]' AS test, * FROM get_hot_count('hot_test');
+
+-- Update indexed array element - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{tags,0}', '"changed"') WHERE id = 1;
+SELECT 'After updating tags[0] (indexed)' AS test, * FROM get_hot_count('hot_test');
+
+-- ============================================================================
+-- Whole column index
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+
+-- Index on entire JSONB column, and a path extraction
+CREATE INDEX hot_test_whole_idx ON hot_test(data);
+CREATE INDEX hot_test_tags_idx ON hot_test((data->'a'));
+
+INSERT INTO hot_test VALUES (1, '{"a": 1, "b": 1}'::jsonb);
+
+SELECT 'Baseline' AS test, * FROM get_hot_count('hot_test');
+
+-- Any modification to data - should NOT be HOT (whole column indexed)
+UPDATE hot_test SET data = jsonb_set(data, '{b}', '2') WHERE id = 1;
+SELECT 'After modifying any field (whole column indexed)' AS test, * FROM get_hot_count('hot_test');
+
+-- ============================================================================
+-- Performance at scale
+-- ============================================================================
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor=50);
+
+CREATE INDEX hot_test_status_idx ON hot_test((data->'status'));
+CREATE INDEX hot_test_priority_idx ON hot_test((data->'priority'));
+
+-- Insert 10000 rows
+INSERT INTO hot_test
+SELECT i, jsonb_build_object(
+    'status', 'active',
+    'priority', 1,
+    'count', 0,
+    'data', 'value_' || i
+)
+FROM generate_series(1, 10000) i;
+
+SELECT 'Baseline (10000 rows)' AS test, * FROM get_hot_count('hot_test');
+
+-- Update non-indexed fields on all rows - should NOT be HOT
+UPDATE hot_test SET data = jsonb_set(data, '{count}', to_jsonb((data->>'count')::int + 1));
+
+SELECT 'After updating 10000 rows (non-indexed)' AS test, * FROM get_hot_count('hot_test');
+
+-- Verify correctness
+SELECT COUNT(*) AS rows_with_count_1 FROM hot_test WHERE (data->>'count')::int = 1;
+
+-- Update indexed field on subset - should NOT be HOT for those rows
+UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"')
+WHERE id <= 10;
+
+SELECT 'After updating 10 rows (indexed)' AS test, * FROM get_hot_count('hot_test');
+
+-- Verify indexes work
+SELECT COUNT(*) FROM hot_test WHERE data->>'status' = 'inactive';
+SELECT COUNT(*) FROM hot_test WHERE data->>'status' = 'active';
+
+-- Only BRIN (summarizing) indexes on non-PK columns
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    ts timestamp,
+    value int,
+    brin_col int
+) USING heap WITH(fillfactor = 50);
+
+CREATE INDEX hot_test_ts_brin ON hot_test USING brin(ts);
+CREATE INDEX hot_test_brin_col_brin ON hot_test USING brin(brin_col);
+
+INSERT INTO hot_test VALUES (1, '2024-01-01', 100, 1000);
+
+-- Update both BRIN columns - should still be HOT (only summarizing indexes)
+UPDATE hot_test SET ts = '2024-01-02', brin_col = 2000 WHERE id = 1;
+SELECT 'After updating ts, brin_col (summarizing-only)' AS test, * FROM get_hot_count('hot_test');
+
+-- Verify BRIN indexes work
+SELECT id FROM hot_test WHERE ts >= '2024-01-02';
+SELECT id FROM hot_test WHERE brin_col >= 2000;
+
+-- TOASTed columns can participate in HOT
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    large_text text
+) USING heap WITH(fillfactor = 50);
+
+CREATE INDEX hot_test_idx ON hot_test(large_text);
+
+-- Insert row with TOASTed column (> 2KB)
+INSERT INTO hot_test VALUES (1, repeat('x', 3000));
+
+-- Update TOASTed column - should NOT be HOT
+UPDATE hot_test SET large_text = repeat('y', 3000);
+SELECT 'After updating large_text (TOASTed)' AS test, * FROM get_hot_count('hot_test');
+
+-- Partitioned tables: HOT works within partitions
+CREATE TABLE hot_test_partitioned (
+    id int,
+    partition_key int,
+    indexed_col int,
+    data text,
+    PRIMARY KEY (id, partition_key)
+) PARTITION BY RANGE (partition_key);
+
+CREATE TABLE hot_test_part1 PARTITION OF hot_test_partitioned
+    FOR VALUES FROM (1) TO (100);
+CREATE TABLE hot_test_part2 PARTITION OF hot_test_partitioned
+    FOR VALUES FROM (100) TO (200);
+
+CREATE INDEX hot_test_partitioned_idx ON hot_test_partitioned(indexed_col);
+CREATE INDEX hot_test_part2_data ON hot_test_part2(data);
+
+INSERT INTO hot_test_partitioned VALUES (1, 50, 100, 'initial1');
+INSERT INTO hot_test_partitioned VALUES (2, 150, 200, 'initial2');
+
+-- Update in partition 1 (non-indexed column) - should be HOT
+UPDATE hot_test_partitioned SET data = 'UPDATED' WHERE id = 1;
+SELECT 'After updating partition 1 data' AS test, * FROM get_hot_count('hot_test_part1');
+
+-- Update in partition 2 (indexed column) - should NOT be HOT
+UPDATE hot_test_partitioned SET data = 'UPDATED' WHERE id = 2;
+SELECT 'After updating large_text (TOASTed)' AS test, * FROM get_hot_count('hot_test_part2');
+
+-- Verify indexes work on partitions
+SELECT id FROM hot_test_partitioned WHERE indexed_col = 100;
+SELECT id FROM hot_test_partitioned WHERE indexed_col = 200;
+
+-- Update indexed column in partition - should NOT be HOT
+-- Partition 1 previously had 1 update and 1 HOT update, this should
+-- change that to 2 updates and 1 HOT update.
+UPDATE hot_test_partitioned SET indexed_col = 150 WHERE id = 1;
+SELECT 'After updating indexed_col' AS test, * FROM get_hot_count('hot_test_part1');
+
+-- ============================================================================
+-- Partial indexes with complex predicates on JSONB
+-- ============================================================================
+-- Test partial indexes with WHERE clauses on JSONB expressions.
+DROP TABLE hot_test;
+CREATE TABLE hot_test (
+    id int PRIMARY KEY,
+    data jsonb
+) USING heap WITH(fillfactor = 50);
+
+-- Partial index: only index status when priority > 5
+CREATE INDEX hot_test_partial_idx ON hot_test((data->'status'))
+    WHERE (data->>'priority')::int > 5;
+
+INSERT INTO hot_test VALUES (
+    1,
+    '{"status": "active", "priority": 10, "count": 0}'::jsonb
+);
+INSERT INTO hot_test VALUES (
+    2,
+    '{"status": "active", "priority": 3, "count": 0}'::jsonb
+);
+
+SELECT 'Partial Index Test: Baseline' AS test, * FROM get_hot_count('hot_test');
+
+-- Update non-indexed path on row inside predicate (priority=10 > 5)
+-- Should NOT be HOT despite {count} is not indexed
+UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 1;
+SELECT 'Partial Index Test: count update, inside predicate' AS test, * FROM get_hot_count('hot_test');
+
+-- Update non-indexed path on row outside predicate (priority=3 <= 5)
+-- Should NOT be HOT dispite {count} is not indexed
+UPDATE hot_test SET data = jsonb_set(data, '{count}', '1') WHERE id = 2;
+SELECT 'Partial Index Test: count update, outside predicate' AS test, * FROM get_hot_count('hot_test');
+
+-- Update indexed path on row inside predicate (priority=10 > 5)
+-- Should NOT be HOT indexed portion is updated
+UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 1;
+SELECT 'Partial Index Test: status update, inside predicate' AS test, * FROM get_hot_count('hot_test');
+
+-- Update indexed path on row outside predicate (priority=3 <= 5)
+-- PostgreSQL makes a conservative choice and treats it as non-HOT because the
+-- indexed column changed, even though the before/after rows are outside the predicate
+UPDATE hot_test SET data = jsonb_set(data, '{status}', '"inactive"') WHERE id = 2;
+SELECT 'Partial Index Test: status update, outside predicate' AS test, * FROM get_hot_count('hot_test');
+
+-- Verify index works
+SELECT id FROM hot_test WHERE data->'status' = '"inactive"'::jsonb AND (data->>'priority')::int > 5;
+-- ============================================================================
+DROP TABLE IF EXISTS hot_test;
+DROP TABLE IF EXISTS hot_test_partitioned CASCADE;
+DROP FUNCTION IF EXISTS has_hot_chain(text, tid);
+DROP FUNCTION IF EXISTS print_hot_chain(text, tid);
+DROP FUNCTION IF EXISTS get_hot_count(text);
+DROP EXTENSION pageinspect;
-- 
2.51.2

