From dbdf297ed1eaa681e46d77a823e695d23515a39a Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <pj@illuminatedcomputing.com>
Date: Sat, 31 Oct 2026 09:55:14 -0700
Subject: [PATCH v1 3/6] Add UPDATE/DELETE FOR PORTION OF

This is an extension of the UPDATE and DELETE commands to do a
"temporal update/delete" based on a range or multirange column. The
user can say UPDATE t FOR PORTION OF valid_at FROM '2001-01-01' TO
'2002-01-01' SET ... (or likewise with DELETE) where valid_at is a
range or multirange column.

The command is automatically limited to rows overlapping the targeted
portion, and only history within those bounds is changed. If a row
represents history partly inside and partly outside the bounds, then
the command truncates the row's application time to fit within the
targeted portion, then it inserts one or more "temporal leftovers":
new rows containing all the original values, except with the
application-time column changed to only represent the untouched part
of history.

To compute the temporal leftovers that are required, we use the *_minus_multi
set-returning functions defined in 5eed8ce50c.

- Added bison support for FOR PORTION OF syntax. The bounds must be
  constant, so we forbid column references, subqueries, etc. We do
  accept functions like NOW().
- Added logic to executor to insert new rows for the "temporal
  leftover" part of a record touched by a FOR PORTION OF query.
- Documented FOR PORTION OF.
- Added tests.

Author: Paul A. Jungwirth <pj@illuminatedcomputing.com>
Co-authored-by: Aleksander Alekseev <aleksander@tigerdata.com>
Co-authored-by: Chao Li <li.evan.chao@gmail.com>
Co-authored-by: jian he <jian.universality@gmail.com>
Co-authored-by: Kiran Kaki <itskkpg@gmail.com>
Co-authored-by: Sergei Patiakin <sergei.patiakin@enterprisedb.com>
Co-authored-by: Tom Lane <tgl@sss.pgh.pa.us>
Co-authored-by: Zsolt Parragi <zsolt.parragi@percona.com>
Reviewed-by: Andres Freund <andres@anarazel.de>
Reviewed-by: Ayush Tiwari <ayushtiwari.slg01@gmail.com>
Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Reviewed-by: Dean Rasheed <dean.a.rasheed@gmail.com>
Reviewed-by: jian he <jian.universality@gmail.com>
Reviewed-by: John Naylor <johncnaylorls@gmail.com>
Reviewed-by: Kirill Reshke <reshkekirill@gmail.com>
Reviewed-by: Noah Misch <noah@leadboat.com>
Reviewed-by: Peter Eisentraut <peter@eisentraut.org>
Reviewed-by: Richard Guo <guofenglinux@gmail.com>
Reviewed-by: solai v <solai.cdac@gmail.com>
Reviewed-by: Tom Lane <tgl@sss.pgh.pa.us>
Reviewed-by: Tomas Vondra <tomas@vondra.me>
Reviewed-by: Yanjie Zhao
Reviewed-by: Yiyang Liu
Reviewed-by: Zheng Wang <hackerzheng666@gmail.com>
Reviewed-by: Zsolt Parragi <zsolt.parragi@percona.com>
Discussion: https://postgr.es/m/ec498c3d-5f2b-48ec-b989-5561c8aa2024@illuminatedcomputing.com
Discussion: https://postgr.es/m/CACJufxHs1Hs00EqsZ4NbuAjmYzMzjJyP1sAj12Ne=cBsEVmQOA@mail.gmail.com
Discussion: https://postgr.es/m/CAD0dvCQLqLzPrQJRjjA2qXDH=D+XShcxhbSPxNhVruC8HGhkbQ@mail.gmail.com
Discussion: https://postgr.es/m/CACJufxFRqg8=gbZ-Q6ZS_UQ+YdwfZpk+9rf7jgWrk8m4RMUm=A@mail.gmail.com
Discussion: https://postgr.es/m/CANE55rCqcse_pwXBMWhbj3_7XROb8Dks6=OLFmKy3bO3zDsCsg@mail.gmail.com
Discussion: https://postgr.es/m/CAHg+QDcd=t69gLf9yQexO07EJ2mx0Z70NFHo6h94X1EDA=hM0g@mail.gmail.com
Discussion: https://postgr.es/m/4245F94D-84F1-4E05-BF81-C458A6CF9901@gmail.com
Discussion: https://postgr.es/m/CA+renyUte0_UJsJiDJQi82oaBsMJn=cct0Wn=vOqXtuDn=YYJA@mail.gmail.com
Discussion: https://postgr.es/m/CA+renyUEKPexUYsH4qeU8_o1jqKsUkEWca1keS6n21shgG1g+A@mail.gmail.com
Discussion: https://postgr.es/m/CAJ7c6TME+ix6VRf-2TPnVTsj8qn_hy6sYAOmMhZEivwsu2wS6g@mail.gmail.com
Discussion: https://postgr.es/m/6C34A987-AC50-4477-BD71-2D4AFEE1A589@gmail.com
Discussion: https://postgr.es/m/CAJTYsWWdeBkoH5g8D-k9LDw9ciqsMxb21EJSiFXAzP4J=XyxOQ@mail.gmail.com
Discussion: https://postgr.es/m/4C89B193-7D54-4705-9CF9-F0D484B9E099@gmail.com
Discussion: https://postgr.es/m/07C125E5-F6ED-460C-A394-E6503DAE18FB@gmail.com
Discussion: https://postgr.es/m/CAN4CZFOuTyhGspG0Nyits8PiK2keoNXkLj-u3APzc66aRcWY9A@mail.gmail.com
Discussion: https://postgr.es/m/19630-9f10ca28426295fa@postgresql.org
Discussion: https://postgr.es/m/03276924-6d55-470d-8192-a49c053c940f@eisentraut.org
Discussion: https://postgr.es/m/CA+renyW1uJp1dw=iK7MsWfbbZf-dKP_4JLoQ65wj=fry35FjCw@mail.gmail.com
Discussion: https://postgr.es/m/CA+renyXci9khYXJ8uSwzF30DVw+zc4QwGb1RdsuOu_YtrFuy1g@mail.gmail.com
Discussion: https://postgr.es/m/vquveff5flfpsgsd55dkjqplhphziah7a7kggnemzfv5krrhet@jxp5ubrpmxhy
Discussion: https://postgr.es/m/CAN4CZFNSOA_LXWKC6sAOduWVsaxjzmFBNEHUQii89KUnMhBtcw@mail.gmail.com
Discussion: https://postgr.es/m/CA+renyV6QLOJYmLo3gbsg1Y+Crho8NqME1jJXgPbO_NgxfBaKQ@mail.gmail.com
---
 contrib/pg_stat_statements/expected/dml.out   |   29 +
 contrib/pg_stat_statements/sql/dml.sql        |   14 +
 .../postgres_fdw/expected/postgres_fdw.out    |   81 +-
 contrib/postgres_fdw/sql/postgres_fdw.sql     |   58 +
 contrib/test_decoding/expected/ddl.out        |   52 +
 contrib/test_decoding/sql/ddl.sql             |   30 +
 doc/src/sgml/ddl.sgml                         |    4 +-
 doc/src/sgml/dml.sgml                         |  142 +
 doc/src/sgml/glossary.sgml                    |   15 +
 doc/src/sgml/images/Makefile                  |    4 +-
 doc/src/sgml/images/meson.build               |    2 +
 doc/src/sgml/images/temporal-delete.svg       |   41 +
 doc/src/sgml/images/temporal-delete.txt       |   10 +
 doc/src/sgml/images/temporal-update.svg       |   45 +
 doc/src/sgml/images/temporal-update.txt       |   10 +
 doc/src/sgml/ref/create_policy.sgml           |   32 +
 doc/src/sgml/ref/create_publication.sgml      |    6 +
 doc/src/sgml/ref/create_view.sgml             |   20 +-
 doc/src/sgml/ref/delete.sgml                  |  122 +-
 doc/src/sgml/ref/update.sgml                  |  125 +-
 doc/src/sgml/trigger.sgml                     |   12 +
 src/backend/commands/copyfrom.c               |    2 +-
 src/backend/commands/trigger.c                |   38 +
 src/backend/executor/execMain.c               |   12 +-
 src/backend/executor/execPartition.c          |   26 +-
 src/backend/executor/nodeModifyTable.c        |  828 ++-
 src/backend/nodes/nodeFuncs.c                 |   37 +
 src/backend/optimizer/plan/createplan.c       |    6 +-
 src/backend/optimizer/plan/planner.c          |   41 +
 src/backend/optimizer/plan/setrefs.c          |    9 +
 src/backend/optimizer/util/pathnode.c         |    3 +-
 src/backend/parser/analyze.c                  |  378 +-
 src/backend/parser/gram.y                     |  111 +-
 src/backend/parser/parse_agg.c                |   10 +
 src/backend/parser/parse_collate.c            |    1 +
 src/backend/parser/parse_expr.c               |   12 +
 src/backend/parser/parse_func.c               |    3 +
 src/backend/parser/parse_merge.c              |    2 +-
 src/backend/rewrite/rewriteHandler.c          |  177 +-
 src/backend/rewrite/rowsecurity.c             |   25 +
 src/backend/utils/adt/ruleutils.c             |   50 +
 src/backend/utils/cache/lsyscache.c           |   25 +
 src/bin/psql/t/010_tab_completion.pl          |   40 +-
 src/bin/psql/tab-complete.in.c                |   30 +-
 src/include/commands/trigger.h                |    2 +
 src/include/executor/executor.h               |    2 +-
 src/include/executor/nodeModifyTable.h        |    7 +
 src/include/nodes/execnodes.h                 |   34 +
 src/include/nodes/parsenodes.h                |   21 +
 src/include/nodes/pathnodes.h                 |    1 +
 src/include/nodes/plannodes.h                 |    2 +
 src/include/nodes/primnodes.h                 |   33 +
 src/include/optimizer/pathnode.h              |    2 +-
 src/include/parser/analyze.h                  |    3 +-
 src/include/parser/kwlist.h                   |    1 +
 src/include/parser/parse_node.h               |    1 +
 src/include/utils/lsyscache.h                 |    1 +
 src/test/isolation/expected/fk-snapshot-3.out |   47 +
 .../expected/for-portion-of-deadlock.out      |   86 +
 .../isolation/expected/for-portion-of.out     | 4689 +++++++++++++++++
 src/test/isolation/isolation_schedule         |    2 +
 src/test/isolation/specs/fk-snapshot-3.spec   |   25 +
 .../specs/for-portion-of-deadlock.spec        |   75 +
 src/test/isolation/specs/for-portion-of.spec  |  762 +++
 src/test/recovery/meson.build                 |    1 +
 src/test/recovery/t/061_for_portion_of_2pc.pl |   81 +
 src/test/regress/expected/for_portion_of.out  | 4196 +++++++++++++++
 src/test/regress/expected/privileges.out      |   38 +
 src/test/regress/expected/rowsecurity.out     |   39 +-
 src/test/regress/expected/updatable_views.out |  276 +
 .../regress/expected/without_overlaps.out     |  245 +-
 src/test/regress/parallel_schedule            |    2 +-
 src/test/regress/sql/for_portion_of.sql       | 2750 ++++++++++
 src/test/regress/sql/privileges.sql           |   35 +
 src/test/regress/sql/rowsecurity.sql          |   33 +-
 src/test/regress/sql/updatable_views.sql      |  200 +
 src/test/regress/sql/without_overlaps.sql     |  120 +-
 src/test/subscription/t/034_temporal.pl       |  190 +-
 src/tools/pgindent/typedefs.list              |    3 +
 79 files changed, 16570 insertions(+), 155 deletions(-)
 create mode 100644 doc/src/sgml/images/temporal-delete.svg
 create mode 100644 doc/src/sgml/images/temporal-delete.txt
 create mode 100644 doc/src/sgml/images/temporal-update.svg
 create mode 100644 doc/src/sgml/images/temporal-update.txt
 create mode 100644 src/test/isolation/expected/for-portion-of-deadlock.out
 create mode 100644 src/test/isolation/expected/for-portion-of.out
 create mode 100644 src/test/isolation/specs/for-portion-of-deadlock.spec
 create mode 100644 src/test/isolation/specs/for-portion-of.spec
 create mode 100644 src/test/recovery/t/061_for_portion_of_2pc.pl
 create mode 100644 src/test/regress/expected/for_portion_of.out
 create mode 100644 src/test/regress/sql/for_portion_of.sql

diff --git a/contrib/pg_stat_statements/expected/dml.out b/contrib/pg_stat_statements/expected/dml.out
index 347cb8699e4..3f486598be5 100644
--- a/contrib/pg_stat_statements/expected/dml.out
+++ b/contrib/pg_stat_statements/expected/dml.out
@@ -139,6 +139,35 @@ SELECT calls, rows, query FROM pg_stat_statements ORDER BY query COLLATE "C";
      1 |    1 | SELECT pg_stat_statements_reset() IS NOT NULL AS t
 (10 rows)
 
+-- FOR PORTION OF: constant bounds are normalized, so statements differing only
+-- in their bounds collapse to a single entry.
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t 
+---
+ t
+(1 row)
+
+CREATE TEMP TABLE pgss_fpo_tab (id int, valid_at int4range, b text);
+INSERT INTO pgss_fpo_tab VALUES (1, '[1,1000)', 'a');
+UPDATE pgss_fpo_tab FOR PORTION OF valid_at FROM 10 TO 20 SET b = 'x';
+UPDATE pgss_fpo_tab FOR PORTION OF valid_at FROM 30 TO 40 SET b = 'y';
+DELETE FROM pgss_fpo_tab FOR PORTION OF valid_at FROM 100 TO 200;
+DELETE FROM pgss_fpo_tab FOR PORTION OF valid_at FROM 300 TO 400;
+SELECT calls, query FROM pg_stat_statements
+  WHERE query LIKE '%FOR PORTION OF%' ORDER BY query COLLATE "C";
+ calls |                                query                                 
+-------+----------------------------------------------------------------------
+     2 | DELETE FROM pgss_fpo_tab FOR PORTION OF valid_at FROM $1 TO $2
+     2 | UPDATE pgss_fpo_tab FOR PORTION OF valid_at FROM $1 TO $2 SET b = $3
+(2 rows)
+
+DROP TABLE pgss_fpo_tab;
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+ t 
+---
+ t
+(1 row)
+
 -- check that [temp] table relation extensions are tracked as writes
 CREATE TABLE pgss_extend_tab (a int, b text);
 CREATE TEMP TABLE pgss_extend_temp_tab (a int, b text);
diff --git a/contrib/pg_stat_statements/sql/dml.sql b/contrib/pg_stat_statements/sql/dml.sql
index 9986b0a22d3..5304e057455 100644
--- a/contrib/pg_stat_statements/sql/dml.sql
+++ b/contrib/pg_stat_statements/sql/dml.sql
@@ -74,6 +74,20 @@ DROP TABLE pgss_dml_tab;
 
 SELECT calls, rows, query FROM pg_stat_statements ORDER BY query COLLATE "C";
 
+-- FOR PORTION OF: constant bounds are normalized, so statements differing only
+-- in their bounds collapse to a single entry.
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+CREATE TEMP TABLE pgss_fpo_tab (id int, valid_at int4range, b text);
+INSERT INTO pgss_fpo_tab VALUES (1, '[1,1000)', 'a');
+UPDATE pgss_fpo_tab FOR PORTION OF valid_at FROM 10 TO 20 SET b = 'x';
+UPDATE pgss_fpo_tab FOR PORTION OF valid_at FROM 30 TO 40 SET b = 'y';
+DELETE FROM pgss_fpo_tab FOR PORTION OF valid_at FROM 100 TO 200;
+DELETE FROM pgss_fpo_tab FOR PORTION OF valid_at FROM 300 TO 400;
+SELECT calls, query FROM pg_stat_statements
+  WHERE query LIKE '%FOR PORTION OF%' ORDER BY query COLLATE "C";
+DROP TABLE pgss_fpo_tab;
+SELECT pg_stat_statements_reset() IS NOT NULL AS t;
+
 -- check that [temp] table relation extensions are tracked as writes
 CREATE TABLE pgss_extend_tab (a int, b text);
 CREATE TEMP TABLE pgss_extend_temp_tab (a int, b text);
diff --git a/contrib/postgres_fdw/expected/postgres_fdw.out b/contrib/postgres_fdw/expected/postgres_fdw.out
index 115ebc7925f..064b9401a12 100644
--- a/contrib/postgres_fdw/expected/postgres_fdw.out
+++ b/contrib/postgres_fdw/expected/postgres_fdw.out
@@ -50,11 +50,19 @@ CREATE TABLE "S 1"."T 4" (
 	c3 text,
 	CONSTRAINT t4_pkey PRIMARY KEY (c1)
 );
+CREATE TABLE "S 1"."T 5" (
+	c1 int4range NOT NULL,
+	c2 int NOT NULL,
+	c3 text,
+	c4 daterange NOT NULL,
+	CONSTRAINT t5_pkey PRIMARY KEY (c1, c4 WITHOUT OVERLAPS)
+);
 -- Disable autovacuum for these tables to avoid unexpected effects of that
 ALTER TABLE "S 1"."T 1" SET (autovacuum_enabled = 'false');
 ALTER TABLE "S 1"."T 2" SET (autovacuum_enabled = 'false');
 ALTER TABLE "S 1"."T 3" SET (autovacuum_enabled = 'false');
 ALTER TABLE "S 1"."T 4" SET (autovacuum_enabled = 'false');
+ALTER TABLE "S 1"."T 5" SET (autovacuum_enabled = 'false');
 INSERT INTO "S 1"."T 1"
 	SELECT id,
 	       id % 10,
@@ -81,10 +89,17 @@ INSERT INTO "S 1"."T 4"
 	       'AAA' || to_char(id, 'FM000')
 	FROM generate_series(1, 100) id;
 DELETE FROM "S 1"."T 4" WHERE c1 % 3 != 0;	-- delete for outer join tests
+INSERT INTO "S 1"."T 5"
+	SELECT int4range(id, id + 1),
+	       id + 1,
+	       'AAA' || to_char(id, 'FM000'),
+        '[2000-01-01,2020-01-01)'
+	FROM generate_series(1, 100) id;
 ANALYZE "S 1"."T 1";
 ANALYZE "S 1"."T 2";
 ANALYZE "S 1"."T 3";
 ANALYZE "S 1"."T 4";
+ANALYZE "S 1"."T 5";
 -- ===================================================================
 -- create foreign tables
 -- ===================================================================
@@ -132,6 +147,12 @@ CREATE FOREIGN TABLE ft7 (
 	c2 int NOT NULL,
 	c3 text
 ) SERVER loopback3 OPTIONS (schema_name 'S 1', table_name 'T 4');
+CREATE FOREIGN TABLE ft8 (
+	c1 int4range NOT NULL,
+	c2 int NOT NULL,
+	c3 text,
+	c4 daterange NOT NULL
+) SERVER loopback OPTIONS (schema_name 'S 1', table_name 'T 5');
 -- ===================================================================
 -- tests for validator
 -- ===================================================================
@@ -214,7 +235,8 @@ ALTER FOREIGN TABLE ft2 ALTER COLUMN c1 OPTIONS (column_name 'C 1');
  public | ft5   | loopback  | (schema_name 'S 1', table_name 'T 4') | 
  public | ft6   | loopback2 | (schema_name 'S 1', table_name 'T 4') | 
  public | ft7   | loopback3 | (schema_name 'S 1', table_name 'T 4') | 
-(6 rows)
+ public | ft8   | loopback  | (schema_name 'S 1', table_name 'T 5') | 
+(7 rows)
 
 -- Test that alteration of server options causes reconnection
 -- Remote's errors might be non-English, so hide them to ensure stable results
@@ -7037,6 +7059,63 @@ DELETE FROM ft2 WHERE c1 = 1200 RETURNING tableoid::regclass;
  ft2
 (1 row)
 
+-- Test UPDATE FOR PORTION OF
+UPDATE ft8 FOR PORTION OF c4 FROM '2005-01-01' TO '2006-01-01'
+  SET c2 = c2 + 1
+  WHERE c1 = '[1,2)'; -- error
+ERROR:  foreign tables don't support FOR PORTION OF
+DETAIL:  "ft8" is a foreign table.
+SELECT * FROM ft8 WHERE c1 = '[1,2)' ORDER BY c1, c4;
+  c1   | c2 |   c3   |           c4            
+-------+----+--------+-------------------------
+ [1,2) |  2 | AAA001 | [01-01-2000,01-01-2020)
+(1 row)
+
+-- Test DELETE FOR PORTION OF
+DELETE FROM ft8 FOR PORTION OF c4 FROM '2005-01-01' TO '2006-01-01'
+  WHERE c1 = '[2,3)'; -- error
+ERROR:  foreign tables don't support FOR PORTION OF
+DETAIL:  "ft8" is a foreign table.
+SELECT * FROM ft8 WHERE c1 = '[2,3)' ORDER BY c1, c4;
+  c1   | c2 |   c3   |           c4            
+-------+----+--------+-------------------------
+ [2,3) |  3 | AAA002 | [01-01-2000,01-01-2020)
+(1 row)
+
+-- FOR PORTION OF fails if a child partition is a foreign table, even if the
+-- root is not. But a child partition that is pruned doesn't cause an error.
+CREATE TABLE fpo_part_parent (
+  c1 int4range NOT NULL,
+  c2 int NOT NULL,
+  c3 text,
+  c4 daterange NOT NULL
+) PARTITION BY LIST (c2);
+CREATE TABLE fpo_part_local PARTITION OF fpo_part_parent FOR VALUES IN (1);
+INSERT INTO fpo_part_local VALUES ('[1,2)', 1, 'one', '[2024-01-01,2024-12-31)');
+CREATE FOREIGN TABLE fpo_part_foreign
+  PARTITION OF fpo_part_parent FOR VALUES IN (6)
+  SERVER loopback OPTIONS (schema_name 'S 1', table_name 'T 5');
+DELETE FROM fpo_part_parent
+  FOR PORTION OF c4 FROM '2001-01-01' TO '2001-02-01' WHERE c2 = 6; -- error
+ERROR:  foreign tables don't support FOR PORTION OF
+DETAIL:  "fpo_part_foreign" is a foreign table.
+UPDATE fpo_part_parent
+  FOR PORTION OF c4 FROM '2001-01-01' TO '2001-02-01' SET c3 = 'x' WHERE c2 = 6; -- error
+ERROR:  foreign tables don't support FOR PORTION OF
+DETAIL:  "fpo_part_foreign" is a foreign table.
+UPDATE fpo_part_parent
+  FOR PORTION OF c4 FROM '2024-06-01' TO '2024-07-01' SET c3 = 'edited' WHERE c2 = 1; -- okay
+DELETE FROM fpo_part_parent
+  FOR PORTION OF c4 FROM '2024-06-01' TO '2024-06-15' WHERE c2 = 1; -- okay
+SELECT c1, c2, c3, c4 FROM fpo_part_local ORDER BY c4;
+  c1   | c2 |   c3   |           c4            
+-------+----+--------+-------------------------
+ [1,2) |  1 | one    | [01-01-2024,06-01-2024)
+ [1,2) |  1 | edited | [06-15-2024,07-01-2024)
+ [1,2) |  1 | one    | [07-01-2024,12-31-2024)
+(3 rows)
+
+DROP TABLE fpo_part_parent;
 -- Test UPDATE/DELETE with RETURNING on a three-table join
 INSERT INTO ft2 (c1,c2,c3)
   SELECT id, id - 1200, to_char(id, 'FM00000') FROM generate_series(1201, 1300) id;
diff --git a/contrib/postgres_fdw/sql/postgres_fdw.sql b/contrib/postgres_fdw/sql/postgres_fdw.sql
index 77c9a25a890..893ae2209e0 100644
--- a/contrib/postgres_fdw/sql/postgres_fdw.sql
+++ b/contrib/postgres_fdw/sql/postgres_fdw.sql
@@ -54,12 +54,20 @@ CREATE TABLE "S 1"."T 4" (
 	c3 text,
 	CONSTRAINT t4_pkey PRIMARY KEY (c1)
 );
+CREATE TABLE "S 1"."T 5" (
+	c1 int4range NOT NULL,
+	c2 int NOT NULL,
+	c3 text,
+	c4 daterange NOT NULL,
+	CONSTRAINT t5_pkey PRIMARY KEY (c1, c4 WITHOUT OVERLAPS)
+);
 
 -- Disable autovacuum for these tables to avoid unexpected effects of that
 ALTER TABLE "S 1"."T 1" SET (autovacuum_enabled = 'false');
 ALTER TABLE "S 1"."T 2" SET (autovacuum_enabled = 'false');
 ALTER TABLE "S 1"."T 3" SET (autovacuum_enabled = 'false');
 ALTER TABLE "S 1"."T 4" SET (autovacuum_enabled = 'false');
+ALTER TABLE "S 1"."T 5" SET (autovacuum_enabled = 'false');
 
 INSERT INTO "S 1"."T 1"
 	SELECT id,
@@ -87,11 +95,18 @@ INSERT INTO "S 1"."T 4"
 	       'AAA' || to_char(id, 'FM000')
 	FROM generate_series(1, 100) id;
 DELETE FROM "S 1"."T 4" WHERE c1 % 3 != 0;	-- delete for outer join tests
+INSERT INTO "S 1"."T 5"
+	SELECT int4range(id, id + 1),
+	       id + 1,
+	       'AAA' || to_char(id, 'FM000'),
+        '[2000-01-01,2020-01-01)'
+	FROM generate_series(1, 100) id;
 
 ANALYZE "S 1"."T 1";
 ANALYZE "S 1"."T 2";
 ANALYZE "S 1"."T 3";
 ANALYZE "S 1"."T 4";
+ANALYZE "S 1"."T 5";
 
 -- ===================================================================
 -- create foreign tables
@@ -146,6 +161,14 @@ CREATE FOREIGN TABLE ft7 (
 	c3 text
 ) SERVER loopback3 OPTIONS (schema_name 'S 1', table_name 'T 4');
 
+CREATE FOREIGN TABLE ft8 (
+	c1 int4range NOT NULL,
+	c2 int NOT NULL,
+	c3 text,
+	c4 daterange NOT NULL
+) SERVER loopback OPTIONS (schema_name 'S 1', table_name 'T 5');
+
+
 -- ===================================================================
 -- tests for validator
 -- ===================================================================
@@ -1867,6 +1890,41 @@ EXPLAIN (verbose, costs off)
 DELETE FROM ft2 WHERE c1 = 1200 RETURNING tableoid::regclass;                       -- can be pushed down
 DELETE FROM ft2 WHERE c1 = 1200 RETURNING tableoid::regclass;
 
+-- Test UPDATE FOR PORTION OF
+UPDATE ft8 FOR PORTION OF c4 FROM '2005-01-01' TO '2006-01-01'
+  SET c2 = c2 + 1
+  WHERE c1 = '[1,2)'; -- error
+SELECT * FROM ft8 WHERE c1 = '[1,2)' ORDER BY c1, c4;
+
+-- Test DELETE FOR PORTION OF
+DELETE FROM ft8 FOR PORTION OF c4 FROM '2005-01-01' TO '2006-01-01'
+  WHERE c1 = '[2,3)'; -- error
+SELECT * FROM ft8 WHERE c1 = '[2,3)' ORDER BY c1, c4;
+
+-- FOR PORTION OF fails if a child partition is a foreign table, even if the
+-- root is not. But a child partition that is pruned doesn't cause an error.
+CREATE TABLE fpo_part_parent (
+  c1 int4range NOT NULL,
+  c2 int NOT NULL,
+  c3 text,
+  c4 daterange NOT NULL
+) PARTITION BY LIST (c2);
+CREATE TABLE fpo_part_local PARTITION OF fpo_part_parent FOR VALUES IN (1);
+INSERT INTO fpo_part_local VALUES ('[1,2)', 1, 'one', '[2024-01-01,2024-12-31)');
+CREATE FOREIGN TABLE fpo_part_foreign
+  PARTITION OF fpo_part_parent FOR VALUES IN (6)
+  SERVER loopback OPTIONS (schema_name 'S 1', table_name 'T 5');
+DELETE FROM fpo_part_parent
+  FOR PORTION OF c4 FROM '2001-01-01' TO '2001-02-01' WHERE c2 = 6; -- error
+UPDATE fpo_part_parent
+  FOR PORTION OF c4 FROM '2001-01-01' TO '2001-02-01' SET c3 = 'x' WHERE c2 = 6; -- error
+UPDATE fpo_part_parent
+  FOR PORTION OF c4 FROM '2024-06-01' TO '2024-07-01' SET c3 = 'edited' WHERE c2 = 1; -- okay
+DELETE FROM fpo_part_parent
+  FOR PORTION OF c4 FROM '2024-06-01' TO '2024-06-15' WHERE c2 = 1; -- okay
+SELECT c1, c2, c3, c4 FROM fpo_part_local ORDER BY c4;
+DROP TABLE fpo_part_parent;
+
 -- Test UPDATE/DELETE with RETURNING on a three-table join
 INSERT INTO ft2 (c1,c2,c3)
   SELECT id, id - 1200, to_char(id, 'FM00000') FROM generate_series(1201, 1300) id;
diff --git a/contrib/test_decoding/expected/ddl.out b/contrib/test_decoding/expected/ddl.out
index 746c53a3231..a129c016d2b 100644
--- a/contrib/test_decoding/expected/ddl.out
+++ b/contrib/test_decoding/expected/ddl.out
@@ -192,6 +192,58 @@ SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'inc
  COMMIT
 (33 rows)
 
+-- FOR PORTION OF setup
+CREATE TABLE replication_example_temporal(id int4range, valid_at daterange, somedata int, text varchar(120), PRIMARY KEY (id, valid_at WITHOUT OVERLAPS));
+INSERT INTO replication_example_temporal VALUES ('[1,2)', '[2000-01-01,2020-01-01)', 1, 'aaa');
+INSERT INTO replication_example_temporal VALUES ('[2,3)', '[2000-01-01,2020-01-01)', 1, 'aaa');
+/* display results */
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+                                                                                   data                                                                                   
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ BEGIN
+ table public.replication_example_temporal: INSERT: id[int4range]:'[1,2)' valid_at[daterange]:'[01-01-2000,01-01-2020)' somedata[integer]:1 text[character varying]:'aaa'
+ COMMIT
+ BEGIN
+ table public.replication_example_temporal: INSERT: id[int4range]:'[2,3)' valid_at[daterange]:'[01-01-2000,01-01-2020)' somedata[integer]:1 text[character varying]:'aaa'
+ COMMIT
+(6 rows)
+
+-- UPDATE FOR PORTION OF support
+BEGIN;
+  UPDATE replication_example_temporal
+    FOR PORTION OF valid_at FROM '2010-01-01' TO '2011-01-01'
+    SET somedata = 2,
+        text = 'bbb'
+    WHERE id = '[1,2)';
+COMMIT;
+/* display results */
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+                                                                                                                               data                                                                                                                               
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ BEGIN
+ table public.replication_example_temporal: UPDATE: old-key: id[int4range]:'[1,2)' valid_at[daterange]:'[01-01-2000,01-01-2020)' new-tuple: id[int4range]:'[1,2)' valid_at[daterange]:'[01-01-2010,01-01-2011)' somedata[integer]:2 text[character varying]:'bbb'
+ table public.replication_example_temporal: INSERT: id[int4range]:'[1,2)' valid_at[daterange]:'[01-01-2000,01-01-2010)' somedata[integer]:1 text[character varying]:'aaa'
+ table public.replication_example_temporal: INSERT: id[int4range]:'[1,2)' valid_at[daterange]:'[01-01-2011,01-01-2020)' somedata[integer]:1 text[character varying]:'aaa'
+ COMMIT
+(5 rows)
+
+-- DELETE FOR PORTION OF support
+BEGIN;
+  DELETE FROM replication_example_temporal
+    FOR PORTION OF valid_at FROM '2012-01-01' TO '2013-01-01'
+    WHERE id = '[2,3)';
+COMMIT;
+/* display results */
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+                                                                                   data                                                                                   
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------
+ BEGIN
+ table public.replication_example_temporal: DELETE: id[int4range]:'[2,3)' valid_at[daterange]:'[01-01-2000,01-01-2020)'
+ table public.replication_example_temporal: INSERT: id[int4range]:'[2,3)' valid_at[daterange]:'[01-01-2000,01-01-2012)' somedata[integer]:1 text[character varying]:'aaa'
+ table public.replication_example_temporal: INSERT: id[int4range]:'[2,3)' valid_at[daterange]:'[01-01-2013,01-01-2020)' somedata[integer]:1 text[character varying]:'aaa'
+ COMMIT
+(5 rows)
+
 -- MERGE support
 BEGIN;
 MERGE INTO replication_example t
diff --git a/contrib/test_decoding/sql/ddl.sql b/contrib/test_decoding/sql/ddl.sql
index 8c5b150079e..de8a7cdc0d2 100644
--- a/contrib/test_decoding/sql/ddl.sql
+++ b/contrib/test_decoding/sql/ddl.sql
@@ -93,6 +93,36 @@ COMMIT;
 /* display results */
 SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
 
+-- FOR PORTION OF setup
+CREATE TABLE replication_example_temporal(id int4range, valid_at daterange, somedata int, text varchar(120), PRIMARY KEY (id, valid_at WITHOUT OVERLAPS));
+INSERT INTO replication_example_temporal VALUES ('[1,2)', '[2000-01-01,2020-01-01)', 1, 'aaa');
+INSERT INTO replication_example_temporal VALUES ('[2,3)', '[2000-01-01,2020-01-01)', 1, 'aaa');
+
+/* display results */
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- UPDATE FOR PORTION OF support
+BEGIN;
+  UPDATE replication_example_temporal
+    FOR PORTION OF valid_at FROM '2010-01-01' TO '2011-01-01'
+    SET somedata = 2,
+        text = 'bbb'
+    WHERE id = '[1,2)';
+COMMIT;
+
+/* display results */
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
+-- DELETE FOR PORTION OF support
+BEGIN;
+  DELETE FROM replication_example_temporal
+    FOR PORTION OF valid_at FROM '2012-01-01' TO '2013-01-01'
+    WHERE id = '[2,3)';
+COMMIT;
+
+/* display results */
+SELECT data FROM pg_logical_slot_get_changes('regression_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
+
 -- MERGE support
 BEGIN;
 MERGE INTO replication_example t
diff --git a/doc/src/sgml/ddl.sgml b/doc/src/sgml/ddl.sgml
index d78d986756e..22a81a4a7af 100644
--- a/doc/src/sgml/ddl.sgml
+++ b/doc/src/sgml/ddl.sgml
@@ -382,7 +382,9 @@ CREATE TABLE people (
    A generated column cannot be written to directly.  In
    <command>INSERT</command> or <command>UPDATE</command> commands, a value
    cannot be specified for a generated column, but the keyword
-   <literal>DEFAULT</literal> may be specified.
+   <literal>DEFAULT</literal> may be specified.  Also, a generated column
+   cannot be used in the <literal>FOR PORTION OF</literal> clause of an
+   <command>UPDATE</command> or <command>DELETE</command> command.
   </para>
 
   <para>
diff --git a/doc/src/sgml/dml.sgml b/doc/src/sgml/dml.sgml
index cd348d5773a..10ea4889e53 100644
--- a/doc/src/sgml/dml.sgml
+++ b/doc/src/sgml/dml.sgml
@@ -261,6 +261,148 @@ DELETE FROM products;
   </para>
  </sect1>
 
+ <sect1 id="dml-application-time-update-delete">
+  <title>Updating and Deleting Temporal Data</title>
+
+  <para>
+   Special syntax is available to update and delete from application-time
+   temporal tables (see <xref linkend="ddl-application-time" />).  (No extra
+   syntax is required to insert into them: the user just provides the
+   application time values like any other column.)  When updating or deleting,
+   the user can target a specific portion of history.  Only rows overlapping
+   that history are affected, and within those rows only the targeted history
+   is changed.  If a row contains more history beyond what is targeted, its
+   application time is reduced to fit within the targeted portion, and new
+   rows are inserted to preserve the history that was not targeted.
+  </para>
+
+  <para>
+   Recall the example table from <xref linkend="temporal-entities-figure" />,
+   containing this data:
+
+<programlisting>
+ product_no | price |        valid_at
+------------+-------+-------------------------
+          5 |  5.00 | [2020-01-01,2022-01-01)
+          5 |  8.00 | [2022-01-01,)
+          6 |  9.00 | [2021-01-01,2024-01-01)
+</programlisting>
+
+   A temporal update might look like this:
+
+<programlisting>
+UPDATE products
+  <emphasis>FOR PORTION OF valid_at FROM '2023-09-01' TO '2025-03-01'</emphasis>
+  SET price = 12.00
+  WHERE product_no = 5;
+</programlisting>
+
+   That command will update the second record for product 5.  It will set the
+   price to 12.00 and the application time to
+   <literal>[2023-09-01,2025-03-01)</literal>.  Then, since the row's
+   application time was originally <literal>[2022-01-01,)</literal>, the
+   command must insert two <glossterm
+   linkend="glossary-temporal-leftovers">temporal leftovers</glossterm>: one
+   for history before September 1, 2023, and another for history since March
+   1, 2025.  After the update, the table has four rows for product 5:
+
+<programlisting>
+ product_no | price |        valid_at
+------------+-------+-------------------------
+          5 |  5.00 | [2020-01-01,2022-01-01)
+          5 |  8.00 | [2022-01-01,2023-09-01)
+          5 | 12.00 | [2023-09-01,2025-03-01)
+          5 |  8.00 | [2025-03-01,)
+          6 |  9.00 | [2021-01-01,2024-01-01)
+</programlisting>
+
+   The new history could be plotted as in <xref linkend="temporal-update-figure"/>.
+  </para>
+
+  <figure id="temporal-update-figure">
+   <title>Temporal Update Example</title>
+   <mediaobject>
+    <imageobject>
+     <imagedata fileref="images/temporal-update.svg" format="SVG" width="100%"/>
+    </imageobject>
+   </mediaobject>
+  </figure>
+
+  <para>
+   Similarly, a specific portion of history may be targeted when deleting rows
+   from a table.  In that case, the original rows are removed, but new
+   <glossterm linkend="glossary-temporal-leftovers">temporal
+   leftovers</glossterm> are inserted to preserve the untouched history.  The
+   syntax for a temporal delete is:
+
+<programlisting>
+DELETE FROM products
+  <emphasis>FOR PORTION OF valid_at FROM '2021-08-01' TO '2023-09-01'</emphasis>
+  WHERE product_no = 5;
+</programlisting>
+
+   Continuing the example, this command would delete two records.  The
+   first record would yield a single temporal leftover, and the second
+   would be deleted entirely.  The rows in the table would now be:
+
+<programlisting>
+ product_no | price |        valid_at
+------------+-------+-------------------------
+          5 |  5.00 | [2020-01-01,2021-08-01)
+          5 | 12.00 | [2023-09-01,2025-03-01)
+          5 |  8.00 | [2025-03-01,)
+          6 |  9.00 | [2021-01-01,2024-01-01)
+</programlisting>
+
+   The new history could be plotted as in <xref linkend="temporal-delete-figure"/>.
+  </para>
+
+  <figure id="temporal-delete-figure">
+   <title>Temporal Delete Example</title>
+   <mediaobject>
+    <imageobject>
+     <imagedata fileref="images/temporal-delete.svg" format="SVG" width="100%"/>
+    </imageobject>
+   </mediaobject>
+  </figure>
+
+  <para>
+   Instead of using the <literal>FROM ... TO ...</literal> syntax, temporal
+   update/delete commands can also give the targeted range/multirange
+   directly, inside parentheses.  For example: <literal>DELETE FROM products
+   FOR PORTION OF valid_at ('[2028-01-01,)') ...</literal>.  This syntax is
+   required when application time is stored in a multirange column.
+  </para>
+
+  <para>
+   When application time is stored in a range type column, zero, one or two
+   temporal leftovers are produced by each row that is updated/deleted.  With
+   a multirange column, only zero or one temporal leftover is produced.  The
+   leftover bounds are computed using <literal>range_minus_multi</literal> and
+   <literal>multirange_minus_multi</literal> (see <xref
+   linkend="functions-range"/>).
+  </para>
+
+  <para>
+   The bounds given to <literal>FOR PORTION OF</literal> must be constant.
+   Functions like <literal>now()</literal> are allowed, but column references
+   are not.
+  </para>
+
+  <para>
+   When temporal leftovers are inserted, all <literal>INSERT</literal>
+   triggers are fired, but the <literal>INSERT</literal> privilege on the
+   table is not required.  Row-level security <literal>INSERT</literal>
+   policies still apply to the leftovers.
+  </para>
+
+  <para>
+   In <literal>READ COMMITTED</literal> mode, a temporal update or delete can
+   fail with a serialization failure when it touches a row that another
+   transaction has concurrently changed.  The transaction can be retried.
+  </para>
+ </sect1>
+
  <sect1 id="dml-returning">
   <title>Returning Data from Modified Rows</title>
 
diff --git a/doc/src/sgml/glossary.sgml b/doc/src/sgml/glossary.sgml
index d28a3729188..b86de809a89 100644
--- a/doc/src/sgml/glossary.sgml
+++ b/doc/src/sgml/glossary.sgml
@@ -1961,6 +1961,21 @@
    </glossdef>
   </glossentry>
 
+  <glossentry id="glossary-temporal-leftovers">
+   <glossterm>Temporal leftovers</glossterm>
+   <glossdef>
+    <para>
+     After a temporal update or delete, the portion of history that was not
+     updated/deleted. When using ranges to track application time, there may be
+     zero, one, or two stretches of history that were not updated/deleted
+     (before and/or after the portion that was updated/deleted). New rows are
+     automatically inserted into the table to preserve that history. A single
+     multirange can accommodate the untouched history before and after the
+     update/delete, so there will be only zero or one leftover.
+    </para>
+   </glossdef>
+  </glossentry>
+
   <glossentry id="glossary-temporal-table">
    <glossterm>Temporal table</glossterm>
    <glossdef>
diff --git a/doc/src/sgml/images/Makefile b/doc/src/sgml/images/Makefile
index e805487344d..7b8ac0fbb32 100644
--- a/doc/src/sgml/images/Makefile
+++ b/doc/src/sgml/images/Makefile
@@ -8,7 +8,9 @@ ALL_IMAGES = \
 	gin.svg \
 	pagelayout.svg \
 	temporal-entities.svg \
-	temporal-references.svg
+	temporal-references.svg \
+	temporal-update.svg \
+	temporal-delete.svg
 
 DITAA = ditaa
 DOT = dot
diff --git a/doc/src/sgml/images/meson.build b/doc/src/sgml/images/meson.build
index 3c2581959b5..aa172a8d9ea 100644
--- a/doc/src/sgml/images/meson.build
+++ b/doc/src/sgml/images/meson.build
@@ -17,6 +17,8 @@ all_files = [
   'pagelayout.txt',
   'temporal-entities.txt',
   'temporal-references.txt',
+  'temporal-update.txt',
+  'temporal-delete.txt',
 ]
 
 foreach file : all_files
diff --git a/doc/src/sgml/images/temporal-delete.svg b/doc/src/sgml/images/temporal-delete.svg
new file mode 100644
index 00000000000..2d8b1d6ec7b
--- /dev/null
+++ b/doc/src/sgml/images/temporal-delete.svg
@@ -0,0 +1,41 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1330 224" width="1330" height="224" shape-rendering="geometricPrecision" version="1.0">
+  <defs>
+    <filter id="f2" x="0" y="0" width="200%" height="200%">
+      <feOffset result="offOut" in="SourceGraphic" dx="5" dy="5"/>
+      <feGaussianBlur result="blurOut" in="offOut" stdDeviation="3"/>
+      <feBlend in="SourceGraphic" in2="blurOut" mode="normal"/>
+    </filter>
+  </defs>
+  <g stroke-width="1" stroke-linecap="square" stroke-linejoin="round">
+    <rect x="0" y="0" width="1330" height="224" style="fill: #ffffff"/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="#99dd99" d="M685.0 63.0 L685.0 147.0 L1005.0 147.0 L1005.0 63.0 z"/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="#99dd99" d="M315.0 63.0 L315.0 147.0 L25.0 147.0 L25.0 63.0 z"/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="#99dd99" d="M1005.0 63.0 L1005.0 147.0 L1275.0 147.0 L1275.0 63.0 z"/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M205.0 168.0 L205.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M25.0 168.0 L25.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M385.0 168.0 L385.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M565.0 168.0 L565.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M745.0 168.0 L745.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M925.0 168.0 L925.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M1105.0 168.0 L1105.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M1285.0 168.0 L1285.0 181.0 "/>
+    <text x="46" y="96" font-family="Courier" font-size="15" stroke="none" fill="#000000">products</text>
+    <text x="40" y="110" font-family="Courier" font-size="15" stroke="none" fill="#000000">(5, 5.00,</text>
+    <text x="83" y="124" font-family="Courier" font-size="15" stroke="none" fill="#000000">[1 Jan 2020,1 Aug 2021))</text>
+    <text x="20" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2020</text>
+    <text x="200" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2021</text>
+    <text x="380" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2022</text>
+    <text x="560" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2023</text>
+    <text x="706" y="96" font-family="Courier" font-size="15" stroke="none" fill="#000000">products</text>
+    <text x="700" y="110" font-family="Courier" font-size="15" stroke="none" fill="#000000">(5, 12.00,</text>
+    <text x="743" y="124" font-family="Courier" font-size="15" stroke="none" fill="#000000">[1 Sep 2023,1 Mar 2025))</text>
+    <text x="740" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2024</text>
+    <text x="920" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2025</text>
+    <text x="1026" y="96" font-family="Courier" font-size="15" stroke="none" fill="#000000">products</text>
+    <text x="1020" y="110" font-family="Courier" font-size="15" stroke="none" fill="#000000">(5, 8.00,</text>
+    <text x="1056" y="124" font-family="Courier" font-size="15" stroke="none" fill="#000000">[1 Mar 2025,))</text>
+    <text x="1100" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2026</text>
+    <text x="1289" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">...</text>
+  </g>
+</svg>
diff --git a/doc/src/sgml/images/temporal-delete.txt b/doc/src/sgml/images/temporal-delete.txt
new file mode 100644
index 00000000000..bf79b2207c3
--- /dev/null
+++ b/doc/src/sgml/images/temporal-delete.txt
@@ -0,0 +1,10 @@
++----------------------------+                                    +-------------------------------+--------------------------+
+| cGRE                       |                                    | cGRE                          | cGRE                     |
+| products                   |                                    | products                      | products                 |
+| (5, 5.00,                  |                                    | (5, 12.00,                    | (5, 8.00,                |
+|   [1 Jan 2020,1 Aug 2021)) |                                    |   [1 Sep 2023,1 Mar 2025))    |   [1 Mar 2025,))         |
+|                            |                                    |                               |                          |
++----------------------------+                                    +-------------------------------+--------------------------+
+
+|                 |                 |                 |                 |                 |                 |                 |
+2020              2021              2022              2023              2024              2025              2026              ...
diff --git a/doc/src/sgml/images/temporal-update.svg b/doc/src/sgml/images/temporal-update.svg
new file mode 100644
index 00000000000..6c7c43c8d22
--- /dev/null
+++ b/doc/src/sgml/images/temporal-update.svg
@@ -0,0 +1,45 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1330 224" width="1330" height="224" shape-rendering="geometricPrecision" version="1.0">
+  <defs>
+    <filter id="f2" x="0" y="0" width="200%" height="200%">
+      <feOffset result="offOut" in="SourceGraphic" dx="5" dy="5"/>
+      <feGaussianBlur result="blurOut" in="offOut" stdDeviation="3"/>
+      <feBlend in="SourceGraphic" in2="blurOut" mode="normal"/>
+    </filter>
+  </defs>
+  <g stroke-width="1" stroke-linecap="square" stroke-linejoin="round">
+    <rect x="0" y="0" width="1330" height="224" style="fill: #ffffff"/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="#99dd99" d="M385.0 63.0 L385.0 147.0 L25.0 147.0 L25.0 63.0 z"/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="#99dd99" d="M1285.0 63.0 L1285.0 147.0 L975.0 147.0 L975.0 63.0 z"/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="#99dd99" d="M385.0 147.0 L685.0 147.0 L685.0 63.0 L385.0 63.0 z"/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="#99dd99" d="M685.0 63.0 L685.0 147.0 L975.0 147.0 L975.0 63.0 z"/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M205.0 168.0 L205.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M25.0 168.0 L25.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M385.0 168.0 L385.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M565.0 168.0 L565.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M745.0 168.0 L745.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M925.0 168.0 L925.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M1105.0 168.0 L1105.0 181.0 "/>
+    <path stroke="#000000" stroke-width="1.000000" stroke-linecap="round" stroke-linejoin="round" fill="none" d="M1285.0 168.0 L1285.0 181.0 "/>
+    <text x="46" y="96" font-family="Courier" font-size="15" stroke="none" fill="#000000">products</text>
+    <text x="40" y="110" font-family="Courier" font-size="15" stroke="none" fill="#000000">(5, 5.00,</text>
+    <text x="86" y="124" font-family="Courier" font-size="15" stroke="none" fill="#000000">[1 Jan 2020,1 Jan 2022))</text>
+    <text x="20" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2020</text>
+    <text x="200" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2021</text>
+    <text x="406" y="96" font-family="Courier" font-size="15" stroke="none" fill="#000000">products</text>
+    <text x="400" y="110" font-family="Courier" font-size="15" stroke="none" fill="#000000">(5, 8.00,</text>
+    <text x="445" y="124" font-family="Courier" font-size="15" stroke="none" fill="#000000">[1 Jan 2022,1 Sep 2023))</text>
+    <text x="380" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2022</text>
+    <text x="560" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2023</text>
+    <text x="706" y="96" font-family="Courier" font-size="15" stroke="none" fill="#000000">products</text>
+    <text x="700" y="110" font-family="Courier" font-size="15" stroke="none" fill="#000000">(5, 12.00,</text>
+    <text x="743" y="124" font-family="Courier" font-size="15" stroke="none" fill="#000000">[1 Sep 2023,1 Mar 2025))</text>
+    <text x="740" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2024</text>
+    <text x="920" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2025</text>
+    <text x="996" y="96" font-family="Courier" font-size="15" stroke="none" fill="#000000">products</text>
+    <text x="990" y="110" font-family="Courier" font-size="15" stroke="none" fill="#000000">(5, 8.00,</text>
+    <text x="1026" y="124" font-family="Courier" font-size="15" stroke="none" fill="#000000">[1 Mar 2025,))</text>
+    <text x="1100" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">2026</text>
+    <text x="1289" y="194" font-family="Courier" font-size="15" stroke="none" fill="#000000">...</text>
+  </g>
+</svg>
diff --git a/doc/src/sgml/images/temporal-update.txt b/doc/src/sgml/images/temporal-update.txt
new file mode 100644
index 00000000000..87a16382810
--- /dev/null
+++ b/doc/src/sgml/images/temporal-update.txt
@@ -0,0 +1,10 @@
++-----------------------------------+-----------------------------+----------------------------+------------------------------+
+| cGRE                              | cGRE                        | cGRE                       | cGRE                         |
+| products                          | products                    | products                   | products                     |
+| (5, 5.00,                         | (5, 8.00,                   | (5, 12.00,                 | (5, 8.00,                    |
+|   [1 Jan 2020,1 Jan 2022))        |   [1 Jan 2022,1 Sep 2023))  |   [1 Sep 2023,1 Mar 2025)) |   [1 Mar 2025,))             |
+|                                   |                             |                            |                              |
++-----------------------------------+-----------------------------+----------------------------+------------------------------+
+
+|                 |                 |                 |                 |                 |                 |                 |
+2020              2021              2022              2023              2024              2025              2026              ...
diff --git a/doc/src/sgml/ref/create_policy.sgml b/doc/src/sgml/ref/create_policy.sgml
index d8a036739c0..0a1699185c9 100644
--- a/doc/src/sgml/ref/create_policy.sgml
+++ b/doc/src/sgml/ref/create_policy.sgml
@@ -530,6 +530,26 @@ CREATE POLICY <replaceable class="parameter">name</replaceable> ON <replaceable
        <entry>Check new row</entry>
        <entry>&mdash;</entry>
       </row>
+      <row>
+       <entry><command>UPDATE ... FOR PORTION OF</command></entry>
+       <entry>
+        Filter existing row &amp; check new row
+       </entry>
+       <entry>
+        Check leftover rows&nbsp;<footnote id="rls-fpo-leftover">
+         <para>
+          <literal>FOR PORTION OF</literal> re-inserts the portions of the
+          affected row that fall outside the targeted range, to preserve them.
+          These leftover rows are checked against the <command>INSERT</command>
+          policy's <literal>WITH CHECK</literal> expression, even though no
+          <literal>INSERT</literal> privilege is required to store them.
+         </para>
+        </footnote>
+       </entry>
+       <entry>Filter existing row</entry>
+       <entry>Check new row</entry>
+       <entry>&mdash;</entry>
+      </row>
       <row>
        <entry><command>DELETE</command></entry>
        <entry>
@@ -540,6 +560,18 @@ CREATE POLICY <replaceable class="parameter">name</replaceable> ON <replaceable
        <entry>&mdash;</entry>
        <entry>Filter existing row</entry>
       </row>
+      <row>
+       <entry><command>DELETE ... FOR PORTION OF</command></entry>
+       <entry>
+        Filter existing row
+       </entry>
+       <entry>
+        Check leftover rows&nbsp;<footnoteref linkend="rls-fpo-leftover"/>
+       </entry>
+       <entry>&mdash;</entry>
+       <entry>&mdash;</entry>
+       <entry>Filter existing row</entry>
+      </row>
       <row>
        <entry><command>INSERT ... ON CONFLICT</command></entry>
        <entry>
diff --git a/doc/src/sgml/ref/create_publication.sgml b/doc/src/sgml/ref/create_publication.sgml
index 8196e6a4235..35c28006f60 100644
--- a/doc/src/sgml/ref/create_publication.sgml
+++ b/doc/src/sgml/ref/create_publication.sgml
@@ -473,6 +473,12 @@ CREATE PUBLICATION <replaceable class="parameter">name</replaceable>
    for each row inserted, updated, or deleted.
   </para>
 
+  <para>
+   For an <command>UPDATE/DELETE ... FOR PORTION OF</command> command, the
+   publication will publish an <command>UPDATE</command> or <command>DELETE</command>,
+   followed by one <command>INSERT</command> for each temporal leftover row inserted.
+  </para>
+
   <para>
    <command>ATTACH</command>ing a table into a partition tree whose root is
    published using a publication with <literal>publish_via_partition_root</literal>
diff --git a/doc/src/sgml/ref/create_view.sgml b/doc/src/sgml/ref/create_view.sgml
index 60215eba3b8..2c08f7e3cff 100644
--- a/doc/src/sgml/ref/create_view.sgml
+++ b/doc/src/sgml/ref/create_view.sgml
@@ -197,11 +197,12 @@ CREATE VIEW [ <replaceable>schema</replaceable> . ] <replaceable>view_name</repl
       commands on the view will be checked to ensure that new rows satisfy the
       view-defining condition (that is, the new rows are checked to ensure that
       they are visible through the view).  If they are not, the update will be
-      rejected.  If the <literal>CHECK OPTION</literal> is not specified,
-      <command>INSERT</command>, <command>UPDATE</command>, and
-      <command>MERGE</command> commands on the view are
-      allowed to create rows that are not visible through the view.  The
-      following check options are supported:
+      rejected.  The <glossterm linkend="glossary-temporal-leftovers">temporal
+      leftovers</glossterm> inserted by an <command>UPDATE</command> or
+      <command>DELETE</command> with a <literal>FOR PORTION OF</literal> clause
+      are checked in the same way.  If the <literal>CHECK OPTION</literal> is
+      not specified, these commands are allowed to create rows that are not
+      visible through the view.  The following check options are supported:
 
       <variablelist>
        <varlistentry>
@@ -431,10 +432,13 @@ CREATE VIEW vista AS SELECT text 'Hello World' AS hello;
     potentially insert base-relation rows
     that do not satisfy the <literal>WHERE</literal> condition and thus are not
     visible through the view (<literal>ON CONFLICT DO SELECT/UPDATE</literal> may
-    similarly affect an existing row not visible through the view).
+    similarly affect an existing row not visible through the view).  An
+    <command>UPDATE</command> or <command>DELETE</command> with a
+    <literal>FOR PORTION OF</literal> clause can do so as well, since the
+    temporal leftovers it inserts may fall outside the
+    <literal>WHERE</literal> condition.
     The <literal>CHECK OPTION</literal> may be used to prevent
-    <command>INSERT</command>, <command>UPDATE</command>, and
-    <command>MERGE</command> commands from creating such rows that are not
+    these commands from creating such rows that are not
     visible through the view.
    </para>
 
diff --git a/doc/src/sgml/ref/delete.sgml b/doc/src/sgml/ref/delete.sgml
index b9367f2b23c..ab5e49be7c9 100644
--- a/doc/src/sgml/ref/delete.sgml
+++ b/doc/src/sgml/ref/delete.sgml
@@ -22,11 +22,18 @@ PostgreSQL documentation
  <refsynopsisdiv>
 <synopsis>
 [ WITH [ RECURSIVE ] <replaceable class="parameter">with_query</replaceable> [, ...] ]
-DELETE FROM [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ] [ [ AS ] <replaceable class="parameter">alias</replaceable> ]
+DELETE FROM [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ]
+    [ FOR PORTION OF <replaceable class="parameter">range_column_name</replaceable> <replaceable class="parameter">for_portion_of_target</replaceable> ]
+    [ [ AS ] <replaceable class="parameter">alias</replaceable> ]
     [ USING <replaceable class="parameter">from_item</replaceable> [, ...] ]
     [ WHERE <replaceable class="parameter">condition</replaceable> | WHERE CURRENT OF <replaceable class="parameter">cursor_name</replaceable> ]
     [ RETURNING [ WITH ( { OLD | NEW } AS <replaceable class="parameter">output_alias</replaceable> [, ...] ) ]
                 { * | <replaceable class="parameter">output_expression</replaceable> [ [ AS ] <replaceable class="parameter">output_name</replaceable> ] } [, ...] ]
+
+<phrase>where <replaceable class="parameter">for_portion_of_target</replaceable> is:</phrase>
+
+{ FROM <replaceable class="parameter">start_time</replaceable> TO <replaceable class="parameter">end_time</replaceable> |
+  ( <replaceable class="parameter">portion</replaceable> ) }
 </synopsis>
  </refsynopsisdiv>
 
@@ -64,12 +71,40 @@ DELETE FROM [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ *
    output list of <command>SELECT</command>.
   </para>
 
+  <para>
+   If the <literal>FOR PORTION OF</literal> clause is used, the delete will
+   only affect rows that overlap the given portion.  Furthermore, if a row's
+   application time extends outside the <literal>FOR PORTION OF</literal>
+   bounds, then the delete will only change the application time within those
+   bounds.  In effect, only the history targeted by <literal>FOR PORTION
+   OF</literal> is deleted, and no moments outside.  Furthermore, after a row
+   is deleted, new <glossterm linkend="glossary-temporal-leftovers">temporal
+   leftovers</glossterm> might be inserted: rows whose range or multirange
+   receives the remaining application time outside the targeted bounds, with
+   the original values in their other columns. For range columns, there will
+   be zero to two inserted records, depending on whether the original
+   application time was completely deleted, extended before/after the change,
+   or both.  Multiranges never require two temporal leftovers, because one
+   value can always contain whatever application time remains.
+   <literal>INSERT</literal> triggers fire for each inserted leftover. If a
+   row-level <literal>BEFORE INSERT</literal> trigger returns
+   <literal>NULL</literal>, no leftover is inserted.
+  </para>
+
   <para>
    You must have the <literal>DELETE</literal> privilege on the table
    to delete from it, as well as the <literal>SELECT</literal>
    privilege for any table in the <literal>USING</literal> clause or
    whose values are read in the <replaceable
    class="parameter">condition</replaceable>.
+   When <literal>FOR PORTION OF</literal> is used, you must also have the
+   <literal>SELECT</literal> privilege on the
+   <replaceable class="parameter">range_column_name</replaceable> column.
+   The secondary inserts, however, do
+   not require <literal>INSERT</literal> privilege on the table.  (This is
+   because conceptually no new information is being added; the inserted rows
+   only preserve existing data about the untargeted time period.)  Row-level
+   security INSERT policies are still checked for these leftover inserts.
   </para>
  </refsect1>
 
@@ -117,6 +152,56 @@ DELETE FROM [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ *
     </listitem>
    </varlistentry>
 
+   <varlistentry>
+    <term><replaceable class="parameter">range_column_name</replaceable></term>
+    <listitem>
+     <para>
+      The range or multirange column to use when performing a temporal delete.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><replaceable class="parameter">for_portion_of_target</replaceable></term>
+    <listitem>
+     <para>
+      The portion to delete. If targeting a range column, this can be in the
+      form <literal>FROM</literal> <replaceable
+      class="parameter">start_time</replaceable> <literal>TO</literal>
+      <replaceable class="parameter">end_time</replaceable>.  Otherwise, it
+      must be in the form <literal>(</literal><replaceable
+      class="parameter">portion</replaceable><literal>)</literal>, where
+      <replaceable class="parameter">portion</replaceable> is an expression
+      that yields a value of the same type as <replaceable
+      class="parameter">range_column_name</replaceable>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><replaceable class="parameter">start_time</replaceable></term>
+    <listitem>
+     <para>
+      The earliest time (inclusive) to change in a temporal delete.  This must
+      be a value matching the base type of the range from <replaceable
+      class="parameter">range_column_name</replaceable>.  A null value here
+      indicates a delete whose beginning is unbounded (as with range types).
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><replaceable class="parameter">end_time</replaceable></term>
+    <listitem>
+     <para>
+      The latest time (exclusive) to change in a temporal delete.  This must
+      be a value matching the base type of the range from <replaceable
+      class="parameter">range_column_name</replaceable>.  A null value here
+      indicates a delete whose end is unbounded (as with range types).
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry>
     <term><replaceable class="parameter">from_item</replaceable></term>
     <listitem>
@@ -153,10 +238,9 @@ DELETE FROM [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ *
       from this cursor.  The cursor must be a non-grouping
       query on the <command>DELETE</command>'s target table.
       Note that <literal>WHERE CURRENT OF</literal> cannot be
-      specified together with a Boolean condition.  See
-      <xref linkend="sql-declare"/>
-      for more information about using cursors with
-      <literal>WHERE CURRENT OF</literal>.
+      specified together with a Boolean condition or <literal>FOR PORTION
+      OF</literal>.  See <xref linkend="sql-declare"/> for more information
+      about using cursors with <literal>WHERE CURRENT OF</literal>.
      </para>
     </listitem>
    </varlistentry>
@@ -238,6 +322,10 @@ DELETE <replaceable class="parameter">count</replaceable>
    suppressed by a <literal>BEFORE DELETE</literal> trigger.  If <replaceable
    class="parameter">count</replaceable> is 0, no rows were deleted by
    the query (this is not considered an error).
+   If <literal>FOR PORTION OF</literal> was used, the
+   <replaceable class="parameter">count</replaceable> does not include
+   <glossterm linkend="glossary-temporal-leftovers">temporal leftovers</glossterm>
+   that were inserted.
   </para>
 
   <para>
@@ -245,7 +333,13 @@ DELETE <replaceable class="parameter">count</replaceable>
    clause, the result will be similar to that of a <command>SELECT</command>
    statement containing the columns and values defined in the
    <literal>RETURNING</literal> list, computed over the row(s) deleted by the
-   command.
+   command. If <literal>FOR PORTION OF</literal> was used, the
+   <literal>RETURNING</literal> clause gives one result for each deleted row,
+   but does not include inserted
+   <glossterm linkend="glossary-temporal-leftovers">temporal leftovers</glossterm>.
+   The value of the application-time column matches the old value of the deleted
+   row(s). Note this will represent more application time than was actually erased,
+   if temporal leftovers were inserted.
   </para>
  </refsect1>
 
@@ -272,6 +366,13 @@ DELETE FROM films
    In some cases the join style is easier to write or faster to
    execute than the sub-select style.
   </para>
+
+  <para>
+   When <literal>FOR PORTION OF</literal> is used, this can result in users
+   who don't have <literal>INSERT</literal> privileges firing
+   <literal>INSERT</literal> triggers.  This should be considered when
+   using <literal>SECURITY DEFINER</literal> trigger functions.
+  </para>
  </refsect1>
 
  <refsect1>
@@ -306,6 +407,15 @@ DELETE FROM tasks WHERE CURRENT OF c_tasks;
 </programlisting>
   </para>
 
+  <para>
+   An example of a temporal delete:
+<programlisting>
+DELETE FROM products
+  FOR PORTION OF valid_at FROM '2021-08-01' TO '2023-09-01'
+  WHERE product_no = 5;
+</programlisting>
+  </para>
+
   <para>
    While there is no <literal>LIMIT</literal> clause
    for <command>DELETE</command>, it is possible to get a similar effect
diff --git a/doc/src/sgml/ref/update.sgml b/doc/src/sgml/ref/update.sgml
index b523766abe3..e376bc9f246 100644
--- a/doc/src/sgml/ref/update.sgml
+++ b/doc/src/sgml/ref/update.sgml
@@ -22,7 +22,9 @@ PostgreSQL documentation
  <refsynopsisdiv>
 <synopsis>
 [ WITH [ RECURSIVE ] <replaceable class="parameter">with_query</replaceable> [, ...] ]
-UPDATE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ] [ [ AS ] <replaceable class="parameter">alias</replaceable> ]
+UPDATE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ]
+    [ FOR PORTION OF <replaceable class="parameter">range_column_name</replaceable> <replaceable class="parameter">for_portion_of_target</replaceable> ]
+    [ [ AS ] <replaceable class="parameter">alias</replaceable> ]
     SET { <replaceable class="parameter">column_name</replaceable> = { <replaceable class="parameter">expression</replaceable> | DEFAULT } |
           ( <replaceable class="parameter">column_name</replaceable> [, ...] ) = [ ROW ] ( { <replaceable class="parameter">expression</replaceable> | DEFAULT } [, ...] ) |
           ( <replaceable class="parameter">column_name</replaceable> [, ...] ) = ( <replaceable class="parameter">sub-SELECT</replaceable> )
@@ -31,6 +33,11 @@ UPDATE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ] [
     [ WHERE <replaceable class="parameter">condition</replaceable> | WHERE CURRENT OF <replaceable class="parameter">cursor_name</replaceable> ]
     [ RETURNING [ WITH ( { OLD | NEW } AS <replaceable class="parameter">output_alias</replaceable> [, ...] ) ]
                 { * | <replaceable class="parameter">output_expression</replaceable> [ [ AS ] <replaceable class="parameter">output_name</replaceable> ] } [, ...] ]
+
+<phrase>where <replaceable class="parameter">for_portion_of_target</replaceable> is:</phrase>
+
+{ FROM <replaceable class="parameter">start_time</replaceable> TO <replaceable class="parameter">end_time</replaceable> |
+  ( <replaceable class="parameter">portion</replaceable> ) }
 </synopsis>
  </refsynopsisdiv>
 
@@ -63,6 +70,29 @@ UPDATE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ] [
    output list of <command>SELECT</command>.
   </para>
 
+  <para>
+   If the <literal>FOR PORTION OF</literal> clause is used, the update will
+   only affect rows that overlap the given portion.  Furthermore, if a row's
+   application time extends outside the <literal>FOR PORTION OF</literal>
+   bounds, then the update will only change the application time within those
+   bounds.  In effect, only the history targeted by <literal>FOR PORTION
+   OF</literal> is updated, and no moments outside.  Furthermore, after a row
+   is updated, the range or multirange is first shrunk so that its application
+   time no longer extends beyond the targeted <literal>FOR PORTION
+   OF</literal> bounds.  Then, new <glossterm
+   linkend="glossary-temporal-leftovers">temporal leftovers</glossterm> might
+   be inserted: rows whose range or multirange receives the remaining
+   application time outside the targeted
+   <literal>FROM</literal>/<literal>TO</literal> bounds, with the original
+   values in their other columns.  For range columns, there will be zero to
+   two inserted records, depending on whether the original application time
+   was completely updated, extended before/after the change, or both.
+   Multiranges never require two temporal leftovers, because one value can
+   always contain whatever application time remains. <literal>INSERT</literal>
+   triggers fire for each inserted leftover. If a row-level <literal>BEFORE
+   INSERT</literal> trigger returns <literal>NULL</literal>, no leftover is inserted.
+  </para>
+
   <para>
    You must have the <literal>UPDATE</literal> privilege on the table,
    or at least on the column(s) that are listed to be updated.
@@ -70,6 +100,14 @@ UPDATE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ] [
    privilege on any column whose values are read in the
    <replaceable class="parameter">expressions</replaceable> or
    <replaceable class="parameter">condition</replaceable>.
+   When <literal>FOR PORTION OF</literal> is used, you must also have the
+   <literal>SELECT</literal> and <literal>UPDATE</literal> privileges on the
+   <replaceable class="parameter">range_column_name</replaceable> column.
+   The secondary inserts, however, do
+   not require <literal>INSERT</literal> privilege on the table.  (This is
+   because conceptually no new information is being added; the inserted rows
+   only preserve existing data about the untargeted time period.)  Row-level
+   security INSERT policies are still checked for these leftover inserts.
   </para>
  </refsect1>
 
@@ -116,6 +154,56 @@ UPDATE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ] [
     </listitem>
    </varlistentry>
 
+   <varlistentry>
+    <term><replaceable class="parameter">range_column_name</replaceable></term>
+    <listitem>
+     <para>
+      The range or multirange column to use when performing a temporal update.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><replaceable class="parameter">for_portion_of_target</replaceable></term>
+    <listitem>
+     <para>
+      The portion to update.  If targeting a range column, this can be in the
+      form <literal>FROM</literal> <replaceable
+      class="parameter">start_time</replaceable> <literal>TO</literal>
+      <replaceable class="parameter">end_time</replaceable>.  Otherwise, it
+      must be in the form <literal>(</literal><replaceable
+      class="parameter">portion</replaceable><literal>)</literal>, where
+      <replaceable class="parameter">portion</replaceable> is an expression
+      that yields a value of the same type as <replaceable
+      class="parameter">range_column_name</replaceable>.
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><replaceable class="parameter">start_time</replaceable></term>
+    <listitem>
+     <para>
+      The earliest time (inclusive) to change in a temporal update.  This must
+      be a value matching the base type of the range from <replaceable
+      class="parameter">range_column_name</replaceable>.  A null value here
+      indicates an update whose beginning is unbounded (as with range types).
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term><replaceable class="parameter">end_time</replaceable></term>
+    <listitem>
+     <para>
+      The latest time (exclusive) to change in a temporal update.  This must
+      be a value matching the base type of the range from <replaceable
+      class="parameter">range_column_name</replaceable>.  A null value here
+      indicates an update whose end is unbounded (as with range types).
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry>
     <term><replaceable class="parameter">column_name</replaceable></term>
     <listitem>
@@ -205,10 +293,9 @@ UPDATE [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ] [
       from this cursor.  The cursor must be a non-grouping
       query on the <command>UPDATE</command>'s target table.
       Note that <literal>WHERE CURRENT OF</literal> cannot be
-      specified together with a Boolean condition.  See
-      <xref linkend="sql-declare"/>
-      for more information about using cursors with
-      <literal>WHERE CURRENT OF</literal>.
+      specified together with a Boolean condition or <literal>FOR PORTION
+      OF</literal>.  See <xref linkend="sql-declare"/> for more information
+      about using cursors with <literal>WHERE CURRENT OF</literal>.
      </para>
     </listitem>
    </varlistentry>
@@ -283,6 +370,10 @@ UPDATE <replaceable class="parameter">count</replaceable>
    updates were suppressed by a <literal>BEFORE UPDATE</literal> trigger.  If
    <replaceable class="parameter">count</replaceable> is 0, no rows were
    updated by the query (this is not considered an error).
+   If <literal>FOR PORTION OF</literal> was used, the
+   <replaceable class="parameter">count</replaceable> does not include
+   <glossterm linkend="glossary-temporal-leftovers">temporal leftovers</glossterm>
+   that were inserted.
   </para>
 
   <para>
@@ -290,7 +381,12 @@ UPDATE <replaceable class="parameter">count</replaceable>
    clause, the result will be similar to that of a <command>SELECT</command>
    statement containing the columns and values defined in the
    <literal>RETURNING</literal> list, computed over the row(s) updated by the
-   command.
+   command. If <literal>FOR PORTION OF</literal> was used, the
+   <literal>RETURNING</literal> clause gives one result for each updated row,
+   but does not include inserted
+   <glossterm linkend="glossary-temporal-leftovers">temporal leftovers</glossterm>.
+   The value of the application-time column matches the new value of the updated
+   row(s).
   </para>
  </refsect1>
 
@@ -355,6 +451,13 @@ UPDATE <replaceable class="parameter">count</replaceable>
    partition that is not the same as the ancestor that's mentioned in the
    <command>UPDATE</command> query.
   </para>
+
+  <para>
+   When <literal>FOR PORTION OF</literal> is used, this can result in users
+   who don't have <literal>INSERT</literal> privileges firing
+   <literal>INSERT</literal> triggers.  This should be considered when
+   using <literal>SECURITY DEFINER</literal> trigger functions.
+  </para>
  </refsect1>
 
  <refsect1>
@@ -477,6 +580,16 @@ UPDATE films SET kind = 'Dramatic' WHERE CURRENT OF c_films;
 </programlisting>
   </para>
 
+  <para>
+   An example of a temporal update:
+<programlisting>
+UPDATE products
+  FOR PORTION OF valid_at FROM '2023-09-01' TO '2025-03-01'
+  SET price = 12.00
+  WHERE product_no = 5;
+</programlisting>
+  </para>
+
   <para id="update-limit">
    Updates affecting many rows can have negative effects on system
    performance, such as table bloat, increased replica lag, and increased
diff --git a/doc/src/sgml/trigger.sgml b/doc/src/sgml/trigger.sgml
index 0062f1a3fd1..8a5e7278212 100644
--- a/doc/src/sgml/trigger.sgml
+++ b/doc/src/sgml/trigger.sgml
@@ -215,6 +215,18 @@
     are fired for the same kind of action.
    </para>
 
+   <para>
+    If an <command>UPDATE</command> or <command>DELETE</command> uses
+    <literal>FOR PORTION OF</literal>, causing new rows to be inserted
+    to preserve the leftover untargeted part of modified records, then
+    <command>INSERT</command> triggers are fired for each inserted
+    row. Each row is inserted separately, so they fire their own
+    statement triggers, and they have their own transition tables.
+    (The <literal>BEFORE DELETE/UPDATE</literal> triggers are fired first,
+    then <literal>BEFORE INSERT</literal>, then <literal>AFTER
+    INSERT</literal>, then <literal>AFTER DELETE/UPDATE</literal>.)
+   </para>
+
    <para>
     Trigger functions invoked by per-statement triggers should always
     return <symbol>NULL</symbol>. Trigger functions invoked by per-row
diff --git a/src/backend/commands/copyfrom.c b/src/backend/commands/copyfrom.c
index 746b4fdf6c3..3782db171be 100644
--- a/src/backend/commands/copyfrom.c
+++ b/src/backend/commands/copyfrom.c
@@ -921,7 +921,7 @@ CopyFrom(CopyFromState cstate)
 	ExecInitResultRelation(estate, resultRelInfo, 1);
 
 	/* Verify the named relation is a valid target for INSERT */
-	CheckValidResultRel(resultRelInfo, CMD_INSERT, ONCONFLICT_NONE, NIL);
+	CheckValidResultRel(resultRelInfo, CMD_INSERT, ONCONFLICT_NONE, NIL, NULL);
 
 	ExecOpenIndices(resultRelInfo, false);
 
diff --git a/src/backend/commands/trigger.c b/src/backend/commands/trigger.c
index ce2088c9cc1..59c32a3b9d2 100644
--- a/src/backend/commands/trigger.c
+++ b/src/backend/commands/trigger.c
@@ -34,6 +34,7 @@
 #include "commands/trigger.h"
 #include "executor/executor.h"
 #include "executor/instrument.h"
+#include "executor/nodeModifyTable.h"
 #include "miscadmin.h"
 #include "nodes/bitmapset.h"
 #include "nodes/makefuncs.h"
@@ -3396,6 +3397,25 @@ ExecASTruncateTriggers(EState *estate, ResultRelInfo *relinfo)
 }
 
 
+/*
+ * Explain an "already modified by an operation triggered by the current
+ * command" error when FOR PORTION OF is involved.
+ *
+ * The accompanying hint blames a BEFORE trigger, which is the only way an
+ * ordinary statement reaches these errors.  But FOR PORTION OF gives a
+ * second way: temporal leftovers fire AFTER triggers before the main command
+ * completes.  We can't tell which one it was here, so just add some extra
+ * detail under FOR PORTION OF.
+ */
+int
+errdetail_temporal_leftovers(ResultRelInfo *relinfo)
+{
+	if (relinfo->ri_forPortionOf == NULL)
+		return 0;
+
+	return errdetail("FOR PORTION OF inserts temporal leftovers while the statement is still running, and those inserts fire their own triggers.");
+}
+
 /*
  * Fetch tuple into "oldslot", dealing with locking and EPQ if necessary
  */
@@ -3418,6 +3438,7 @@ GetTupleForTrigger(EState *estate,
 		TM_Result	test;
 		TM_FailureData tmfd;
 		int			lockflags = 0;
+		ItemPointerData origtid = *tid;
 
 		*epqslot = NULL;
 
@@ -3457,6 +3478,7 @@ GetTupleForTrigger(EState *estate,
 					ereport(ERROR,
 							(errcode(ERRCODE_TRIGGERED_DATA_CHANGE_VIOLATION),
 							 errmsg("tuple to be updated was already modified by an operation triggered by the current command"),
+							 errdetail_temporal_leftovers(relinfo),
 							 errhint("Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows.")));
 
 				/* treat it as deleted; do not process */
@@ -3465,6 +3487,20 @@ GetTupleForTrigger(EState *estate,
 			case TM_Ok:
 				if (tmfd.traversed)
 				{
+					/*
+					 * For FOR PORTION OF, following the update chain isn't
+					 * enough: complain if the concurrent change moved part of
+					 * our target out of this row.  table_tuple_lock() has
+					 * advanced tid to the latest version, so look up the
+					 * range the row had when we chose it by its original tid.
+					 * See ExecForPortionOfCheckSplit().
+					 */
+					if (relinfo->ri_forPortionOf)
+						ExecForPortionOfCheckSplit(relinfo,
+												   ExecForPortionOfOldRange(relinfo,
+																			&origtid),
+												   oldslot);
+
 					/*
 					 * Recheck the tuple using EPQ, if requested.  Otherwise,
 					 * just return that it was concurrently updated.
@@ -3508,6 +3544,8 @@ GetTupleForTrigger(EState *estate,
 					ereport(ERROR,
 							(errcode(ERRCODE_T_R_SERIALIZATION_FAILURE),
 							 errmsg("could not serialize access due to concurrent delete")));
+				if (relinfo->ri_forPortionOf)
+					ExecForPortionOfConcurrentDelete();
 				/* tuple was deleted */
 				return false;
 
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index d7a8f36ca1d..fa0b435b40e 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -1063,7 +1063,8 @@ InitPlan(QueryDesc *queryDesc, int eflags)
  */
 void
 CheckValidResultRel(ResultRelInfo *resultRelInfo, CmdType operation,
-					OnConflictAction onConflictAction, List *mergeActions)
+					OnConflictAction onConflictAction, List *mergeActions,
+					ModifyTable *mtnode)
 {
 	Relation	resultRel = resultRelInfo->ri_RelationDesc;
 	FdwRoutine *fdwroutine;
@@ -1126,6 +1127,14 @@ CheckValidResultRel(ResultRelInfo *resultRelInfo, CmdType operation,
 								RelationGetRelationName(resultRel))));
 			break;
 		case RELKIND_FOREIGN_TABLE:
+			/* We don't support FOR PORTION OF FDW queries. */
+			if (mtnode && mtnode->forPortionOf)
+				ereport(ERROR,
+						errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+						errmsg("foreign tables don't support FOR PORTION OF"),
+						errdetail("\"%s\" is a foreign table.",
+								  RelationGetRelationName(resultRel)));
+
 			/* Okay only if the FDW supports it */
 			fdwroutine = resultRelInfo->ri_FdwRoutine;
 			switch (operation)
@@ -1338,6 +1347,7 @@ InitResultRelInfo(ResultRelInfo *resultRelInfo,
 	resultRelInfo->ri_projectReturning = NULL;
 	resultRelInfo->ri_onConflictArbiterIndexes = NIL;
 	resultRelInfo->ri_onConflict = NULL;
+	resultRelInfo->ri_forPortionOf = NULL;
 	resultRelInfo->ri_ReturningSlot = NULL;
 	resultRelInfo->ri_TrigOldSlot = NULL;
 	resultRelInfo->ri_TrigNewSlot = NULL;
diff --git a/src/backend/executor/execPartition.c b/src/backend/executor/execPartition.c
index 4ec69f2bf99..a95fb41a1ab 100644
--- a/src/backend/executor/execPartition.c
+++ b/src/backend/executor/execPartition.c
@@ -368,7 +368,7 @@ ExecFindPartition(ModifyTableState *mtstate,
 					/* Verify this ResultRelInfo allows INSERTs */
 					CheckValidResultRel(rri, CMD_INSERT,
 										node ? node->onConflictAction : ONCONFLICT_NONE,
-										NIL);
+										NIL, node);
 
 					/*
 					 * Initialize information needed to insert this and
@@ -532,10 +532,11 @@ ExecInitPartitionInfo(ModifyTableState *mtstate, EState *estate,
 	/*
 	 * Verify result relation is a valid target for an INSERT.  An UPDATE of a
 	 * partition-key becomes a DELETE+INSERT operation, so this check is still
-	 * required when the operation is CMD_UPDATE.
+	 * required when the operation is CMD_UPDATE.  It is also required for
+	 * CMD_DELETE, because DELETE ... FOR PORTION OF inserts leftover rows.
 	 */
 	CheckValidResultRel(leaf_part_rri, CMD_INSERT,
-						node ? node->onConflictAction : ONCONFLICT_NONE, NIL);
+						node ? node->onConflictAction : ONCONFLICT_NONE, NIL, node);
 
 	/*
 	 * Open partition indices.  The user may have asked to check for conflicts
@@ -553,8 +554,9 @@ ExecInitPartitionInfo(ModifyTableState *mtstate, EState *estate,
 	 * Build WITH CHECK OPTION constraints for the partition.  Note that we
 	 * didn't build the withCheckOptionList for partitions within the planner,
 	 * but simple translation of varattnos will suffice.  This only occurs for
-	 * the INSERT case or in the case of UPDATE/MERGE tuple routing where we
-	 * didn't find a result rel to reuse.
+	 * the INSERT case or in the case of UPDATE/DELETE/MERGE tuple routing
+	 * where we didn't find a result rel to reuse.  We reach here with DELETE
+	 * only when inserting temporal leftovers.
 	 */
 	if (node && node->withCheckOptionLists != NIL)
 	{
@@ -565,7 +567,8 @@ ExecInitPartitionInfo(ModifyTableState *mtstate, EState *estate,
 		/*
 		 * In the case of INSERT on a partitioned table, there is only one
 		 * plan.  Likewise, there is only one WCO list, not one per partition.
-		 * For UPDATE/MERGE, there are as many WCO lists as there are plans.
+		 * For UPDATE/DELETE/MERGE, there are as many WCO lists as there are
+		 * plans.
 		 */
 		Assert((node->operation == CMD_INSERT &&
 				list_length(node->withCheckOptionLists) == 1 &&
@@ -573,6 +576,9 @@ ExecInitPartitionInfo(ModifyTableState *mtstate, EState *estate,
 			   (node->operation == CMD_UPDATE &&
 				list_length(node->withCheckOptionLists) ==
 				list_length(node->resultRelations)) ||
+			   (node->operation == CMD_DELETE &&
+				list_length(node->withCheckOptionLists) ==
+				list_length(node->resultRelations)) ||
 			   (node->operation == CMD_MERGE &&
 				list_length(node->withCheckOptionLists) ==
 				list_length(node->resultRelations)));
@@ -620,8 +626,9 @@ ExecInitPartitionInfo(ModifyTableState *mtstate, EState *estate,
 	 * Build the RETURNING projection for the partition.  Note that we didn't
 	 * build the returningList for partitions within the planner, but simple
 	 * translation of varattnos will suffice.  This only occurs for the INSERT
-	 * case or in the case of UPDATE/MERGE tuple routing where we didn't find
-	 * a result rel to reuse.
+	 * case or in the case of UPDATE/DELETE/MERGE tuple routing where we
+	 * didn't find a result rel to reuse.  We reach here with DELETE only when
+	 * inserting temporal leftovers.
 	 */
 	if (node && node->returningLists != NIL)
 	{
@@ -636,6 +643,9 @@ ExecInitPartitionInfo(ModifyTableState *mtstate, EState *estate,
 			   (node->operation == CMD_UPDATE &&
 				list_length(node->returningLists) ==
 				list_length(node->resultRelations)) ||
+			   (node->operation == CMD_DELETE &&
+				list_length(node->returningLists) ==
+				list_length(node->resultRelations)) ||
 			   (node->operation == CMD_MERGE &&
 				list_length(node->returningLists) ==
 				list_length(node->resultRelations)));
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 558ceece9bb..89740494260 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -73,6 +73,7 @@
 #include "utils/builtins.h"
 #include "utils/datum.h"
 #include "utils/injection_point.h"
+#include "utils/rangetypes.h"
 #include "utils/rel.h"
 #include "utils/snapmgr.h"
 
@@ -169,6 +170,10 @@ static bool ExecOnConflictSelect(ModifyTableContext *context,
 								 TupleTableSlot *excludedSlot,
 								 bool canSetTag,
 								 TupleTableSlot **returning);
+static void ExecForPortionOfLeftovers(ModifyTableContext *context,
+									  EState *estate,
+									  ResultRelInfo *resultRelInfo,
+									  ItemPointer tupleid);
 static TupleTableSlot *ExecPrepareTupleRouting(ModifyTableState *mtstate,
 											   EState *estate,
 											   PartitionTupleRouting *proute,
@@ -191,6 +196,16 @@ static TupleTableSlot *ExecMergeMatched(ModifyTableContext *context,
 static TupleTableSlot *ExecMergeNotMatched(ModifyTableContext *context,
 										   ResultRelInfo *resultRelInfo,
 										   bool canSetTag);
+static void ExecSetupTransitionCaptureState(ModifyTableState *mtstate,
+											EState *estate,
+											ResultRelInfo *targetRelInfo,
+											CmdType cmdType);
+static void fireBSTriggers(ModifyTableState *node,
+						   ResultRelInfo *resultRelInfo, CmdType cmdType);
+static void fireASTriggers(ModifyTableState *node,
+						   ResultRelInfo *resultRelInfo, CmdType cmdType);
+static void ExecInitForPortionOf(ModifyTableState *mtstate, EState *estate,
+								 ResultRelInfo *resultRelInfo);
 
 
 /*
@@ -1085,8 +1100,14 @@ ExecInsert(ModifyTableContext *context,
 		 *
 		 * If we're running MERGE, we refer to the action that we're executing
 		 * to know if we're doing an INSERT or UPDATE to a partition table.
+		 *
+		 * A FOR PORTION OF temporal leftover is an insert of its own, not a
+		 * row moving between partitions, so it gets INSERT policies even
+		 * though the enclosing statement is an UPDATE or DELETE.
 		 */
-		if (mtstate->operation == CMD_UPDATE)
+		if (mtstate->mt_forPortionOfLeftover)
+			wco_kind = WCO_RLS_INSERT_CHECK;
+		else if (mtstate->operation == CMD_UPDATE)
 			wco_kind = WCO_RLS_UPDATE_CHECK;
 		else if (mtstate->operation == CMD_MERGE)
 			wco_kind = (mtstate->mt_merge_action->mas_action->commandType == CMD_UPDATE) ?
@@ -1285,7 +1306,8 @@ ExecInsert(ModifyTableContext *context,
 	 * because they happen on different tables.
 	 */
 	ar_insert_trig_tcs = mtstate->mt_transition_capture;
-	if (mtstate->operation == CMD_UPDATE && mtstate->mt_transition_capture
+	if (mtstate->operation == CMD_UPDATE && !mtstate->mt_forPortionOfLeftover
+		&& mtstate->mt_transition_capture
 		&& mtstate->mt_transition_capture->tcs_update_new_table)
 	{
 		ExecARUpdateTriggers(estate, resultRelInfo,
@@ -1325,8 +1347,17 @@ ExecInsert(ModifyTableContext *context,
 	if (resultRelInfo->ri_WithCheckOptions != NIL)
 		ExecWithCheckOptions(WCO_VIEW_CHECK, resultRelInfo, slot, estate);
 
-	/* Process RETURNING if present */
-	if (resultRelInfo->ri_projectReturning)
+	/*
+	 * Process RETURNING if present.
+	 *
+	 * If this is an UPDATE/DELETE ... FOR PORTION OF, we do not return the
+	 * leftover rows inserted by ExecForPortionOfLeftovers().  We do still
+	 * want to process the newly inserted row of a cross-partition UPDATE with
+	 * a FOR PORTION OF clause, which is why this tests the leftover flag
+	 * rather than how we got here.
+	 */
+	if (resultRelInfo->ri_projectReturning &&
+		!mtstate->mt_forPortionOfLeftover)
 	{
 		TupleTableSlot *oldSlot = NULL;
 
@@ -1386,6 +1417,410 @@ ExecInsert(ModifyTableContext *context,
 	return result;
 }
 
+/* ----------------------------------------------------------------
+ *		ExecForPortionOfOldRange
+ *
+ *		Return the range column of the row version at tupleid.
+ *
+ *		The result points into the FOR PORTION OF "existing tuple" slot, so
+ *		it is only valid until something else stores into that slot.
+ * ----------------------------------------------------------------
+ */
+Datum
+ExecForPortionOfOldRange(ResultRelInfo *resultRelInfo, ItemPointer tupleid)
+{
+	ForPortionOfState *fpoState = resultRelInfo->ri_forPortionOf;
+	TupleTableSlot *slot = fpoState->fp_existing;
+	Datum		range;
+	bool		isnull;
+
+	if (!table_tuple_fetch_row_version(resultRelInfo->ri_RelationDesc,
+									   tupleid, SnapshotAny, slot))
+		elog(ERROR, "failed to fetch tuple for FOR PORTION OF");
+
+	range = slot_getattr(slot, fpoState->fp_rangeAttno, &isnull);
+	if (isnull)
+		elog(ERROR, "found a NULL range in a temporal table");
+
+	return range;
+}
+
+/* ----------------------------------------------------------------
+ *		ExecForPortionOfCheckSplit
+ *
+ *		Complain if a concurrent transaction has moved part of the history
+ *		we were asked to change out of the row we are about to change.
+ *
+ *		In READ COMMITTED mode, when we find that the row we picked has
+ *		been updated by a concurrent transaction, we lock the latest
+ *		version and re-check it with EvalPlanQual().  That is enough for an
+ *		ordinary UPDATE, because a row has only one successor and
+ *		EvalPlanQual() reaches it by following the ctid chain.  It is not
+ *		enough here: a FOR PORTION OF update or delete narrows the row's
+ *		range and re-inserts the rest of its history as brand new rows,
+ *		which are not chained off the original and which our snapshot
+ *		cannot see.  If we simply carried on, we would change only the part
+ *		of the history that stayed in this row and silently leave the rest
+ *		alone -- a lost update.
+ *
+ *		We cannot go and find those rows: they are not reachable from the
+ *		row we hold, and re-scanning for them under a new snapshot would be
+ *		a different feature.  So we detect the case and report a
+ *		serialization failure, which is what the higher isolation levels
+ *		would have done, and which the client can retry.  MERGE does the
+ *		same thing when a concurrent update moves a row to another
+ *		partition and the executor cannot follow it.
+ *
+ *		The test is precise: we only complain if some part of the range the
+ *		row used to have, and no longer has, overlaps the portion this
+ *		statement targets.  A concurrent update that does not touch the
+ *		range column, or a FOR PORTION OF update whose target contains
+ *		ours, leaves nothing behind for us to miss.
+ * ----------------------------------------------------------------
+ */
+void
+ExecForPortionOfCheckSplit(ResultRelInfo *resultRelInfo,
+						   Datum oldRange,
+						   TupleTableSlot *newSlot)
+{
+	ForPortionOfState *fpoState = resultRelInfo->ri_forPortionOf;
+	PgStat_FunctionCallUsage fcusage;
+	FmgrInfo	flinfo;
+	ReturnSetInfo rsi_data;
+	ReturnSetInfo *rsi = &rsi_data;
+	Datum		newRange;
+	bool		isnull;
+
+	LOCAL_FCINFO(fcinfo, 2);
+
+	newRange = slot_getattr(newSlot, fpoState->fp_rangeAttno, &isnull);
+	if (isnull)
+		elog(ERROR, "found a NULL range in a temporal table");
+
+	/*
+	 * Ask for the parts of the old range that the row no longer covers.
+	 *
+	 * We call the same support function ExecForPortionOfLeftovers() does, but
+	 * with our own call state rather than the one prepared for it: its second
+	 * argument is the targeted range, set once at executor start-up, and we
+	 * would have to overwrite it here.  This path only runs when a concurrent
+	 * change was detected, so setting up a call each time costs nothing worth
+	 * saving.
+	 */
+	fmgr_info(fpoState->fp_withoutPortionInfo->fn_oid, &flinfo);
+
+	rsi->type = T_ReturnSetInfo;
+	rsi->econtext = fpoState->fp_withoutPortionRsi->econtext;
+	rsi->expectedDesc = NULL;
+	rsi->allowedModes = (int) (SFRM_ValuePerCall);
+	rsi->returnMode = SFRM_ValuePerCall;
+	/* isDone is filled in for each call */
+	rsi->setResult = NULL;
+	rsi->setDesc = NULL;
+
+	InitFunctionCallInfoData(*fcinfo, &flinfo, 2, InvalidOid, NULL, (Node *) rsi);
+
+	fcinfo->args[0].value = oldRange;
+	fcinfo->args[0].isnull = false;
+	fcinfo->args[1].value = newRange;
+	fcinfo->args[1].isnull = false;
+
+	while (true)
+	{
+		Datum		lost;
+
+		pgstat_init_function_usage(fcinfo, &fcusage);
+
+		fcinfo->isnull = false;
+		rsi->isDone = ExprSingleResult;
+		lost = FunctionCallInvoke(fcinfo);
+
+		pgstat_end_function_usage(&fcusage,
+								  rsi->isDone != ExprMultipleResult);
+
+		if (rsi->returnMode != SFRM_ValuePerCall)
+			elog(ERROR, "without_portion function violated function call protocol");
+
+		if (rsi->isDone == ExprEndResult)
+			break;
+
+		if (fcinfo->isnull)
+			elog(ERROR, "got a null from without_portion function");
+
+		if (DatumGetBool(FunctionCall2Coll(fpoState->fp_overlapsInfo,
+										   fpoState->fp_overlapsCollation,
+										   lost, fpoState->fp_targetRange)))
+			ereport(ERROR,
+					(errcode(ERRCODE_T_R_SERIALIZATION_FAILURE),
+					 errmsg("could not serialize access due to concurrent update"),
+					 errdetail("A concurrent transaction moved part of the history targeted by FOR PORTION OF into a row this statement cannot see."),
+					 errhint("Retry the transaction.")));
+	}
+}
+
+/* ----------------------------------------------------------------
+ *		ExecForPortionOfConcurrentDelete
+ *
+ *		Report a concurrent delete of a row targeted by FOR PORTION OF.
+ *
+ *		A FOR PORTION OF delete that only covers part of a row's history
+ *		deletes the row and inserts the remaining history as new rows, so a
+ *		concurrently deleted row may have left behind history we were
+ *		supposed to change.  We cannot tell that case apart from a plain
+ *		delete, which we would be entitled to ignore, so we report a
+ *		serialization failure for both.
+ * ----------------------------------------------------------------
+ */
+void
+ExecForPortionOfConcurrentDelete(void)
+{
+	ereport(ERROR,
+			(errcode(ERRCODE_T_R_SERIALIZATION_FAILURE),
+			 errmsg("could not serialize access due to concurrent delete"),
+			 errdetail("A concurrent transaction deleted a row targeted by FOR PORTION OF, which may have left behind history this statement cannot see."),
+			 errhint("Retry the transaction.")));
+}
+
+/* ----------------------------------------------------------------
+ *		ExecForPortionOfLeftovers
+ *
+ *		Insert tuples for the untouched portion of a row in a FOR
+ *		PORTION OF UPDATE/DELETE.
+ *
+ *		If a row's history exceeds the portion targeted, we need to insert
+ *		temporal leftovers to avoid changing more than the user requested. So we
+ *		fetch the pre-UPDATE tuple, set just its start/end time, and insert a
+ *		copy as a leftover.
+ *
+ *		To find the untouched portion(s) of history, we call a set-returning
+ *		support function based on the type of the application-time column. (XXX:
+ *		Currently only rangetypes and multirangetypes are supported, but we
+ *		would like to support any type that offers the necessary
+ *		functions/operators.) For a rangetype, there can be zero, one, or two
+ *		leftovers. For a multirange, which can accommodate gaps, there can be at
+ *		most one. User-defined types may return more.
+ *
+ *		Each temporal leftover gets its own insert "statement", per the SQL
+ *		standard. So we fire statement triggers and row triggers (complete with
+ *		transition tables). These leftovers do not contribute to the command tag
+ *		or ROW_COUNT.
+ * ----------------------------------------------------------------
+ */
+static void
+ExecForPortionOfLeftovers(ModifyTableContext *context,
+						  EState *estate,
+						  ResultRelInfo *resultRelInfo,
+						  ItemPointer tupleid)
+{
+	ModifyTableState *mtstate = context->mtstate;
+	ModifyTable *node = (ModifyTable *) mtstate->ps.plan;
+	ForPortionOfExpr *forPortionOf = (ForPortionOfExpr *) node->forPortionOf;
+	Datum		oldRange;
+	ForPortionOfState *fpoState;
+	TupleTableSlot *oldtupleSlot;
+	TupleTableSlot *leftoverSlot;
+	TupleConversionMap *map = NULL;
+	HeapTuple	oldtuple = NULL;
+	TransitionCaptureState *oldTcs;
+	PgStat_FunctionCallUsage fcusage;
+	FunctionCallInfo fcinfo;
+	ReturnSetInfo *rsi;
+	bool		didInit = false;
+	bool		shouldFree = false;
+	ResultRelInfo *rootRelInfo = mtstate->rootResultRelInfo;
+	bool		partitionRouting =
+		rootRelInfo &&
+		rootRelInfo->ri_RelationDesc->rd_rel->relkind == RELKIND_PARTITIONED_TABLE;
+
+	fpoState = resultRelInfo->ri_forPortionOf;
+	oldtupleSlot = fpoState->fp_existing;
+	leftoverSlot = fpoState->fp_leftover;
+	fcinfo = fpoState->fp_withoutPortionFcinfo;
+	rsi = fpoState->fp_withoutPortionRsi;
+
+	/*
+	 * Get the old pre-UPDATE/DELETE tuple. We will use its range to compute
+	 * untouched parts of history, and if necessary we will insert copies with
+	 * truncated start/end times.
+	 *
+	 * We have already locked the tuple, in ExecUpdate/ExecDelete or, if there
+	 * is a BEFORE row trigger, in GetTupleForTrigger, and it has passed
+	 * EvalPlanQual. This ensures that concurrent updates in READ COMMITTED
+	 * can't insert conflicting temporal leftovers.
+	 *
+	 * That alone would not stop us from overlooking a concurrent update or
+	 * delete's leftovers, but if a concurrent change moved part of our target
+	 * out of this row, ExecForPortionOfCheckSplit() has already raised a
+	 * serialization failure.
+	 */
+	if (!table_tuple_fetch_row_version(resultRelInfo->ri_RelationDesc, tupleid, SnapshotAny, oldtupleSlot))
+		elog(ERROR, "failed to fetch tuple for FOR PORTION OF");
+
+	slot_getallattrs(oldtupleSlot);
+
+	/* Get the old range of the record being updated/deleted. */
+	if (oldtupleSlot->tts_isnull[fpoState->fp_rangeAttno - 1])
+		elog(ERROR, "found a NULL range in a temporal table");
+	oldRange = oldtupleSlot->tts_values[fpoState->fp_rangeAttno - 1];
+
+	/*
+	 * Get the ranges to the left/right of the targeted range. We call a SETOF
+	 * support function and insert as many temporal leftovers as it gives us.
+	 * Although rangetypes have 0/1/2 leftovers, multiranges have 0/1, and
+	 * other types may have more.
+	 */
+
+	fcinfo->args[0].value = oldRange;
+
+	/*
+	 * For partitioned tables, we must read leftovers with the tuple
+	 * descriptor of the child table, but insert into the root table to enable
+	 * tuple routing. So leftoverSlot is configured with the root's tuple
+	 * descriptor. But for traditional table inheritance, we don't need tuple
+	 * routing and just insert directly into the child table to preserve
+	 * child-specific columns. In that case, leftoverSlot uses the child's
+	 * (resultRelInfo) tuple descriptor.
+	 */
+	if (partitionRouting)
+	{
+		map = ExecGetChildToRootMap(resultRelInfo);
+		resultRelInfo = resultRelInfo->ri_RootResultRelInfo;
+	}
+
+	/*
+	 * Insert a leftover for each value returned by the without_portion helper
+	 * function
+	 */
+	while (true)
+	{
+		Datum		leftover;
+
+		/* Call the function one time */
+		pgstat_init_function_usage(fcinfo, &fcusage);
+
+		fcinfo->isnull = false;
+		rsi->isDone = ExprSingleResult;
+		leftover = FunctionCallInvoke(fcinfo);
+
+		pgstat_end_function_usage(&fcusage,
+								  rsi->isDone != ExprMultipleResult);
+
+		/*
+		 * XXX: Support other returnModes. For now we only call
+		 * range_minus_multi and multirange_minus_multi, so we can depend on
+		 * the value-per-call protocol.
+		 */
+		if (rsi->returnMode != SFRM_ValuePerCall)
+			elog(ERROR, "without_portion function violated function call protocol");
+
+		/* Are we done? */
+		if (rsi->isDone == ExprEndResult)
+			break;
+
+		if (fcinfo->isnull)
+			elog(ERROR, "got a null from without_portion function");
+
+		/*
+		 * Does the new Datum violate domain checks? Row-level CHECK
+		 * constraints are validated by ExecInsert, so we don't need to do
+		 * anything here for those.
+		 */
+		if (forPortionOf->isDomain)
+			domain_check(leftover, false, forPortionOf->rangeVar->vartype,
+						 &fpoState->fp_domaininfo, estate->es_query_cxt);
+
+		if (!didInit)
+		{
+			/*
+			 * Get the pre-UPDATE row. We'll make copies of it below for each
+			 * leftover we need to insert.
+			 */
+			if (map == NULL)
+				oldtuple = ExecFetchSlotHeapTuple(oldtupleSlot, false, &shouldFree);
+
+			/*
+			 * Mark that the inserts below are temporal leftovers, and save
+			 * the transition capture state, which we replace for each one.
+			 *
+			 * XXX: Should we create our own ModifyTableState instead?  That
+			 * would be tidier than borrowing this one, but it would have to
+			 * duplicate a fair amount of setup.
+			 */
+			mtstate->mt_forPortionOfLeftover = true;
+			oldTcs = mtstate->mt_transition_capture;
+
+			didInit = true;
+		}
+
+		/*
+		 * Copy the pre-UPDATE row, then overwrite the range column. We need a
+		 * new copy for each leftover, because ExecInsert might pass the slot
+		 * to BEFORE ROW INSERT triggers, which can modify its contents.
+		 *
+		 * If the table is partitioned, map back to the root's format, so that
+		 * we get tuple routing.
+		 */
+		if (map != NULL)
+			leftoverSlot = execute_attr_map_slot(map->attrMap,
+												 oldtupleSlot,
+												 leftoverSlot);
+		else
+			ExecForceStoreHeapTuple(oldtuple, leftoverSlot, false);
+
+		leftoverSlot->tts_values[resultRelInfo->ri_forPortionOf->fp_rangeAttno - 1] = leftover;
+		leftoverSlot->tts_isnull[resultRelInfo->ri_forPortionOf->fp_rangeAttno - 1] = false;
+		ExecMaterializeSlot(leftoverSlot);
+
+		/*
+		 * The standard says that each temporal leftover should execute its
+		 * own INSERT statement, firing all statement and row triggers, but
+		 * skipping insert permission checks. Therefore we give each insert
+		 * its own transition table. If we just push & pop a new trigger level
+		 * for each insert, we get exactly what we need.
+		 *
+		 * We have to make sure that the inserts don't add to the ROW_COUNT
+		 * diagnostic or the command tag, so we pass false for canSetTag.
+		 */
+		AfterTriggerBeginQuery();
+		ExecSetupTransitionCaptureState(mtstate, estate, resultRelInfo,
+										CMD_INSERT);
+
+		/*
+		 * When inserting directly into a child table (for traditional
+		 * inheritance), we must avoid converting to the root table's format.
+		 */
+		if (!partitionRouting && mtstate->mt_transition_capture != NULL)
+			mtstate->mt_transition_capture->tcs_original_insert_tuple =
+				leftoverSlot;
+
+		fireBSTriggers(mtstate, resultRelInfo, CMD_INSERT);
+		ExecInsert(context, resultRelInfo, leftoverSlot, false, NULL, NULL);
+		fireASTriggers(mtstate, resultRelInfo, CMD_INSERT);
+
+		/* Reset the transition state, as ExecCrossPartitionUpdate() does. */
+		if (mtstate->mt_transition_capture != NULL)
+			mtstate->mt_transition_capture->tcs_original_insert_tuple = NULL;
+
+		AfterTriggerEndQuery(estate);
+
+		/* Don't leak the capture state */
+		if (mtstate->mt_transition_capture != NULL)
+		{
+			pfree(mtstate->mt_transition_capture);
+			mtstate->mt_transition_capture = NULL;
+		}
+	}
+
+	if (didInit)
+	{
+		mtstate->mt_forPortionOfLeftover = false;
+		mtstate->mt_transition_capture = oldTcs;
+
+		if (shouldFree)
+			heap_freetuple(oldtuple);
+	}
+}
+
 /* ----------------------------------------------------------------
  *		ExecBatchInsert
  *
@@ -1543,7 +1978,8 @@ ExecDeleteAct(ModifyTableContext *context, ResultRelInfo *resultRelInfo,
  *
  * Closing steps of tuple deletion; this invokes AFTER FOR EACH ROW triggers,
  * including the UPDATE triggers if the deletion is being done as part of a
- * cross-partition tuple move.
+ * cross-partition tuple move. It also inserts temporal leftovers from a
+ * DELETE FOR PORTION OF.
  */
 static void
 ExecDeleteEpilogue(ModifyTableContext *context, ResultRelInfo *resultRelInfo,
@@ -1576,6 +2012,10 @@ ExecDeleteEpilogue(ModifyTableContext *context, ResultRelInfo *resultRelInfo,
 		ar_delete_trig_tcs = NULL;
 	}
 
+	/* Compute temporal leftovers in FOR PORTION OF */
+	if (((ModifyTable *) context->mtstate->ps.plan)->forPortionOf)
+		ExecForPortionOfLeftovers(context, estate, resultRelInfo, tupleid);
+
 	/* AFTER ROW DELETE Triggers */
 	ExecARDeleteTriggers(estate, resultRelInfo, tupleid, oldtuple,
 						 ar_delete_trig_tcs, changingPart);
@@ -1719,6 +2159,7 @@ ldelete:
 					ereport(ERROR,
 							(errcode(ERRCODE_TRIGGERED_DATA_CHANGE_VIOLATION),
 							 errmsg("tuple to be deleted was already modified by an operation triggered by the current command"),
+							 errdetail_temporal_leftovers(resultRelInfo),
 							 errhint("Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows.")));
 
 				/* Else, already deleted by self; nothing to do */
@@ -1731,12 +2172,24 @@ ldelete:
 				{
 					TupleTableSlot *inputslot;
 					TupleTableSlot *epqslot;
+					bool		forPortionOf;
+					Datum		oldRange = (Datum) 0;
 
 					if (IsolationUsesXactSnapshot())
 						ereport(ERROR,
 								(errcode(ERRCODE_T_R_SERIALIZATION_FAILURE),
 								 errmsg("could not serialize access due to concurrent update")));
 
+					/*
+					 * For FOR PORTION OF, remember the range this row had
+					 * when we chose it, so we can tell below whether the
+					 * concurrent change split any of our target away.
+					 */
+					forPortionOf = resultRelInfo->ri_forPortionOf != NULL;
+					if (forPortionOf)
+						oldRange = ExecForPortionOfOldRange(resultRelInfo,
+															tupleid);
+
 					/*
 					 * Already know that we're going to need to do EPQ, so
 					 * fetch tuple directly into the right slot.
@@ -1756,6 +2209,12 @@ ldelete:
 					{
 						case TM_Ok:
 							Assert(context->tmfd.traversed);
+
+							if (forPortionOf)
+								ExecForPortionOfCheckSplit(resultRelInfo,
+														   oldRange,
+														   inputslot);
+
 							epqslot = EvalPlanQual(context->epqstate,
 												   resultRelationDesc,
 												   resultRelInfo->ri_RangeTableIndex,
@@ -1793,10 +2252,13 @@ ldelete:
 								ereport(ERROR,
 										(errcode(ERRCODE_TRIGGERED_DATA_CHANGE_VIOLATION),
 										 errmsg("tuple to be deleted was already modified by an operation triggered by the current command"),
+										 errdetail_temporal_leftovers(resultRelInfo),
 										 errhint("Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows.")));
 							return NULL;
 
 						case TM_Deleted:
+							if (forPortionOf)
+								ExecForPortionOfConcurrentDelete();
 							/* tuple already deleted; nothing to do */
 							return NULL;
 
@@ -1826,6 +2288,8 @@ ldelete:
 					ereport(ERROR,
 							(errcode(ERRCODE_T_R_SERIALIZATION_FAILURE),
 							 errmsg("could not serialize access due to concurrent delete")));
+				if (resultRelInfo->ri_forPortionOf)
+					ExecForPortionOfConcurrentDelete();
 				/* tuple already deleted; nothing to do */
 				return NULL;
 
@@ -2001,7 +2465,10 @@ ExecCrossPartitionUpdate(ModifyTableContext *context,
 	if (resultRelInfo == mtstate->rootResultRelInfo)
 		ExecPartitionCheckEmitError(resultRelInfo, slot, estate);
 
-	/* Initialize tuple routing info if not already done. */
+	/*
+	 * Initialize tuple routing info if not already done. Note whatever we do
+	 * here must be done in ExecInitModifyTable for FOR PORTION OF as well.
+	 */
 	if (mtstate->mt_partition_tuple_routing == NULL)
 	{
 		Relation	rootRel = mtstate->rootResultRelInfo->ri_RelationDesc;
@@ -2351,7 +2818,8 @@ lreplace:
  * ExecUpdateEpilogue -- subroutine for ExecUpdate
  *
  * Closing steps of updating a tuple.  Must be called if ExecUpdateAct
- * returns indicating that the tuple was updated.
+ * returns indicating that the tuple was updated. It also inserts temporal
+ * leftovers from an UPDATE FOR PORTION OF.
  */
 static void
 ExecUpdateEpilogue(ModifyTableContext *context, UpdateContext *updateCxt,
@@ -2373,23 +2841,13 @@ ExecUpdateEpilogue(ModifyTableContext *context, UpdateContext *updateCxt,
 											   NULL);
 	}
 
-	/* AFTER ROW UPDATE Triggers */
-	ExecARUpdateTriggers(context->estate, resultRelInfo,
-						 NULL, NULL,
-						 tupleid, oldtuple, slot,
-						 recheckIndexes,
-						 mtstate->operation == CMD_INSERT ?
-						 mtstate->mt_oc_transition_capture :
-						 mtstate->mt_transition_capture,
-						 false);
-
-	list_free(recheckIndexes);
-
 	/*
 	 * Check any WITH CHECK OPTION constraints from parent views.  We are
 	 * required to do this after testing all constraints and uniqueness
 	 * violations per the SQL spec, so we do it after actually updating the
-	 * record in the heap and all indexes.
+	 * record in the heap and all indexes.  We do it before inserting any
+	 * temporal leftovers, so that if both this row and a leftover violate a
+	 * check option, the error names this row.
 	 *
 	 * ExecWithCheckOptions() will skip any WCOs which are not of the kind we
 	 * are looking for at this point.
@@ -2397,6 +2855,22 @@ ExecUpdateEpilogue(ModifyTableContext *context, UpdateContext *updateCxt,
 	if (resultRelInfo->ri_WithCheckOptions != NIL)
 		ExecWithCheckOptions(WCO_VIEW_CHECK, resultRelInfo,
 							 slot, context->estate);
+
+	/* Compute temporal leftovers in FOR PORTION OF */
+	if (((ModifyTable *) context->mtstate->ps.plan)->forPortionOf)
+		ExecForPortionOfLeftovers(context, context->estate, resultRelInfo, tupleid);
+
+	/* AFTER ROW UPDATE Triggers */
+	ExecARUpdateTriggers(context->estate, resultRelInfo,
+						 NULL, NULL,
+						 tupleid, oldtuple, slot,
+						 recheckIndexes,
+						 mtstate->operation == CMD_INSERT ?
+						 mtstate->mt_oc_transition_capture :
+						 mtstate->mt_transition_capture,
+						 false);
+
+	list_free(recheckIndexes);
 }
 
 /*
@@ -2625,6 +3099,7 @@ redo_act:
 					ereport(ERROR,
 							(errcode(ERRCODE_TRIGGERED_DATA_CHANGE_VIOLATION),
 							 errmsg("tuple to be updated was already modified by an operation triggered by the current command"),
+							 errdetail_temporal_leftovers(resultRelInfo),
 							 errhint("Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows.")));
 
 				/* Else, already updated by self; nothing to do */
@@ -2637,12 +3112,24 @@ redo_act:
 				{
 					TupleTableSlot *inputslot;
 					TupleTableSlot *epqslot;
+					bool		forPortionOf;
+					Datum		oldRange = (Datum) 0;
 
 					if (IsolationUsesXactSnapshot())
 						ereport(ERROR,
 								(errcode(ERRCODE_T_R_SERIALIZATION_FAILURE),
 								 errmsg("could not serialize access due to concurrent update")));
 
+					/*
+					 * For FOR PORTION OF, remember the range this row had
+					 * when we chose it, so we can tell below whether the
+					 * concurrent change split any of our target away.
+					 */
+					forPortionOf = resultRelInfo->ri_forPortionOf != NULL;
+					if (forPortionOf)
+						oldRange = ExecForPortionOfOldRange(resultRelInfo,
+															tupleid);
+
 					/*
 					 * Already know that we're going to need to do EPQ, so
 					 * fetch tuple directly into the right slot.
@@ -2662,6 +3149,11 @@ redo_act:
 						case TM_Ok:
 							Assert(context->tmfd.traversed);
 
+							if (forPortionOf)
+								ExecForPortionOfCheckSplit(resultRelInfo,
+														   oldRange,
+														   inputslot);
+
 							epqslot = EvalPlanQual(context->epqstate,
 												   resultRelationDesc,
 												   resultRelInfo->ri_RangeTableIndex,
@@ -2695,6 +3187,8 @@ redo_act:
 							goto redo_act;
 
 						case TM_Deleted:
+							if (forPortionOf)
+								ExecForPortionOfConcurrentDelete();
 							/* tuple already deleted; nothing to do */
 							return NULL;
 
@@ -2715,6 +3209,7 @@ redo_act:
 								ereport(ERROR,
 										(errcode(ERRCODE_TRIGGERED_DATA_CHANGE_VIOLATION),
 										 errmsg("tuple to be updated was already modified by an operation triggered by the current command"),
+										 errdetail_temporal_leftovers(resultRelInfo),
 										 errhint("Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows.")));
 							return NULL;
 
@@ -2733,6 +3228,8 @@ redo_act:
 					ereport(ERROR,
 							(errcode(ERRCODE_T_R_SERIALIZATION_FAILURE),
 							 errmsg("could not serialize access due to concurrent delete")));
+				if (resultRelInfo->ri_forPortionOf)
+					ExecForPortionOfConcurrentDelete();
 				/* tuple already deleted; nothing to do */
 				return NULL;
 
@@ -4218,12 +4715,12 @@ ExecInitMergeTupleSlots(ModifyTableState *mtstate,
  * Process BEFORE EACH STATEMENT triggers
  */
 static void
-fireBSTriggers(ModifyTableState *node)
+fireBSTriggers(ModifyTableState *node, ResultRelInfo *resultRelInfo,
+			   CmdType cmdType)
 {
 	ModifyTable *plan = (ModifyTable *) node->ps.plan;
-	ResultRelInfo *resultRelInfo = node->rootResultRelInfo;
 
-	switch (node->operation)
+	switch (cmdType)
 	{
 		case CMD_INSERT:
 			ExecBSInsertTriggers(node->ps.state, resultRelInfo);
@@ -4255,12 +4752,12 @@ fireBSTriggers(ModifyTableState *node)
  * Process AFTER EACH STATEMENT triggers
  */
 static void
-fireASTriggers(ModifyTableState *node)
+fireASTriggers(ModifyTableState *node, ResultRelInfo *resultRelInfo,
+			   CmdType cmdType)
 {
 	ModifyTable *plan = (ModifyTable *) node->ps.plan;
-	ResultRelInfo *resultRelInfo = node->rootResultRelInfo;
 
-	switch (node->operation)
+	switch (cmdType)
 	{
 		case CMD_INSERT:
 			if (plan->onConflictAction == ONCONFLICT_UPDATE)
@@ -4298,18 +4795,23 @@ fireASTriggers(ModifyTableState *node)
 /*
  * Set up the state needed for collecting transition tuples for AFTER
  * triggers.
+ *
+ * targetRelInfo is the relation whose transition tables we collect into.
+ * Normally this is the statement's target relation, but temporal leftovers are
+ * inserted directly into child tables (for traditional inheritance), so it
+ * needs to match.
  */
 static void
-ExecSetupTransitionCaptureState(ModifyTableState *mtstate, EState *estate)
+ExecSetupTransitionCaptureState(ModifyTableState *mtstate, EState *estate,
+								ResultRelInfo *targetRelInfo, CmdType cmdType)
 {
 	ModifyTable *plan = (ModifyTable *) mtstate->ps.plan;
-	ResultRelInfo *targetRelInfo = mtstate->rootResultRelInfo;
 
-	/* Check for transition tables on the directly targeted relation. */
+	/* Check for transition tables on the given relation. */
 	mtstate->mt_transition_capture =
 		MakeTransitionCaptureState(targetRelInfo->ri_TrigDesc,
 								   RelationGetRelid(targetRelInfo->ri_RelationDesc),
-								   mtstate->operation);
+								   cmdType);
 	if (plan->operation == CMD_INSERT &&
 		plan->onConflictAction == ONCONFLICT_UPDATE)
 		mtstate->mt_oc_transition_capture =
@@ -4433,7 +4935,7 @@ ExecModifyTable(PlanState *pstate)
 	 */
 	if (node->fireBSTriggers)
 	{
-		fireBSTriggers(node);
+		fireBSTriggers(node, node->rootResultRelInfo, node->operation);
 		node->fireBSTriggers = false;
 	}
 
@@ -4551,6 +5053,16 @@ ExecModifyTable(PlanState *pstate)
 														 false, true);
 		}
 
+		/*
+		 * If we don't have a ForPortionOfState yet, we must be a partition or
+		 * inheritance child being hit for the first time. Make a copy from
+		 * the root, with our own TupleTableSlot. We do this lazily so that we
+		 * don't pay the price of unused partitions.
+		 */
+		if (((ModifyTable *) context.mtstate->ps.plan)->forPortionOf &&
+			!resultRelInfo->ri_forPortionOf)
+			ExecInitForPortionOf(context.mtstate, estate, resultRelInfo);
+
 		/*
 		 * If resultRelInfo->ri_usesFdwDirectModify is true, all we need to do
 		 * here is compute the RETURNING expressions.
@@ -4804,7 +5316,7 @@ ExecModifyTable(PlanState *pstate)
 	/*
 	 * We're done, but fire AFTER STATEMENT triggers before exiting.
 	 */
-	fireASTriggers(node);
+	fireASTriggers(node, node->rootResultRelInfo, node->operation);
 
 	node->mt_done = true;
 
@@ -5049,7 +5561,9 @@ ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags)
 	 * valid trigger query context, so skip it in explain-only mode.
 	 */
 	if (!(eflags & EXEC_FLAG_EXPLAIN_ONLY))
-		ExecSetupTransitionCaptureState(mtstate, estate);
+		ExecSetupTransitionCaptureState(mtstate, estate,
+										mtstate->rootResultRelInfo,
+										mtstate->operation);
 
 	/*
 	 * Open all the result relations and initialize the ResultRelInfo structs.
@@ -5088,7 +5602,7 @@ ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags)
 		 * Verify result relation is a valid target for the current operation
 		 */
 		CheckValidResultRel(resultRelInfo, operation, node->onConflictAction,
-							mergeActions);
+							mergeActions, node);
 
 		resultRelInfo++;
 		i++;
@@ -5357,6 +5871,171 @@ ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags)
 		}
 	}
 
+	/*
+	 * If needed, initialize the target range for FOR PORTION OF.
+	 */
+	if (node->forPortionOf)
+	{
+		ResultRelInfo *rootRelInfo;
+		TupleDesc	tupDesc;
+		ForPortionOfExpr *forPortionOf;
+		ForPortionOfState *fpoState;
+
+		rootRelInfo = mtstate->resultRelInfo;
+		if (rootRelInfo->ri_RootResultRelInfo)
+			rootRelInfo = rootRelInfo->ri_RootResultRelInfo;
+
+		tupDesc = rootRelInfo->ri_RelationDesc->rd_att;
+		forPortionOf = (ForPortionOfExpr *) node->forPortionOf;
+
+		/* Create state for FOR PORTION OF operation */
+
+		fpoState = makeNode(ForPortionOfState);
+		fpoState->fp_rangeAttno = forPortionOf->rangeVar->varattno;
+
+		/*
+		 * Evaluate the target and prepare to call the without_portion
+		 * set-returning function. None of this varies across partitions or
+		 * child tables.
+		 */
+		if (!(eflags & EXEC_FLAG_EXPLAIN_ONLY))
+		{
+			bool		isNull;
+			ExprContext *econtext;
+			ExprState  *exprState;
+			FmgrInfo   *flinfo;
+			FunctionCallInfo fcinfo;
+			ReturnSetInfo *rsi;
+			OpExpr	   *overlapsOp;
+
+			/*
+			 * We need an expression context to evaluate the target and to
+			 * call the without_portion support function.
+			 */
+			if (mtstate->ps.ps_ExprContext == NULL)
+				ExecAssignExprContext(estate, &mtstate->ps);
+			econtext = mtstate->ps.ps_ExprContext;
+
+			/* The planner has already prepared the expression. */
+			exprState = ExecInitExpr((Expr *) forPortionOf->targetRange,
+									 &mtstate->ps);
+
+			fpoState->fp_targetRange = ExecEvalExpr(exprState, econtext, &isNull);
+
+			/*
+			 * FOR PORTION OF ... FROM ... TO should never give us a NULL
+			 * target, but FOR PORTION OF (...) could.
+			 */
+			if (isNull)
+				ereport(ERROR,
+						(errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
+						 errmsg("FOR PORTION OF target must not be null"),
+						 executor_errposition(estate, forPortionOf->targetLocation)));
+
+			/*
+			 * Prepare the call to the without_portion support function. Only
+			 * its first arg varies row-by-row.
+			 */
+
+			flinfo = palloc0_object(FmgrInfo);
+			fcinfo = palloc0(SizeForFunctionCallInfo(2));
+			rsi = makeNode(ReturnSetInfo);
+
+			fmgr_info(forPortionOf->withoutPortionProc, flinfo);
+
+			rsi->econtext = econtext;
+			rsi->expectedDesc = NULL;
+			rsi->allowedModes = (int) (SFRM_ValuePerCall);
+			rsi->returnMode = SFRM_ValuePerCall;
+			/* isDone is filled in for each call */
+			rsi->setResult = NULL;
+			rsi->setDesc = NULL;
+
+			InitFunctionCallInfoData(*fcinfo, flinfo, 2, InvalidOid, NULL, (Node *) rsi);
+
+			/* Neither argument is ever null; only args[0] varies per row. */
+			fcinfo->args[0].isnull = false;
+			fcinfo->args[1].isnull = false;
+			fcinfo->args[1].value = fpoState->fp_targetRange;
+
+			fpoState->fp_withoutPortionInfo = flinfo;
+			fpoState->fp_withoutPortionFcinfo = fcinfo;
+			fpoState->fp_withoutPortionRsi = rsi;
+
+			/*
+			 * Also prepare the range column's "overlaps" function, which
+			 * ExecForPortionOfCheckSplit() uses.  The parser has already
+			 * looked up the operator to build the extra qual, so borrow it
+			 * from there.
+			 */
+			overlapsOp = castNode(OpExpr, forPortionOf->overlapsExpr);
+			fpoState->fp_overlapsInfo = palloc0_object(FmgrInfo);
+			fmgr_info(overlapsOp->opfuncid, fpoState->fp_overlapsInfo);
+			fpoState->fp_overlapsCollation = overlapsOp->inputcollid;
+		}
+
+		/* Initialize slot for the existing tuple */
+
+		fpoState->fp_existing =
+			table_slot_create(rootRelInfo->ri_RelationDesc,
+							  &mtstate->ps.state->es_tupleTable);
+
+		/*
+		 * Create the tuple slot for INSERTing the temporal leftovers.
+		 *
+		 * A virtual slot lets us set the range column and re-map attributes
+		 * for partitions. A heap tuple would still need deforming and
+		 * re-forming.
+		 */
+
+		fpoState->fp_leftover =
+			ExecInitExtraTupleSlot(mtstate->ps.state, tupDesc, &TTSOpsVirtual);
+
+		rootRelInfo->ri_forPortionOf = fpoState;
+
+		/*
+		 * Make sure the root relation has the FOR PORTION OF clause too. Each
+		 * partition needs its own TupleTableSlot, since they can have
+		 * different descriptors, so they'll use the root fpoState to
+		 * initialize one if necessary.
+		 */
+		if (node->rootRelation > 0)
+			mtstate->rootResultRelInfo->ri_forPortionOf = fpoState;
+
+		if (rel->rd_rel->relkind == RELKIND_PARTITIONED_TABLE &&
+			mtstate->mt_partition_tuple_routing == NULL)
+		{
+			/*
+			 * We will need tuple routing to insert temporal leftovers. Since
+			 * we are initializing things before ExecCrossPartitionUpdate
+			 * runs, we must do everything it needs as well.
+			 */
+			Relation	rootRel = mtstate->rootResultRelInfo->ri_RelationDesc;
+			MemoryContext oldcxt;
+
+			/* Things built here have to last for the query duration. */
+			oldcxt = MemoryContextSwitchTo(estate->es_query_cxt);
+
+			mtstate->mt_partition_tuple_routing =
+				ExecSetupPartitionTupleRouting(estate, rootRel);
+
+			/*
+			 * Before a partition's tuple can be re-routed, it must first be
+			 * converted to the root's format, so we'll need a slot for
+			 * storing such tuples.
+			 */
+			Assert(mtstate->mt_root_tuple_slot == NULL);
+			mtstate->mt_root_tuple_slot = table_slot_create(rootRel, NULL);
+
+			MemoryContextSwitchTo(oldcxt);
+		}
+
+		/*
+		 * Don't free the ExprContext here because the result must last for
+		 * the whole query.
+		 */
+	}
+
 	/*
 	 * If we have any secondary relations in an UPDATE or DELETE, they need to
 	 * be treated like non-locked relations in SELECT FOR UPDATE, i.e., the
@@ -5550,3 +6229,84 @@ ExecReScanModifyTable(ModifyTableState *node)
 	 */
 	elog(ERROR, "ExecReScanModifyTable is not implemented");
 }
+
+/* ----------------------------------------------------------------
+ *		ExecInitForPortionOf
+ *
+ *		Initializes resultRelInfo->ri_forPortionOf for child tables.
+ *
+ *		Partitions share the root leftover slot, since they must insert via
+ *		the root relation to get tuple routing. Plain inheritance children
+ *		must keep their own leftover slot and insert back into the child, or
+ *		else child-only column values and physical placement would be lost.
+ * ----------------------------------------------------------------
+ */
+static void
+ExecInitForPortionOf(ModifyTableState *mtstate, EState *estate,
+					 ResultRelInfo *resultRelInfo)
+{
+	MemoryContext oldcxt;
+	ForPortionOfState *leafState;
+	ResultRelInfo *rootRelInfo = mtstate->rootResultRelInfo;
+	ForPortionOfState *fpoState;
+	TupleConversionMap *map;
+
+	if (!rootRelInfo)
+		elog(ERROR, "no root relation but ri_forPortionOf is uninitialized");
+
+	fpoState = rootRelInfo->ri_forPortionOf;
+	Assert(fpoState);
+
+	/* Things built here have to last for the query duration. */
+	oldcxt = MemoryContextSwitchTo(estate->es_query_cxt);
+
+	leafState = makeNode(ForPortionOfState);
+
+	leafState->fp_targetRange = fpoState->fp_targetRange;
+
+	/*
+	 * The support function and its call state depend only on the range type,
+	 * which is the same for every child, so share the root's.
+	 */
+	leafState->fp_withoutPortionInfo = fpoState->fp_withoutPortionInfo;
+	leafState->fp_withoutPortionFcinfo = fpoState->fp_withoutPortionFcinfo;
+	leafState->fp_withoutPortionRsi = fpoState->fp_withoutPortionRsi;
+	leafState->fp_overlapsInfo = fpoState->fp_overlapsInfo;
+	leafState->fp_overlapsCollation = fpoState->fp_overlapsCollation;
+	map = ExecGetChildToRootMap(resultRelInfo);
+
+	/*
+	 * fp_rangeAttno must match the tuple layout used for reading the old
+	 * range value. The query uses the target relation's attno, so translate
+	 * it to the child attno when the child has a different column layout.
+	 */
+	if (map)
+		leafState->fp_rangeAttno = map->attrMap->attnums[fpoState->fp_rangeAttno - 1];
+	else
+		leafState->fp_rangeAttno = fpoState->fp_rangeAttno;
+
+	/*
+	 * For partitioned tables we must read the leftovers using the child
+	 * table's tuple descriptor, but then insert them into the root table
+	 * (using its tuple descriptor) so we get tuple routing.
+	 *
+	 * For traditional table inheritance, we read and insert directly into
+	 * this resultRelInfo; no tuple routing via the parent is required.
+	 */
+	if (rootRelInfo->ri_RelationDesc->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
+		leafState->fp_leftover = fpoState->fp_leftover;
+	else
+		leafState->fp_leftover =
+			ExecInitExtraTupleSlot(mtstate->ps.state,
+								   RelationGetDescr(resultRelInfo->ri_RelationDesc),
+								   &TTSOpsVirtual);
+
+	/* Each child relation needs a slot matching its tuple descriptor. */
+	leafState->fp_existing =
+		table_slot_create(resultRelInfo->ri_RelationDesc,
+						  &mtstate->ps.state->es_tupleTable);
+
+	resultRelInfo->ri_forPortionOf = leafState;
+
+	MemoryContextSwitchTo(oldcxt);
+}
diff --git a/src/backend/nodes/nodeFuncs.c b/src/backend/nodes/nodeFuncs.c
index 678b9a046ce..f5b2a17636a 100644
--- a/src/backend/nodes/nodeFuncs.c
+++ b/src/backend/nodes/nodeFuncs.c
@@ -2582,6 +2582,24 @@ expression_tree_walker_impl(Node *node,
 					return true;
 			}
 			break;
+		case T_ForPortionOfExpr:
+			{
+				ForPortionOfExpr *forPortionOf = (ForPortionOfExpr *) node;
+
+				if (WALK(forPortionOf->rangeVar))
+					return true;
+				if (WALK(forPortionOf->targetFrom))
+					return true;
+				if (WALK(forPortionOf->targetTo))
+					return true;
+				if (WALK(forPortionOf->targetRange))
+					return true;
+				if (WALK(forPortionOf->overlapsExpr))
+					return true;
+				if (WALK(forPortionOf->rangeTargetList))
+					return true;
+			}
+			break;
 		case T_PartitionPruneStepOp:
 			{
 				PartitionPruneStepOp *opstep = (PartitionPruneStepOp *) node;
@@ -2731,6 +2749,8 @@ query_tree_walker_impl(Query *query,
 		return true;
 	if (WALK(query->mergeJoinCondition))
 		return true;
+	if (WALK(query->forPortionOf))
+		return true;
 	if (WALK(query->returningList))
 		return true;
 	if (WALK(query->jointree))
@@ -3627,6 +3647,22 @@ expression_tree_mutator_impl(Node *node,
 				return (Node *) newnode;
 			}
 			break;
+		case T_ForPortionOfExpr:
+			{
+				ForPortionOfExpr *fpo = (ForPortionOfExpr *) node;
+				ForPortionOfExpr *newnode;
+
+				FLATCOPY(newnode, fpo, ForPortionOfExpr);
+				MUTATE(newnode->rangeVar, fpo->rangeVar, Var *);
+				MUTATE(newnode->targetFrom, fpo->targetFrom, Node *);
+				MUTATE(newnode->targetTo, fpo->targetTo, Node *);
+				MUTATE(newnode->targetRange, fpo->targetRange, Node *);
+				MUTATE(newnode->overlapsExpr, fpo->overlapsExpr, Node *);
+				MUTATE(newnode->rangeTargetList, fpo->rangeTargetList, List *);
+
+				return (Node *) newnode;
+			}
+			break;
 		case T_PartitionPruneStepOp:
 			{
 				PartitionPruneStepOp *opstep = (PartitionPruneStepOp *) node;
@@ -3809,6 +3845,7 @@ query_tree_mutator_impl(Query *query,
 	MUTATE(query->onConflict, query->onConflict, OnConflictExpr *);
 	MUTATE(query->mergeActionList, query->mergeActionList, List *);
 	MUTATE(query->mergeJoinCondition, query->mergeJoinCondition, Node *);
+	MUTATE(query->forPortionOf, query->forPortionOf, ForPortionOfExpr *);
 	MUTATE(query->returningList, query->returningList, List *);
 	MUTATE(query->jointree, query->jointree, FromExpr *);
 	MUTATE(query->setOperations, query->setOperations, Node *);
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index e9d3b168ad9..d0a7e51c025 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -325,7 +325,7 @@ static ModifyTable *make_modifytable(PlannerInfo *root, Plan *subplan,
 									 List *withCheckOptionLists, List *returningLists,
 									 List *rowMarks, OnConflictExpr *onconflict,
 									 List *mergeActionLists, List *mergeJoinConditions,
-									 int epqParam);
+									 ForPortionOfExpr *forPortionOf, int epqParam);
 static GatherMerge *create_gather_merge_plan(PlannerInfo *root,
 											 GatherMergePath *best_path);
 
@@ -2688,6 +2688,7 @@ create_modifytable_plan(PlannerInfo *root, ModifyTablePath *best_path)
 							best_path->onconflict,
 							best_path->mergeActionLists,
 							best_path->mergeJoinConditions,
+							best_path->forPortionOf,
 							best_path->epqParam);
 
 	copy_generic_path_info(&plan->plan, &best_path->path);
@@ -7097,7 +7098,7 @@ make_modifytable(PlannerInfo *root, Plan *subplan,
 				 List *withCheckOptionLists, List *returningLists,
 				 List *rowMarks, OnConflictExpr *onconflict,
 				 List *mergeActionLists, List *mergeJoinConditions,
-				 int epqParam)
+				 ForPortionOfExpr *forPortionOf, int epqParam)
 {
 	ModifyTable *node = makeNode(ModifyTable);
 	bool		returning_old_or_new = false;
@@ -7170,6 +7171,7 @@ make_modifytable(PlannerInfo *root, Plan *subplan,
 		node->exclRelTlist = onconflict->exclRelTlist;
 	}
 	node->updateColnosLists = updateColnosLists;
+	node->forPortionOf = (Node *) forPortionOf;
 	node->withCheckOptionLists = withCheckOptionLists;
 	node->returningOldAlias = root->parse->returningOldAlias;
 	node->returningNewAlias = root->parse->returningNewAlias;
diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c
index edaa42d713a..2a5ca94f2a0 100644
--- a/src/backend/optimizer/plan/planner.c
+++ b/src/backend/optimizer/plan/planner.c
@@ -858,6 +858,34 @@ subquery_planner(PlannerGlobal *glob, Query *parse, char *plan_name,
 	 */
 	transform_MERGE_to_join(parse);
 
+	/*
+	 * Reject FOR PORTION OF on a generated column.  We can't write to a
+	 * virtual generated column, and a stored generated column should be
+	 * written by its own expression.
+	 *
+	 * We do this in the planner rather than parse analysis so that updatable
+	 * views have been rewritten; otherwise they would mask which columns are
+	 * generated.  We need to check before preprocess_relation_rtes(), so that
+	 * for virtual generated columns we still have the rangeVar.  After that
+	 * it is replaced by the column's expression.
+	 *
+	 * XXX: We plan to implement PERIODs as stored generated columns, so later
+	 * we will loosen this restriction if the column belongs to a PERIOD.
+	 */
+	if (parse->forPortionOf)
+	{
+		ForPortionOfExpr *forPortionOf = parse->forPortionOf;
+		RangeTblEntry *rte = rt_fetch(parse->resultRelation, parse->rtable);
+
+		if (get_attgenerated(rte->relid, forPortionOf->rangeVar->varattno))
+			ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg("cannot use generated column \"%s\" in FOR PORTION OF",
+							get_attname(rte->relid,
+										forPortionOf->rangeVar->varattno,
+										false))));
+	}
+
 	/*
 	 * Scan the rangetable for relation RTEs and retrieve the necessary
 	 * catalog information for each relation.  Using this information, clear
@@ -1073,6 +1101,18 @@ subquery_planner(PlannerGlobal *glob, Query *parse, char *plan_name,
 		/* exclRelTlist contains only Vars, so no preprocessing needed */
 	}
 
+	if (parse->forPortionOf)
+	{
+		parse->forPortionOf->targetRange =
+			preprocess_expression(root,
+								  parse->forPortionOf->targetRange,
+								  EXPRKIND_TARGET);
+		if (contain_volatile_functions(parse->forPortionOf->targetRange))
+			ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg("FOR PORTION OF bounds cannot contain volatile functions")));
+	}
+
 	foreach(l, parse->mergeActionList)
 	{
 		MergeAction *action = (MergeAction *) lfirst(l);
@@ -2430,6 +2470,7 @@ grouping_planner(PlannerInfo *root, double tuple_fraction,
 										parse->onConflict,
 										mergeActionLists,
 										mergeJoinConditions,
+										parse->forPortionOf,
 										assign_special_exec_param(root));
 		}
 
diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c
index af78892fafc..e97829b767d 100644
--- a/src/backend/optimizer/plan/setrefs.c
+++ b/src/backend/optimizer/plan/setrefs.c
@@ -1119,6 +1119,15 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset)
 					fix_scan_list(root, splan->withCheckOptionLists,
 								  rtoffset, 1);
 
+				/*
+				 * FOR PORTION OF cannot contain subplan variable refs, so
+				 * fix_scan_expr works for it.
+				 */
+				if (splan->forPortionOf)
+					splan->forPortionOf = fix_scan_expr(root,
+														splan->forPortionOf,
+														rtoffset, 1);
+
 				if (splan->returningLists)
 				{
 					List	   *newRL = NIL;
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 3af494f269d..c01984e3b90 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -3723,7 +3723,7 @@ create_modifytable_path(PlannerInfo *root, RelOptInfo *rel,
 						List *withCheckOptionLists, List *returningLists,
 						List *rowMarks, OnConflictExpr *onconflict,
 						List *mergeActionLists, List *mergeJoinConditions,
-						int epqParam)
+						ForPortionOfExpr *forPortionOf, int epqParam)
 {
 	ModifyTablePath *pathnode = makeNode(ModifyTablePath);
 
@@ -3789,6 +3789,7 @@ create_modifytable_path(PlannerInfo *root, RelOptInfo *rel,
 	pathnode->returningLists = returningLists;
 	pathnode->rowMarks = rowMarks;
 	pathnode->onconflict = onconflict;
+	pathnode->forPortionOf = forPortionOf;
 	pathnode->epqParam = epqParam;
 	pathnode->mergeActionLists = mergeActionLists;
 	pathnode->mergeJoinConditions = mergeJoinConditions;
diff --git a/src/backend/parser/analyze.c b/src/backend/parser/analyze.c
index 2dc81a8d3b2..c1fec2369a3 100644
--- a/src/backend/parser/analyze.c
+++ b/src/backend/parser/analyze.c
@@ -24,8 +24,11 @@
 
 #include "postgres.h"
 
+#include "access/stratnum.h"
 #include "access/sysattr.h"
 #include "catalog/dependency.h"
+#include "catalog/pg_am.h"
+#include "catalog/pg_operator.h"
 #include "catalog/pg_proc.h"
 #include "catalog/pg_type.h"
 #include "commands/defrem.h"
@@ -51,7 +54,10 @@
 #include "parser/parsetree.h"
 #include "utils/backend_status.h"
 #include "utils/builtins.h"
+#include "utils/fmgroids.h"
 #include "utils/guc.h"
+#include "utils/lsyscache.h"
+#include "utils/rangetypes.h"
 #include "utils/rel.h"
 #include "utils/syscache.h"
 
@@ -72,6 +78,12 @@ static Query *transformDeleteStmt(ParseState *pstate, DeleteStmt *stmt);
 static Query *transformInsertStmt(ParseState *pstate, InsertStmt *stmt);
 static OnConflictExpr *transformOnConflictClause(ParseState *pstate,
 												 OnConflictClause *onConflictClause);
+static ForPortionOfExpr *transformForPortionOfClause(ParseState *pstate,
+													 int rtindex,
+													 const ForPortionOfClause *forPortionOf,
+													 const Node *whereClause,
+													 bool isUpdate);
+static void checkForPortionOfBound(ParseState *pstate, Node *expr);
 static int	count_rowexpr_columns(ParseState *pstate, Node *expr);
 static Query *transformSelectStmt(ParseState *pstate, SelectStmt *stmt,
 								  SelectStmtPassthrough *passthru);
@@ -616,6 +628,17 @@ transformDeleteStmt(ParseState *pstate, DeleteStmt *stmt)
 	nsitem->p_lateral_only = false;
 	nsitem->p_lateral_ok = true;
 
+	/*
+	 * FOR PORTION OF bounds can't reference columns, but the error message
+	 * is more intelligible if we transform them after FROM.
+	 */
+	if (stmt->forPortionOf)
+		qry->forPortionOf = transformForPortionOfClause(pstate,
+														qry->resultRelation,
+														stmt->forPortionOf,
+														stmt->whereClause,
+														false);
+
 	qual = transformWhereClause(pstate, stmt->whereClause,
 								EXPR_KIND_WHERE, "WHERE");
 
@@ -1259,7 +1282,7 @@ transformOnConflictClause(ParseState *pstate,
 		/* Process the UPDATE SET clause */
 		if (onConflictClause->action == ONCONFLICT_UPDATE)
 			onConflictSet =
-				transformUpdateTargetList(pstate, onConflictClause->targetList);
+				transformUpdateTargetList(pstate, onConflictClause->targetList, NULL);
 
 		/* Process the SELECT/UPDATE WHERE clause */
 		onConflictWhere = transformWhereClause(pstate,
@@ -1291,6 +1314,329 @@ transformOnConflictClause(ParseState *pstate,
 	return result;
 }
 
+/*
+ * transformForPortionOfClause
+ *
+ *	  Transforms a ForPortionOfClause in an UPDATE/DELETE statement.
+ *
+ *	  - Look up the range/period requested.
+ *	  - Build a compatible range value from the FROM and TO expressions.
+ *	  - Build an "overlaps" expression for filtering, used later by the
+ *		rewriter.
+ *	  - For UPDATEs, build an "intersects" expression the rewriter can add
+ *		to the targetList to change the temporal bounds.
+ */
+static ForPortionOfExpr *
+transformForPortionOfClause(ParseState *pstate,
+							int rtindex,
+							const ForPortionOfClause *forPortionOf,
+							const Node *whereClause,
+							bool isUpdate)
+{
+	Relation	targetrel = pstate->p_target_relation;
+	int			range_attno = InvalidAttrNumber;
+	Form_pg_attribute attr;
+	Oid			attbasetype;
+	Oid			opclass;
+	Oid			funcid = InvalidOid;
+	StrategyNumber strat;
+	Oid			opid;
+	OpExpr	   *op;
+	ForPortionOfExpr *result;
+	Var		   *rangeVar;
+
+	/* disallow FOR PORTION OF ... WHERE CURRENT OF */
+	if (whereClause && IsA(whereClause, CurrentOfExpr))
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("WHERE CURRENT OF with FOR PORTION OF is not implemented"));
+
+	result = makeNode(ForPortionOfExpr);
+
+	/* Look up the FOR PORTION OF name requested. */
+	range_attno = attnameAttNum(targetrel, forPortionOf->range_name, false);
+	if (range_attno == InvalidAttrNumber)
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_COLUMN),
+				 errmsg("column \"%s\" of relation \"%s\" does not exist",
+						forPortionOf->range_name,
+						RelationGetRelationName(targetrel)),
+				 parser_errposition(pstate, forPortionOf->location)));
+	attr = TupleDescAttr(targetrel->rd_att, range_attno - 1);
+
+	attbasetype = getBaseType(attr->atttypid);
+
+	rangeVar = makeVar(rtindex,
+					   range_attno,
+					   attr->atttypid,
+					   attr->atttypmod,
+					   attr->attcollation,
+					   0);
+	rangeVar->location = forPortionOf->location;
+	result->rangeVar = rangeVar;
+
+	/* Require SELECT privilege on the application-time column. */
+	markVarForSelectPriv(pstate, rangeVar);
+
+	/*
+	 * Use the basetype for the target, which shouldn't be required to follow
+	 * domain rules. The table's column type is in the Var if we need it.
+	 */
+	result->isDomain = attbasetype != attr->atttypid;
+
+	if (forPortionOf->target)
+	{
+		Oid			declared_target_type = attbasetype;
+		Oid			actual_target_type;
+
+		/*
+		 * We were already given an expression for the target, so we don't
+		 * have to build anything. We still have to make sure we got the right
+		 * type. NULL will be caught be the executor.
+		 */
+
+		result->targetRange = transformExpr(pstate,
+											forPortionOf->target,
+											EXPR_KIND_FOR_PORTION);
+		checkForPortionOfBound(pstate, result->targetRange);
+
+		actual_target_type = exprType(result->targetRange);
+
+		if (!can_coerce_type(1, &actual_target_type, &declared_target_type, COERCION_IMPLICIT))
+			ereport(ERROR,
+					(errcode(ERRCODE_DATATYPE_MISMATCH),
+					 errmsg("could not coerce FOR PORTION OF target from %s to %s",
+							format_type_be(actual_target_type),
+							format_type_be(declared_target_type)),
+					 parser_errposition(pstate, exprLocation(forPortionOf->target))));
+
+		result->targetRange = coerce_type(pstate,
+										  result->targetRange,
+										  actual_target_type,
+										  declared_target_type,
+										  -1,
+										  COERCION_IMPLICIT,
+										  COERCE_IMPLICIT_CAST,
+										  exprLocation(forPortionOf->target));
+
+		/*
+		 * XXX: For now we only support ranges and multiranges, so we fail on
+		 * anything else.
+		 */
+		if (!type_is_range(attbasetype) && !type_is_multirange(attbasetype))
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_COLUMN_REFERENCE),
+					 errmsg("column \"%s\" of relation \"%s\" is not a range or multirange type",
+							forPortionOf->range_name,
+							RelationGetRelationName(targetrel)),
+					 parser_errposition(pstate, forPortionOf->location)));
+
+	}
+	else
+	{
+		Oid			rngsubtype;
+		Oid			declared_arg_types[2];
+		Oid			actual_arg_types[2];
+		List	   *args;
+
+		/*
+		 * Make sure it's a range column. XXX: We could support this syntax on
+		 * multirange columns too, if we just built a one-range multirange
+		 * from the FROM/TO phrases.
+		 */
+		if (!type_is_range(attbasetype))
+			ereport(ERROR,
+					(errcode(ERRCODE_INVALID_COLUMN_REFERENCE),
+					 errmsg("column \"%s\" of relation \"%s\" is not a range type",
+							forPortionOf->range_name,
+							RelationGetRelationName(targetrel)),
+					 parser_errposition(pstate, forPortionOf->location)));
+
+		rngsubtype = get_range_subtype(attbasetype);
+		declared_arg_types[0] = rngsubtype;
+		declared_arg_types[1] = rngsubtype;
+
+		/*
+		 * Build a range from the FROM ... TO ... bounds. This should give a
+		 * constant result, so we accept functions like NOW() but not column
+		 * references, subqueries, etc.
+		 */
+		result->targetFrom = transformExpr(pstate,
+										   forPortionOf->target_start,
+										   EXPR_KIND_FOR_PORTION);
+		checkForPortionOfBound(pstate, result->targetFrom);
+		result->targetTo = transformExpr(pstate,
+										 forPortionOf->target_end,
+										 EXPR_KIND_FOR_PORTION);
+		checkForPortionOfBound(pstate, result->targetTo);
+		actual_arg_types[0] = exprType(result->targetFrom);
+		actual_arg_types[1] = exprType(result->targetTo);
+		args = list_make2(result->targetFrom, result->targetTo);
+
+		/*
+		 * Check the bound types separately, for better error message and
+		 * location
+		 */
+		if (!can_coerce_type(1, actual_arg_types, declared_arg_types, COERCION_IMPLICIT))
+			ereport(ERROR,
+					(errcode(ERRCODE_DATATYPE_MISMATCH),
+					 errmsg("could not coerce FOR PORTION OF %s bound from %s to %s",
+							"FROM",
+							format_type_be(actual_arg_types[0]),
+							format_type_be(declared_arg_types[0])),
+					 parser_errposition(pstate, exprLocation(forPortionOf->target_start))));
+		if (!can_coerce_type(1, &actual_arg_types[1], &declared_arg_types[1], COERCION_IMPLICIT))
+			ereport(ERROR,
+					(errcode(ERRCODE_DATATYPE_MISMATCH),
+					 errmsg("could not coerce FOR PORTION OF %s bound from %s to %s",
+							"TO",
+							format_type_be(actual_arg_types[1]),
+							format_type_be(declared_arg_types[1])),
+					 parser_errposition(pstate, exprLocation(forPortionOf->target_end))));
+
+		make_fn_arguments(pstate, args, actual_arg_types, declared_arg_types);
+
+		/*
+		 * Keep the *coerced* bounds.  This lets prepared statements use
+		 * parameters without explicit casts, and it improves deparsing when
+		 * FOR PORTION OF appears in a function or RULE.
+		 */
+		result->targetFrom = copyObject((Node *) linitial(args));
+		result->targetTo = copyObject((Node *) lsecond(args));
+
+		result->targetRange = (Node *) makeFuncExpr(get_range_constructor2(attbasetype),
+													attbasetype,
+													args,
+													InvalidOid, InvalidOid, COERCE_EXPLICIT_CALL);
+	}
+
+	/*
+	 * Build overlapsExpr to use as an extra qual. This means we only hit rows
+	 * matching the FROM & TO bounds. We must look up the overlaps operator
+	 * (usually "&&").
+	 */
+	opclass = GetDefaultOpClass(attr->atttypid, GIST_AM_OID);
+	if (!OidIsValid(opclass))
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_OBJECT),
+				 errmsg("data type %s has no default operator class for access method \"%s\"",
+						format_type_be(attr->atttypid), "gist"),
+				 errhint("You must define a default operator class for the data type.")));
+
+	/* Look up the operators and functions we need. */
+	GetOperatorFromCompareType(opclass, InvalidOid, COMPARE_OVERLAP, &opid, &strat);
+	op = makeNode(OpExpr);
+	op->opno = opid;
+	op->opfuncid = get_opcode(opid);
+	op->opresulttype = BOOLOID;
+	op->args = list_make2(copyObject(rangeVar), copyObject(result->targetRange));
+	result->overlapsExpr = (Node *) op;
+
+	/*
+	 * Look up the without_portion func. This computes the bounds of temporal
+	 * leftovers.
+	 *
+	 * XXX: Find a more extensible way to look up the function, permitting
+	 * user-defined types. An opclass support function doesn't make sense,
+	 * since there is no index involved. Perhaps a type support function.
+	 */
+	if (type_is_range(attbasetype))
+		result->withoutPortionProc = F_RANGE_MINUS_MULTI;
+	else if (type_is_multirange(attbasetype))
+		result->withoutPortionProc = F_MULTIRANGE_MINUS_MULTI;
+	else
+		elog(ERROR, "unexpected type for FOR PORTION OF column: %u",
+			 attbasetype);
+
+	if (isUpdate)
+	{
+		/*
+		 * Now make sure we update the start/end time of the record. For a
+		 * range col (r) this is `r = r * targetRange` (where * is the
+		 * intersect operator).
+		 */
+		Oid			intersectoperoid;
+		List	   *funcArgs;
+		Node	   *rangeTLEExpr;
+		TargetEntry *tle;
+		RTEPermissionInfo *target_perminfo = pstate->p_target_nsitem->p_perminfo;
+
+		/*
+		 * Whatever operator is used for intersect by temporal foreign keys,
+		 * we can use its backing procedure for intersects in FOR PORTION OF.
+		 * XXX: Share code with FindFKPeriodOpers?
+		 */
+		if (type_is_range(attbasetype))
+			intersectoperoid = OID_RANGE_INTERSECT_RANGE_OP;
+		else if (type_is_multirange(attbasetype))
+			intersectoperoid = OID_MULTIRANGE_INTERSECT_MULTIRANGE_OP;
+		else
+			elog(ERROR, "unexpected type for FOR PORTION OF column: %u",
+				 attbasetype);
+
+		funcid = get_opcode(intersectoperoid);
+		if (!OidIsValid(funcid))
+			ereport(ERROR,
+					errcode(ERRCODE_UNDEFINED_OBJECT),
+					errmsg("could not identify an intersect function for type %s",
+						   format_type_be(attbasetype)));
+
+		funcArgs = list_make2(copyObject(rangeVar),
+							  copyObject(result->targetRange));
+		rangeTLEExpr = (Node *) makeFuncExpr(funcid, attbasetype, funcArgs,
+											 InvalidOid, InvalidOid,
+											 COERCE_EXPLICIT_CALL);
+
+		/*
+		 * Coerce to domain if necessary. If we skip this, we will allow
+		 * updating to forbidden values.
+		 */
+		rangeTLEExpr = coerce_type(pstate,
+								   rangeTLEExpr,
+								   attbasetype,
+								   attr->atttypid,
+								   -1,
+								   COERCION_IMPLICIT,
+								   COERCE_IMPLICIT_CAST,
+								   exprLocation(forPortionOf->target));
+
+		/* Make a TLE to set the range column */
+		result->rangeTargetList = NIL;
+		tle = makeTargetEntry((Expr *) rangeTLEExpr, range_attno,
+							  forPortionOf->range_name, false);
+		result->rangeTargetList = lappend(result->rangeTargetList, tle);
+
+		/* Mark the range column as requiring update permissions */
+		target_perminfo->updatedCols = bms_add_member(target_perminfo->updatedCols,
+													  range_attno - FirstLowInvalidHeapAttributeNumber);
+	}
+	else
+		result->rangeTargetList = NIL;
+
+	result->location = forPortionOf->location;
+	result->targetLocation = forPortionOf->target_location;
+
+	return result;
+}
+
+/*
+ * checkForPortionOfBound
+ *
+ *	  Complain if a transformed FOR PORTION OF bound references a column.
+ *
+ *	  Allow PL/pgSQL vars and SQL function params, but forbid columns from
+ *	  expanding whole-row references. In checkColumnRefAllowed we don't have the
+ *	  context to tell those apart.
+ */
+static void
+checkForPortionOfBound(ParseState *pstate, Node *expr)
+{
+	if (contain_var_clause(expr))
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("cannot use column reference in FOR PORTION OF expression"),
+				 parser_errposition(pstate, locate_var_of_level(expr, 0))));
+}
 
 /*
  * BuildOnConflictExcludedTargetlist
@@ -2572,6 +2918,17 @@ transformUpdateStmt(ParseState *pstate, UpdateStmt *stmt)
 	nsitem->p_lateral_only = false;
 	nsitem->p_lateral_ok = true;
 
+	/*
+	 * FOR PORTION OF bounds can't reference columns, but the error message
+	 * is more intelligible if we transform them after FROM.
+	 */
+	if (stmt->forPortionOf)
+		qry->forPortionOf = transformForPortionOfClause(pstate,
+														qry->resultRelation,
+														stmt->forPortionOf,
+														stmt->whereClause,
+														true);
+
 	qual = transformWhereClause(pstate, stmt->whereClause,
 								EXPR_KIND_WHERE, "WHERE");
 
@@ -2582,7 +2939,8 @@ transformUpdateStmt(ParseState *pstate, UpdateStmt *stmt)
 	 * Now we are done with SELECT-like processing, and can get on with
 	 * transforming the target list to match the UPDATE target columns.
 	 */
-	qry->targetList = transformUpdateTargetList(pstate, stmt->targetList);
+	qry->targetList = transformUpdateTargetList(pstate, stmt->targetList,
+												qry->forPortionOf);
 
 	qry->rtable = pstate->p_rtable;
 	qry->rteperminfos = pstate->p_rteperminfos;
@@ -2601,7 +2959,7 @@ transformUpdateStmt(ParseState *pstate, UpdateStmt *stmt)
  *	handle SET clause in UPDATE/MERGE/INSERT ... ON CONFLICT UPDATE
  */
 List *
-transformUpdateTargetList(ParseState *pstate, List *origTlist)
+transformUpdateTargetList(ParseState *pstate, List *origTlist, ForPortionOfExpr *forPortionOf)
 {
 	List	   *tlist = NIL;
 	RTEPermissionInfo *target_perminfo;
@@ -2654,6 +3012,20 @@ transformUpdateTargetList(ParseState *pstate, List *origTlist)
 					 errhint("SET target columns cannot be qualified with the relation name.") : 0,
 					 parser_errposition(pstate, origTarget->location)));
 
+		/*
+		 * If this is a FOR PORTION OF update, forbid directly setting the
+		 * range column, since that would conflict with the implicit updates.
+		 */
+		if (forPortionOf != NULL)
+		{
+			if (attrno == forPortionOf->rangeVar->varattno)
+				ereport(ERROR,
+						(errcode(ERRCODE_SYNTAX_ERROR),
+						 errmsg("cannot update column \"%s\" because it is used in FOR PORTION OF",
+								origTarget->name),
+						 parser_errposition(pstate, origTarget->location)));
+		}
+
 		updateTargetListEntry(pstate, tle, origTarget->name,
 							  attrno,
 							  origTarget->indirection,
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 0563453fe24..143e2876388 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -556,6 +556,8 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 %type <range>	relation_expr
 %type <range>	extended_relation_expr
 %type <range>	relation_expr_opt_alias
+%type <alias>	for_portion_of_opt_alias
+%type <node>	for_portion_of_clause
 %type <node>	tablesample_clause opt_repeatable_clause
 %type <target>	target_el set_target insert_column_item
 
@@ -776,7 +778,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
 	OVER OVERLAPS OVERLAY OVERRIDING OWNED OWNER
 
 	PARALLEL PARAMETER PARSER PARTIAL PARTITION PASSING PASSWORD PATH
-	PERIOD PLACING PLAN PLANS POLICY
+	PERIOD PLACING PLAN PLANS POLICY PORTION
 	POSITION PRECEDING PRECISION PRESERVE PREPARE PREPARED PRIMARY
 	PRIOR PRIVILEGES PROCEDURAL PROCEDURE PROCEDURES PROGRAM PUBLICATION
 
@@ -895,12 +897,15 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
  * json_predicate_type_constraint and json_key_uniqueness_constraint_opt
  * productions (see comments there).
  *
+ * TO is assigned the same precedence as IDENT, to support the opt_interval
+ * production (see comment there).
+ *
  * Like the UNBOUNDED PRECEDING/FOLLOWING case, NESTED is assigned a lower
  * precedence than PATH to fix ambiguity in the json_table production.
  */
 %nonassoc	UNBOUNDED NESTED /* ideally would have same precedence as IDENT */
 %nonassoc	IDENT PARTITION RANGE ROWS GROUPS PRECEDING FOLLOWING CUBE ROLLUP
-			SET KEYS OBJECT_P SCALAR VALUE_P WITH WITHOUT PATH
+			SET KEYS OBJECT_P SCALAR TO USING VALUE_P WITH WITHOUT PATH
 %left		Op OPERATOR		/* multi-character ops and user-defined operators */
 %left		'+' '-'
 %left		'*' '/' '%'
@@ -12712,6 +12717,21 @@ DeleteStmt: opt_with_clause DELETE_P FROM relation_expr_opt_alias
 					n->withClause = $1;
 					$$ = (Node *) n;
 				}
+			| opt_with_clause DELETE_P FROM relation_expr
+			for_portion_of_clause for_portion_of_opt_alias
+			using_clause where_or_current_clause returning_clause
+				{
+					DeleteStmt *n = makeNode(DeleteStmt);
+
+					n->relation = $4;
+					n->forPortionOf = (ForPortionOfClause *) $5;
+					n->relation->alias = $6;
+					n->usingClause = $7;
+					n->whereClause = $8;
+					n->returningClause = $9;
+					n->withClause = $1;
+					$$ = (Node *) n;
+				}
 		;
 
 using_clause:
@@ -12786,6 +12806,25 @@ UpdateStmt: opt_with_clause UPDATE relation_expr_opt_alias
 					n->withClause = $1;
 					$$ = (Node *) n;
 				}
+			| opt_with_clause UPDATE relation_expr
+			for_portion_of_clause for_portion_of_opt_alias
+			SET set_clause_list
+			from_clause
+			where_or_current_clause
+			returning_clause
+				{
+					UpdateStmt *n = makeNode(UpdateStmt);
+
+					n->relation = $3;
+					n->forPortionOf = (ForPortionOfClause *) $4;
+					n->relation->alias = $5;
+					n->targetList = $7;
+					n->fromClause = $8;
+					n->whereClause = $9;
+					n->returningClause = $10;
+					n->withClause = $1;
+					$$ = (Node *) n;
+				}
 		;
 
 set_clause_list:
@@ -14276,6 +14315,55 @@ relation_expr_opt_alias: relation_expr					%prec UMINUS
 				}
 		;
 
+/*
+ * If an UPDATE/DELETE has FOR PORTION OF, then the relation_expr is separated
+ * from its potential alias by the for_portion_of_clause. So this production
+ * handles the potential alias in those cases. We need to solve the same
+ * problems as relation_expr_opt_alias, in particular resolving a shift/reduce
+ * conflict where "set set" could be an alias plus the SET keyword, or the SET
+ * keyword then a column name. As above, we force the latter interpretation by
+ * giving the non-alias choice a higher precedence.
+ */
+for_portion_of_opt_alias:
+			AS ColId
+				{
+					Alias	   *alias = makeNode(Alias);
+
+					alias->aliasname = $2;
+					$$ = alias;
+				}
+			| BareColLabel
+				{
+					Alias	   *alias = makeNode(Alias);
+
+					alias->aliasname = $1;
+					$$ = alias;
+				}
+			| /* empty */ %prec UMINUS { $$ = NULL; }
+		;
+
+for_portion_of_clause:
+			FOR PORTION OF ColId '(' a_expr ')'
+				{
+					ForPortionOfClause *n = makeNode(ForPortionOfClause);
+					n->range_name = $4;
+					n->location = @4;
+					n->target = $6;
+					n->target_location = @6;
+					$$ = (Node *) n;
+				}
+			| FOR PORTION OF ColId FROM a_expr TO a_expr
+				{
+					ForPortionOfClause *n = makeNode(ForPortionOfClause);
+					n->range_name = $4;
+					n->location = @4;
+					n->target_start = $6;
+					n->target_end = $8;
+					n->target_location = @5;
+					$$ = (Node *) n;
+				}
+		;
+
 /*
  * TABLESAMPLE decoration in a FROM item
  */
@@ -15194,16 +15282,25 @@ opt_timezone:
 			| /*EMPTY*/								{ $$ = false; }
 		;
 
+/*
+ * We need to handle this shift/reduce conflict:
+ * FOR PORTION OF valid_at FROM t + INTERVAL '1' YEAR TO MONTH.
+ * We don't see far enough ahead to know if there is another TO coming.
+ * We prefer to interpret this as FROM (t + INTERVAL '1' YEAR TO MONTH),
+ * i.e. to shift.
+ * That gives the user the option of adding parentheses to get the other meaning.
+ * If we reduced, intervals could never have a TO.
+ */
 opt_interval:
-			YEAR_P
+			YEAR_P																%prec IS
 				{ $$ = list_make1(makeIntConst(INTERVAL_MASK(YEAR), @1)); }
 			| MONTH_P
 				{ $$ = list_make1(makeIntConst(INTERVAL_MASK(MONTH), @1)); }
-			| DAY_P
+			| DAY_P																%prec IS
 				{ $$ = list_make1(makeIntConst(INTERVAL_MASK(DAY), @1)); }
-			| HOUR_P
+			| HOUR_P															%prec IS
 				{ $$ = list_make1(makeIntConst(INTERVAL_MASK(HOUR), @1)); }
-			| MINUTE_P
+			| MINUTE_P														%prec IS
 				{ $$ = list_make1(makeIntConst(INTERVAL_MASK(MINUTE), @1)); }
 			| interval_second
 				{ $$ = $1; }
@@ -18278,6 +18375,7 @@ unreserved_keyword:
 			| PLAN
 			| PLANS
 			| POLICY
+			| PORTION
 			| PRECEDING
 			| PREPARE
 			| PREPARED
@@ -18910,6 +19008,7 @@ bare_label_keyword:
 			| PLAN
 			| PLANS
 			| POLICY
+			| PORTION
 			| POSITION
 			| PRECEDING
 			| PREPARE
diff --git a/src/backend/parser/parse_agg.c b/src/backend/parser/parse_agg.c
index a5424c9d24c..fac401e85d5 100644
--- a/src/backend/parser/parse_agg.c
+++ b/src/backend/parser/parse_agg.c
@@ -584,6 +584,13 @@ check_agglevels_and_constraints(ParseState *pstate, Node *expr)
 		case EXPR_KIND_CYCLE_MARK:
 			errkind = true;
 			break;
+		case EXPR_KIND_FOR_PORTION:
+			if (isAgg)
+				err = _("aggregate functions are not allowed in FOR PORTION OF expressions");
+			else
+				err = _("grouping operations are not allowed in FOR PORTION OF expressions");
+
+			break;
 
 			/*
 			 * There is intentionally no default: case here, so that the
@@ -1024,6 +1031,9 @@ transformWindowFuncCall(ParseState *pstate, WindowFunc *wfunc,
 		case EXPR_KIND_CYCLE_MARK:
 			errkind = true;
 			break;
+		case EXPR_KIND_FOR_PORTION:
+			err = _("window functions are not allowed in FOR PORTION OF expressions");
+			break;
 
 			/*
 			 * There is intentionally no default: case here, so that the
diff --git a/src/backend/parser/parse_collate.c b/src/backend/parser/parse_collate.c
index ba7df2a7789..2f2da1f4203 100644
--- a/src/backend/parser/parse_collate.c
+++ b/src/backend/parser/parse_collate.c
@@ -484,6 +484,7 @@ assign_collations_walker(Node *node, assign_collations_context *context)
 		case T_JoinExpr:
 		case T_FromExpr:
 		case T_OnConflictExpr:
+		case T_ForPortionOfExpr:
 		case T_SortGroupClause:
 		case T_MergeAction:
 			(void) expression_tree_walker(node,
diff --git a/src/backend/parser/parse_expr.c b/src/backend/parser/parse_expr.c
index e69a1859d49..e2010a89b06 100644
--- a/src/backend/parser/parse_expr.c
+++ b/src/backend/parser/parse_expr.c
@@ -566,6 +566,13 @@ checkColumnRefAllowed(ParseState *pstate, ParseExprKind exprKind, int location)
 			/* okay */
 			break;
 
+		case EXPR_KIND_FOR_PORTION:
+			/*
+			 * Actual columns are forbidden, but we need to allow PL/pgSQL
+			 * variables, so we enforce this in checkForPortionOfBound.
+			 */
+			break;
+
 		case EXPR_KIND_COLUMN_DEFAULT:
 			err = _("cannot use column reference in DEFAULT expression");
 			break;
@@ -1883,6 +1890,9 @@ transformSubLink(ParseState *pstate, SubLink *sublink)
 		case EXPR_KIND_GENERATED_COLUMN:
 			err = _("cannot use subquery in column generation expression");
 			break;
+		case EXPR_KIND_FOR_PORTION:
+			err = _("cannot use subquery in FOR PORTION OF expression");
+			break;
 
 			/*
 			 * There is intentionally no default: case here, so that the
@@ -3242,6 +3252,8 @@ ParseExprKindName(ParseExprKind exprKind)
 			return "GENERATED AS";
 		case EXPR_KIND_CYCLE_MARK:
 			return "CYCLE";
+		case EXPR_KIND_FOR_PORTION:
+			return "FOR PORTION OF";
 
 			/*
 			 * There is intentionally no default: case here, so that the
diff --git a/src/backend/parser/parse_func.c b/src/backend/parser/parse_func.c
index 410e54accd8..17f41d6eb05 100644
--- a/src/backend/parser/parse_func.c
+++ b/src/backend/parser/parse_func.c
@@ -2842,6 +2842,9 @@ check_srf_call_placement(ParseState *pstate, Node *last_srf, int location)
 		case EXPR_KIND_CYCLE_MARK:
 			errkind = true;
 			break;
+		case EXPR_KIND_FOR_PORTION:
+			err = _("set-returning functions are not allowed in FOR PORTION OF expressions");
+			break;
 
 			/*
 			 * There is intentionally no default: case here, so that the
diff --git a/src/backend/parser/parse_merge.c b/src/backend/parser/parse_merge.c
index 0a70d48fd4c..2e6dd166c98 100644
--- a/src/backend/parser/parse_merge.c
+++ b/src/backend/parser/parse_merge.c
@@ -381,7 +381,7 @@ transformMergeStmt(ParseState *pstate, MergeStmt *stmt)
 			case CMD_UPDATE:
 				action->targetList =
 					transformUpdateTargetList(pstate,
-											  mergeWhenClause->targetList);
+											  mergeWhenClause->targetList, NULL);
 				break;
 			case CMD_DELETE:
 				break;
diff --git a/src/backend/rewrite/rewriteHandler.c b/src/backend/rewrite/rewriteHandler.c
index 27964cb83c6..e3814ab2bee 100644
--- a/src/backend/rewrite/rewriteHandler.c
+++ b/src/backend/rewrite/rewriteHandler.c
@@ -3469,6 +3469,53 @@ rewriteTargetView(Query *parsetree, Relation view)
 		}
 	}
 
+	/*
+	 * Similarly, make sure the FOR PORTION OF column is updateable. This is
+	 * not included in the columns tested above, and we have to test it even
+	 * for DELETEs.
+	 */
+	if (parsetree->forPortionOf)
+	{
+		AttrNumber	rangeAttno;
+		Bitmapset  *fpo_cols;
+		char	   *non_updatable_col;
+		const char *fpo_update_detail;
+
+		rangeAttno = parsetree->forPortionOf->rangeVar->varattno;
+		fpo_cols = bms_make_singleton(rangeAttno - FirstLowInvalidHeapAttributeNumber);
+
+		fpo_update_detail = view_cols_are_auto_updatable(viewquery,
+														 fpo_cols,
+														 NULL,
+														 &non_updatable_col);
+		if (fpo_update_detail)
+		{
+			switch (parsetree->commandType)
+			{
+				case CMD_UPDATE:
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("cannot update column \"%s\" of view \"%s\"",
+									non_updatable_col,
+									RelationGetRelationName(view)),
+							 errdetail_internal("%s", _(fpo_update_detail))));
+					break;
+				case CMD_DELETE:
+					ereport(ERROR,
+							(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+							 errmsg("cannot delete from view \"%s\" using FOR PORTION OF \"%s\"",
+									RelationGetRelationName(view),
+									non_updatable_col),
+							 errdetail_internal("%s", _(fpo_update_detail))));
+					break;
+				default:
+					elog(ERROR, "unrecognized CmdType: %d",
+						 (int) parsetree->commandType);
+					break;
+			}
+		}
+	}
+
 	/*
 	 * For MERGE, there must not be any INSTEAD OF triggers on an otherwise
 	 * updatable view.  The caller already checked that there isn't a full set
@@ -3810,6 +3857,30 @@ rewriteTargetView(Query *parsetree, Relation view)
 									  &parsetree->hasSubLinks);
 	}
 
+	if (parsetree->forPortionOf && parsetree->commandType == CMD_UPDATE)
+	{
+		/*
+		 * Like the INSERT/UPDATE code above, update the resnos in the
+		 * auxiliary UPDATE targetlist to refer to columns of the base
+		 * relation.
+		 */
+		foreach(lc, parsetree->forPortionOf->rangeTargetList)
+		{
+			TargetEntry *tle = (TargetEntry *) lfirst(lc);
+			TargetEntry *view_tle;
+
+			if (tle->resjunk)
+				continue;
+
+			view_tle = get_tle_by_resno(view_targetlist, tle->resno);
+			if (view_tle != NULL && !view_tle->resjunk && IsA(view_tle->expr, Var))
+				tle->resno = ((Var *) view_tle->expr)->varattno;
+			else
+				elog(ERROR, "attribute number %d not found in view targetlist",
+					 tle->resno);
+		}
+	}
+
 	/*
 	 * For UPDATE/DELETE/MERGE, pull up any WHERE quals from the view.  We
 	 * know that any Vars in the quals must reference the one base relation,
@@ -3870,8 +3941,14 @@ rewriteTargetView(Query *parsetree, Relation view)
 	 * the WITH CHECK OPTION, or any parent view specified WITH CASCADED CHECK
 	 * OPTION, add the quals from the view to the query's withCheckOptions
 	 * list.
+	 *
+	 * DELETE FOR PORTION OF needs this too: it inserts temporal leftovers to
+	 * preserve the untouched parts of the deleted row, and those must not
+	 * escape the view either.  For UPDATE, any WCO we add below will apply to
+	 * inserted leftovers as well.
 	 */
-	if (insert_or_update)
+	if (insert_or_update ||
+		(parsetree->commandType == CMD_DELETE && parsetree->forPortionOf != NULL))
 	{
 		bool		has_wco = RelationHasCheckOption(view);
 		bool		cascaded = RelationHasCascadedCheckOption(view);
@@ -4088,6 +4165,14 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length,
 		 */
 		rt_entry_relation = relation_open(rt_entry->relid, NoLock);
 
+		/* We don't support FOR PORTION OF on views with INSTEAD OF triggers. */
+		if (parsetree->forPortionOf &&
+			rt_entry_relation->rd_rel->relkind == RELKIND_VIEW &&
+			view_has_instead_trigger(rt_entry_relation, event, NIL))
+			ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg("views with INSTEAD OF triggers do not support FOR PORTION OF")));
+
 		/*
 		 * Rewrite the targetlist as needed for the command type.
 		 */
@@ -4166,6 +4251,61 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length,
 		else if (event == CMD_UPDATE)
 		{
 			Assert(parsetree->override == OVERRIDING_NOT_SET);
+
+			if (parsetree->forPortionOf)
+			{
+				/*
+				 * Don't add FOR PORTION OF details until we're done rewriting
+				 * a view update, so that we don't add the same qual and TLE
+				 * on the recursion.
+				 *
+				 * Views don't need to do anything special here to remap Vars;
+				 * that is handled by the tree walker.
+				 */
+				if (rt_entry_relation->rd_rel->relkind != RELKIND_VIEW)
+				{
+					ListCell   *tl;
+
+					/*
+					 * Add qual: UPDATE FOR PORTION OF should be limited to
+					 * rows that overlap the target range.
+					 */
+					AddQual(parsetree, parsetree->forPortionOf->overlapsExpr);
+
+					/* Update FOR PORTION OF column(s) automatically. */
+					foreach(tl, parsetree->forPortionOf->rangeTargetList)
+					{
+						TargetEntry *tle = (TargetEntry *) lfirst(tl);
+						ListCell   *tl2;
+
+						/*
+						 * Detect multiple-assignment early, so that we can
+						 * report the same error as against a regular table.
+						 * If we detect it later, the error message will use
+						 * the column names of the base table, not the view.
+						 */
+						foreach(tl2, parsetree->targetList)
+						{
+							TargetEntry *prior = (TargetEntry *) lfirst(tl2);
+
+							if (prior->resjunk || prior->resno != tle->resno)
+								continue;
+
+							ereport(ERROR,
+									(errcode(ERRCODE_SYNTAX_ERROR),
+									 errmsg("cannot update column \"%s\" because it is used in FOR PORTION OF",
+											prior->resname),
+									 strcmp(prior->resname, tle->resname) != 0 ?
+									 errdetail("Columns \"%s\" and \"%s\" are the same column of relation \"%s\".",
+											   prior->resname, tle->resname,
+											   RelationGetRelationName(rt_entry_relation)) : 0));
+						}
+
+						parsetree->targetList = lappend(parsetree->targetList, tle);
+					}
+				}
+			}
+
 			parsetree->targetList =
 				rewriteTargetListIU(parsetree->targetList,
 									parsetree->commandType,
@@ -4211,7 +4351,25 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length,
 		}
 		else if (event == CMD_DELETE)
 		{
-			/* Nothing to do here */
+			if (parsetree->forPortionOf)
+			{
+				/*
+				 * Don't add FOR PORTION OF details until we're done rewriting
+				 * a view delete, so that we don't add the same qual on the
+				 * recursion.
+				 *
+				 * Views don't need to do anything special here to remap Vars;
+				 * that is handled by the tree walker.
+				 */
+				if (rt_entry_relation->rd_rel->relkind != RELKIND_VIEW)
+				{
+					/*
+					 * Add qual: DELETE FOR PORTION OF should be limited to
+					 * rows that overlap the target range.
+					 */
+					AddQual(parsetree, parsetree->forPortionOf->overlapsExpr);
+				}
+			}
 		}
 		else
 			elog(ERROR, "unrecognized commandType: %d", (int) event);
@@ -4315,6 +4473,21 @@ RewriteQuery(Query *parsetree, List *rewrite_events, int orig_rt_length,
 										 parsetree->mergeActionList,
 										 gettext_noop("Views with conditional DO INSTEAD rules are not automatically updatable."));
 
+			/*
+			 * We don't support FOR PORTION OF on views with DO ALSO rules
+			 * either.  product_queries holds their actions, and fireRules()
+			 * built those from a query that does not carry the FOR PORTION OF
+			 * restriction yet: we add the overlaps qual and the range
+			 * assignment only after the view has been rewritten down to its
+			 * base relation, so as not to add them twice.  So a rule action
+			 * would see an unnarrowed NEW range and would not be limited to
+			 * the rows the statement targets.
+			 */
+			if (parsetree->forPortionOf && product_queries != NIL)
+				ereport(ERROR,
+						(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+						 errmsg("views with DO ALSO rules do not support FOR PORTION OF")));
+
 			/*
 			 * Attempt to rewrite the query to automatically update the view.
 			 * This throws an error if the view can't be automatically
diff --git a/src/backend/rewrite/rowsecurity.c b/src/backend/rewrite/rowsecurity.c
index e88a1bc1a89..46d88f79894 100644
--- a/src/backend/rewrite/rowsecurity.c
+++ b/src/backend/rewrite/rowsecurity.c
@@ -393,6 +393,31 @@ get_row_security_policies(Query *root, RangeTblEntry *rte, int rt_index,
 		}
 	}
 
+	/*
+	 * UPDATE/DELETE FOR PORTION OF may insert leftover rows to preserve the
+	 * portions of the old row not covered by the target range.  Those hidden
+	 * inserts go through ExecInsert(), so they need the same INSERT RLS WITH
+	 * CHECK options as ordinary INSERTs.  SELECT rights are never needed for
+	 * the leftover rows, because they are not considered by RETURNING.
+	 */
+	if (root->forPortionOf != NULL && rt_index == root->resultRelation &&
+		(commandType == CMD_UPDATE || commandType == CMD_DELETE))
+	{
+		List	   *insert_permissive_policies;
+		List	   *insert_restrictive_policies;
+
+		get_policies_for_relation(rel, CMD_INSERT, user_id,
+								  &insert_permissive_policies,
+								  &insert_restrictive_policies);
+		add_with_check_options(rel, rt_index,
+							   WCO_RLS_INSERT_CHECK,
+							   insert_permissive_policies,
+							   insert_restrictive_policies,
+							   withCheckOptions,
+							   hasSubLinks,
+							   false);
+	}
+
 	/*
 	 * FOR MERGE, we fetch policies for UPDATE, DELETE and INSERT (and ALL)
 	 * and set them up so that we can enforce the appropriate policy depending
diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c
index 8f6a46fa614..863ff9551ce 100644
--- a/src/backend/utils/adt/ruleutils.c
+++ b/src/backend/utils/adt/ruleutils.c
@@ -518,6 +518,9 @@ static void get_rte_alias(RangeTblEntry *rte, int varno, bool use_as,
 						  deparse_context *context);
 static void get_column_alias_list(deparse_columns *colinfo,
 								  deparse_context *context);
+static void get_for_portion_of(ForPortionOfExpr *forPortionOf,
+							   RangeTblEntry *rte,
+							   deparse_context *context);
 static void get_from_clause_coldeflist(RangeTblFunction *rtfunc,
 									   deparse_columns *colinfo,
 									   deparse_context *context);
@@ -7198,6 +7201,9 @@ get_update_query_def(Query *query, deparse_context *context)
 					 only_marker(rte),
 					 generate_relation_name(rte->relid, NIL));
 
+	/* Print the FOR PORTION OF, if needed */
+	get_for_portion_of(query->forPortionOf, rte, context);
+
 	/*
 	 * Print the relation alias, if needed.  Always write AS, since a bare
 	 * alias named "set" would be read as the SET keyword.
@@ -7405,6 +7411,9 @@ get_delete_query_def(Query *query, deparse_context *context)
 					 only_marker(rte),
 					 generate_relation_name(rte->relid, NIL));
 
+	/* Print the FOR PORTION OF, if needed */
+	get_for_portion_of(query->forPortionOf, rte, context);
+
 	/*
 	 * Print the relation alias, if needed.  Always write AS, since a bare
 	 * alias named "set" would be read as the SET keyword.
@@ -12997,6 +13006,47 @@ get_rte_alias(RangeTblEntry *rte, int varno, bool use_as,
 						 quote_identifier(refname));
 }
 
+/*
+ * get_for_portion_of - print FOR PORTION OF if needed
+ * XXX: Newlines would help here, at least when pretty-printing. But then the
+ * alias and SET will be on their own line with a leading space.
+ */
+static void
+get_for_portion_of(ForPortionOfExpr *forPortionOf, RangeTblEntry *rte,
+				   deparse_context *context)
+{
+	if (forPortionOf)
+	{
+		char	   *range_name;
+
+		range_name = get_attname(rte->relid,
+								 forPortionOf->rangeVar->varattno,
+								 false);
+		appendStringInfo(context->buf, " FOR PORTION OF %s",
+						 quote_identifier(range_name));
+
+		/*
+		 * Try to write it as FROM (...) TO (...) if we received it that way,
+		 * otherwise (targetRange). The first form needs parens in case it uses
+		 * INTERVAL hour TO minute etc.
+		 */
+		if (forPortionOf->targetFrom && forPortionOf->targetTo)
+		{
+			appendStringInfoString(context->buf, " FROM (");
+			get_rule_expr(forPortionOf->targetFrom, context, false);
+			appendStringInfoString(context->buf, ") TO (");
+			get_rule_expr(forPortionOf->targetTo, context, false);
+			appendStringInfoChar(context->buf, ')');
+		}
+		else
+		{
+			appendStringInfoString(context->buf, " (");
+			get_rule_expr(forPortionOf->targetRange, context, false);
+			appendStringInfoChar(context->buf, ')');
+		}
+	}
+}
+
 /*
  * get_column_alias_list - print column alias list for an RTE
  *
diff --git a/src/backend/utils/cache/lsyscache.c b/src/backend/utils/cache/lsyscache.c
index 65b2d64529a..2b12070fdb3 100644
--- a/src/backend/utils/cache/lsyscache.c
+++ b/src/backend/utils/cache/lsyscache.c
@@ -3782,6 +3782,31 @@ get_range_collation(Oid rangeOid)
 		return InvalidOid;
 }
 
+/*
+ * get_range_constructor2
+ *		Gets the 2-arg constructor for the given rangetype.
+ *
+ *	Raises an error if not found.
+ */
+RegProcedure
+get_range_constructor2(Oid rangeOid)
+{
+	HeapTuple	tp;
+
+	tp = SearchSysCache1(RANGETYPE, ObjectIdGetDatum(rangeOid));
+	if (HeapTupleIsValid(tp))
+	{
+		Form_pg_range rngtup = (Form_pg_range) GETSTRUCT(tp);
+		RegProcedure result;
+
+		result = rngtup->rngconstruct2;
+		ReleaseSysCache(tp);
+		return result;
+	}
+	else
+		elog(ERROR, "cache lookup failed for range type %u", rangeOid);
+}
+
 /*
  * get_range_multirange
  *		Returns the multirange type of a given range type
diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 1d2e5f5b92a..64e27ef87a3 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -44,7 +44,9 @@ $node->safe_psql('postgres',
 	  . "CREATE TABLE mytab246 (f1 int, f2 text);\n"
 	  . "CREATE TABLE \"mixedName\" (f1 int, f2 text);\n"
 	  . "CREATE TYPE enum1 AS ENUM ('foo', 'bar', 'baz', 'BLACK');\n"
-	  . "CREATE PUBLICATION some_publication;\n");
+	  . "CREATE PUBLICATION some_publication;\n"
+	  . "CREATE TABLE fpo_test (id int4range, valid_at daterange, name text);\n"
+);
 
 # In a VPATH build, we'll be started in the source directory, but we want
 # to run in the build directory so that we can use relative paths to
@@ -422,6 +424,42 @@ check_completion(
 
 clear_line();
 
+# check tab completion for DELETE ... FOR PORTION OF
+check_completion(
+	"DELETE FROM fpo_test F\t",
+	qr/FOR /,
+	"complete DELETE FROM <table> F<tab> to FOR");
+
+check_completion("P\t", qr/PORTION /, "complete FOR P<tab> to PORTION");
+
+check_completion("O\t", qr/OF /, "complete PORTION O<tab> to OF");
+
+check_completion("v\t", qr/valid_at /,
+	"complete FOR PORTION OF offers column names");
+
+check_completion("FR\t", qr/FROM /,
+	"complete FOR PORTION OF <col> FR<tab> to FROM");
+
+clear_query();
+
+# check tab completion for UPDATE ... FOR PORTION OF
+check_completion(
+	"UPDATE fpo_test F\t",
+	qr/FOR /,
+	"complete UPDATE <table> F<tab> to FOR");
+
+check_completion("P\t", qr/PORTION /, "complete FOR P<tab> to PORTION");
+
+check_completion("O\t", qr/OF /, "complete PORTION O<tab> to OF");
+
+check_completion("v\t", qr/valid_at /,
+	"complete FOR PORTION OF offers column names");
+
+check_completion("FR\t", qr/FROM /,
+	"complete FOR PORTION OF <col> FR<tab> to FROM");
+
+clear_query();
+
 # send psql an explicit \q to shut it down, else pty won't close properly
 $h->quit or die "psql returned $?";
 
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 96de78cd087..fbf31647d68 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -4301,7 +4301,19 @@ match_previous_words(int pattern_id,
 		COMPLETE_WITH_SCHEMA_QUERY(Query_for_list_of_updatables);
 	/* Complete DELETE FROM <table> */
 	else if (TailMatches("DELETE", "FROM", MatchAny))
-		COMPLETE_WITH("USING", "WHERE");
+		COMPLETE_WITH("FOR", "USING", "WHERE");
+	/* Complete DELETE FROM <table> FOR with PORTION */
+	else if (TailMatches("DELETE", "FROM", MatchAny, "FOR"))
+		COMPLETE_WITH("PORTION");
+	/* Complete DELETE FROM <table> FOR PORTION with OF */
+	else if (TailMatches("DELETE", "FROM", MatchAny, "FOR", "PORTION"))
+		COMPLETE_WITH("OF");
+	/* Complete DELETE FROM <table> FOR PORTION OF with column names */
+	else if (TailMatches("DELETE", "FROM", MatchAny, "FOR", "PORTION", "OF"))
+		COMPLETE_WITH_ATTR(prev4_wd);
+	/* Complete DELETE FROM <table> FOR PORTION OF <period> with FROM */
+	else if (TailMatches("DELETE", "FROM", MatchAny, "FOR", "PORTION", "OF", MatchAny))
+		COMPLETE_WITH("FROM");
 	/* Complete DELETE FROM <table> USING with relations supporting SELECT */
 	else if (TailMatches("DELETE", "FROM", MatchAny, "USING"))
 		COMPLETE_WITH_SCHEMA_QUERY(Query_for_list_of_selectables);
@@ -5354,9 +5366,21 @@ match_previous_words(int pattern_id,
 	/* If prev. word is UPDATE suggest a list of tables */
 	else if (TailMatches("UPDATE"))
 		COMPLETE_WITH_SCHEMA_QUERY(Query_for_list_of_updatables);
-	/* Complete UPDATE <table> with "SET" */
+	/* Complete UPDATE <table> with "SET" or "FOR" (for FOR PORTION OF) */
 	else if (TailMatches("UPDATE", MatchAny))
-		COMPLETE_WITH("SET");
+		COMPLETE_WITH("FOR", "SET");
+	/* Complete UPDATE <table> FOR with PORTION */
+	else if (TailMatches("UPDATE", MatchAny, "FOR"))
+		COMPLETE_WITH("PORTION");
+	/* Complete UPDATE <table> FOR PORTION with OF */
+	else if (TailMatches("UPDATE", MatchAny, "FOR", "PORTION"))
+		COMPLETE_WITH("OF");
+	/* Complete UPDATE <table> FOR PORTION OF with column names */
+	else if (TailMatches("UPDATE", MatchAny, "FOR", "PORTION", "OF"))
+		COMPLETE_WITH_ATTR(prev4_wd);
+	/* Complete UPDATE <table> FOR PORTION OF <period> with FROM */
+	else if (TailMatches("UPDATE", MatchAny, "FOR", "PORTION", "OF", MatchAny))
+		COMPLETE_WITH("FROM");
 	/* Complete UPDATE <table> SET with list of attributes */
 	else if (TailMatches("UPDATE", MatchAny, "SET"))
 		COMPLETE_WITH_ATTR(prev2_wd);
diff --git a/src/include/commands/trigger.h b/src/include/commands/trigger.h
index d5cb16597f4..51ca2b73187 100644
--- a/src/include/commands/trigger.h
+++ b/src/include/commands/trigger.h
@@ -258,6 +258,7 @@ extern void ExecBSTruncateTriggers(EState *estate,
 								   ResultRelInfo *relinfo);
 extern void ExecASTruncateTriggers(EState *estate,
 								   ResultRelInfo *relinfo);
+extern int	errdetail_temporal_leftovers(ResultRelInfo *relinfo);
 
 extern void AfterTriggerBeginXact(void);
 extern void AfterTriggerBeginQuery(void);
@@ -277,6 +278,7 @@ extern bool RI_FKey_pk_upd_check_required(Trigger *trigger, Relation pk_rel,
 										  TupleTableSlot *oldslot, TupleTableSlot *newslot);
 extern bool RI_FKey_fk_upd_check_required(Trigger *trigger, Relation fk_rel,
 										  TupleTableSlot *oldslot, TupleTableSlot *newslot);
+
 extern bool RI_Initial_Check(Trigger *trigger,
 							 Relation fk_rel, Relation pk_rel);
 extern void RI_PartitionRemove_Check(Trigger *trigger, Relation fk_rel,
diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h
index 152a1dfa568..50e5a95323f 100644
--- a/src/include/executor/executor.h
+++ b/src/include/executor/executor.h
@@ -249,7 +249,7 @@ extern bool ExecCheckPermissions(List *rangeTable,
 extern bool ExecCheckOneRelPerms(RTEPermissionInfo *perminfo);
 extern void CheckValidResultRel(ResultRelInfo *resultRelInfo, CmdType operation,
 								OnConflictAction onConflictAction,
-								List *mergeActions);
+								List *mergeActions, ModifyTable *mtnode);
 extern void InitResultRelInfo(ResultRelInfo *resultRelInfo,
 							  Relation resultRelationDesc,
 							  Index resultRelationIndex,
diff --git a/src/include/executor/nodeModifyTable.h b/src/include/executor/nodeModifyTable.h
index f6070e1cdf3..ef64605e2e5 100644
--- a/src/include/executor/nodeModifyTable.h
+++ b/src/include/executor/nodeModifyTable.h
@@ -30,4 +30,11 @@ extern void ExecReScanModifyTable(ModifyTableState *node);
 extern void ExecInitMergeTupleSlots(ModifyTableState *mtstate,
 									ResultRelInfo *resultRelInfo);
 
+extern Datum ExecForPortionOfOldRange(ResultRelInfo *resultRelInfo,
+									  ItemPointer tupleid);
+extern void ExecForPortionOfCheckSplit(ResultRelInfo *resultRelInfo,
+									   Datum oldRange,
+									   TupleTableSlot *newSlot);
+pg_noreturn extern void ExecForPortionOfConcurrentDelete(void);
+
 #endif							/* NODEMODIFYTABLE_H */
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index fe8681ba642..478a75d1ae4 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -39,6 +39,7 @@
 #include "partitioning/partdefs.h"
 #include "storage/buf.h"
 #include "utils/reltrigger.h"
+#include "utils/typcache.h"
 
 
 /*
@@ -466,6 +467,27 @@ typedef struct MergeActionState
 	ExprState  *mas_whenqual;	/* WHEN [NOT] MATCHED AND conditions */
 } MergeActionState;
 
+/*
+ * ForPortionOfState
+ *
+ * Executor state of a FOR PORTION OF operation.
+ */
+typedef struct ForPortionOfState
+{
+	NodeTag		type;
+
+	int			fp_rangeAttno;	/* the attno of the range column */
+	Datum		fp_targetRange; /* the range/multirange from FOR PORTION OF */
+	TupleTableSlot *fp_existing;	/* slot to store old tuple */
+	TupleTableSlot *fp_leftover;	/* slot to store leftover */
+	FmgrInfo   *fp_withoutPortionInfo;	/* without_portion flinfo */
+	FunctionCallInfo fp_withoutPortionFcinfo;	/* without_portion fcinfo */
+	ReturnSetInfo *fp_withoutPortionRsi;	/* without_portion rsi */
+	FmgrInfo   *fp_overlapsInfo;	/* overlaps flinfo, for CheckSplit */
+	Oid			fp_overlapsCollation;	/* overlaps input collation */
+	void	   *fp_domaininfo;	/* cache space for domain_check() */
+} ForPortionOfState;
+
 /*
  * ResultRelInfo
  *
@@ -602,6 +624,9 @@ typedef struct ResultRelInfo
 	/* for MERGE, expr state for checking the join condition */
 	ExprState  *ri_MergeJoinCondition;
 
+	/* FOR PORTION OF evaluation state */
+	ForPortionOfState *ri_forPortionOf;
+
 	/* partition check expression state (NULL if not set up yet) */
 	ExprState  *ri_PartitionCheckExpr;
 
@@ -1460,6 +1485,15 @@ typedef struct ModifyTableState
 	/* controls transition table population for INSERT...ON CONFLICT UPDATE */
 	struct TransitionCaptureState *mt_oc_transition_capture;
 
+	/*
+	 * True while ExecForPortionOfLeftovers() is inserting a temporal
+	 * leftover.  Such an insert is a statement in its own right as far as
+	 * the SQL standard is concerned, even though mt_operation is CMD_UPDATE
+	 * or CMD_DELETE, so the few places that would otherwise treat it as part
+	 * of the enclosing statement consult this instead.
+	 */
+	bool		mt_forPortionOfLeftover;
+
 	/* Flags showing which subcommands are present INS/UPD/DEL/DO NOTHING */
 	int			mt_merge_subcommands;
 
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 0debcd193ab..8eb3ab89fc6 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -149,6 +149,9 @@ typedef struct Query
 	 */
 	int			resultRelation pg_node_attr(query_jumble_ignore);
 
+	/* FOR PORTION OF clause for UPDATE/DELETE */
+	ForPortionOfExpr *forPortionOf;
+
 	/* has aggregates in tlist or havingQual */
 	bool		hasAggs pg_node_attr(query_jumble_ignore);
 	/* has window functions in tlist */
@@ -1612,6 +1615,22 @@ typedef struct RowMarkClause
 	bool		pushedDown;		/* pushed down from higher query level? */
 } RowMarkClause;
 
+/*
+ * ForPortionOfClause
+ *		representation of FOR PORTION OF <range-name> FROM <target-start> TO
+ *		<target-end> or FOR PORTION OF <range-name> (<target>)
+ */
+typedef struct ForPortionOfClause
+{
+	NodeTag		type;
+	char	   *range_name;		/* column name of the range/multirange */
+	ParseLoc	location;		/* token location, or -1 if unknown */
+	ParseLoc	target_location;	/* token location, or -1 if unknown */
+	Node	   *target;			/* Expr from FOR PORTION OF col (...) syntax */
+	Node	   *target_start;	/* Expr from FROM ... TO ... syntax */
+	Node	   *target_end;		/* Expr from FROM ... TO ... syntax */
+} ForPortionOfClause;
+
 /*
  * WithClause -
  *	   representation of WITH clause
@@ -2169,6 +2188,7 @@ typedef struct DeleteStmt
 	Node	   *whereClause;	/* qualifications */
 	ReturningClause *returningClause;	/* RETURNING clause */
 	WithClause *withClause;		/* WITH clause */
+	ForPortionOfClause *forPortionOf;	/* FOR PORTION OF clause */
 } DeleteStmt;
 
 /* ----------------------
@@ -2184,6 +2204,7 @@ typedef struct UpdateStmt
 	List	   *fromClause;		/* optional from clause for more tables */
 	ReturningClause *returningClause;	/* RETURNING clause */
 	WithClause *withClause;		/* WITH clause */
+	ForPortionOfClause *forPortionOf;	/* FOR PORTION OF clause */
 } UpdateStmt;
 
 /* ----------------------
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index ff46a9f7419..84510ba0fe7 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -2741,6 +2741,7 @@ typedef struct ModifyTablePath
 	List	   *returningLists; /* per-target-table RETURNING tlists */
 	List	   *rowMarks;		/* PlanRowMarks (non-locking only) */
 	OnConflictExpr *onconflict; /* ON CONFLICT clause, or NULL */
+	ForPortionOfExpr *forPortionOf; /* FOR PORTION OF clause for UPDATE/DELETE */
 	int			epqParam;		/* ID of Param for EvalPlanQual re-eval */
 	List	   *mergeActionLists;	/* per-target-table lists of actions for
 									 * MERGE */
diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h
index 9e538f3c44a..09a7fda9cbe 100644
--- a/src/include/nodes/plannodes.h
+++ b/src/include/nodes/plannodes.h
@@ -376,6 +376,8 @@ typedef struct ModifyTable
 	List	   *onConflictCols;
 	/* WHERE for ON CONFLICT DO SELECT/UPDATE */
 	Node	   *onConflictWhere;
+	/* FOR PORTION OF clause for UPDATE/DELETE */
+	Node	   *forPortionOf;
 	/* RTI of the EXCLUDED pseudo relation */
 	Index		exclRelRTI;
 	/* tlist of the EXCLUDED pseudo relation */
diff --git a/src/include/nodes/primnodes.h b/src/include/nodes/primnodes.h
index 726c186c7d2..9f52eb042cf 100644
--- a/src/include/nodes/primnodes.h
+++ b/src/include/nodes/primnodes.h
@@ -2398,4 +2398,37 @@ typedef struct OnConflictExpr
 	List	   *exclRelTlist;	/* tlist of the EXCLUDED pseudo relation */
 } OnConflictExpr;
 
+/*----------
+ * ForPortionOfExpr - represents a FOR PORTION OF ... expression
+ *
+ * We set up an expression to make a range from the FROM/TO bounds,
+ * so that we can use range operators with it.
+ *
+ * Then we set up an overlaps expression between that and the range column,
+ * so that we can find the rows we need to update/delete.
+ *
+ * If the user used the FROM ... TO ... syntax, we save the individual
+ * expressions so that we can deparse them.
+ *
+ * In the executor we'll also build an intersect expression between the
+ * targeted range and the range column, so that we can update the start/end
+ * bounds of the UPDATE'd record.
+ *----------
+ */
+typedef struct ForPortionOfExpr
+{
+	NodeTag		type;
+	Var		   *rangeVar;		/* Range column */
+	Node	   *targetFrom;		/* FOR PORTION OF FROM bound, if given */
+	Node	   *targetTo;		/* FOR PORTION OF TO bound, if given */
+	Node	   *targetRange;	/* FOR PORTION OF bounds as a range/multirange */
+	bool		isDomain;		/* Is rangeVar a domain? */
+	Node	   *overlapsExpr;	/* range && targetRange */
+	List	   *rangeTargetList;	/* List of TargetEntrys to set the time
+									 * column(s) */
+	Oid			withoutPortionProc; /* SRF proc for old_range - target_range */
+	ParseLoc	location;		/* token location, or -1 if unknown */
+	ParseLoc	targetLocation; /* token location, or -1 if unknown */
+} ForPortionOfExpr;
+
 #endif							/* PRIMNODES_H */
diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h
index e74a95d0d22..50b2c9f3f62 100644
--- a/src/include/optimizer/pathnode.h
+++ b/src/include/optimizer/pathnode.h
@@ -319,7 +319,7 @@ extern ModifyTablePath *create_modifytable_path(PlannerInfo *root,
 												List *withCheckOptionLists, List *returningLists,
 												List *rowMarks, OnConflictExpr *onconflict,
 												List *mergeActionLists, List *mergeJoinConditions,
-												int epqParam);
+												ForPortionOfExpr *forPortionOf, int epqParam);
 extern LimitPath *create_limit_path(PlannerInfo *root, RelOptInfo *rel,
 									Path *subpath,
 									Node *limitOffset, Node *limitCount,
diff --git a/src/include/parser/analyze.h b/src/include/parser/analyze.h
index 4f7bf4bd0a9..b48a48978e8 100644
--- a/src/include/parser/analyze.h
+++ b/src/include/parser/analyze.h
@@ -43,7 +43,8 @@ extern List *transformInsertRow(ParseState *pstate, List *exprlist,
 								List *stmtcols, List *icolumns, List *attrnos,
 								bool strip_indirection);
 extern List *transformUpdateTargetList(ParseState *pstate,
-									   List *origTlist);
+									   List *origTlist,
+									   ForPortionOfExpr *forPortionOf);
 extern void transformReturningClause(ParseState *pstate, Query *qry,
 									 ReturningClause *returningClause,
 									 ParseExprKind exprKind);
diff --git a/src/include/parser/kwlist.h b/src/include/parser/kwlist.h
index 53ae96c0399..beda7a22385 100644
--- a/src/include/parser/kwlist.h
+++ b/src/include/parser/kwlist.h
@@ -347,6 +347,7 @@ PG_KEYWORD("placing", PLACING, RESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("plan", PLAN, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("plans", PLANS, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("policy", POLICY, UNRESERVED_KEYWORD, BARE_LABEL)
+PG_KEYWORD("portion", PORTION, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("position", POSITION, COL_NAME_KEYWORD, BARE_LABEL)
 PG_KEYWORD("preceding", PRECEDING, UNRESERVED_KEYWORD, BARE_LABEL)
 PG_KEYWORD("precision", PRECISION, COL_NAME_KEYWORD, AS_LABEL)
diff --git a/src/include/parser/parse_node.h b/src/include/parser/parse_node.h
index f23e21f318b..3eaeb7a90e1 100644
--- a/src/include/parser/parse_node.h
+++ b/src/include/parser/parse_node.h
@@ -56,6 +56,7 @@ typedef enum ParseExprKind
 	EXPR_KIND_UPDATE_SOURCE,	/* UPDATE assignment source item */
 	EXPR_KIND_UPDATE_TARGET,	/* UPDATE assignment target item */
 	EXPR_KIND_MERGE_WHEN,		/* MERGE WHEN [NOT] MATCHED condition */
+	EXPR_KIND_FOR_PORTION,		/* UPDATE/DELETE FOR PORTION OF item */
 	EXPR_KIND_GROUP_BY,			/* GROUP BY */
 	EXPR_KIND_ORDER_BY,			/* ORDER BY */
 	EXPR_KIND_DISTINCT_ON,		/* DISTINCT ON */
diff --git a/src/include/utils/lsyscache.h b/src/include/utils/lsyscache.h
index 171fbda9a71..99d1d2b5000 100644
--- a/src/include/utils/lsyscache.h
+++ b/src/include/utils/lsyscache.h
@@ -205,6 +205,7 @@ extern char *get_namespace_name_or_temp(Oid nspid);
 extern char *get_qualified_objname(Oid nspid, char *objname);
 extern Oid	get_range_subtype(Oid rangeOid);
 extern Oid	get_range_collation(Oid rangeOid);
+extern Oid	get_range_constructor2(Oid rangeOid);
 extern Oid	get_range_multirange(Oid rangeOid);
 extern Oid	get_multirange_range(Oid multirangeOid);
 extern Oid	get_index_column_opclass(Oid index_oid, int attno);
diff --git a/src/test/isolation/expected/fk-snapshot-3.out b/src/test/isolation/expected/fk-snapshot-3.out
index 2dc490be48b..d0c5dd3a0fd 100644
--- a/src/test/isolation/expected/fk-snapshot-3.out
+++ b/src/test/isolation/expected/fk-snapshot-3.out
@@ -211,3 +211,50 @@ step s1c: COMMIT;
 step s2ins: <... completed>
 ERROR:  could not serialize access due to concurrent update
 step s2c: COMMIT;
+
+starting permutation: s1rc s2rc s2ins s1delfpook s2c s1c
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2ins: 
+  INSERT INTO child VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)');
+
+step s1delfpook: DELETE FROM parent FOR PORTION OF valid_at FROM '2026-01-01' TO '2027-01-01'; <waiting ...>
+step s2c: COMMIT;
+step s1delfpook: <... completed>
+step s1c: COMMIT;
+
+starting permutation: s1rc s2rc s1delfpook s2ins s1c s2c
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1delfpook: DELETE FROM parent FOR PORTION OF valid_at FROM '2026-01-01' TO '2027-01-01';
+step s2ins: 
+  INSERT INTO child VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)');
+ <waiting ...>
+step s1c: COMMIT;
+step s2ins: <... completed>
+ERROR:  insert or update on table "child" violates foreign key constraint "child_parent_id_valid_at_fkey"
+step s2c: COMMIT;
+
+starting permutation: s1rc s2rc s2ins s1delfpobad s2c s1c
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2ins: 
+  INSERT INTO child VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)');
+
+step s1delfpobad: DELETE FROM parent FOR PORTION OF valid_at FROM '2024-01-01' TO '2025-01-01'; <waiting ...>
+step s2c: COMMIT;
+step s1delfpobad: <... completed>
+ERROR:  update or delete on table "parent" violates foreign key constraint "child_parent_id_valid_at_fkey" on table "child"
+step s1c: COMMIT;
+
+starting permutation: s1rc s2rc s1delfpobad s2ins s1c s2c
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1delfpobad: DELETE FROM parent FOR PORTION OF valid_at FROM '2024-01-01' TO '2025-01-01';
+step s2ins: 
+  INSERT INTO child VALUES ('[1,2)', '[2020-01-01,2025-01-01)', '[1,2)');
+ <waiting ...>
+step s1c: COMMIT;
+step s2ins: <... completed>
+ERROR:  insert or update on table "child" violates foreign key constraint "child_parent_id_valid_at_fkey"
+step s2c: COMMIT;
diff --git a/src/test/isolation/expected/for-portion-of-deadlock.out b/src/test/isolation/expected/for-portion-of-deadlock.out
new file mode 100644
index 00000000000..28da78d9bc2
--- /dev/null
+++ b/src/test/isolation/expected/for-portion-of-deadlock.out
@@ -0,0 +1,86 @@
+Parsed test spec with 3 sessions
+
+starting permutation: s1b s2b s3b s1lock2020 s2lock2021 s3lock2022 s1lock2021 s2lock2022 s3lock2020 s2upd2022 s2c s1upd2021 s1c s3c s1q
+step s1b: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2b: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s3b: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2020: SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2020-01-01,2021-01-01)' FOR UPDATE;
+price
+-----
+ 5.00
+(1 row)
+
+step s2lock2021: SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2021-01-01,2022-01-01)' FOR UPDATE;
+price
+-----
+ 5.00
+(1 row)
+
+step s3lock2022: SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2022-01-01,2023-01-01)' FOR UPDATE;
+price
+-----
+ 5.00
+(1 row)
+
+step s1lock2021: SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2021-01-01,2022-01-01)' FOR UPDATE; <waiting ...>
+step s2lock2022: SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2022-01-01,2023-01-01)' FOR UPDATE; <waiting ...>
+step s3lock2020: SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2020-01-01,2021-01-01)' FOR UPDATE; <waiting ...>
+step s3lock2020: <... completed>
+ERROR:  deadlock detected
+step s2lock2022: <... completed>
+price
+-----
+ 5.00
+(1 row)
+
+step s2upd2022: UPDATE products FOR PORTION OF valid_at FROM '2022-03-01' TO '2022-04-01' SET price = 8.00 WHERE id = '[1,2)';
+step s2c: COMMIT;
+step s1lock2021: <... completed>
+price
+-----
+ 5.00
+(1 row)
+
+step s1upd2021: UPDATE products FOR PORTION OF valid_at FROM '2021-03-01' TO '2021-04-01' SET price = 8.00 WHERE id = '[1,2)';
+step s1c: COMMIT;
+step s3c: COMMIT;
+step s1q: SELECT id, valid_at, price FROM products ORDER BY valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2021-01-01)| 5.00
+[1,2)|[2021-01-01,2021-03-01)| 5.00
+[1,2)|[2021-03-01,2021-04-01)| 8.00
+[1,2)|[2021-04-01,2022-01-01)| 5.00
+[1,2)|[2022-01-01,2022-03-01)| 5.00
+[1,2)|[2022-03-01,2022-04-01)| 8.00
+[1,2)|[2022-04-01,2023-01-01)| 5.00
+(7 rows)
+
+
+starting permutation: s1b s2b s3b s1lock2020 s2lock2020 s3lock2020 s1c s2c s3c
+step s1b: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2b: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s3b: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2020: SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2020-01-01,2021-01-01)' FOR UPDATE;
+price
+-----
+ 5.00
+(1 row)
+
+step s2lock2020: SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2020-01-01,2021-01-01)' FOR UPDATE; <waiting ...>
+step s3lock2020: SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2020-01-01,2021-01-01)' FOR UPDATE; <waiting ...>
+step s1c: COMMIT;
+step s2lock2020: <... completed>
+price
+-----
+ 5.00
+(1 row)
+
+step s2c: COMMIT;
+step s3lock2020: <... completed>
+price
+-----
+ 5.00
+(1 row)
+
+step s3c: COMMIT;
diff --git a/src/test/isolation/expected/for-portion-of.out b/src/test/isolation/expected/for-portion-of.out
new file mode 100644
index 00000000000..af336ee84b6
--- /dev/null
+++ b/src/test/isolation/expected/for-portion-of.out
@@ -0,0 +1,4689 @@
+Parsed test spec with 3 sessions
+
+starting permutation: s1rc s2rc s2lock2027 s2upd2027 s2c s1lock2025 s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s2lock202503 s2upd202503 s2c s1lock2025 s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(3 rows)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-03-01,2025-04-01)| 8.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s2lock20252026 s2upd20252026 s2c s1lock2025 s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2025-06-01,2026-06-01)|10.00
+(2 rows)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2025-06-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1upd2025 s1c s2lock2027 s2upd2027 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1upd2025 s1c s2lock202503 s2upd202503 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+(1 row)
+
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1upd2025 s1c s2lock20252026 s2upd20252026 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2025-06-01,2026-01-01)|10.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s2upd2027 s1upd2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2lock2027 s2upd2027 s1lock2025 s2c s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s2upd202503 s1upd2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2lock202503 s2upd202503 s1lock2025 s2c s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2025-03-01,2025-04-01)|10.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-03-01,2025-04-01)| 8.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s2upd20252026 s1upd2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2025-06-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2lock20252026 s2upd20252026 s1lock2025 s2c s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2025-06-01,2026-06-01)|10.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2025-06-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s2updprice s1upd2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2updprice: 
+  UPDATE products
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)|10.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)|10.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2upd20242027 s1upd2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2upd20242027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2024-01-01' TO '2027-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2024-01-01)| 5.00
+[1,2)|[2024-01-01,2025-01-01)|10.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)|10.00
+[1,2)|[2027-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s2lock2027 s2del2027 s2c s1lock2025 s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rc s2rc s2lock202503 s2del202503 s2c s1lock2025 s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(2 rows)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rc s2rc s2lock20252026 s2del20252026 s2c s1lock2025 s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1upd2025 s1c s2lock2027 s2del2027 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1upd2025 s1c s2lock202503 s2del202503 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+(1 row)
+
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1upd2025 s1c s2lock20252026 s2del20252026 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2del2027 s1upd2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s2lock2027 s2del2027 s1lock2025 s2c s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rc s2rc s2del202503 s1upd2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s2lock202503 s2del202503 s1lock2025 s2c s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rc s2rc s2del20252026 s1upd2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s2lock20252026 s2del20252026 s1lock2025 s2c s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2lock2027 s2upd2027 s2c s1lock2025 s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rc s2rc s2lock202503 s2upd202503 s2c s1lock2025 s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(3 rows)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s2lock20252026 s2upd20252026 s2c s1lock2025 s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2025-06-01,2026-06-01)|10.00
+(2 rows)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1del2025 s1c s2lock2027 s2upd2027 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1del2025 s1c s2lock202503 s2upd202503 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1del2025 s1c s2lock20252026 s2upd20252026 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2upd2027 s1del2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2lock2027 s2upd2027 s1lock2025 s2c s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rc s2rc s2upd202503 s1del2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2lock202503 s2upd202503 s1lock2025 s2c s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2025-03-01,2025-04-01)|10.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s2upd20252026 s1del2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2025-06-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2lock20252026 s2upd20252026 s1lock2025 s2c s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2025-06-01,2026-06-01)|10.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2lock2027 s2del2027 s2c s1lock2025 s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2lock202503 s2del202503 s2c s1lock2025 s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(2 rows)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s2lock20252026 s2del20252026 s2c s1lock2025 s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1del2025 s1c s2lock2027 s2del2027 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1del2025 s1c s2lock202503 s2del202503 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s1lock2025 s1del2025 s1c s2lock20252026 s2del20252026 s2c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s2del2027 s1del2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s2lock2027 s2del2027 s1lock2025 s2c s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rc s2rc s2del202503 s1del2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s2lock202503 s2del202503 s1lock2025 s2c s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock202503: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s2del20252026 s1del2025 s2c s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rc s2rc s2lock20252026 s2del20252026 s1lock2025 s2c s1del2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s2upd2027 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rr s2rr s2upd202503 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-03-01,2025-04-01)| 8.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rr s2rr s2upd20252026 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2025-06-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rr s2rr s1upd2025 s1c s2upd2027 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rr s2rr s1upd2025 s1c s2upd202503 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rr s2rr s1upd2025 s1c s2upd20252026 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2025-06-01,2026-01-01)|10.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rr s2rr s2upd2027 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s2upd202503 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s2upd20252026 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2025-06-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd2027 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd202503 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd20252026 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2025-06-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s1upd2025 s1c s2upd2027 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rr s2rr s1q s1upd2025 s1c s2upd202503 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rr s2rr s1q s1upd2025 s1c s2upd20252026 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2025-06-01,2026-01-01)|10.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd2027 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd202503 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd20252026 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2025-06-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s2del2027 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rr s2rr s2del202503 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rr s2rr s2del20252026 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1upd2025 s1c s2del2027 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rr s2rr s1upd2025 s1c s2del202503 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rr s2rr s1upd2025 s1c s2del20252026 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s2del2027 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s2del202503 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s2del20252026 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del2027 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del202503 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del20252026 s2c s1upd2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s1upd2025 s1c s2del2027 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rr s2rr s1q s1upd2025 s1c s2del202503 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-03-01)| 8.00
+[1,2)|[2025-04-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rr s2rr s1q s1upd2025 s1c s2del20252026 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del2027 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del202503 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del20252026 s1upd2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s2upd2027 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rr s2rr s2upd202503 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s2upd20252026 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1del2025 s1c s2upd2027 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rr s2rr s1del2025 s1c s2upd202503 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1del2025 s1c s2upd20252026 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s2upd2027 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s2upd202503 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s2upd20252026 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2025-06-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd2027 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd202503 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd20252026 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2025-06-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s1del2025 s1c s2upd2027 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(4 rows)
+
+
+starting permutation: s1rr s2rr s1q s1del2025 s1c s2upd202503 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s1del2025 s1c s2upd20252026 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd2027 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd202503 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd202503: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-03-01,2025-04-01)|10.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s2upd20252026 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2025-06-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s2del2027 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s2del202503 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s2del20252026 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1del2025 s1c s2del2027 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1del2025 s1c s2del202503 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1del2025 s1c s2del20252026 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s2del2027 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s2del202503 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s2del20252026 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del2027 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del202503 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del20252026 s2c s1del2025 s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s1del2025 s1c s2del2027 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1rr s2rr s1q s1del2025 s1c s2del202503 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s1del2025 s1c s2del20252026 s2c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del2027 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del202503 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del202503: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-03-01)| 5.00
+[1,2)|[2025-04-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1rr s2rr s1q s2del20252026 s1del2025 s2c s1c s1q
+step s1rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s2rr: BEGIN ISOLATION LEVEL REPEATABLE READ;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2del20252026: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1ser s2ser s2upd2027 s2c s1upd2025 s1c s1q
+step s1ser: BEGIN ISOLATION LEVEL SERIALIZABLE;
+step s2ser: BEGIN ISOLATION LEVEL SERIALIZABLE;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1ser s2ser s2upd20252026 s2c s1upd2025 s1c s1q
+step s1ser: BEGIN ISOLATION LEVEL SERIALIZABLE;
+step s2ser: BEGIN ISOLATION LEVEL SERIALIZABLE;
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s2c: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2025-06-01)| 8.00
+[1,2)|[2025-06-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s2lock2027 s2upd2027 s1lock2025 s2c s2rc2 s2lock20252026 s2upd20252026 s1upd2025 s2c2 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s2rc2: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock20252026: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+(1 row)
+
+step s2upd20252026: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c2: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-06-01)| 5.00
+[1,2)|[2025-06-01,2026-06-01)|10.00
+[1,2)|[2026-06-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s2lock2027 s2upd2027 s1lock2025 s2c s2rc2 s2lock2021 s2upd2021 s1upd2025 s2c2 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s2rc2: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2021: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2021-01-01,2022-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+(1 row)
+
+step s2upd2021: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2021-01-01' TO '2022-01-01'
+  SET price = 20.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c2: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2021-01-01)| 5.00
+[1,2)|[2021-01-01,2022-01-01)|20.00
+[1,2)|[2022-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(5 rows)
+
+
+starting permutation: s1rc s2rc s2lock2027 s2upd2027 s1lock2025 s2c s2rc2 s2lock2021 s2upd2021 s2c2 s1upd2025 s1c s1q
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2027: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2030-01-01)| 5.00
+(1 row)
+
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1lock2025: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+ <waiting ...>
+step s2c: COMMIT;
+step s1lock2025: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s2rc2: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2lock2021: 
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2021-01-01,2022-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+(1 row)
+
+step s2upd2021: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2021-01-01' TO '2022-01-01'
+  SET price = 20.00
+  WHERE id = '[1,2)';
+
+step s2c2: COMMIT;
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2021-01-01)| 5.00
+[1,2)|[2021-01-01,2022-01-01)|20.00
+[1,2)|[2022-01-01,2025-01-01)| 5.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(7 rows)
+
+
+starting permutation: s1rc s2rc s3rc s1lockgap s1delgap s2lockgap s1c s3lockgap s3updgap s2updgap s3c s2c s1qgap
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s3rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lockgap: 
+  SELECT * FROM fpo_lockall_delete_gap WHERE id = '[1,2)' FOR UPDATE;
+
+id   |valid_at|price
+-----+--------+-----
+[1,2)|[0,10)  |    0
+(1 row)
+
+step s1delgap: 
+  DELETE FROM fpo_lockall_delete_gap
+  FOR PORTION OF valid_at FROM 0 TO 1
+  WHERE id = '[1,2)'
+  RETURNING *;
+
+id   |valid_at|price
+-----+--------+-----
+[1,2)|[0,10)  |    0
+(1 row)
+
+step s2lockgap: 
+  SELECT * FROM fpo_lockall_delete_gap WHERE id = '[1,2)' FOR UPDATE;
+ <waiting ...>
+step s1c: COMMIT;
+step s2lockgap: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s3lockgap: 
+  SELECT * FROM fpo_lockall_delete_gap WHERE id = '[1,2)' FOR UPDATE;
+
+id   |valid_at|price
+-----+--------+-----
+[1,2)|[1,10)  |    0
+(1 row)
+
+step s3updgap: 
+  UPDATE fpo_lockall_delete_gap
+  FOR PORTION OF valid_at FROM 2 TO 3
+  SET price = 3
+  WHERE id = '[1,2)'
+  RETURNING *;
+
+id   |valid_at|price
+-----+--------+-----
+[1,2)|[2,3)   |    3
+(1 row)
+
+step s2updgap: 
+  UPDATE fpo_lockall_delete_gap
+  FOR PORTION OF valid_at FROM 4 TO 5
+  SET price = 2
+  WHERE id = '[1,2)'
+  RETURNING *;
+ <waiting ...>
+step s3c: COMMIT;
+step s2updgap: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s2c: COMMIT;
+step s1qgap: SELECT * FROM fpo_lockall_delete_gap ORDER BY id, valid_at;
+id   |valid_at|price
+-----+--------+-----
+[1,2)|[1,2)   |    0
+[1,2)|[2,3)   |    3
+[1,2)|[3,10)  |    0
+(3 rows)
+
+
+starting permutation: s1rc s2rc s3rc s1lockgap s1delgap s2lockgap s3lockgap s1c s3updgap s2updgap s3c s2c s1qgap
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s3rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s1lockgap: 
+  SELECT * FROM fpo_lockall_delete_gap WHERE id = '[1,2)' FOR UPDATE;
+
+id   |valid_at|price
+-----+--------+-----
+[1,2)|[0,10)  |    0
+(1 row)
+
+step s1delgap: 
+  DELETE FROM fpo_lockall_delete_gap
+  FOR PORTION OF valid_at FROM 0 TO 1
+  WHERE id = '[1,2)'
+  RETURNING *;
+
+id   |valid_at|price
+-----+--------+-----
+[1,2)|[0,10)  |    0
+(1 row)
+
+step s2lockgap: 
+  SELECT * FROM fpo_lockall_delete_gap WHERE id = '[1,2)' FOR UPDATE;
+ <waiting ...>
+step s3lockgap: 
+  SELECT * FROM fpo_lockall_delete_gap WHERE id = '[1,2)' FOR UPDATE;
+ <waiting ...>
+step s1c: COMMIT;
+step s2lockgap: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s3lockgap: <... completed>
+id|valid_at|price
+--+--------+-----
+(0 rows)
+
+step s3updgap: 
+  UPDATE fpo_lockall_delete_gap
+  FOR PORTION OF valid_at FROM 2 TO 3
+  SET price = 3
+  WHERE id = '[1,2)'
+  RETURNING *;
+
+id   |valid_at|price
+-----+--------+-----
+[1,2)|[2,3)   |    3
+(1 row)
+
+step s2updgap: 
+  UPDATE fpo_lockall_delete_gap
+  FOR PORTION OF valid_at FROM 4 TO 5
+  SET price = 2
+  WHERE id = '[1,2)'
+  RETURNING *;
+ <waiting ...>
+step s3c: COMMIT;
+step s2updgap: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s2c: COMMIT;
+step s1qgap: SELECT * FROM fpo_lockall_delete_gap ORDER BY id, valid_at;
+id   |valid_at|price
+-----+--------+-----
+[1,2)|[1,2)   |    0
+[1,2)|[2,3)   |    3
+[1,2)|[3,10)  |    0
+(3 rows)
+
+
+starting permutation: s1trig s1rc s2rc s2upd2027 s1upd2025 s2c s1c s1q
+step s1trig: 
+  CREATE TRIGGER products_noop BEFORE UPDATE OR DELETE ON products
+  FOR EACH ROW EXECUTE FUNCTION fpo_noop_trig();
+
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1trig s1rc s2rc s2del2027 s1upd2025 s2c s1c s1q
+step s1trig: 
+  CREATE TRIGGER products_noop BEFORE UPDATE OR DELETE ON products
+  FOR EACH ROW EXECUTE FUNCTION fpo_noop_trig();
+
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1trig s1rc s2rc s2upd2027 s1del2025 s2c s1c s1q
+step s1trig: 
+  CREATE TRIGGER products_noop BEFORE UPDATE OR DELETE ON products
+  FOR EACH ROW EXECUTE FUNCTION fpo_noop_trig();
+
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
+
+starting permutation: s1trig s1rc s2rc s2del2027 s1del2025 s2c s1c s1q
+step s1trig: 
+  CREATE TRIGGER products_noop BEFORE UPDATE OR DELETE ON products
+  FOR EACH ROW EXECUTE FUNCTION fpo_noop_trig();
+
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2del2027: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+
+step s1del2025: 
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1del2025: <... completed>
+ERROR:  could not serialize access due to concurrent delete
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(2 rows)
+
+
+starting permutation: s1trig s1rc s2rc s2updprice s1upd2025 s2c s1c s1q
+step s1trig: 
+  CREATE TRIGGER products_noop BEFORE UPDATE OR DELETE ON products
+  FOR EACH ROW EXECUTE FUNCTION fpo_noop_trig();
+
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2updprice: 
+  UPDATE products
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2025-01-01)|10.00
+[1,2)|[2025-01-01,2026-01-01)| 8.00
+[1,2)|[2026-01-01,2030-01-01)|10.00
+(3 rows)
+
+
+starting permutation: s1trig s1trigoff s1rc s2rc s2upd2027 s1upd2025 s2c s1c s1q
+step s1trig: 
+  CREATE TRIGGER products_noop BEFORE UPDATE OR DELETE ON products
+  FOR EACH ROW EXECUTE FUNCTION fpo_noop_trig();
+
+step s1trigoff: ALTER TABLE products DISABLE TRIGGER products_noop;
+step s1rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2rc: BEGIN ISOLATION LEVEL READ COMMITTED;
+step s2upd2027: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+
+step s1upd2025: 
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+ <waiting ...>
+step s2c: COMMIT;
+step s1upd2025: <... completed>
+ERROR:  could not serialize access due to concurrent update
+step s1c: COMMIT;
+step s1q: SELECT * FROM products ORDER BY id, valid_at;
+id   |valid_at               |price
+-----+-----------------------+-----
+[1,2)|[2020-01-01,2027-01-01)| 5.00
+[1,2)|[2027-01-01,2028-01-01)|10.00
+[1,2)|[2028-01-01,2030-01-01)| 5.00
+(3 rows)
+
diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule
index bf9a2037c70..58f3476be02 100644
--- a/src/test/isolation/isolation_schedule
+++ b/src/test/isolation/isolation_schedule
@@ -128,6 +128,8 @@ test: serializable-parallel-2
 test: serializable-parallel-3
 test: matview-write-skew
 test: lock-nowait
+test: for-portion-of
+test: for-portion-of-deadlock
 test: ddl-dependency-locking
 test: tablespace-dependency-locking
 test: pub-concurrent-drop
diff --git a/src/test/isolation/specs/fk-snapshot-3.spec b/src/test/isolation/specs/fk-snapshot-3.spec
index d8237ca2e7a..7900a790ffe 100644
--- a/src/test/isolation/specs/fk-snapshot-3.spec
+++ b/src/test/isolation/specs/fk-snapshot-3.spec
@@ -27,6 +27,10 @@ step s1ser	{ BEGIN ISOLATION LEVEL SERIALIZABLE; }
 step s1del	{ DELETE FROM parent WHERE id = '[1,2)'; }
 step s1upok	{ UPDATE parent SET valid_at = '[2020-01-01,2026-01-01)' WHERE id = '[1,2)'; }
 step s1upbad	{ UPDATE parent SET valid_at = '[2020-01-01,2024-01-01)' WHERE id = '[1,2)'; }
+# Same coverage changes as s1upok/s1upbad, but done with DELETE ... FOR PORTION OF,
+# which trims the parent row and inserts leftovers:
+step s1delfpook		{ DELETE FROM parent FOR PORTION OF valid_at FROM '2026-01-01' TO '2027-01-01'; }
+step s1delfpobad	{ DELETE FROM parent FOR PORTION OF valid_at FROM '2024-01-01' TO '2025-01-01'; }
 step s1c	{ COMMIT; }
 
 session s2
@@ -80,3 +84,24 @@ permutation s1rr s2rr s1upbad s2ins s1c s2c
 # ...with SERIALIZABLE:
 permutation s1ser s2ser s2ins s1upbad s2c s1c
 permutation s1ser s2ser s1upbad s2ins s1c s2c
+
+# Trim the valid time with DELETE FOR PORTION OF, keeping enough coverage that
+# the child stays valid: the remaining spans [2020-01-01,2026-01-01) and
+# [2027-01-01,2030-01-01) still contain the child's [2020-01-01,2025-01-01).
+# DELETE FOR PORTION OF removes the matched parent row and reinserts leftovers
+# for the untouched spans, rather than updating the row in place.  So unlike the
+# plain narrowing UPDATE in s1upok above -- which leaves less coverage and yet
+# lets a concurrent child insert succeed -- when s1 changes the parent first the
+# child's RI recheck follows the deleted parent tuple via EvalPlanQual and does
+# not rescan to find the newly-inserted leftover, so the insert is rejected.
+# This is the same READ COMMITTED FOR PORTION OF anomaly documented in
+# for-portion-of.spec; explicit locking is the workaround.  Only READ COMMITTED
+# is interesting here: at the higher levels these orderings raise a
+# serialization failure, just as they do for the plain UPDATE steps above.
+permutation s1rc s2rc s2ins s1delfpook s2c s1c
+permutation s1rc s2rc s1delfpook s2ins s1c s2c
+
+# Carve a hole out of the valid time with DELETE FOR PORTION OF, removing
+# coverage the child needs.  The foreign key is still enforced:
+permutation s1rc s2rc s2ins s1delfpobad s2c s1c
+permutation s1rc s2rc s1delfpobad s2ins s1c s2c
diff --git a/src/test/isolation/specs/for-portion-of-deadlock.spec b/src/test/isolation/specs/for-portion-of-deadlock.spec
new file mode 100644
index 00000000000..9c5020cb65a
--- /dev/null
+++ b/src/test/isolation/specs/for-portion-of-deadlock.spec
@@ -0,0 +1,75 @@
+# Deadlock behavior of FOR PORTION OF when sessions lock rows first, with 3
+# sessions.
+#
+# Locking the portion of history you intend to change with SELECT ... FOR
+# UPDATE before running FOR PORTION OF used to be the documented way around
+# the READ COMMITTED leftover-visibility anomaly.  It is no longer recommended
+# -- it isn't a dependable protection, see for-portion-of.spec, and the
+# anomaly now raises a serialization failure instead -- but clients may still
+# lock rows first, and doing so should carry no deadlock risk beyond the usual
+# one: as long as everyone takes the locks in the same order, three or more
+# sessions are no different from two.
+#
+# This spec exercises that with three sessions:
+#  - If the sessions lock history portions in an inconsistent (cyclic) order,
+#    they deadlock; PostgreSQL detects it, rolls one session back, and the other
+#    two complete their FOR PORTION OF updates normally.
+#  - If every session locks the same portion first (a consistent policy), they
+#    serialize with no deadlock.
+#
+# The table holds three disjoint validity periods for one id, so there are three
+# separate rows to lock.  deadlock_timeout is tiny in s3 and huge in s1/s2, so
+# s3 is always the session that runs the deadlock detector and is rolled back.
+# datestyle and deadlock_timeout are set at session level (not inside a
+# transaction) so they survive s3's rollback.
+
+setup
+{
+  CREATE TABLE products (
+	id int4range NOT NULL,
+	valid_at daterange NOT NULL,
+	price decimal NOT NULL,
+	PRIMARY KEY (id, valid_at WITHOUT OVERLAPS));
+  INSERT INTO products VALUES
+	('[1,2)', '[2020-01-01,2021-01-01)', 5.00),
+	('[1,2)', '[2021-01-01,2022-01-01)', 5.00),
+	('[1,2)', '[2022-01-01,2023-01-01)', 5.00);
+}
+
+teardown { DROP TABLE products; }
+
+session s1
+setup	{ SET datestyle TO ISO, YMD; SET deadlock_timeout = '100s'; }
+step s1b	{ BEGIN ISOLATION LEVEL READ COMMITTED; }
+step s1lock2020	{ SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2020-01-01,2021-01-01)' FOR UPDATE; }
+step s1lock2021	{ SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2021-01-01,2022-01-01)' FOR UPDATE; }
+step s1upd2021	{ UPDATE products FOR PORTION OF valid_at FROM '2021-03-01' TO '2021-04-01' SET price = 8.00 WHERE id = '[1,2)'; }
+step s1c	{ COMMIT; }
+step s1q	{ SELECT id, valid_at, price FROM products ORDER BY valid_at; }
+
+session s2
+setup	{ SET datestyle TO ISO, YMD; SET deadlock_timeout = '100s'; }
+step s2b	{ BEGIN ISOLATION LEVEL READ COMMITTED; }
+step s2lock2020	{ SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2020-01-01,2021-01-01)' FOR UPDATE; }
+step s2lock2021	{ SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2021-01-01,2022-01-01)' FOR UPDATE; }
+step s2lock2022	{ SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2022-01-01,2023-01-01)' FOR UPDATE; }
+step s2upd2022	{ UPDATE products FOR PORTION OF valid_at FROM '2022-03-01' TO '2022-04-01' SET price = 8.00 WHERE id = '[1,2)'; }
+step s2c	{ COMMIT; }
+
+session s3
+setup	{ SET datestyle TO ISO, YMD; SET deadlock_timeout = '10ms'; }
+step s3b	{ BEGIN ISOLATION LEVEL READ COMMITTED; }
+step s3lock2020	{ SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2020-01-01,2021-01-01)' FOR UPDATE; }
+step s3lock2022	{ SELECT price FROM products WHERE id = '[1,2)' AND valid_at && '[2022-01-01,2023-01-01)' FOR UPDATE; }
+step s3c	{ COMMIT; }
+
+# Cyclic lock order: s1 holds 2020 wants 2021; s2 holds 2021 wants 2022;
+# s3 holds 2022 wants 2020.  s3 (tiny deadlock_timeout) detects the deadlock and
+# is rolled back; s2 then s1 finish their FOR PORTION OF updates and commit.
+# The blocking marks force s3's deadlock error to be reported before s2lock2022
+# unblocks (the same timing fix used in deadlock-hard.spec).
+permutation s1b s2b s3b s1lock2020 s2lock2021 s3lock2022 s1lock2021 s2lock2022(s3lock2020) s3lock2020(*) s2upd2022 s2c s1upd2021 s1c s3c s1q
+
+# Consistent lock order: all three lock the 2020 row first, so they serialize
+# with no deadlock.
+permutation s1b s2b s3b s1lock2020 s2lock2020 s3lock2020 s1c s2c s3c
diff --git a/src/test/isolation/specs/for-portion-of.spec b/src/test/isolation/specs/for-portion-of.spec
new file mode 100644
index 00000000000..e307f73f456
--- /dev/null
+++ b/src/test/isolation/specs/for-portion-of.spec
@@ -0,0 +1,762 @@
+# UPDATE/DELETE FOR PORTION OF test
+#
+# Test inserting temporal leftovers from a FOR PORTION OF update/delete.
+#
+# The interesting case is READ COMMITTED.  When a statement finds a row that a
+# concurrent transaction has already changed, it waits, then re-checks the
+# latest version with EvalPlanQual.  That is enough for an ordinary update,
+# but a FOR PORTION OF update or delete narrows the row's application time and
+# re-inserts the rest of its history as new rows.  Those leftovers are not
+# chained off the row we found and are not visible to our snapshot, so the
+# portion of history we were asked to change may no longer be reachable.
+# Rather than silently skip it, we raise a serialization failure; see
+# ExecForPortionOfCheckSplit().  The client can retry.
+#
+# The check is precise: it fires only when part of the range the row used to
+# have, and no longer has, overlaps the portion we target.  A concurrent
+# update that leaves the application time alone, or one whose target contains
+# ours, does not raise anything.  Both cases are tested below.
+#
+# A concurrently deleted row is reported the same way, because a partial
+# FOR PORTION OF delete removes the row and re-inserts the history it did not
+# target, and we cannot tell that apart from a plain delete.
+#
+# Locking the rows first with SELECT FOR UPDATE is not a dependable
+# alternative to retrying, even though many of the permutations here do it,
+# and the last few permutations in this file show two ways it fails.  Locking
+# the targeted portion can come away with a lock on a row that a concurrent
+# transaction has already narrowed out of that portion.  Locking the whole key
+# instead does not help either: after a DELETE FOR PORTION OF there is no
+# surviving row for the waiting lock to follow, so it takes no lock at all.
+#
+# We run the same tests under REPEATABLE READ, where a concurrent
+# update/delete failure is reported before we get that far.  In general they
+# do what you'd want with no explicit locking required.  If there is a prior
+# read by s1, concurrent update/delete failures are more common.
+#
+# To save on test time, we only run a couple SERIALIZABLE tests (for the more
+# problematic permutations).
+#
+# We test updates where s2 updates history that is:
+#
+# - non-overlapping with s1,
+# - contained entirely in s1,
+# - partly contained in s1,
+# - containing s1 entirely.
+#
+# We don't need to test where s2 entirely contains s1 in both orderings
+# because of symmetry: we test both when s1 precedes s2 and when s2 precedes
+# s1, so that scenario is covered.
+#
+# We test various orderings of the update/delete/commit from s1 and s2.
+# Note that `s1lock s2lock s1change` is boring because it's the same as
+# `s1lock s1change s2lock`. In other words it doesn't matter if something
+# interposes between the lock and its change (as long as everyone is following
+# the same policy).
+
+setup
+{
+  CREATE TABLE products (
+	id int4range NOT NULL,
+	valid_at daterange NOT NULL,
+	price decimal NOT NULL,
+	PRIMARY KEY (id, valid_at WITHOUT OVERLAPS));
+  INSERT INTO products VALUES
+	('[1,2)', '[2020-01-01,2030-01-01)', 5.00);
+
+  CREATE TABLE fpo_lockall_delete_gap (
+	id int4range NOT NULL,
+	valid_at int4range NOT NULL,
+	price integer NOT NULL,
+	PRIMARY KEY (id, valid_at WITHOUT OVERLAPS));
+  INSERT INTO fpo_lockall_delete_gap VALUES ('[1,2)', '[0,10)', 0);
+
+  CREATE FUNCTION fpo_noop_trig() RETURNS trigger LANGUAGE plpgsql AS $$
+  BEGIN
+	IF TG_OP = 'DELETE' THEN
+	  RETURN OLD;
+	END IF;
+	RETURN NEW;
+  END;
+  $$;
+}
+
+teardown
+{
+  DROP TABLE products, fpo_lockall_delete_gap;
+  DROP FUNCTION fpo_noop_trig();
+}
+
+session s1
+setup		{ SET datestyle TO ISO, YMD; }
+step s1rc	{ BEGIN ISOLATION LEVEL READ COMMITTED; }
+step s1rr	{ BEGIN ISOLATION LEVEL REPEATABLE READ; }
+step s1ser	{ BEGIN ISOLATION LEVEL SERIALIZABLE; }
+step s1lock2025 {
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-01-01,2026-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+}
+step s1upd2025	{
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  SET price = 8.00
+  WHERE id = '[1,2)';
+}
+step s1del2025 {
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-01-01' TO '2026-01-01'
+  WHERE id = '[1,2)';
+}
+step s1q	{ SELECT * FROM products ORDER BY id, valid_at; }
+step s1c	{ COMMIT; }
+step s1lockgap {
+  SELECT * FROM fpo_lockall_delete_gap WHERE id = '[1,2)' FOR UPDATE;
+}
+step s1delgap {
+  DELETE FROM fpo_lockall_delete_gap
+  FOR PORTION OF valid_at FROM 0 TO 1
+  WHERE id = '[1,2)'
+  RETURNING *;
+}
+step s1qgap	{ SELECT * FROM fpo_lockall_delete_gap ORDER BY id, valid_at; }
+step s1trig {
+  CREATE TRIGGER products_noop BEFORE UPDATE OR DELETE ON products
+  FOR EACH ROW EXECUTE FUNCTION fpo_noop_trig();
+}
+step s1trigoff	{ ALTER TABLE products DISABLE TRIGGER products_noop; }
+
+session s2
+setup		{ SET datestyle TO ISO, YMD; }
+step s2rc	{ BEGIN ISOLATION LEVEL READ COMMITTED; }
+step s2rr	{ BEGIN ISOLATION LEVEL REPEATABLE READ; }
+step s2ser	{ BEGIN ISOLATION LEVEL SERIALIZABLE; }
+step s2lock202503 {
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-03-01,2025-04-01)'
+  ORDER BY valid_at FOR UPDATE;
+}
+step s2lock20252026 {
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2025-06-01,2026-06-01)'
+  ORDER BY valid_at FOR UPDATE;
+}
+step s2lock2027 {
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2027-01-01,2028-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+}
+step s2lock2021 {
+  SELECT * FROM products
+  WHERE id = '[1,2)' AND valid_at && '[2021-01-01,2022-01-01)'
+  ORDER BY valid_at FOR UPDATE;
+}
+step s2upd2021 {
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2021-01-01' TO '2022-01-01'
+  SET price = 20.00
+  WHERE id = '[1,2)';
+}
+step s2upd202503 {
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+}
+step s2upd20252026 {
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+}
+step s2upd2027 {
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+}
+step s2del202503 {
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-04-01'
+  WHERE id = '[1,2)';
+}
+step s2del20252026 {
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2025-06-01' TO '2026-06-01'
+  WHERE id = '[1,2)';
+}
+step s2del2027 {
+  DELETE FROM products
+  FOR PORTION OF valid_at FROM '2027-01-01' TO '2028-01-01'
+  WHERE id = '[1,2)';
+}
+step s2upd20242027 {
+  UPDATE products
+  FOR PORTION OF valid_at FROM '2024-01-01' TO '2027-01-01'
+  SET price = 10.00
+  WHERE id = '[1,2)';
+}
+step s2updprice {
+  UPDATE products
+  SET price = 10.00
+  WHERE id = '[1,2)';
+}
+step s2c	{ COMMIT; }
+step s2rc2	{ BEGIN ISOLATION LEVEL READ COMMITTED; }
+step s2c2	{ COMMIT; }
+step s2lockgap {
+  SELECT * FROM fpo_lockall_delete_gap WHERE id = '[1,2)' FOR UPDATE;
+}
+step s2updgap {
+  UPDATE fpo_lockall_delete_gap
+  FOR PORTION OF valid_at FROM 4 TO 5
+  SET price = 2
+  WHERE id = '[1,2)'
+  RETURNING *;
+}
+
+session s3
+setup		{ SET datestyle TO ISO, YMD; }
+step s3rc	{ BEGIN ISOLATION LEVEL READ COMMITTED; }
+step s3lockgap {
+  SELECT * FROM fpo_lockall_delete_gap WHERE id = '[1,2)' FOR UPDATE;
+}
+step s3updgap {
+  UPDATE fpo_lockall_delete_gap
+  FOR PORTION OF valid_at FROM 2 TO 3
+  SET price = 3
+  WHERE id = '[1,2)'
+  RETURNING *;
+}
+step s3c	{ COMMIT; }
+
+# ########################################
+# READ COMMITTED tests, UPDATE+UPDATE:
+# ########################################
+
+# s1 sees the leftovers
+permutation s1rc s2rc s2lock2027 s2upd2027 s2c s1lock2025 s1upd2025 s1c s1q
+
+# s1 reloads the updated row and sees its leftovers
+permutation s1rc s2rc s2lock202503 s2upd202503 s2c s1lock2025 s1upd2025 s1c s1q
+
+# s1 reloads the updated row and sees its leftovers
+permutation s1rc s2rc s2lock20252026 s2upd20252026 s2c s1lock2025 s1upd2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rc s2rc s1lock2025 s1upd2025 s1c s2lock2027 s2upd2027 s2c s1q
+
+# s2 loads the updated row
+permutation s1rc s2rc s1lock2025 s1upd2025 s1c s2lock202503 s2upd202503 s2c s1q
+
+# s2 loads the updated row and sees its leftovers
+permutation s1rc s2rc s1lock2025 s1upd2025 s1c s2lock20252026 s2upd20252026 s2c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# and EvalPlanQual no longer matches the row to be updated either.
+permutation s1rc s2rc s2upd2027 s1upd2025 s2c s1c s1q
+
+# Workaround:
+# s1 updates the leftovers from s2
+# Locking is required or s1 won't see the leftovers.
+permutation s1rc s2rc s2lock2027 s2upd2027 s1lock2025 s2c s1upd2025 s1c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# but EvalPlanQual still matches the row to be updated.
+permutation s1rc s2rc s2upd202503 s1upd2025 s2c s1c s1q
+
+# Workaround:
+# s1 overwrites the row from s2 and sees its leftovers
+permutation s1rc s2rc s2lock202503 s2upd202503 s1lock2025 s2c s1upd2025 s1c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# but EvalPlanQual still matches the row to be updated,
+# and s1's leftovers don't conflict with s2's.
+permutation s1rc s2rc s2upd20252026 s1upd2025 s2c s1c s1q
+
+# Workaround:
+# s1 overwrites the row from s2 and sees its leftovers
+# Locking is required or s1 won't see the leftovers.
+permutation s1rc s2rc s2lock20252026 s2upd20252026 s1lock2025 s2c s1upd2025 s1c s1q
+
+# Not a problem:
+# s2's update leaves the application time alone, so nothing s1 targets was
+# moved out of the row and s1 can go ahead.
+permutation s1rc s2rc s2updprice s1upd2025 s2c s1c s1q
+
+# Not a problem:
+# s2's target contains s1's, so the portion s1 wants is still in the row s1
+# finds, even though s2 did leave leftovers behind.
+permutation s1rc s2rc s2upd20242027 s1upd2025 s2c s1c s1q
+
+# ########################################
+# READ COMMITTED tests, UPDATE+DELETE:
+# ########################################
+
+# s1 sees the leftovers
+permutation s1rc s2rc s2lock2027 s2del2027 s2c s1lock2025 s1upd2025 s1c s1q
+
+# s1 ignores the deleted row and sees its leftovers
+permutation s1rc s2rc s2lock202503 s2del202503 s2c s1lock2025 s1upd2025 s1c s1q
+
+# s1 ignores the deleted row and sees its leftovers
+permutation s1rc s2rc s2lock20252026 s2del20252026 s2c s1lock2025 s1upd2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rc s2rc s1lock2025 s1upd2025 s1c s2lock2027 s2del2027 s2c s1q
+
+# s2 loads the updated row
+permutation s1rc s2rc s1lock2025 s1upd2025 s1c s2lock202503 s2del202503 s2c s1q
+
+# s2 loads the updated row and sees its leftovers
+permutation s1rc s2rc s1lock2025 s1upd2025 s1c s2lock20252026 s2del20252026 s2c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# and EvalPlanQual no longer matches the row to be updated either.
+permutation s1rc s2rc s2del2027 s1upd2025 s2c s1c s1q
+
+# Workaround:
+# s1 updates the leftovers from s2
+# Locking is required or s1 won't see the leftovers.
+permutation s1rc s2rc s2lock2027 s2del2027 s1lock2025 s2c s1upd2025 s1c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# and EvalPlanQual no longer matches the row to be updated either.
+permutation s1rc s2rc s2del202503 s1upd2025 s2c s1c s1q
+
+# Workaround:
+# s1 sees the leftovers from s2
+# Locking is required or s1 won't see the leftovers.
+permutation s1rc s2rc s2lock202503 s2del202503 s1lock2025 s2c s1upd2025 s1c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# and EvalPlanQual no longer matches the row to be updated either.
+permutation s1rc s2rc s2del20252026 s1upd2025 s2c s1c s1q
+
+# Workaround:
+# s1 sees the leftovers from s2
+# Locking is required or s1 won't see the leftovers.
+permutation s1rc s2rc s2lock20252026 s2del20252026 s1lock2025 s2c s1upd2025 s1c s1q
+
+# ########################################
+# READ COMMITTED tests, DELETE+UPDATE:
+# ########################################
+
+# s1 sees the leftovers
+permutation s1rc s2rc s2lock2027 s2upd2027 s2c s1lock2025 s1del2025 s1c s1q
+
+# s1 reloads the updated row and sees its leftovers
+permutation s1rc s2rc s2lock202503 s2upd202503 s2c s1lock2025 s1del2025 s1c s1q
+
+# s1 reloads the updated row and sees its leftovers
+permutation s1rc s2rc s2lock20252026 s2upd20252026 s2c s1lock2025 s1del2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rc s2rc s1lock2025 s1del2025 s1c s2lock2027 s2upd2027 s2c s1q
+
+# s2 ignores the deleted row
+permutation s1rc s2rc s1lock2025 s1del2025 s1c s2lock202503 s2upd202503 s2c s1q
+
+# s2 ignores the deleted row and sees its leftovers
+permutation s1rc s2rc s1lock2025 s1del2025 s1c s2lock20252026 s2upd20252026 s2c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# and EvalPlanQual no longer matches the row to be deleted either.
+permutation s1rc s2rc s2upd2027 s1del2025 s2c s1c s1q
+
+# Workaround:
+# s1 deletes the leftovers from s2
+# Locking is required or s1 won't see the leftovers.
+permutation s1rc s2rc s2lock2027 s2upd2027 s1lock2025 s2c s1del2025 s1c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# but EvalPlanQual still matches the row to be deleted.
+permutation s1rc s2rc s2upd202503 s1del2025 s2c s1c s1q
+
+# Workaround:
+# s1 deletes the new row from s2 and its leftovers
+# Locking is required or s1 won't see the leftovers.
+permutation s1rc s2rc s2lock202503 s2upd202503 s1lock2025 s2c s1del2025 s1c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# but EvalPlanQual still matches the row to be deleted,
+# and s1 leaves leftovers from the row created by s2.
+permutation s1rc s2rc s2upd20252026 s1del2025 s2c s1c s1q
+
+# Workaround:
+# s1 deletes the new row from s2 and its leftovers
+# Locking is required or s1 won't see the leftovers.
+permutation s1rc s2rc s2lock20252026 s2upd20252026 s1lock2025 s2c s1del2025 s1c s1q
+
+# ########################################
+# READ COMMITTED tests, DELETE+DELETE:
+# ########################################
+
+# s1 sees the leftovers
+permutation s1rc s2rc s2lock2027 s2del2027 s2c s1lock2025 s1del2025 s1c s1q
+
+# s1 ignores the deleted row and sees its leftovers
+permutation s1rc s2rc s2lock202503 s2del202503 s2c s1lock2025 s1del2025 s1c s1q
+
+# s1 ignores the deleted row and sees its leftovers
+permutation s1rc s2rc s2lock20252026 s2del20252026 s2c s1lock2025 s1del2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rc s2rc s1lock2025 s1del2025 s1c s2lock2027 s2del2027 s2c s1q
+
+# s2 ignores the deleted row
+permutation s1rc s2rc s1lock2025 s1del2025 s1c s2lock202503 s2del202503 s2c s1q
+
+# s2 ignores the deleted row and sees its leftovers
+permutation s1rc s2rc s1lock2025 s1del2025 s1c s2lock20252026 s2del20252026 s2c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# and EvalPlanQual no longer matches the row to be deleted either.
+permutation s1rc s2rc s2del2027 s1del2025 s2c s1c s1q
+
+# Workaround:
+# s1 deletes the leftovers from s2
+# Locking is required or s1 won't see the leftovers.
+permutation s1rc s2rc s2lock2027 s2del2027 s1lock2025 s2c s1del2025 s1c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# and EvalPlanQual no longer matches the row to be deleted either.
+permutation s1rc s2rc s2del202503 s1del2025 s2c s1c s1q
+
+# Workaround:
+# s1 deletes the leftovers from s2
+# Locking is required or s1 won't see the leftovers.
+permutation s1rc s2rc s2lock202503 s2del202503 s1lock2025 s2c s1del2025 s1c s1q
+
+# Problem:
+# s1 (without locking) overlooks the leftovers from s2
+# and EvalPlanQual no longer matches the row to be deleted either.
+permutation s1rc s2rc s2del20252026 s1del2025 s2c s1c s1q
+
+# Workaround:
+# s1 deletes the leftovers from s2
+# Locking is required or s1 won't see the leftovers.
+permutation s1rc s2rc s2lock20252026 s2del20252026 s1lock2025 s2c s1del2025 s1c s1q
+
+# ########################################
+# REPEATABLE READ tests, UPDATE+UPDATE:
+# ########################################
+
+# s1 sees the leftovers
+permutation s1rr s2rr s2upd2027 s2c s1upd2025 s1c s1q
+
+# s1 reloads the updated row and sees its leftovers
+permutation s1rr s2rr s2upd202503 s2c s1upd2025 s1c s1q
+
+# s1 reloads the updated row and sees its leftovers
+permutation s1rr s2rr s2upd20252026 s2c s1upd2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rr s2rr s1upd2025 s1c s2upd2027 s2c s1q
+
+# s2 loads the updated row and sees its leftovers
+permutation s1rr s2rr s1upd2025 s1c s2upd202503 s2c s1q
+
+# s2 loads the updated row and sees its leftovers
+permutation s1rr s2rr s1upd2025 s1c s2upd20252026 s2c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s2upd2027 s1upd2025 s2c s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s2upd202503 s1upd2025 s2c s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s2upd20252026 s1upd2025 s2c s1c s1q
+
+## with prior read by s1:
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd2027 s2c s1upd2025 s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd202503 s2c s1upd2025 s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd20252026 s2c s1upd2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rr s2rr s1q s1upd2025 s1c s2upd2027 s2c s1q
+
+# s2 loads the updated row
+permutation s1rr s2rr s1q s1upd2025 s1c s2upd202503 s2c s1q
+
+# s2 loads the updated row and sees its leftovers
+permutation s1rr s2rr s1q s1upd2025 s1c s2upd20252026 s2c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd2027 s1upd2025 s2c s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd202503 s1upd2025 s2c s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd20252026 s1upd2025 s2c s1c s1q
+
+# ########################################
+# REPEATABLE READ tests, UPDATE+DELETE:
+# ########################################
+
+# s1 sees the leftovers
+permutation s1rr s2rr s2del2027 s2c s1upd2025 s1c s1q
+
+# s1 ignores the deleted row and sees its leftovers
+permutation s1rr s2rr s2del202503 s2c s1upd2025 s1c s1q
+
+# s1 ignores the deleted row and sees its leftovers
+permutation s1rr s2rr s2del20252026 s2c s1upd2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rr s2rr s1upd2025 s1c s2del2027 s2c s1q
+
+# s2 loads the updated row
+permutation s1rr s2rr s1upd2025 s1c s2del202503 s2c s1q
+
+# s2 loads the updated row and sees its leftovers
+permutation s1rr s2rr s1upd2025 s1c s2del20252026 s2c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s2del2027 s1upd2025 s2c s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s2del202503 s1upd2025 s2c s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s2del20252026 s1upd2025 s2c s1c s1q
+
+## with prior read by s1:
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del2027 s2c s1upd2025 s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del202503 s2c s1upd2025 s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del20252026 s2c s1upd2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rr s2rr s1q s1upd2025 s1c s2del2027 s2c s1q
+
+# s2 loads the updated row
+permutation s1rr s2rr s1q s1upd2025 s1c s2del202503 s2c s1q
+
+# s2 loads the updated row and sees its leftovers
+permutation s1rr s2rr s1q s1upd2025 s1c s2del20252026 s2c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del2027 s1upd2025 s2c s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del202503 s1upd2025 s2c s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del20252026 s1upd2025 s2c s1c s1q
+
+# ########################################
+# REPEATABLE READ tests, DELETE+UPDATE:
+# ########################################
+
+# s1 sees the leftovers
+permutation s1rr s2rr s2upd2027 s2c s1del2025 s1c s1q
+
+# s1 reloads the updated row and sees its leftovers
+permutation s1rr s2rr s2upd202503 s2c s1del2025 s1c s1q
+
+# s1 reloads the updated row and sees its leftovers
+permutation s1rr s2rr s2upd20252026 s2c s1del2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rr s2rr s1del2025 s1c s2upd2027 s2c s1q
+
+# s2 ignores the deleted row
+permutation s1rr s2rr s1del2025 s1c s2upd202503 s2c s1q
+
+# s2 ignores the deleted row and sees its leftovers
+permutation s1rr s2rr s1del2025 s1c s2upd20252026 s2c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s2upd2027 s1del2025 s2c s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s2upd202503 s1del2025 s2c s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s2upd20252026 s1del2025 s2c s1c s1q
+
+## with prior read by s1:
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd2027 s2c s1del2025 s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd202503 s2c s1del2025 s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd20252026 s2c s1del2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rr s2rr s1q s1del2025 s1c s2upd2027 s2c s1q
+
+# s2 ignores the deleted row
+permutation s1rr s2rr s1q s1del2025 s1c s2upd202503 s2c s1q
+
+# s2 ignores the deleted row and sees its leftovers
+permutation s1rr s2rr s1q s1del2025 s1c s2upd20252026 s2c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd2027 s1del2025 s2c s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd202503 s1del2025 s2c s1c s1q
+
+# s1 fails from concurrent update
+permutation s1rr s2rr s1q s2upd20252026 s1del2025 s2c s1c s1q
+
+# ########################################
+# REPEATABLE READ tests, DELETE+DELETE:
+# ########################################
+
+# s1 sees the leftovers
+permutation s1rr s2rr s2del2027 s2c s1del2025 s1c s1q
+
+# s1 reloads the updated row and sees its leftovers
+permutation s1rr s2rr s2del202503 s2c s1del2025 s1c s1q
+
+# s1 reloads the updated row and sees its leftovers
+permutation s1rr s2rr s2del20252026 s2c s1del2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rr s2rr s1del2025 s1c s2del2027 s2c s1q
+
+# s2 ignores the deleted row
+permutation s1rr s2rr s1del2025 s1c s2del202503 s2c s1q
+
+# s2 ignores the deleted row and sees its leftovers
+permutation s1rr s2rr s1del2025 s1c s2del20252026 s2c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s2del2027 s1del2025 s2c s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s2del202503 s1del2025 s2c s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s2del20252026 s1del2025 s2c s1c s1q
+
+## with prior read by s1:
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del2027 s2c s1del2025 s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del202503 s2c s1del2025 s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del20252026 s2c s1del2025 s1c s1q
+
+# s2 sees the leftovers
+permutation s1rr s2rr s1q s1del2025 s1c s2del2027 s2c s1q
+
+# s2 ignores the deleted row
+permutation s1rr s2rr s1q s1del2025 s1c s2del202503 s2c s1q
+
+# s2 ignores the deleted row and sees its leftovers
+permutation s1rr s2rr s1q s1del2025 s1c s2del20252026 s2c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del2027 s1del2025 s2c s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del202503 s1del2025 s2c s1c s1q
+
+# s1 fails from concurrent delete
+permutation s1rr s2rr s1q s2del20252026 s1del2025 s2c s1c s1q
+
+# ########################################
+# SERIALIZABLE tests, UPDATE+UPDATE:
+# ########################################
+
+# s1 sees the leftovers
+permutation s1ser s2ser s2upd2027 s2c s1upd2025 s1c s1q
+
+# s1 reloads the updated row and sees its leftovers
+permutation s1ser s2ser s2upd20252026 s2c s1upd2025 s1c s1q
+
+# ########################################
+# Locking the targeted portion first does not help either:
+# ########################################
+
+# Every writer here locks the portion it is about to change, in the same
+# transaction as the change, so the protocol is being followed by everyone.
+# It still fails.  s1's lock waits for s2, and by the time EvalPlanQual
+# re-checks, s2 has narrowed the row out of the portion s1 asked for: the lock
+# lands on that narrowed row and s1 gets nothing back.  The history s1 wants
+# has moved into a leftover, which s1 never locked and no ctid chain leads to.
+# So s2's next transaction locks that leftover without conflicting with s1,
+# and the two run concurrently after all.  Reported by Andres Freund.
+permutation s1rc s2rc s2lock2027 s2upd2027 s1lock2025 s2c s2rc2 s2lock20252026 s2upd20252026 s1upd2025 s2c2 s1c s1q
+
+# Andres Freund's original form of the same failure: s2's second transaction
+# changes a disjoint portion (2021), so s1's lock and EvalPlanQual find the row
+# s2 narrowed and nothing else.  Without the check, s1's update of 2025 is
+# silently lost.
+permutation s1rc s2rc s2lock2027 s2upd2027 s1lock2025 s2c s2rc2 s2lock2021 s2upd2021 s1upd2025 s2c2 s1c s1q
+
+# The same with s2's second transaction committing before s1 updates: then s1's
+# UPDATE takes a new snapshot that sees everything, and nothing is lost or
+# reported.  This shows the failure above is a concurrency artifact.
+permutation s1rc s2rc s2lock2027 s2upd2027 s1lock2025 s2c s2rc2 s2lock2021 s2upd2021 s2c2 s1upd2025 s1c s1q
+
+# ########################################
+# Locking the whole key does not serialize across DELETE FOR PORTION OF:
+# ########################################
+
+# All three writers lock every row of the key in the same transaction as their
+# DML, which is as careful as a client can be.  It still isn't enough.
+#
+# s1 deletes the original tuple and inserts [1,10) as an independent leftover.
+# s2's locking scan waits for s1, then skips the deleted tuple.  Its snapshot
+# cannot see the leftover, so it finishes without locking any live row.  s3 can
+# therefore lock the whole key and update [2,3).  s2's subsequent UPDATE sees
+# [1,10), but waits for s3 and follows its update chain only to [2,3).  That
+# tuple fails s2's overlap qual, and s3's [3,10) leftover is invisible to s2's
+# UPDATE snapshot, so without the check s2 would change no rows and leave
+# [4,5) at price 0.  Reported by Andres Freund.
+permutation s1rc s2rc s3rc s1lockgap s1delgap s2lockgap s1c s3lockgap s3updgap s2updgap s3c s2c s1qgap
+permutation s1rc s2rc s3rc s1lockgap s1delgap s2lockgap s3lockgap s1c s3updgap s2updgap s3c s2c s1qgap
+
+# ########################################
+# READ COMMITTED with a BEFORE row trigger:
+# ########################################
+
+# A BEFORE row trigger makes the executor lock the row and run EvalPlanQual
+# before calling the trigger, rather than when the update or delete finds the
+# row changed.  The check has to happen there too, or s1's change is lost.
+permutation s1trig s1rc s2rc s2upd2027 s1upd2025 s2c s1c s1q
+permutation s1trig s1rc s2rc s2del2027 s1upd2025 s2c s1c s1q
+permutation s1trig s1rc s2rc s2upd2027 s1del2025 s2c s1c s1q
+permutation s1trig s1rc s2rc s2del2027 s1del2025 s2c s1c s1q
+
+# A concurrent update that doesn't move s1's target out of the row is still
+# followed without complaint.
+permutation s1trig s1rc s2rc s2updprice s1upd2025 s2c s1c s1q
+
+# A disabled trigger still takes that path.
+permutation s1trig s1trigoff s1rc s2rc s2upd2027 s1upd2025 s2c s1c s1q
diff --git a/src/test/recovery/meson.build b/src/test/recovery/meson.build
index b6ac9dd07a7..1273c45a1e6 100644
--- a/src/test/recovery/meson.build
+++ b/src/test/recovery/meson.build
@@ -69,6 +69,7 @@ tests += {
       't/058_shutdown_crash_restart.pl',
       't/059_remote_apply_status_interval.pl',
       't/060_multixact_crashed_updater.pl',
+      't/061_for_portion_of_2pc.pl',
     ],
   },
 }
diff --git a/src/test/recovery/t/061_for_portion_of_2pc.pl b/src/test/recovery/t/061_for_portion_of_2pc.pl
new file mode 100644
index 00000000000..5fb5035d28f
--- /dev/null
+++ b/src/test/recovery/t/061_for_portion_of_2pc.pl
@@ -0,0 +1,81 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Test PREPARE TRANSACTION (two-phase commit) wrapping UPDATE/DELETE FOR PORTION OF.
+#
+# A FOR PORTION OF statement truncates the matched row and inserts temporal
+# leftovers.  When it runs inside a prepared transaction, the truncation and the
+# leftover inserts must survive PREPARE TRANSACTION and a server restart, then
+# take effect atomically at COMMIT PREPARED (or vanish at ROLLBACK PREPARED).
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init;
+$node->append_conf(
+	'postgresql.conf', qq(
+	max_prepared_transactions = 10
+	datestyle = 'ISO, YMD'
+));
+$node->start;
+
+$node->safe_psql(
+	'postgres', qq{
+	CREATE TABLE fpo (id int4range, valid_at daterange, a text,
+		PRIMARY KEY (id, valid_at WITHOUT OVERLAPS));
+	INSERT INTO fpo VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a');
+});
+
+# UPDATE FOR PORTION OF inside a prepared transaction.
+$node->safe_psql(
+	'postgres', qq{
+	BEGIN;
+	UPDATE fpo FOR PORTION OF valid_at FROM '2004-01-01' TO '2006-01-01'
+		SET a = 'b' WHERE id = '[1,2)';
+	PREPARE TRANSACTION 'fpo_update';
+});
+
+# Nothing is visible yet: still the single original row.
+is( $node->safe_psql('postgres', "SELECT id, valid_at, a FROM fpo ORDER BY valid_at"),
+	'[1,2)|[2000-01-01,2010-01-01)|a',
+	'FOR PORTION OF not visible while only prepared');
+
+# The prepared transaction (and its pending leftovers) survives a restart.
+$node->restart;
+
+is( $node->safe_psql('postgres', "SELECT gid FROM pg_prepared_xacts ORDER BY gid"),
+	'fpo_update',
+	'prepared FOR PORTION OF transaction survives restart');
+
+$node->safe_psql('postgres', "COMMIT PREPARED 'fpo_update'");
+
+# COMMIT PREPARED applies the truncation and both leftovers atomically.
+is( $node->safe_psql('postgres', "SELECT id, valid_at, a FROM fpo ORDER BY valid_at"),
+	"[1,2)|[2000-01-01,2004-01-01)|a\n"
+	  . "[1,2)|[2004-01-01,2006-01-01)|b\n"
+	  . "[1,2)|[2006-01-01,2010-01-01)|a",
+	'COMMIT PREPARED applies FOR PORTION OF truncation and leftovers');
+
+# DELETE FOR PORTION OF inside a prepared transaction, then ROLLBACK PREPARED.
+$node->safe_psql(
+	'postgres', qq{
+	BEGIN;
+	DELETE FROM fpo FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+		WHERE id = '[1,2)';
+	PREPARE TRANSACTION 'fpo_delete';
+});
+$node->safe_psql('postgres', "ROLLBACK PREPARED 'fpo_delete'");
+
+# ROLLBACK PREPARED discards the FOR PORTION OF entirely.
+is( $node->safe_psql('postgres', "SELECT id, valid_at, a FROM fpo ORDER BY valid_at"),
+	"[1,2)|[2000-01-01,2004-01-01)|a\n"
+	  . "[1,2)|[2004-01-01,2006-01-01)|b\n"
+	  . "[1,2)|[2006-01-01,2010-01-01)|a",
+	'ROLLBACK PREPARED discards FOR PORTION OF');
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
new file mode 100644
index 00000000000..ff2c999518b
--- /dev/null
+++ b/src/test/regress/expected/for_portion_of.out
@@ -0,0 +1,4196 @@
+-- Tests for UPDATE/DELETE FOR PORTION OF
+SET datestyle TO ISO, YMD;
+-- Works on non-PK columns
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text NOT NULL
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-02,2020-01-01)', 'one');
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01'
+  SET name = 'one^1';
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [1,2) | [2018-01-02,2018-01-15) | one
+ [1,2) | [2018-01-15,2019-01-01) | one^1
+ [1,2) | [2019-01-01,2020-01-01) | one
+(3 rows)
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-01-15' TO '2019-01-20';
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [1,2) | [2018-01-02,2018-01-15) | one
+ [1,2) | [2018-01-15,2019-01-01) | one^1
+ [1,2) | [2019-01-01,2019-01-15) | one
+ [1,2) | [2019-01-20,2020-01-01) | one
+(4 rows)
+
+-- With a table alias with AS
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-02-01' TO '2019-02-03' AS t
+  SET name = 'one^2';
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-02-03' TO '2019-02-04' AS t;
+-- With a table alias without AS
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-02-04' TO '2019-02-05' t
+  SET name = 'one^3';
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-02-05' TO '2019-02-06' t;
+-- UPDATE with FROM
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-03-01' to '2019-03-02'
+  SET name = 'one^4'
+  FROM (SELECT '[1,2)'::int4range) AS t2(id)
+  WHERE for_portion_of_test.id = t2.id;
+-- DELETE with USING
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-03-02' TO '2019-03-03'
+  USING (SELECT '[1,2)'::int4range) AS t2(id)
+  WHERE for_portion_of_test.id = t2.id;
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [1,2) | [2018-01-02,2018-01-15) | one
+ [1,2) | [2018-01-15,2019-01-01) | one^1
+ [1,2) | [2019-01-01,2019-01-15) | one
+ [1,2) | [2019-01-20,2019-02-01) | one
+ [1,2) | [2019-02-01,2019-02-03) | one^2
+ [1,2) | [2019-02-04,2019-02-05) | one^3
+ [1,2) | [2019-02-06,2019-03-01) | one
+ [1,2) | [2019-03-01,2019-03-02) | one^4
+ [1,2) | [2019-03-03,2020-01-01) | one
+(9 rows)
+
+-- Works on more than one range
+DROP TABLE for_portion_of_test;
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid1_at daterange,
+  valid2_at daterange,
+  name text NOT NULL
+);
+INSERT INTO for_portion_of_test (id, valid1_at, valid2_at, name) VALUES
+  ('[1,2)', '[2018-01-02,2018-02-03)', '[2015-01-01,2025-01-01)', 'one');
+UPDATE for_portion_of_test
+  FOR PORTION OF valid1_at FROM '2018-01-15' TO NULL
+  SET name = 'foo';
+SELECT * FROM for_portion_of_test ORDER BY id, valid1_at, valid2_at;
+  id   |        valid1_at        |        valid2_at        | name 
+-------+-------------------------+-------------------------+------
+ [1,2) | [2018-01-02,2018-01-15) | [2015-01-01,2025-01-01) | one
+ [1,2) | [2018-01-15,2018-02-03) | [2015-01-01,2025-01-01) | foo
+(2 rows)
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid2_at FROM '2018-01-15' TO NULL
+  SET name = 'bar';
+SELECT * FROM for_portion_of_test ORDER BY id, valid1_at, valid2_at;
+  id   |        valid1_at        |        valid2_at        | name 
+-------+-------------------------+-------------------------+------
+ [1,2) | [2018-01-02,2018-01-15) | [2015-01-01,2018-01-15) | one
+ [1,2) | [2018-01-02,2018-01-15) | [2018-01-15,2025-01-01) | bar
+ [1,2) | [2018-01-15,2018-02-03) | [2015-01-01,2018-01-15) | foo
+ [1,2) | [2018-01-15,2018-02-03) | [2018-01-15,2025-01-01) | bar
+(4 rows)
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid1_at FROM '2018-01-20' TO NULL;
+SELECT * FROM for_portion_of_test ORDER BY id, valid1_at, valid2_at;
+  id   |        valid1_at        |        valid2_at        | name 
+-------+-------------------------+-------------------------+------
+ [1,2) | [2018-01-02,2018-01-15) | [2015-01-01,2018-01-15) | one
+ [1,2) | [2018-01-02,2018-01-15) | [2018-01-15,2025-01-01) | bar
+ [1,2) | [2018-01-15,2018-01-20) | [2015-01-01,2018-01-15) | foo
+ [1,2) | [2018-01-15,2018-01-20) | [2018-01-15,2025-01-01) | bar
+(4 rows)
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid2_at FROM '2018-01-20' TO NULL;
+SELECT * FROM for_portion_of_test ORDER BY id, valid1_at, valid2_at;
+  id   |        valid1_at        |        valid2_at        | name 
+-------+-------------------------+-------------------------+------
+ [1,2) | [2018-01-02,2018-01-15) | [2015-01-01,2018-01-15) | one
+ [1,2) | [2018-01-02,2018-01-15) | [2018-01-15,2018-01-20) | bar
+ [1,2) | [2018-01-15,2018-01-20) | [2015-01-01,2018-01-15) | foo
+ [1,2) | [2018-01-15,2018-01-20) | [2018-01-15,2018-01-20) | bar
+(4 rows)
+
+-- Test with NULLs in the scalar/range key columns.
+-- This won't happen if there is a PRIMARY KEY or UNIQUE constraint
+-- but FOR PORTION OF shouldn't require that.
+DROP TABLE for_portion_of_test;
+CREATE UNLOGGED TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', NULL, '1 null'),
+  ('[1,2)', '(,)', '1 unbounded'),
+  ('[1,2)', 'empty', '1 empty'),
+  (NULL, NULL, NULL),
+  (NULL, daterange('2018-01-01', '2019-01-01'), 'null key');
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO NULL
+  SET name = 'NULL to NULL';
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+  id   |        valid_at         |     name     
+-------+-------------------------+--------------
+ [1,2) | empty                   | 1 empty
+ [1,2) | (,)                     | NULL to NULL
+ [1,2) |                         | 1 null
+       | [2018-01-01,2019-01-01) | NULL to NULL
+       |                         | 
+(5 rows)
+
+DROP TABLE for_portion_of_test;
+--
+-- UPDATE tests
+--
+CREATE TABLE for_portion_of_test (
+  id int4range NOT NULL,
+  valid_at daterange NOT NULL,
+  name text NOT NULL,
+  CONSTRAINT for_portion_of_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-02,2018-02-03)', 'one'),
+  ('[1,2)', '[2018-02-03,2018-03-03)', 'one'),
+  ('[1,2)', '[2018-03-03,2018-04-04)', 'one'),
+  ('[2,3)', '[2018-01-01,2018-01-05)', 'two'),
+  ('[3,4)', '[2018-01-01,)', 'three'),
+  ('[4,5)', '(,2018-04-01)', 'four'),
+  ('[5,6)', '(,)', 'five')
+  ;
+\set QUIET false
+-- Updating with a missing column fails
+UPDATE for_portion_of_test
+  FOR PORTION OF invalid_at FROM '2018-06-01' TO NULL
+  SET name = 'foo'
+  WHERE id = '[5,6)';
+ERROR:  column "invalid_at" of relation "for_portion_of_test" does not exist
+LINE 2:   FOR PORTION OF invalid_at FROM '2018-06-01' TO NULL
+                         ^
+-- Updating the range fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO NULL
+  SET valid_at = '[1990-01-01,1999-01-01)'
+  WHERE id = '[5,6)';
+ERROR:  cannot update column "valid_at" because it is used in FOR PORTION OF
+LINE 3:   SET valid_at = '[1990-01-01,1999-01-01)'
+              ^
+-- The wrong start type fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM 1 TO '2020-01-01'
+  SET name = 'nope'
+  WHERE id = '[3,4)';
+ERROR:  could not coerce FOR PORTION OF FROM bound from integer to date
+LINE 2:   FOR PORTION OF valid_at FROM 1 TO '2020-01-01'
+                                       ^
+-- The wrong end type fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2000-01-01' TO 4
+  SET name = 'nope'
+  WHERE id = '[3,4)';
+ERROR:  could not coerce FOR PORTION OF TO bound from integer to date
+LINE 2:   FOR PORTION OF valid_at FROM '2000-01-01' TO 4
+                                                       ^
+-- Updating with timestamps reversed fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO '2018-01-01'
+  SET name = 'three^1'
+  WHERE id = '[3,4)';
+ERROR:  range lower bound must be less than or equal to range upper bound
+-- Updating with a subquery fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM (SELECT '2018-01-01') TO '2018-06-01'
+  SET name = 'nope'
+  WHERE id = '[3,4)';
+ERROR:  cannot use subquery in FOR PORTION OF expression
+LINE 2:   FOR PORTION OF valid_at FROM (SELECT '2018-01-01') TO '201...
+                                       ^
+-- Updating with a column fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM lower(valid_at) TO NULL
+  SET name = 'nope'
+  WHERE id = '[3,4)';
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 2:   FOR PORTION OF valid_at FROM lower(valid_at) TO NULL
+                                             ^
+-- and so does a whole-row reference, which takes a different path
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at ((ROW(for_portion_of_test.*)).f2)
+  SET name = 'nope'
+  WHERE id = '[3,4)';
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 2:   FOR PORTION OF valid_at ((ROW(for_portion_of_test.*)).f2)
+                                        ^
+-- Updating with timestamps equal does nothing
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-04-01'
+  SET name = 'three^0'
+  WHERE id = '[3,4)';
+UPDATE 0
+-- Updating a finite/open portion with a finite/open target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO NULL
+  SET name = 'three^1'
+  WHERE id = '[3,4)';
+UPDATE 1
+SELECT * FROM for_portion_of_test WHERE id = '[3,4)' ORDER BY id, valid_at;
+  id   |        valid_at         |  name   
+-------+-------------------------+---------
+ [3,4) | [2018-01-01,2018-06-01) | three
+ [3,4) | [2018-06-01,)           | three^1
+(2 rows)
+
+-- Updating a finite/open portion with an open/finite target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-03-01'
+  SET name = 'three^2'
+  WHERE id = '[3,4)';
+UPDATE 1
+SELECT * FROM for_portion_of_test WHERE id = '[3,4)' ORDER BY id, valid_at;
+  id   |        valid_at         |  name   
+-------+-------------------------+---------
+ [3,4) | [2018-01-01,2018-03-01) | three^2
+ [3,4) | [2018-03-01,2018-06-01) | three
+ [3,4) | [2018-06-01,)           | three^1
+(3 rows)
+
+-- Updating an open/finite portion with an open/finite target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-02-01'
+  SET name = 'four^1'
+  WHERE id = '[4,5)';
+UPDATE 1
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+  id   |        valid_at         |  name  
+-------+-------------------------+--------
+ [4,5) | (,2018-02-01)           | four^1
+ [4,5) | [2018-02-01,2018-04-01) | four
+(2 rows)
+
+-- Updating an open/finite portion with a finite/open target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2017-01-01' TO NULL
+  SET name = 'four^2'
+  WHERE id = '[4,5)';
+UPDATE 2
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+  id   |        valid_at         |  name  
+-------+-------------------------+--------
+ [4,5) | (,2017-01-01)           | four^1
+ [4,5) | [2017-01-01,2018-02-01) | four^2
+ [4,5) | [2018-02-01,2018-04-01) | four^2
+(3 rows)
+
+-- Updating a finite/finite portion with an exact fit
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2017-01-01' TO '2018-02-01'
+  SET name = 'four^3'
+  WHERE id = '[4,5)';
+UPDATE 1
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+  id   |        valid_at         |  name  
+-------+-------------------------+--------
+ [4,5) | (,2017-01-01)           | four^1
+ [4,5) | [2017-01-01,2018-02-01) | four^3
+ [4,5) | [2018-02-01,2018-04-01) | four^2
+(3 rows)
+
+-- Updating an enclosed span
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO NULL
+  SET name = 'two^2'
+  WHERE id = '[2,3)';
+UPDATE 1
+SELECT * FROM for_portion_of_test WHERE id = '[2,3)' ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [2,3) | [2018-01-01,2018-01-05) | two^2
+(1 row)
+
+-- Updating an open/open portion with a finite/finite target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-01-01' TO '2019-01-01'
+  SET name = 'five^1'
+  WHERE id = '[5,6)';
+UPDATE 1
+SELECT * FROM for_portion_of_test WHERE id = '[5,6)' ORDER BY id, valid_at;
+  id   |        valid_at         |  name  
+-------+-------------------------+--------
+ [5,6) | (,2018-01-01)           | five
+ [5,6) | [2018-01-01,2019-01-01) | five^1
+ [5,6) | [2019-01-01,)           | five
+(3 rows)
+
+-- Updating an enclosed span with separate protruding spans
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2017-01-01' TO '2020-01-01'
+  SET name = 'five^2'
+  WHERE id = '[5,6)';
+UPDATE 3
+SELECT * FROM for_portion_of_test WHERE id = '[5,6)' ORDER BY id, valid_at;
+  id   |        valid_at         |  name  
+-------+-------------------------+--------
+ [5,6) | (,2017-01-01)           | five
+ [5,6) | [2017-01-01,2018-01-01) | five^2
+ [5,6) | [2018-01-01,2019-01-01) | five^2
+ [5,6) | [2019-01-01,2020-01-01) | five^2
+ [5,6) | [2020-01-01,)           | five
+(5 rows)
+
+-- Updating multiple enclosed spans
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO NULL
+  SET name = 'one^2'
+  WHERE id = '[1,2)';
+UPDATE 3
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [1,2) | [2018-01-02,2018-02-03) | one^2
+ [1,2) | [2018-02-03,2018-03-03) | one^2
+ [1,2) | [2018-03-03,2018-04-04) | one^2
+(3 rows)
+
+-- Updating with a direct target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at (daterange('2018-03-10', '2018-03-15'))
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+UPDATE 1
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [1,2) | [2018-01-02,2018-02-03) | one^2
+ [1,2) | [2018-02-03,2018-03-03) | one^2
+ [1,2) | [2018-03-03,2018-03-10) | one^2
+ [1,2) | [2018-03-10,2018-03-15) | one^3
+ [1,2) | [2018-03-15,2018-04-04) | one^2
+(5 rows)
+
+-- Updating with a direct target, coerced from a string
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at ('[2018-03-15,2018-03-17)')
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+UPDATE 1
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [1,2) | [2018-01-02,2018-02-03) | one^2
+ [1,2) | [2018-02-03,2018-03-03) | one^2
+ [1,2) | [2018-03-03,2018-03-10) | one^2
+ [1,2) | [2018-03-10,2018-03-15) | one^3
+ [1,2) | [2018-03-15,2018-03-17) | one^3
+ [1,2) | [2018-03-17,2018-04-04) | one^2
+(6 rows)
+
+-- Updating with a direct target of the wrong range subtype fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at (int4range(1, 4))
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+ERROR:  could not coerce FOR PORTION OF target from int4range to daterange
+LINE 2:   FOR PORTION OF valid_at (int4range(1, 4))
+                                   ^
+-- Updating with a direct target of a non-rangetype fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at (4)
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+ERROR:  could not coerce FOR PORTION OF target from integer to daterange
+LINE 2:   FOR PORTION OF valid_at (4)
+                                   ^
+-- Updating with a direct target of NULL fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at (NULL)
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+ERROR:  FOR PORTION OF target must not be null
+LINE 2:   FOR PORTION OF valid_at (NULL)
+                                   ^
+-- Updating with a direct target of empty does nothing
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at ('empty')
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+UPDATE 0
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [1,2) | [2018-01-02,2018-02-03) | one^2
+ [1,2) | [2018-02-03,2018-03-03) | one^2
+ [1,2) | [2018-03-03,2018-03-10) | one^2
+ [1,2) | [2018-03-10,2018-03-15) | one^3
+ [1,2) | [2018-03-15,2018-03-17) | one^3
+ [1,2) | [2018-03-17,2018-04-04) | one^2
+(6 rows)
+
+-- Updating the non-range part of the PK:
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-15' TO NULL
+  SET id = '[6,7)'
+  WHERE id = '[1,2)';
+UPDATE 5
+SELECT * FROM for_portion_of_test WHERE id IN ('[1,2)', '[6,7)') ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [1,2) | [2018-01-02,2018-02-03) | one^2
+ [1,2) | [2018-02-03,2018-02-15) | one^2
+ [6,7) | [2018-02-15,2018-03-03) | one^2
+ [6,7) | [2018-03-03,2018-03-10) | one^2
+ [6,7) | [2018-03-10,2018-03-15) | one^3
+ [6,7) | [2018-03-15,2018-03-17) | one^3
+ [6,7) | [2018-03-17,2018-04-04) | one^2
+(7 rows)
+
+-- UPDATE with no WHERE clause
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2030-01-01' TO NULL
+  SET name = name || '*';
+UPDATE 2
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+  id   |        valid_at         |   name   
+-------+-------------------------+----------
+ [1,2) | [2018-01-02,2018-02-03) | one^2
+ [1,2) | [2018-02-03,2018-02-15) | one^2
+ [2,3) | [2018-01-01,2018-01-05) | two^2
+ [3,4) | [2018-01-01,2018-03-01) | three^2
+ [3,4) | [2018-03-01,2018-06-01) | three
+ [3,4) | [2018-06-01,2030-01-01) | three^1
+ [3,4) | [2030-01-01,)           | three^1*
+ [4,5) | (,2017-01-01)           | four^1
+ [4,5) | [2017-01-01,2018-02-01) | four^3
+ [4,5) | [2018-02-01,2018-04-01) | four^2
+ [5,6) | (,2017-01-01)           | five
+ [5,6) | [2017-01-01,2018-01-01) | five^2
+ [5,6) | [2018-01-01,2019-01-01) | five^2
+ [5,6) | [2019-01-01,2020-01-01) | five^2
+ [5,6) | [2020-01-01,2030-01-01) | five
+ [5,6) | [2030-01-01,)           | five*
+ [6,7) | [2018-02-15,2018-03-03) | one^2
+ [6,7) | [2018-03-03,2018-03-10) | one^2
+ [6,7) | [2018-03-10,2018-03-15) | one^3
+ [6,7) | [2018-03-15,2018-03-17) | one^3
+ [6,7) | [2018-03-17,2018-04-04) | one^2
+(21 rows)
+
+\set QUIET true
+-- Updating with a shift/reduce conflict
+-- (requires a tsrange column)
+CREATE UNLOGGED TABLE for_portion_of_test2 (
+  id int4range,
+  valid_at tsrange,
+  name text
+);
+INSERT INTO for_portion_of_test2 (id, valid_at, name) VALUES
+  ('[1,2)', '[2000-01-01,2020-01-01)', 'one');
+-- updates [2011-03-01 01:02:00, 2012-01-01) (note 2 minutes)
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at
+    FROM '2011-03-01'::timestamp + INTERVAL '1:02:03' HOUR TO MINUTE
+    TO '2012-01-01'
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+-- TO is used for the bound but not the INTERVAL:
+-- syntax error
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at
+    FROM '2013-03-01'::timestamp + INTERVAL '1:02:03' HOUR
+    TO '2014-01-01'
+  SET name = 'one^2'
+  WHERE id = '[1,2)';
+ERROR:  syntax error at or near "'2014-01-01'"
+LINE 4:     TO '2014-01-01'
+               ^
+-- adding parens fixes it
+-- updates [2015-03-01 01:00:00, 2016-01-01) (no minutes)
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at
+    FROM ('2015-03-01'::timestamp + INTERVAL '1:02:03' HOUR)
+    TO '2016-01-01'
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+  id   |                   valid_at                    | name  
+-------+-----------------------------------------------+-------
+ [1,2) | ["2000-01-01 00:00:00","2011-03-01 01:02:00") | one
+ [1,2) | ["2011-03-01 01:02:00","2012-01-01 00:00:00") | one^1
+ [1,2) | ["2012-01-01 00:00:00","2015-03-01 01:00:00") | one
+ [1,2) | ["2015-03-01 01:00:00","2016-01-01 00:00:00") | one^3
+ [1,2) | ["2016-01-01 00:00:00","2020-01-01 00:00:00") | one
+(5 rows)
+
+DROP TABLE for_portion_of_test2;
+-- UPDATE FOR PORTION OF in a CTE:
+-- The outer query sees the table how it was before the updates,
+-- and with no leftovers yet,
+-- but it also sees the new values via the RETURNING clause.
+-- (We test RETURNING more directly, without a CTE, below.)
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[10,11)', '[2018-01-01,2020-01-01)', 'ten');
+WITH update_apr AS (
+  UPDATE for_portion_of_test
+    FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-05-01'
+    SET name = 'Apr 2018'
+    WHERE id = '[10,11)'
+    RETURNING id, valid_at, name
+)
+SELECT *
+  FROM for_portion_of_test AS t, update_apr
+  WHERE t.id = update_apr.id;
+   id    |        valid_at         | name |   id    |        valid_at         |   name   
+---------+-------------------------+------+---------+-------------------------+----------
+ [10,11) | [2018-01-01,2020-01-01) | ten  | [10,11) | [2018-04-01,2018-05-01) | Apr 2018
+(1 row)
+
+SELECT * FROM for_portion_of_test WHERE id = '[10,11)' ORDER BY id, valid_at;
+   id    |        valid_at         |   name   
+---------+-------------------------+----------
+ [10,11) | [2018-01-01,2018-04-01) | ten
+ [10,11) | [2018-04-01,2018-05-01) | Apr 2018
+ [10,11) | [2018-05-01,2020-01-01) | ten
+(3 rows)
+
+-- UPDATE FOR PORTION OF with current_date
+-- (We take care not to make the expectation depend on the timestamp.)
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[99,100)', '[2000-01-01,)', 'foo');
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM current_date TO null
+  SET name = 'bar'
+  WHERE id = '[99,100)';
+SELECT name, lower(valid_at) FROM for_portion_of_test
+  WHERE id = '[99,100)' AND valid_at @> current_date - 1;
+ name |   lower    
+------+------------
+ foo  | 2000-01-01
+(1 row)
+
+SELECT name, upper(valid_at) FROM for_portion_of_test
+  WHERE id = '[99,100)' AND valid_at @> current_date + 1;
+ name | upper 
+------+-------
+ bar  | 
+(1 row)
+
+-- UPDATE FOR PORTION OF with clock_timestamp()
+-- fails because the function is volatile:
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM clock_timestamp()::date TO null
+  SET name = 'baz'
+  WHERE id = '[99,100)';
+ERROR:  FOR PORTION OF bounds cannot contain volatile functions
+-- clean up:
+DELETE FROM for_portion_of_test WHERE id = '[99,100)';
+-- Not visible to UPDATE:
+-- Tuples updated/inserted within the CTE are not visible to the main query yet,
+-- but neither are old tuples the CTE changed:
+-- (This is the same behavior as without FOR PORTION OF.)
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[11,12)', '[2018-01-01,2020-01-01)', 'eleven');
+WITH update_apr AS (
+  UPDATE for_portion_of_test
+    FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-05-01'
+    SET name = 'Apr 2018'
+    WHERE id = '[11,12)'
+    RETURNING id, valid_at, name
+)
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  AS t
+  SET name = 'May 2018'
+  FROM update_apr AS j
+  WHERE t.id = j.id;
+SELECT * FROM for_portion_of_test WHERE id = '[11,12)' ORDER BY id, valid_at;
+   id    |        valid_at         |   name   
+---------+-------------------------+----------
+ [11,12) | [2018-01-01,2018-04-01) | eleven
+ [11,12) | [2018-04-01,2018-05-01) | Apr 2018
+ [11,12) | [2018-05-01,2020-01-01) | eleven
+(3 rows)
+
+DELETE FROM for_portion_of_test WHERE id IN ('[10,11)', '[11,12)');
+-- UPDATE FOR PORTION OF in a PL/pgSQL function
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[10,11)', '[2018-01-01,2020-01-01)', 'ten');
+CREATE FUNCTION fpo_update(_id int4range, _target_from date, _target_til date)
+RETURNS void LANGUAGE plpgsql AS
+$$
+BEGIN
+  UPDATE for_portion_of_test
+    FOR PORTION OF valid_at FROM $2 TO $3
+    SET name = concat(_target_from::text, ' to ', _target_til::text)
+    WHERE id = $1;
+END;
+$$;
+SELECT fpo_update('[10,11)', '2015-01-01', '2019-01-01');
+ fpo_update 
+------------
+ 
+(1 row)
+
+SELECT * FROM for_portion_of_test WHERE id = '[10,11)';
+   id    |        valid_at         |           name           
+---------+-------------------------+--------------------------
+ [10,11) | [2018-01-01,2019-01-01) | 2015-01-01 to 2019-01-01
+ [10,11) | [2019-01-01,2020-01-01) | ten
+(2 rows)
+
+-- UPDATE FOR PORTION OF in a compiled SQL function
+CREATE FUNCTION fpo_update()
+RETURNS text
+BEGIN ATOMIC
+  UPDATE for_portion_of_test
+    FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01'
+    SET name = 'one^1'
+    RETURNING name;
+END;
+\sf+ fpo_update()
+        CREATE OR REPLACE FUNCTION public.fpo_update()
+         RETURNS text
+         LANGUAGE sql
+1       BEGIN ATOMIC
+2        UPDATE for_portion_of_test FOR PORTION OF valid_at FROM ('2018-01-15'::date) TO ('2019-01-01'::date) SET name = 'one^1'::text
+3          RETURNING for_portion_of_test.name;
+4       END
+CREATE OR REPLACE function fpo_update()
+RETURNS text
+BEGIN ATOMIC
+  UPDATE for_portion_of_test
+    FOR PORTION OF valid_at (daterange('2018-01-15', '2020-01-01') * daterange('2019-01-01', '2022-01-01'))
+    SET name = 'one^1'
+    RETURNING name;
+END;
+\sf+ fpo_update()
+        CREATE OR REPLACE FUNCTION public.fpo_update()
+         RETURNS text
+         LANGUAGE sql
+1       BEGIN ATOMIC
+2        UPDATE for_portion_of_test FOR PORTION OF valid_at ((daterange('2018-01-15'::date, '2020-01-01'::date) * daterange('2019-01-01'::date, '2022-01-01'::date))) SET name = 'one^1'::text
+3          RETURNING for_portion_of_test.name;
+4       END
+DROP FUNCTION fpo_update();
+DROP TABLE for_portion_of_test;
+--
+-- DELETE tests
+--
+CREATE TABLE for_portion_of_test (
+  id int4range NOT NULL,
+  valid_at daterange NOT NULL,
+  name text NOT NULL,
+  CONSTRAINT for_portion_of_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-02,2018-02-03)', 'one'),
+  ('[1,2)', '[2018-02-03,2018-03-03)', 'one'),
+  ('[1,2)', '[2018-03-03,2018-04-04)', 'one'),
+  ('[2,3)', '[2018-01-01,2018-01-05)', 'two'),
+  ('[3,4)', '[2018-01-01,)', 'three'),
+  ('[4,5)', '(,2018-04-01)', 'four'),
+  ('[5,6)', '(,)', 'five'),
+  ('[6,7)', '[2018-01-01,)', 'six'),
+  ('[7,8)', '(,2018-04-01)', 'seven'),
+  ('[8,9)', '[2018-01-02,2018-02-03)', 'eight'),
+  ('[8,9)', '[2018-02-03,2018-03-03)', 'eight'),
+  ('[8,9)', '[2018-03-03,2018-04-04)', 'eight')
+  ;
+\set QUIET false
+-- Deleting with a missing column fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF invalid_at FROM '2018-06-01' TO NULL
+  WHERE id = '[5,6)';
+ERROR:  column "invalid_at" of relation "for_portion_of_test" does not exist
+LINE 2:   FOR PORTION OF invalid_at FROM '2018-06-01' TO NULL
+                         ^
+-- The wrong start type fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM 1 TO '2020-01-01'
+  WHERE id = '[3,4)';
+ERROR:  could not coerce FOR PORTION OF FROM bound from integer to date
+LINE 2:   FOR PORTION OF valid_at FROM 1 TO '2020-01-01'
+                                       ^
+-- The wrong end type fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2000-01-01' TO 4
+  WHERE id = '[3,4)';
+ERROR:  could not coerce FOR PORTION OF TO bound from integer to date
+LINE 2:   FOR PORTION OF valid_at FROM '2000-01-01' TO 4
+                                                       ^
+-- Deleting with timestamps reversed fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO '2018-01-01'
+  WHERE id = '[3,4)';
+ERROR:  range lower bound must be less than or equal to range upper bound
+-- Deleting with a subquery fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM (SELECT '2018-01-01') TO '2018-06-01'
+  WHERE id = '[3,4)';
+ERROR:  cannot use subquery in FOR PORTION OF expression
+LINE 2:   FOR PORTION OF valid_at FROM (SELECT '2018-01-01') TO '201...
+                                       ^
+-- Deleting with a column fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM lower(valid_at) TO NULL
+  WHERE id = '[3,4)';
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 2:   FOR PORTION OF valid_at FROM lower(valid_at) TO NULL
+                                             ^
+-- and so does a whole-row reference, which takes a different path
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at ((ROW(for_portion_of_test.*)).f2)
+  WHERE id = '[3,4)';
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 2:   FOR PORTION OF valid_at ((ROW(for_portion_of_test.*)).f2)
+                                        ^
+-- Deleting with timestamps equal does nothing
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-04-01'
+  WHERE id = '[3,4)';
+DELETE 0
+-- Deleting a finite/open portion with a finite/open target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO NULL
+  WHERE id = '[3,4)';
+DELETE 1
+SELECT * FROM for_portion_of_test WHERE id = '[3,4)' ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [3,4) | [2018-01-01,2018-06-01) | three
+(1 row)
+
+-- Deleting a finite/open portion with an open/finite target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-03-01'
+  WHERE id = '[6,7)';
+DELETE 1
+SELECT * FROM for_portion_of_test WHERE id = '[6,7)' ORDER BY id, valid_at;
+  id   |   valid_at    | name 
+-------+---------------+------
+ [6,7) | [2018-03-01,) | six
+(1 row)
+
+-- Deleting an open/finite portion with an open/finite target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-02-01'
+  WHERE id = '[4,5)';
+DELETE 1
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [4,5) | [2018-02-01,2018-04-01) | four
+(1 row)
+
+-- Deleting an open/finite portion with a finite/open target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2017-01-01' TO NULL
+  WHERE id = '[7,8)';
+DELETE 1
+SELECT * FROM for_portion_of_test WHERE id = '[7,8)' ORDER BY id, valid_at;
+  id   |   valid_at    | name  
+-------+---------------+-------
+ [7,8) | (,2017-01-01) | seven
+(1 row)
+
+-- Deleting a finite/finite portion with an exact fit
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-01' TO '2018-04-01'
+  WHERE id = '[4,5)';
+DELETE 1
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+-- Deleting an enclosed span
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO NULL
+  WHERE id = '[2,3)';
+DELETE 1
+SELECT * FROM for_portion_of_test WHERE id = '[2,3)' ORDER BY id, valid_at;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+-- Deleting an open/open portion with a finite/finite target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-01-01' TO '2019-01-01'
+  WHERE id = '[5,6)';
+DELETE 1
+SELECT * FROM for_portion_of_test WHERE id = '[5,6)' ORDER BY id, valid_at;
+  id   |   valid_at    | name 
+-------+---------------+------
+ [5,6) | (,2018-01-01) | five
+ [5,6) | [2019-01-01,) | five
+(2 rows)
+
+-- Deleting an enclosed span with separate protruding spans
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-03' TO '2018-03-03'
+  WHERE id = '[1,2)';
+DELETE 1
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [1,2) | [2018-01-02,2018-02-03) | one
+ [1,2) | [2018-03-03,2018-04-04) | one
+(2 rows)
+
+-- Deleting multiple enclosed spans
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO NULL
+  WHERE id = '[8,9)';
+DELETE 3
+SELECT * FROM for_portion_of_test WHERE id = '[8,9)' ORDER BY id, valid_at;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+-- Deleting with a direct target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at (daterange('2018-03-10', '2018-03-15'))
+  WHERE id = '[1,2)';
+DELETE 1
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [1,2) | [2018-01-02,2018-02-03) | one
+ [1,2) | [2018-03-03,2018-03-10) | one
+ [1,2) | [2018-03-15,2018-04-04) | one
+(3 rows)
+
+-- Deleting with a direct target, coerced from a string
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at ('[2018-03-15,2018-03-17)')
+  WHERE id = '[1,2)';
+DELETE 1
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [1,2) | [2018-01-02,2018-02-03) | one
+ [1,2) | [2018-03-03,2018-03-10) | one
+ [1,2) | [2018-03-17,2018-04-04) | one
+(3 rows)
+
+-- Deleting with a direct target of the wrong range subtype fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at (int4range(1, 4))
+  WHERE id = '[1,2)';
+ERROR:  could not coerce FOR PORTION OF target from int4range to daterange
+LINE 2:   FOR PORTION OF valid_at (int4range(1, 4))
+                                   ^
+-- Deleting with a direct target of a non-rangetype fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at (4)
+  WHERE id = '[1,2)';
+ERROR:  could not coerce FOR PORTION OF target from integer to daterange
+LINE 2:   FOR PORTION OF valid_at (4)
+                                   ^
+-- Deleting with a direct target of NULL fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at (NULL)
+  WHERE id = '[1,2)';
+ERROR:  FOR PORTION OF target must not be null
+LINE 2:   FOR PORTION OF valid_at (NULL)
+                                   ^
+-- Deleting with a direct target of empty does nothing
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at ('empty')
+  WHERE id = '[1,2)';
+DELETE 0
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [1,2) | [2018-01-02,2018-02-03) | one
+ [1,2) | [2018-03-03,2018-03-10) | one
+ [1,2) | [2018-03-17,2018-04-04) | one
+(3 rows)
+
+-- DELETE with no WHERE clause
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2030-01-01' TO NULL;
+DELETE 2
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [1,2) | [2018-01-02,2018-02-03) | one
+ [1,2) | [2018-03-03,2018-03-10) | one
+ [1,2) | [2018-03-17,2018-04-04) | one
+ [3,4) | [2018-01-01,2018-06-01) | three
+ [5,6) | (,2018-01-01)           | five
+ [5,6) | [2019-01-01,2030-01-01) | five
+ [6,7) | [2018-03-01,2030-01-01) | six
+ [7,8) | (,2017-01-01)           | seven
+(8 rows)
+
+\set QUIET true
+-- UPDATE ... RETURNING returns only the updated values
+-- (not the inserted side values, which are added by a separate "statement"):
+CREATE FUNCTION fpo_returning_row(text)
+RETURNS text LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE 'RETURNING %', $1;
+  RETURN $1;
+END;
+$$;
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-01' TO '2018-02-15'
+  SET name = 'three^3'
+  WHERE id = '[3,4)'
+  RETURNING *, fpo_returning_row(for_portion_of_test::text);
+NOTICE:  RETURNING ("[3,4)","[2018-02-01,2018-02-15)",three^3)
+  id   |        valid_at         |  name   |              fpo_returning_row              
+-------+-------------------------+---------+---------------------------------------------
+ [3,4) | [2018-02-01,2018-02-15) | three^3 | ("[3,4)","[2018-02-01,2018-02-15)",three^3)
+(1 row)
+
+-- UPDATE ... RETURNING supports NEW and OLD valid_at
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-10' TO '2018-02-20'
+  SET name = 'three^4'
+  WHERE id = '[3,4)'
+  RETURNING OLD.name, NEW.name, OLD.valid_at, NEW.valid_at;
+  name   |  name   |        valid_at         |        valid_at         
+---------+---------+-------------------------+-------------------------
+ three^3 | three^4 | [2018-02-01,2018-02-15) | [2018-02-10,2018-02-15)
+ three   | three^4 | [2018-02-15,2018-06-01) | [2018-02-15,2018-02-20)
+(2 rows)
+
+-- DELETE FOR PORTION OF with current_date
+-- (We take care not to make the expectation depend on the timestamp.)
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[99,100)', '[2000-01-01,)', 'foo');
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM current_date TO null
+  WHERE id = '[99,100)';
+SELECT name, lower(valid_at) FROM for_portion_of_test
+  WHERE id = '[99,100)' AND valid_at @> current_date - 1;
+ name |   lower    
+------+------------
+ foo  | 2000-01-01
+(1 row)
+
+SELECT name, upper(valid_at) FROM for_portion_of_test
+  WHERE id = '[99,100)' AND valid_at @> current_date + 1;
+ name | upper 
+------+-------
+(0 rows)
+
+-- DELETE FOR PORTION OF with clock_timestamp()
+-- fails because the function is volatile:
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM clock_timestamp()::date TO null
+  WHERE id = '[99,100)';
+ERROR:  FOR PORTION OF bounds cannot contain volatile functions
+-- clean up:
+DELETE FROM for_portion_of_test WHERE id = '[99,100)';
+-- DELETE ... RETURNING returns the deleted values, regardless of bounds
+-- (not the inserted side values, which are added by a separate "statement"):
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-02' TO '2018-02-03'
+  WHERE id = '[3,4)'
+  RETURNING *, fpo_returning_row(for_portion_of_test::text);
+NOTICE:  RETURNING ("[3,4)","[2018-02-01,2018-02-10)",three^3)
+  id   |        valid_at         |  name   |              fpo_returning_row              
+-------+-------------------------+---------+---------------------------------------------
+ [3,4) | [2018-02-01,2018-02-10) | three^3 | ("[3,4)","[2018-02-01,2018-02-10)",three^3)
+(1 row)
+
+-- DELETE FOR PORTION OF in a PL/pgSQL function
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[10,11)', '[2018-01-01,2020-01-01)', 'ten');
+CREATE FUNCTION fpo_delete(_id int4range, _target_from date, _target_til date)
+RETURNS void LANGUAGE plpgsql AS
+$$
+BEGIN
+  DELETE FROM for_portion_of_test
+    FOR PORTION OF valid_at FROM $2 TO $3
+    WHERE id = $1;
+END;
+$$;
+SELECT fpo_delete('[10,11)', '2015-01-01', '2019-01-01');
+ fpo_delete 
+------------
+ 
+(1 row)
+
+SELECT * FROM for_portion_of_test WHERE id = '[10,11)';
+   id    |        valid_at         | name 
+---------+-------------------------+------
+ [10,11) | [2019-01-01,2020-01-01) | ten
+(1 row)
+
+DELETE FROM for_portion_of_test WHERE id IN ('[10,11)');
+-- DELETE FOR PORTION OF in a compiled SQL function
+CREATE FUNCTION fpo_delete()
+RETURNS text
+BEGIN ATOMIC
+  DELETE FROM for_portion_of_test
+    FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01'
+    RETURNING name;
+END;
+\sf+ fpo_delete()
+        CREATE OR REPLACE FUNCTION public.fpo_delete()
+         RETURNS text
+         LANGUAGE sql
+1       BEGIN ATOMIC
+2        DELETE FROM for_portion_of_test FOR PORTION OF valid_at FROM ('2018-01-15'::date) TO ('2019-01-01'::date)
+3          RETURNING for_portion_of_test.name;
+4       END
+CREATE OR REPLACE function fpo_delete()
+RETURNS text
+BEGIN ATOMIC
+  DELETE FROM for_portion_of_test
+    FOR PORTION OF valid_at (daterange('2018-01-15', '2020-01-01') * daterange('2019-01-01', '2022-01-01'))
+    RETURNING name;
+END;
+\sf+ fpo_delete()
+        CREATE OR REPLACE FUNCTION public.fpo_delete()
+         RETURNS text
+         LANGUAGE sql
+1       BEGIN ATOMIC
+2        DELETE FROM for_portion_of_test FOR PORTION OF valid_at ((daterange('2018-01-15'::date, '2020-01-01'::date) * daterange('2019-01-01'::date, '2022-01-01'::date)))
+3          RETURNING for_portion_of_test.name;
+4       END
+DROP FUNCTION fpo_delete();
+-- test domains and CHECK constraints
+-- With a domain on a rangetype
+CREATE DOMAIN daterange_d AS daterange CHECK (upper(VALUE) <> '2005-05-05'::date);
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at daterange_d,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '[2000-01-01,2020-01-01)', 'one'),
+  (2, '[2000-01-01,2020-01-01)', 'two');
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '[2000-01-01,2005-05-05)', 'nope');
+ERROR:  value for domain daterange_d violates check constraint "daterange_d_check"
+-- UPDATE works:
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2010-01-01' TO '2010-01-05'
+  SET name = 'one^1'
+  WHERE id = 1;
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('[2010-01-07,2010-01-09)')
+  SET name = 'one^2'
+  WHERE id = 1;
+SELECT * FROM for_portion_of_test2 WHERE id = 1 ORDER BY valid_at;
+ id |        valid_at         | name  
+----+-------------------------+-------
+  1 | [2000-01-01,2010-01-01) | one
+  1 | [2010-01-01,2010-01-05) | one^1
+  1 | [2010-01-05,2010-01-07) | one
+  1 | [2010-01-07,2010-01-09) | one^2
+  1 | [2010-01-09,2020-01-01) | one
+(5 rows)
+
+-- The target is allowed to violate the domain:
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '1999-01-01' TO '2005-05-05'
+  SET name = 'miss'
+  WHERE id = -1;
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('[1999-01-01,2005-05-05)')
+  SET name = 'miss'
+  WHERE id = -1;
+-- test the updated row violating the domain
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '1999-01-01' TO '2005-05-05'
+  SET name = 'one^3'
+  WHERE id = 1;
+ERROR:  value for domain daterange_d violates check constraint "daterange_d_check"
+-- test inserts violating the domain
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2005-05-05' TO '2010-01-01'
+  SET name = 'one^3'
+  WHERE id = 1;
+ERROR:  value for domain daterange_d violates check constraint "daterange_d_check"
+-- test updated row violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (upper(valid_at) <> '2001-01-11');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-11'
+  SET name = 'one^3'
+  WHERE id = 1;
+ERROR:  new row for relation "for_portion_of_test2" violates check constraint "fpo2_check"
+DETAIL:  Failing row contains (1, [2000-01-01,2001-01-11), one^3).
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+-- test inserts violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (lower(valid_at) <> '2002-02-02');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-02-02'
+  SET name = 'one^3'
+  WHERE id = 1;
+ERROR:  new row for relation "for_portion_of_test2" violates check constraint "fpo2_check"
+DETAIL:  Failing row contains (1, [2002-02-02,2010-01-01), one).
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+SELECT * FROM for_portion_of_test2 WHERE id = 1 ORDER BY valid_at;
+ id |        valid_at         | name  
+----+-------------------------+-------
+  1 | [2000-01-01,2010-01-01) | one
+  1 | [2010-01-01,2010-01-05) | one^1
+  1 | [2010-01-05,2010-01-07) | one
+  1 | [2010-01-07,2010-01-09) | one^2
+  1 | [2010-01-09,2020-01-01) | one
+(5 rows)
+
+-- DELETE works:
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2010-01-01' TO '2010-01-05'
+  WHERE id = 2;
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('[2010-01-07,2010-01-09)')
+  WHERE id = 2;
+SELECT * FROM for_portion_of_test2 WHERE id = 2 ORDER BY valid_at;
+ id |        valid_at         | name 
+----+-------------------------+------
+  2 | [2000-01-01,2010-01-01) | two
+  2 | [2010-01-05,2010-01-07) | two
+  2 | [2010-01-09,2020-01-01) | two
+(3 rows)
+
+-- The target is allowed to violate the domain:
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at FROM '1999-01-01' TO '2005-05-05'
+  WHERE id = -1;
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('[1999-01-01,2005-05-05)')
+  WHERE id = -1;
+-- test inserts violating the domain
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2005-05-05' TO '2010-01-01'
+  WHERE id = 2;
+ERROR:  value for domain daterange_d violates check constraint "daterange_d_check"
+-- test inserts violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (lower(valid_at) <> '2002-02-02');
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-02-02'
+  WHERE id = 2;
+ERROR:  new row for relation "for_portion_of_test2" violates check constraint "fpo2_check"
+DETAIL:  Failing row contains (2, [2002-02-02,2010-01-01), two).
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+SELECT * FROM for_portion_of_test2 WHERE id = 2 ORDER BY valid_at;
+ id |        valid_at         | name 
+----+-------------------------+------
+  2 | [2000-01-01,2010-01-01) | two
+  2 | [2010-01-05,2010-01-07) | two
+  2 | [2010-01-09,2020-01-01) | two
+(3 rows)
+
+DROP TABLE for_portion_of_test2;
+-- The domain is checked for every leftover, not just the first one.  The
+-- lookup is cached across rows, so make sure a violation is still caught on a
+-- row after one that passed.
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at daterange_d,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '[2006-01-01,2020-01-01)', 'one'),
+  (2, '[2000-01-01,2020-01-01)', 'two'),
+  (3, '[2000-01-01,2020-01-01)', 'three');
+-- Every row's leftovers are fine here: several rows, several checks.
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2010-01-01' TO '2011-01-01'
+  SET name = name || '!';
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+ id |        valid_at         |  name  
+----+-------------------------+--------
+  1 | [2006-01-01,2010-01-01) | one
+  1 | [2010-01-01,2011-01-01) | one!
+  1 | [2011-01-01,2020-01-01) | one
+  2 | [2000-01-01,2010-01-01) | two
+  2 | [2010-01-01,2011-01-01) | two!
+  2 | [2011-01-01,2020-01-01) | two
+  3 | [2000-01-01,2010-01-01) | three
+  3 | [2010-01-01,2011-01-01) | three!
+  3 | [2011-01-01,2020-01-01) | three
+(9 rows)
+
+-- Now the first row's leftovers pass but the second row's violate the domain.
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2005-05-05' TO '2007-01-01'
+  SET name = 'nope';
+ERROR:  value for domain daterange_d violates check constraint "daterange_d_check"
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+ id |        valid_at         |  name  
+----+-------------------------+--------
+  1 | [2006-01-01,2010-01-01) | one
+  1 | [2010-01-01,2011-01-01) | one!
+  1 | [2011-01-01,2020-01-01) | one
+  2 | [2000-01-01,2010-01-01) | two
+  2 | [2010-01-01,2011-01-01) | two!
+  2 | [2011-01-01,2020-01-01) | two
+  3 | [2000-01-01,2010-01-01) | three
+  3 | [2010-01-01,2011-01-01) | three!
+  3 | [2011-01-01,2020-01-01) | three
+(9 rows)
+
+DROP TABLE for_portion_of_test2;
+-- With a domain on a multirangetype
+CREATE FUNCTION multirange_lowers(mr anymultirange) RETURNS anyarray LANGUAGE sql AS $$
+  SELECT array_agg(lower(r)) FROM UNNEST(mr) u(r);
+$$;
+CREATE FUNCTION multirange_uppers(mr anymultirange) RETURNS anyarray LANGUAGE sql AS $$
+  SELECT array_agg(upper(r)) FROM UNNEST(mr) u(r);
+$$;
+CREATE DOMAIN datemultirange_d AS datemultirange CHECK (NOT '2005-05-05'::date = ANY (multirange_uppers(VALUE)));
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at datemultirange_d,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '{[2000-01-01,2020-01-01)}', 'one'),
+  (2, '{[2000-01-01,2020-01-01)}', 'two');
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '{[2000-01-01,2005-05-05)}', 'nope');
+ERROR:  value for domain datemultirange_d violates check constraint "datemultirange_d_check"
+-- UPDATE works:
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2010-01-07,2010-01-09)}')
+  SET name = 'one^2'
+  WHERE id = 1;
+SELECT * FROM for_portion_of_test2 WHERE id = 1 ORDER BY valid_at;
+ id |                     valid_at                      | name  
+----+---------------------------------------------------+-------
+  1 | {[2000-01-01,2010-01-07),[2010-01-09,2020-01-01)} | one
+  1 | {[2010-01-07,2010-01-09)}                         | one^2
+(2 rows)
+
+-- The target is allowed to violate the domain:
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[1999-01-01,2005-05-05)}')
+  SET name = 'miss'
+  WHERE id = -1;
+-- test the updated row violating the domain
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[1999-01-01,2005-05-05)}')
+  SET name = 'one^3'
+  WHERE id = 1;
+ERROR:  value for domain datemultirange_d violates check constraint "datemultirange_d_check"
+-- test inserts violating the domain
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2005-05-05,2010-01-01)}')
+  SET name = 'one^3'
+  WHERE id = 1;
+ERROR:  value for domain datemultirange_d violates check constraint "datemultirange_d_check"
+-- test updated row violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (upper(valid_at) <> '2001-01-11');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2000-01-01,2001-01-11)}')
+  SET name = 'one^3'
+  WHERE id = 1;
+ERROR:  new row for relation "for_portion_of_test2" violates check constraint "fpo2_check"
+DETAIL:  Failing row contains (1, {[2000-01-01,2001-01-11)}, one^3).
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+-- test inserts violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (NOT '2002-02-02'::date = ANY (multirange_lowers(valid_at)));
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2001-01-01,2002-02-02)}')
+  SET name = 'one^3'
+  WHERE id = 1;
+ERROR:  new row for relation "for_portion_of_test2" violates check constraint "fpo2_check"
+DETAIL:  Failing row contains (1, {[2000-01-01,2001-01-01),[2002-02-02,2010-01-07),[2010-01-09,202..., one).
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+SELECT * FROM for_portion_of_test2 WHERE id = 1 ORDER BY valid_at;
+ id |                     valid_at                      | name  
+----+---------------------------------------------------+-------
+  1 | {[2000-01-01,2010-01-07),[2010-01-09,2020-01-01)} | one
+  1 | {[2010-01-07,2010-01-09)}                         | one^2
+(2 rows)
+
+-- DELETE works:
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2010-01-07,2010-01-09)}')
+  WHERE id = 2;
+SELECT * FROM for_portion_of_test2 WHERE id = 2 ORDER BY valid_at;
+ id |                     valid_at                      | name 
+----+---------------------------------------------------+------
+  2 | {[2000-01-01,2010-01-07),[2010-01-09,2020-01-01)} | two
+(1 row)
+
+-- The target is allowed to violate the domain:
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[1999-01-01,2005-05-05)}')
+  WHERE id = -1;
+-- test inserts violating the domain
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2005-05-05,2010-01-01)}')
+  WHERE id = 2;
+ERROR:  value for domain datemultirange_d violates check constraint "datemultirange_d_check"
+-- test inserts violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (NOT '2002-02-02'::date = ANY (multirange_lowers(valid_at)));
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2001-01-01,2002-02-02)}')
+  WHERE id = 2;
+ERROR:  new row for relation "for_portion_of_test2" violates check constraint "fpo2_check"
+DETAIL:  Failing row contains (2, {[2000-01-01,2001-01-01),[2002-02-02,2010-01-07),[2010-01-09,202..., two).
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+SELECT * FROM for_portion_of_test2 WHERE id = 2 ORDER BY valid_at;
+ id |                     valid_at                      | name 
+----+---------------------------------------------------+------
+  2 | {[2000-01-01,2010-01-07),[2010-01-09,2020-01-01)} | two
+(1 row)
+
+DROP TABLE for_portion_of_test2;
+-- test on non-range/multirange columns
+-- With a direct target and a scalar column
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at date,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES (1, '2020-01-01', 'one');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('2010-01-01')
+  SET name = 'one^1';
+ERROR:  column "valid_at" of relation "for_portion_of_test2" is not a range or multirange type
+LINE 2:   FOR PORTION OF valid_at ('2010-01-01')
+                         ^
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('2010-01-01');
+ERROR:  column "valid_at" of relation "for_portion_of_test2" is not a range or multirange type
+LINE 2:   FOR PORTION OF valid_at ('2010-01-01');
+                         ^
+DROP TABLE for_portion_of_test2;
+-- With a direct target and a non-{,multi}range gistable column without overlaps
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at point,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES (1, '0,0', 'one');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('1,1')
+  SET name = 'one^1';
+ERROR:  column "valid_at" of relation "for_portion_of_test2" is not a range or multirange type
+LINE 2:   FOR PORTION OF valid_at ('1,1')
+                         ^
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('1,1');
+ERROR:  column "valid_at" of relation "for_portion_of_test2" is not a range or multirange type
+LINE 2:   FOR PORTION OF valid_at ('1,1');
+                         ^
+DROP TABLE for_portion_of_test2;
+-- With a direct target and a non-{,multi}range column with overlaps
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at box,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES (1, '0,0,4,4', 'one');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('1,1,2,2')
+  SET name = 'one^1';
+ERROR:  column "valid_at" of relation "for_portion_of_test2" is not a range or multirange type
+LINE 2:   FOR PORTION OF valid_at ('1,1,2,2')
+                         ^
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('1,1,2,2');
+ERROR:  column "valid_at" of relation "for_portion_of_test2" is not a range or multirange type
+LINE 2:   FOR PORTION OF valid_at ('1,1,2,2');
+                         ^
+DROP TABLE for_portion_of_test2;
+-- test that we run triggers on the UPDATE/DELETEd row and the INSERTed rows
+CREATE FUNCTION dump_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '%: % % %:',
+    TG_NAME, TG_WHEN, TG_OP, TG_LEVEL;
+
+  IF TG_ARGV[0] THEN
+    RAISE NOTICE '  old: %', (SELECT string_agg(old_table::text, '\n       ') FROM old_table);
+  ELSE
+    RAISE NOTICE '  old: %', OLD.valid_at;
+  END IF;
+  IF TG_ARGV[1] THEN
+    RAISE NOTICE '  new: %', (SELECT string_agg(new_table::text, '\n       ') FROM new_table);
+  ELSE
+    RAISE NOTICE '  new: %', NEW.valid_at;
+  END IF;
+
+  IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
+    RETURN NEW;
+  ELSIF TG_OP = 'DELETE' THEN
+    RETURN OLD;
+  END IF;
+END;
+$$;
+-- statement triggers:
+CREATE TRIGGER fpo_before_stmt
+  BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_stmt1
+  BEFORE UPDATE OF valid_at ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_after_insert_stmt
+  AFTER INSERT ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_after_update_stmt
+  AFTER UPDATE ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_after_delete_stmt
+  AFTER DELETE ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+-- row triggers:
+CREATE TRIGGER fpo_before_row
+  BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_after_insert_row
+  AFTER INSERT ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_after_update_row
+  AFTER UPDATE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_after_delete_row
+  AFTER DELETE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2021-01-01' TO '2022-01-01'
+  SET name = 'five^3'
+  WHERE id = '[5,6)';
+NOTICE:  fpo_before_stmt: BEFORE UPDATE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_stmt1: BEFORE UPDATE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE UPDATE ROW:
+NOTICE:    old: [2019-01-01,2030-01-01)
+NOTICE:    new: [2021-01-01,2022-01-01)
+NOTICE:  fpo_before_row1: BEFORE UPDATE ROW:
+NOTICE:    old: [2019-01-01,2030-01-01)
+NOTICE:    new: [2021-01-01,2022-01-01)
+NOTICE:  fpo_before_stmt: BEFORE INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2019-01-01,2021-01-01)
+NOTICE:  fpo_after_insert_row: AFTER INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2019-01-01,2021-01-01)
+NOTICE:  fpo_after_insert_stmt: AFTER INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_stmt: BEFORE INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2022-01-01,2030-01-01)
+NOTICE:  fpo_after_insert_row: AFTER INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2022-01-01,2030-01-01)
+NOTICE:  fpo_after_insert_stmt: AFTER INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_after_update_row: AFTER UPDATE ROW:
+NOTICE:    old: [2019-01-01,2030-01-01)
+NOTICE:    new: [2021-01-01,2022-01-01)
+NOTICE:  fpo_after_update_stmt: AFTER UPDATE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2023-01-01' TO '2024-01-01'
+  WHERE id = '[5,6)';
+NOTICE:  fpo_before_stmt: BEFORE DELETE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE DELETE ROW:
+NOTICE:    old: [2022-01-01,2030-01-01)
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_stmt: BEFORE INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2022-01-01,2023-01-01)
+NOTICE:  fpo_after_insert_row: AFTER INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2022-01-01,2023-01-01)
+NOTICE:  fpo_after_insert_stmt: AFTER INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_stmt: BEFORE INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2024-01-01,2030-01-01)
+NOTICE:  fpo_after_insert_row: AFTER INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2024-01-01,2030-01-01)
+NOTICE:  fpo_after_insert_stmt: AFTER INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_after_delete_row: AFTER DELETE ROW:
+NOTICE:    old: [2022-01-01,2030-01-01)
+NOTICE:    new: <NULL>
+NOTICE:  fpo_after_delete_stmt: AFTER DELETE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+  id   |        valid_at         |  name   
+-------+-------------------------+---------
+ [1,2) | [2018-01-02,2018-02-03) | one
+ [1,2) | [2018-03-03,2018-03-10) | one
+ [1,2) | [2018-03-17,2018-04-04) | one
+ [3,4) | [2018-01-01,2018-02-01) | three
+ [3,4) | [2018-02-01,2018-02-02) | three^3
+ [3,4) | [2018-02-03,2018-02-10) | three^3
+ [3,4) | [2018-02-10,2018-02-15) | three^4
+ [3,4) | [2018-02-15,2018-02-20) | three^4
+ [3,4) | [2018-02-20,2018-06-01) | three
+ [5,6) | (,2018-01-01)           | five
+ [5,6) | [2019-01-01,2021-01-01) | five
+ [5,6) | [2021-01-01,2022-01-01) | five^3
+ [5,6) | [2022-01-01,2023-01-01) | five
+ [5,6) | [2024-01-01,2030-01-01) | five
+ [6,7) | [2018-03-01,2030-01-01) | six
+ [7,8) | (,2017-01-01)           | seven
+(16 rows)
+
+-- Triggers with a custom transition table name:
+DROP TABLE for_portion_of_test;
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO for_portion_of_test VALUES ('[1,2)', '[2018-01-01,2020-01-01)', 'one');
+-- statement triggers:
+CREATE TRIGGER fpo_before_stmt
+  BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_after_insert_stmt
+  AFTER INSERT ON for_portion_of_test
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, true);
+CREATE TRIGGER fpo_after_update_stmt
+  AFTER UPDATE ON for_portion_of_test
+  REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(true, true);
+CREATE TRIGGER fpo_after_delete_stmt
+  AFTER DELETE ON for_portion_of_test
+  REFERENCING OLD TABLE AS old_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(true, false);
+-- row triggers:
+CREATE TRIGGER fpo_before_row
+  BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_after_insert_row
+  AFTER INSERT ON for_portion_of_test
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, true);
+CREATE TRIGGER fpo_after_update_row
+  AFTER UPDATE ON for_portion_of_test
+  REFERENCING OLD TABLE AS old_table NEW TABLE AS new_table
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(true, true);
+CREATE TRIGGER fpo_after_delete_row
+  AFTER DELETE ON for_portion_of_test
+  REFERENCING OLD TABLE AS old_table
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(true, false);
+BEGIN;
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01'
+  SET name = '2018-01-15_to_2019-01-01';
+NOTICE:  fpo_before_stmt: BEFORE UPDATE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE UPDATE ROW:
+NOTICE:    old: [2018-01-01,2020-01-01)
+NOTICE:    new: [2018-01-15,2019-01-01)
+NOTICE:  fpo_before_stmt: BEFORE INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2018-01-01,2018-01-15)
+NOTICE:  fpo_after_insert_row: AFTER INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: ("[1,2)","[2018-01-01,2018-01-15)",one)
+NOTICE:  fpo_after_insert_stmt: AFTER INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: ("[1,2)","[2018-01-01,2018-01-15)",one)
+NOTICE:  fpo_before_stmt: BEFORE INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2019-01-01,2020-01-01)
+NOTICE:  fpo_after_insert_row: AFTER INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: ("[1,2)","[2019-01-01,2020-01-01)",one)
+NOTICE:  fpo_after_insert_stmt: AFTER INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: ("[1,2)","[2019-01-01,2020-01-01)",one)
+NOTICE:  fpo_after_update_row: AFTER UPDATE ROW:
+NOTICE:    old: ("[1,2)","[2018-01-01,2020-01-01)",one)
+NOTICE:    new: ("[1,2)","[2018-01-15,2019-01-01)",2018-01-15_to_2019-01-01)
+NOTICE:  fpo_after_update_stmt: AFTER UPDATE STATEMENT:
+NOTICE:    old: ("[1,2)","[2018-01-01,2020-01-01)",one)
+NOTICE:    new: ("[1,2)","[2018-01-15,2019-01-01)",2018-01-15_to_2019-01-01)
+ROLLBACK;
+BEGIN;
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-01-21';
+NOTICE:  fpo_before_stmt: BEFORE DELETE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE DELETE ROW:
+NOTICE:    old: [2018-01-01,2020-01-01)
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_stmt: BEFORE INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2018-01-21,2020-01-01)
+NOTICE:  fpo_after_insert_row: AFTER INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: ("[1,2)","[2018-01-21,2020-01-01)",one)
+NOTICE:  fpo_after_insert_stmt: AFTER INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: ("[1,2)","[2018-01-21,2020-01-01)",one)
+NOTICE:  fpo_after_delete_row: AFTER DELETE ROW:
+NOTICE:    old: ("[1,2)","[2018-01-01,2020-01-01)",one)
+NOTICE:    new: <NULL>
+NOTICE:  fpo_after_delete_stmt: AFTER DELETE STATEMENT:
+NOTICE:    old: ("[1,2)","[2018-01-01,2020-01-01)",one)
+NOTICE:    new: <NULL>
+ROLLBACK;
+BEGIN;
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-01-02'
+  SET name = 'NULL_to_2018-01-01';
+NOTICE:  fpo_before_stmt: BEFORE UPDATE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE UPDATE ROW:
+NOTICE:    old: [2018-01-01,2020-01-01)
+NOTICE:    new: [2018-01-01,2018-01-02)
+NOTICE:  fpo_before_stmt: BEFORE INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row: BEFORE INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2018-01-02,2020-01-01)
+NOTICE:  fpo_after_insert_row: AFTER INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: ("[1,2)","[2018-01-02,2020-01-01)",one)
+NOTICE:  fpo_after_insert_stmt: AFTER INSERT STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: ("[1,2)","[2018-01-02,2020-01-01)",one)
+NOTICE:  fpo_after_update_row: AFTER UPDATE ROW:
+NOTICE:    old: ("[1,2)","[2018-01-01,2020-01-01)",one)
+NOTICE:    new: ("[1,2)","[2018-01-01,2018-01-02)",NULL_to_2018-01-01)
+NOTICE:  fpo_after_update_stmt: AFTER UPDATE STATEMENT:
+NOTICE:    old: ("[1,2)","[2018-01-01,2020-01-01)",one)
+NOTICE:    new: ("[1,2)","[2018-01-01,2018-01-02)",NULL_to_2018-01-01)
+ROLLBACK;
+-- Deferred triggers
+-- (must be CONSTRAINT triggers thus AFTER ROW with no transition tables)
+DROP TABLE for_portion_of_test;
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-01,2020-01-01)', 'one');
+CREATE CONSTRAINT TRIGGER fpo_after_insert_row
+  AFTER INSERT ON for_portion_of_test
+  DEFERRABLE INITIALLY DEFERRED
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE CONSTRAINT TRIGGER fpo_after_update_row
+  AFTER UPDATE ON for_portion_of_test
+  DEFERRABLE INITIALLY DEFERRED
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE CONSTRAINT TRIGGER fpo_after_delete_row
+  AFTER DELETE ON for_portion_of_test
+  DEFERRABLE INITIALLY DEFERRED
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+BEGIN;
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01'
+  SET name = '2018-01-15_to_2019-01-01';
+COMMIT;
+NOTICE:  fpo_after_insert_row: AFTER INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2018-01-01,2018-01-15)
+NOTICE:  fpo_after_insert_row: AFTER INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2019-01-01,2020-01-01)
+NOTICE:  fpo_after_update_row: AFTER UPDATE ROW:
+NOTICE:    old: [2018-01-01,2020-01-01)
+NOTICE:    new: [2018-01-15,2019-01-01)
+BEGIN;
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-01-21';
+COMMIT;
+NOTICE:  fpo_after_insert_row: AFTER INSERT ROW:
+NOTICE:    old: <NULL>
+NOTICE:    new: [2018-01-21,2019-01-01)
+NOTICE:  fpo_after_delete_row: AFTER DELETE ROW:
+NOTICE:    old: [2018-01-15,2019-01-01)
+NOTICE:    new: <NULL>
+NOTICE:  fpo_after_delete_row: AFTER DELETE ROW:
+NOTICE:    old: [2018-01-01,2018-01-15)
+NOTICE:    new: <NULL>
+BEGIN;
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-01-02'
+  SET name = 'NULL_to_2018-01-01';
+COMMIT;
+SELECT * FROM for_portion_of_test;
+  id   |        valid_at         |           name           
+-------+-------------------------+--------------------------
+ [1,2) | [2019-01-01,2020-01-01) | one
+ [1,2) | [2018-01-21,2019-01-01) | 2018-01-15_to_2019-01-01
+(2 rows)
+
+-- test FOR PORTION OF from triggers during FOR PORTION OF:
+DROP TABLE for_portion_of_test;
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-01,2020-01-01)', 'one'),
+  ('[2,3)', '[2018-01-01,2020-01-01)', 'two'),
+  ('[3,4)', '[2018-01-01,2020-01-01)', 'three'),
+  ('[4,5)', '[2018-01-01,2020-01-01)', 'four');
+CREATE FUNCTION trg_fpo_update()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  IF pg_trigger_depth() = 1 THEN
+    UPDATE for_portion_of_test
+      FOR PORTION OF valid_at FROM '2018-02-01' TO '2018-03-01'
+      SET name = CONCAT(name, '^')
+      WHERE id = OLD.id;
+  END IF;
+  RETURN CASE WHEN 'TG_OP' = 'DELETE' THEN OLD ELSE NEW END;
+END;
+$$;
+CREATE FUNCTION trg_fpo_delete()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  IF pg_trigger_depth() = 1 THEN
+    DELETE FROM for_portion_of_test
+      FOR PORTION OF valid_at FROM '2018-03-01' TO '2018-04-01'
+      WHERE id = OLD.id;
+  END IF;
+  RETURN CASE WHEN 'TG_OP' = 'DELETE' THEN OLD ELSE NEW END;
+END;
+$$;
+-- UPDATE FOR PORTION OF from a trigger fired by UPDATE FOR PORTION OF
+CREATE TRIGGER fpo_after_update_row
+  AFTER UPDATE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE trg_fpo_update();
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  SET name = CONCAT(name, '*')
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [1,2) | [2018-01-01,2018-02-01) | one
+ [1,2) | [2018-02-01,2018-03-01) | one^
+ [1,2) | [2018-03-01,2018-05-01) | one
+ [1,2) | [2018-05-01,2018-06-01) | one*
+ [1,2) | [2018-06-01,2020-01-01) | one
+(5 rows)
+
+DROP TRIGGER fpo_after_update_row ON for_portion_of_test;
+-- UPDATE FOR PORTION OF from a trigger fired by DELETE FOR PORTION OF
+CREATE TRIGGER fpo_after_delete_row
+  AFTER DELETE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE trg_fpo_update();
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  WHERE id = '[2,3)';
+SELECT * FROM for_portion_of_test WHERE id = '[2,3)' ORDER BY id, valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [2,3) | [2018-01-01,2018-02-01) | two
+ [2,3) | [2018-02-01,2018-03-01) | two^
+ [2,3) | [2018-03-01,2018-05-01) | two
+ [2,3) | [2018-06-01,2020-01-01) | two
+(4 rows)
+
+DROP TRIGGER fpo_after_delete_row ON for_portion_of_test;
+-- DELETE FOR PORTION OF from a trigger fired by UPDATE FOR PORTION OF
+CREATE TRIGGER fpo_after_update_row
+  AFTER UPDATE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE trg_fpo_delete();
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  SET name = CONCAT(name, '*')
+  WHERE id = '[3,4)';
+SELECT * FROM for_portion_of_test WHERE id = '[3,4)' ORDER BY id, valid_at;
+  id   |        valid_at         |  name  
+-------+-------------------------+--------
+ [3,4) | [2018-01-01,2018-03-01) | three
+ [3,4) | [2018-04-01,2018-05-01) | three
+ [3,4) | [2018-05-01,2018-06-01) | three*
+ [3,4) | [2018-06-01,2020-01-01) | three
+(4 rows)
+
+DROP TRIGGER fpo_after_update_row ON for_portion_of_test;
+-- DELETE FOR PORTION OF from a trigger fired by DELETE FOR PORTION OF
+CREATE TRIGGER fpo_after_delete_row
+  AFTER DELETE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE trg_fpo_delete();
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  WHERE id = '[4,5)';
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [4,5) | [2018-01-01,2018-03-01) | four
+ [4,5) | [2018-04-01,2018-05-01) | four
+ [4,5) | [2018-06-01,2020-01-01) | four
+(3 rows)
+
+DROP TRIGGER fpo_after_delete_row ON for_portion_of_test;
+-- Test that a tuple-modifying BEFORE INSERT ROW trigger acts
+-- consistently on both temporal leftovers.
+-- When FOR PORTION OF splits a row into two leftovers, both triggers
+-- should get the original row's values.
+DROP TABLE for_portion_of_test;
+CREATE TABLE for_portion_of_test (
+  id int,
+  valid_at daterange,
+  name text
+);
+CREATE FUNCTION fpo_append_name_suffix()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  NEW.name := NEW.name || '+insert';
+  RETURN NEW;
+END;
+$$;
+CREATE TRIGGER fpo_before_insert_row
+  BEFORE INSERT ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE fpo_append_name_suffix();
+INSERT INTO for_portion_of_test VALUES (1, '[2020-01-01,2020-12-31)', 'foo');
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2020-04-01' TO '2020-08-01'
+  SET name = 'bar'
+  WHERE id = 1;
+-- Both leftovers should have the same name: 'foo+insert+insert'.
+SELECT * FROM for_portion_of_test ORDER BY valid_at;
+ id |        valid_at         |       name        
+----+-------------------------+-------------------
+  1 | [2020-01-01,2020-04-01) | foo+insert+insert
+  1 | [2020-04-01,2020-08-01) | bar
+  1 | [2020-08-01,2020-12-31) | foo+insert+insert
+(3 rows)
+
+DROP FUNCTION fpo_append_name_suffix CASCADE;
+NOTICE:  drop cascades to trigger fpo_before_insert_row on table for_portion_of_test
+DROP TABLE for_portion_of_test;
+-- A BEFORE UPDATE trigger that changes the application-time column is allowed,
+-- even if the results are senseless.
+-- Note this is likely to cause a primary key violation.
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+CREATE FUNCTION trg_fpo_change_valid_at()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  NEW.valid_at = daterange('2018-01-01', '2019-01-01');
+  RETURN NEW;
+END;
+$$;
+CREATE TRIGGER fpo_before_update_row
+  BEFORE UPDATE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE trg_fpo_change_valid_at();
+INSERT INTO for_portion_of_test VALUES ('[1,2)', '[2010-01-01,2020-01-01)', 'foo');
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  SET name = CONCAT(name, '!')
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [1,2) | [2010-01-01,2018-05-01) | foo
+ [1,2) | [2018-01-01,2019-01-01) | foo!
+ [1,2) | [2018-06-01,2020-01-01) | foo
+(3 rows)
+
+-- A primary key should reject anything invalid:
+TRUNCATE for_portion_of_test;
+ALTER TABLE for_portion_of_test
+  ADD CONSTRAINT for_portion_of_test_key
+  PRIMARY KEY (id, valid_at WITHOUT OVERLAPS);
+INSERT INTO for_portion_of_test VALUES ('[1,2)', '[2010-01-01,2020-01-01)', 'foo');
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  SET name = CONCAT(name, '!')
+  WHERE id = '[1,2)';
+ERROR:  conflicting key value violates exclusion constraint "for_portion_of_test_key"
+DETAIL:  Key (id, valid_at)=([1,2), [2010-01-01,2018-05-01)) conflicts with existing key (id, valid_at)=([1,2), [2018-01-01,2019-01-01)).
+DROP TRIGGER fpo_before_update_row ON for_portion_of_test;
+DROP FUNCTION trg_fpo_change_valid_at();
+DROP TABLE for_portion_of_test;
+-- Test with multiranges
+CREATE TABLE for_portion_of_test2 (
+  id int4range NOT NULL,
+  valid_at datemultirange NOT NULL,
+  name text NOT NULL,
+  CONSTRAINT for_portion_of_test2_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+INSERT INTO for_portion_of_test2 (id, valid_at, name) VALUES
+  ('[1,2)', datemultirange(daterange('2018-01-02', '2018-02-03)'), daterange('2018-02-04', '2018-03-03')), 'one'),
+  ('[1,2)', datemultirange(daterange('2018-03-03', '2018-04-04)')), 'one'),
+  ('[2,3)', datemultirange(daterange('2018-01-01', '2018-05-01)')), 'two'),
+  ('[3,4)', datemultirange(daterange('2018-01-01', null)), 'three');
+  ;
+-- Updating with FROM/TO
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2000-01-01' TO '2010-01-01'
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+ERROR:  column "valid_at" of relation "for_portion_of_test2" is not a range type
+LINE 2:   FOR PORTION OF valid_at FROM '2000-01-01' TO '2010-01-01'
+                         ^
+-- Updating with multirange
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at (datemultirange(daterange('2018-01-10', '2018-02-10'), daterange('2018-03-05', '2018-05-01')))
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test2 WHERE id = '[1,2)' ORDER BY valid_at;
+  id   |                     valid_at                      | name  
+-------+---------------------------------------------------+-------
+ [1,2) | {[2018-01-02,2018-01-10),[2018-02-10,2018-03-03)} | one
+ [1,2) | {[2018-01-10,2018-02-03),[2018-02-04,2018-02-10)} | one^1
+ [1,2) | {[2018-03-03,2018-03-05)}                         | one
+ [1,2) | {[2018-03-05,2018-04-04)}                         | one^1
+(4 rows)
+
+-- Updating with string coercion
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2018-03-05,2018-03-10)}')
+  SET name = 'one^2'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test2 WHERE id = '[1,2)' ORDER BY valid_at;
+  id   |                     valid_at                      | name  
+-------+---------------------------------------------------+-------
+ [1,2) | {[2018-01-02,2018-01-10),[2018-02-10,2018-03-03)} | one
+ [1,2) | {[2018-01-10,2018-02-03),[2018-02-04,2018-02-10)} | one^1
+ [1,2) | {[2018-03-03,2018-03-05)}                         | one
+ [1,2) | {[2018-03-05,2018-03-10)}                         | one^2
+ [1,2) | {[2018-03-10,2018-04-04)}                         | one^1
+(5 rows)
+
+-- Updating with the wrong range subtype fails
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[1,4)}'::int4multirange)
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+ERROR:  could not coerce FOR PORTION OF target from int4multirange to datemultirange
+LINE 2:   FOR PORTION OF valid_at ('{[1,4)}'::int4multirange)
+                                   ^
+-- Updating with a non-multirangetype fails
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at (4)
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+ERROR:  could not coerce FOR PORTION OF target from integer to datemultirange
+LINE 2:   FOR PORTION OF valid_at (4)
+                                   ^
+-- Updating with NULL fails
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at (NULL)
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+ERROR:  FOR PORTION OF target must not be null
+LINE 2:   FOR PORTION OF valid_at (NULL)
+                                   ^
+-- Updating with empty does nothing
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{}')
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test2 WHERE id = '[1,2)' ORDER BY valid_at;
+  id   |                     valid_at                      | name  
+-------+---------------------------------------------------+-------
+ [1,2) | {[2018-01-02,2018-01-10),[2018-02-10,2018-03-03)} | one
+ [1,2) | {[2018-01-10,2018-02-03),[2018-02-04,2018-02-10)} | one^1
+ [1,2) | {[2018-03-03,2018-03-05)}                         | one
+ [1,2) | {[2018-03-05,2018-03-10)}                         | one^2
+ [1,2) | {[2018-03-10,2018-04-04)}                         | one^1
+(5 rows)
+
+-- Deleting with FROM/TO
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2000-01-01' TO '2010-01-01'
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+ERROR:  column "valid_at" of relation "for_portion_of_test2" is not a range type
+LINE 2:   FOR PORTION OF valid_at FROM '2000-01-01' TO '2010-01-01'
+                         ^
+-- Deleting with multirange
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at (datemultirange(daterange('2018-01-15', '2018-02-15'), daterange('2018-03-01', '2018-03-15')))
+  WHERE id = '[2,3)';
+SELECT * FROM for_portion_of_test2 WHERE id = '[2,3)' ORDER BY valid_at;
+  id   |                                 valid_at                                  | name 
+-------+---------------------------------------------------------------------------+------
+ [2,3) | {[2018-01-01,2018-01-15),[2018-02-15,2018-03-01),[2018-03-15,2018-05-01)} | two
+(1 row)
+
+-- Deleting with string coercion
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2018-03-05,2018-03-20)}')
+  WHERE id = '[2,3)';
+SELECT * FROM for_portion_of_test2 WHERE id = '[2,3)' ORDER BY valid_at;
+  id   |                                 valid_at                                  | name 
+-------+---------------------------------------------------------------------------+------
+ [2,3) | {[2018-01-01,2018-01-15),[2018-02-15,2018-03-01),[2018-03-20,2018-05-01)} | two
+(1 row)
+
+-- Deleting with the wrong range subtype fails
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[1,4)}'::int4multirange)
+  WHERE id = '[2,3)';
+ERROR:  could not coerce FOR PORTION OF target from int4multirange to datemultirange
+LINE 2:   FOR PORTION OF valid_at ('{[1,4)}'::int4multirange)
+                                   ^
+-- Deleting with a non-multirangetype fails
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at (4)
+  WHERE id = '[2,3)';
+ERROR:  could not coerce FOR PORTION OF target from integer to datemultirange
+LINE 2:   FOR PORTION OF valid_at (4)
+                                   ^
+-- Deleting with NULL fails
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at (NULL)
+  WHERE id = '[2,3)';
+ERROR:  FOR PORTION OF target must not be null
+LINE 2:   FOR PORTION OF valid_at (NULL)
+                                   ^
+-- Deleting with empty does nothing
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{}')
+  WHERE id = '[2,3)';
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+  id   |                                 valid_at                                  | name  
+-------+---------------------------------------------------------------------------+-------
+ [1,2) | {[2018-01-02,2018-01-10),[2018-02-10,2018-03-03)}                         | one
+ [1,2) | {[2018-01-10,2018-02-03),[2018-02-04,2018-02-10)}                         | one^1
+ [1,2) | {[2018-03-03,2018-03-05)}                                                 | one
+ [1,2) | {[2018-03-05,2018-03-10)}                                                 | one^2
+ [1,2) | {[2018-03-10,2018-04-04)}                                                 | one^1
+ [2,3) | {[2018-01-01,2018-01-15),[2018-02-15,2018-03-01),[2018-03-20,2018-05-01)} | two
+ [3,4) | {[2018-01-01,)}                                                           | three
+(7 rows)
+
+DROP TABLE for_portion_of_test2;
+-- Test with a custom range type
+CREATE TYPE mydaterange AS range(subtype=date);
+CREATE TABLE for_portion_of_test2 (
+  id int4range NOT NULL,
+  valid_at mydaterange NOT NULL,
+  name text NOT NULL,
+  CONSTRAINT for_portion_of_test2_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+INSERT INTO for_portion_of_test2 (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-02,2018-02-03)', 'one'),
+  ('[1,2)', '[2018-02-03,2018-03-03)', 'one'),
+  ('[1,2)', '[2018-03-03,2018-04-04)', 'one'),
+  ('[2,3)', '[2018-01-01,2018-05-01)', 'two'),
+  ('[3,4)', '[2018-01-01,)', 'three');
+  ;
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2018-01-10' TO '2018-02-10'
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2018-01-15' TO '2018-02-15'
+  WHERE id = '[2,3)';
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [1,2) | [2018-01-02,2018-01-10) | one
+ [1,2) | [2018-01-10,2018-02-03) | one^1
+ [1,2) | [2018-02-03,2018-02-10) | one^1
+ [1,2) | [2018-02-10,2018-03-03) | one
+ [1,2) | [2018-03-03,2018-04-04) | one
+ [2,3) | [2018-01-01,2018-01-15) | two
+ [2,3) | [2018-02-15,2018-05-01) | two
+ [3,4) | [2018-01-01,)           | three
+(8 rows)
+
+DROP TABLE for_portion_of_test2;
+DROP TYPE mydaterange;
+-- Test FOR PORTION OF against a partitioned table.
+-- Include a stored generated column to test updatedCols column mapping.
+-- temporal_partitioned_1 has the same attnums as the root
+-- temporal_partitioned_3 has the different attnums from the root
+-- temporal_partitioned_5 has the different attnums too, but reversed
+CREATE TABLE temporal_partitioned (
+  id int4range,
+  valid_at daterange,
+  name text,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
+  CONSTRAINT temporal_paritioned_uq UNIQUE (id, valid_at WITHOUT OVERLAPS)
+) PARTITION BY LIST (id);
+CREATE TABLE temporal_partitioned_1 PARTITION OF temporal_partitioned FOR VALUES IN ('[1,2)', '[2,3)');
+CREATE TABLE temporal_partitioned_3 PARTITION OF temporal_partitioned FOR VALUES IN ('[3,4)', '[4,5)');
+CREATE TABLE temporal_partitioned_5 PARTITION OF temporal_partitioned FOR VALUES IN ('[5,6)', '[6,7)');
+ALTER TABLE temporal_partitioned DETACH PARTITION temporal_partitioned_3;
+ALTER TABLE temporal_partitioned_3 DROP COLUMN id, DROP COLUMN valid_at CASCADE;
+NOTICE:  drop cascades to column range_len of table temporal_partitioned_3
+ALTER TABLE temporal_partitioned_3 ADD COLUMN id int4range NOT NULL, ADD COLUMN valid_at daterange NOT NULL;
+ALTER TABLE temporal_partitioned_3 ADD COLUMN range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED;
+ALTER TABLE temporal_partitioned ATTACH PARTITION temporal_partitioned_3 FOR VALUES IN ('[3,4)', '[4,5)');
+ALTER TABLE temporal_partitioned DETACH PARTITION temporal_partitioned_5;
+ALTER TABLE temporal_partitioned_5 DROP COLUMN id, DROP COLUMN valid_at CASCADE;
+NOTICE:  drop cascades to column range_len of table temporal_partitioned_5
+ALTER TABLE temporal_partitioned_5 ADD COLUMN valid_at daterange NOT NULL, ADD COLUMN id int4range NOT NULL;
+ALTER TABLE temporal_partitioned_5 ADD COLUMN range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED;
+ALTER TABLE temporal_partitioned ATTACH PARTITION temporal_partitioned_5 FOR VALUES IN ('[5,6)', '[6,7)');
+INSERT INTO temporal_partitioned (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one'),
+  ('[3,4)', daterange('2000-01-01', '2010-01-01'), 'three'),
+  ('[5,6)', daterange('2000-01-01', '2010-01-01'), 'five');
+SELECT * FROM temporal_partitioned;
+  id   |        valid_at         | name  | range_len 
+-------+-------------------------+-------+-----------
+ [1,2) | [2000-01-01,2010-01-01) | one   |      3653
+ [3,4) | [2000-01-01,2010-01-01) | three |      3653
+ [5,6) | [2000-01-01,2010-01-01) | five  |      3653
+(3 rows)
+
+-- Update without moving within partition 1
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-04-01'
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+-- Update without moving within partition 3
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-04-01'
+  SET name = 'three^1'
+  WHERE id = '[3,4)';
+-- Update without moving within partition 5
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-04-01'
+  SET name = 'five^1'
+  WHERE id = '[5,6)';
+-- Move from partition 1 to partition 3
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01'
+  SET name = 'one^2',
+      id = '[4,5)'
+  WHERE id = '[1,2)'
+  RETURNING id, valid_at, name, fpo_returning_row(temporal_partitioned::text);
+NOTICE:  RETURNING ("[4,5)","[2000-06-01,2000-07-01)",one^2,30)
+  id   |        valid_at         | name  |              fpo_returning_row               
+-------+-------------------------+-------+----------------------------------------------
+ [4,5) | [2000-06-01,2000-07-01) | one^2 | ("[4,5)","[2000-06-01,2000-07-01)",one^2,30)
+(1 row)
+
+-- Move from partition 3 to partition 1
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01'
+  SET name = 'three^2',
+      id = '[2,3)'
+  WHERE id = '[3,4)';
+-- Move from partition 5 to partition 3
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01'
+  SET name = 'five^2',
+      id = '[3,4)'
+  WHERE id = '[5,6)';
+-- Update all partitions at once (each with leftovers)
+SELECT *, upper(valid_at) - lower(valid_at) FROM temporal_partitioned ORDER BY id, valid_at;
+  id   |        valid_at         |  name   | range_len | ?column? 
+-------+-------------------------+---------+-----------+----------
+ [1,2) | [2000-01-01,2000-03-01) | one     |        60 |       60
+ [1,2) | [2000-03-01,2000-04-01) | one^1   |        31 |       31
+ [1,2) | [2000-04-01,2000-06-01) | one     |        61 |       61
+ [1,2) | [2000-07-01,2010-01-01) | one     |      3471 |     3471
+ [2,3) | [2000-06-01,2000-07-01) | three^2 |        30 |       30
+ [3,4) | [2000-01-01,2000-03-01) | three   |        60 |       60
+ [3,4) | [2000-03-01,2000-04-01) | three^1 |        31 |       31
+ [3,4) | [2000-04-01,2000-06-01) | three   |        61 |       61
+ [3,4) | [2000-06-01,2000-07-01) | five^2  |        30 |       30
+ [3,4) | [2000-07-01,2010-01-01) | three   |      3471 |     3471
+ [4,5) | [2000-06-01,2000-07-01) | one^2   |        30 |       30
+ [5,6) | [2000-01-01,2000-03-01) | five    |        60 |       60
+ [5,6) | [2000-03-01,2000-04-01) | five^1  |        31 |       31
+ [5,6) | [2000-04-01,2000-06-01) | five    |        61 |       61
+ [5,6) | [2000-07-01,2010-01-01) | five    |      3471 |     3471
+(15 rows)
+
+SELECT * FROM temporal_partitioned_1 ORDER BY id, valid_at;
+  id   |        valid_at         |  name   | range_len 
+-------+-------------------------+---------+-----------
+ [1,2) | [2000-01-01,2000-03-01) | one     |        60
+ [1,2) | [2000-03-01,2000-04-01) | one^1   |        31
+ [1,2) | [2000-04-01,2000-06-01) | one     |        61
+ [1,2) | [2000-07-01,2010-01-01) | one     |      3471
+ [2,3) | [2000-06-01,2000-07-01) | three^2 |        30
+(5 rows)
+
+SELECT * FROM temporal_partitioned_3 ORDER BY id, valid_at;
+  name   |  id   |        valid_at         | range_len 
+---------+-------+-------------------------+-----------
+ three   | [3,4) | [2000-01-01,2000-03-01) |        60
+ three^1 | [3,4) | [2000-03-01,2000-04-01) |        31
+ three   | [3,4) | [2000-04-01,2000-06-01) |        61
+ five^2  | [3,4) | [2000-06-01,2000-07-01) |        30
+ three   | [3,4) | [2000-07-01,2010-01-01) |      3471
+ one^2   | [4,5) | [2000-06-01,2000-07-01) |        30
+(6 rows)
+
+SELECT * FROM temporal_partitioned_5 ORDER BY id, valid_at;
+  name  |        valid_at         |  id   | range_len 
+--------+-------------------------+-------+-----------
+ five   | [2000-01-01,2000-03-01) | [5,6) |        60
+ five^1 | [2000-03-01,2000-04-01) | [5,6) |        31
+ five   | [2000-04-01,2000-06-01) | [5,6) |        61
+ five   | [2000-07-01,2010-01-01) | [5,6) |      3471
+(4 rows)
+
+DROP FUNCTION fpo_returning_row;
+DROP TABLE temporal_partitioned;
+-- Test FOR PORTION OF when the partition key depends on the range column.
+-- Then a leftover can belong to a partition that is not one of the plan's
+-- result relations, and we must build a new ResultRelInfo for it.
+CREATE TABLE temporal_partitioned_by_valid_at (
+  id int4range,
+  valid_at daterange,
+  name text
+) PARTITION BY RANGE (lower(valid_at));
+CREATE TABLE temporal_partitioned_early
+  PARTITION OF temporal_partitioned_by_valid_at
+  FOR VALUES FROM (MINVALUE) TO ('2000-06-01');
+CREATE TABLE temporal_partitioned_late
+  PARTITION OF temporal_partitioned_by_valid_at
+  FOR VALUES FROM ('2000-06-01') TO (MAXVALUE);
+INSERT INTO temporal_partitioned_by_valid_at (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one');
+-- The WHERE clause prunes away the late partition, but the second leftover
+-- belongs there.  The leftovers must not appear in the RETURNING output.
+DELETE FROM temporal_partitioned_by_valid_at
+  FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-07-01'
+  WHERE lower(valid_at) < '2000-06-01'
+  RETURNING id, valid_at, name;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [1,2) | [2000-01-01,2010-01-01) | one
+(1 row)
+
+SELECT tableoid::regclass, * FROM temporal_partitioned_by_valid_at
+  ORDER BY id, valid_at;
+          tableoid          |  id   |        valid_at         | name 
+----------------------------+-------+-------------------------+------
+ temporal_partitioned_early | [1,2) | [2000-01-01,2000-03-01) | one
+ temporal_partitioned_late  | [1,2) | [2000-07-01,2010-01-01) | one
+(2 rows)
+
+-- The same thing for UPDATE
+DELETE FROM temporal_partitioned_by_valid_at;
+INSERT INTO temporal_partitioned_by_valid_at (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one');
+UPDATE temporal_partitioned_by_valid_at
+  FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-07-01'
+  SET name = 'one^1'
+  WHERE lower(valid_at) < '2000-06-01'
+  RETURNING id, valid_at, name;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [1,2) | [2000-03-01,2000-07-01) | one^1
+(1 row)
+
+SELECT tableoid::regclass, * FROM temporal_partitioned_by_valid_at
+  ORDER BY id, valid_at;
+          tableoid          |  id   |        valid_at         | name  
+----------------------------+-------+-------------------------+-------
+ temporal_partitioned_early | [1,2) | [2000-01-01,2000-03-01) | one
+ temporal_partitioned_early | [1,2) | [2000-03-01,2000-07-01) | one^1
+ temporal_partitioned_late  | [1,2) | [2000-07-01,2010-01-01) | one
+(3 rows)
+
+DROP TABLE temporal_partitioned_by_valid_at;
+-- UPDATE/DELETE FOR PORTION OF with RULEs
+CREATE TABLE fpo_rule (f1 bigint, f2 int4range);
+INSERT INTO fpo_rule VALUES (1, '[1, 11)');
+CREATE RULE fpo_rule1 AS ON INSERT TO fpo_rule
+  DO INSTEAD UPDATE fpo_rule FOR PORTION OF f2 FROM 1 TO 4 SET f1 = 2;
+INSERT INTO fpo_rule VALUES (1, '[1, 11)');
+SELECT * FROM fpo_rule ORDER BY f1;
+ f1 |   f2   
+----+--------
+  1 | [4,11)
+  2 | [1,4)
+(2 rows)
+
+CREATE RULE fpo_rule2 AS ON INSERT TO fpo_rule
+  DO INSTEAD DELETE FROM fpo_rule FOR PORTION OF f2 FROM 1 TO 4;
+INSERT INTO fpo_rule VALUES (1, '[1, 11)');
+SELECT * FROM fpo_rule ORDER BY f1;
+ f1 |   f2   
+----+--------
+  1 | [4,11)
+(1 row)
+
+CREATE RULE fpo_rule3 AS ON DELETE TO fpo_rule
+  DO INSTEAD UPDATE fpo_rule FOR PORTION OF f2 FROM 1 TO 8 SET f1 = 2;
+DELETE FROM fpo_rule FOR PORTION OF f2 FROM 1 TO 5;
+SELECT * FROM fpo_rule ORDER BY f1;
+ f1 |   f2   
+----+--------
+  1 | [8,11)
+  2 | [4,8)
+(2 rows)
+
+DROP RULE fpo_rule3 ON fpo_rule;
+CREATE RULE fpo_rule4 AS ON UPDATE TO fpo_rule
+  DO INSTEAD DELETE FROM fpo_rule FOR PORTION OF f2 FROM 6 TO 9;
+UPDATE fpo_rule FOR PORTION OF f2 FROM 4 TO 9 SET f1 = 12;
+SELECT * FROM fpo_rule ORDER BY f1;
+ f1 |   f2   
+----+--------
+  1 | [9,11)
+  2 | [4,6)
+(2 rows)
+
+DROP RULE fpo_rule4 ON fpo_rule;
+CREATE RULE fpo_rule5 AS ON UPDATE TO fpo_rule
+  DO ALSO DELETE FROM fpo_rule FOR PORTION OF f2 FROM 4 TO 6;
+UPDATE fpo_rule FOR PORTION OF f2 FROM 9 TO 10 SET f1 = 3;
+SELECT * FROM fpo_rule ORDER BY f1;
+ f1 |   f2    
+----+---------
+  1 | [10,11)
+  3 | [9,10)
+(2 rows)
+
+DROP TABLE fpo_rule;
+-- Deparsing FOR PORTION OF must use the range column's current name,
+-- not the name it had when the rule was created.
+CREATE TABLE fpo_rename (f1 bigint, f2 int4range);
+CREATE TABLE fpo_rename_src (x int);
+CREATE RULE fpo_rename_rule1 AS ON UPDATE TO fpo_rename_src
+  DO INSTEAD UPDATE fpo_rename FOR PORTION OF f2 FROM 3 TO 6 SET f1 = 2;
+CREATE RULE fpo_rename_rule2 AS ON DELETE TO fpo_rename_src
+  DO INSTEAD DELETE FROM fpo_rename FOR PORTION OF f2 (int4range(3, 6));
+\d+ fpo_rename_src
+                              Table "public.fpo_rename_src"
+ Column |  Type   | Collation | Nullable | Default | Storage | Stats target | Description 
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ x      | integer |           |          |         | plain   |              | 
+Rules:
+    fpo_rename_rule1 AS
+    ON UPDATE TO fpo_rename_src DO INSTEAD  UPDATE fpo_rename FOR PORTION OF f2 FROM (3) TO (6) SET f1 = 2
+    fpo_rename_rule2 AS
+    ON DELETE TO fpo_rename_src DO INSTEAD  DELETE FROM fpo_rename FOR PORTION OF f2 (int4range(3, 6))
+
+ALTER TABLE fpo_rename RENAME COLUMN f1 TO ff1;
+ALTER TABLE fpo_rename RENAME COLUMN f2 TO ff2;
+\d+ fpo_rename_src
+                              Table "public.fpo_rename_src"
+ Column |  Type   | Collation | Nullable | Default | Storage | Stats target | Description 
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ x      | integer |           |          |         | plain   |              | 
+Rules:
+    fpo_rename_rule1 AS
+    ON UPDATE TO fpo_rename_src DO INSTEAD  UPDATE fpo_rename FOR PORTION OF ff2 FROM (3) TO (6) SET ff1 = 2
+    fpo_rename_rule2 AS
+    ON DELETE TO fpo_rename_src DO INSTEAD  DELETE FROM fpo_rename FOR PORTION OF ff2 (int4range(3, 6))
+
+DROP TABLE fpo_rename, fpo_rename_src;
+-- Deparsed FROM/TO bounds must be re-loadable.  A bound ending in an interval
+-- type name with a field qualifier would otherwise run into the following TO,
+-- since "interval hour to minute" is a type name too.
+CREATE TYPE fpo_intervalrange AS RANGE (subtype = interval);
+CREATE TABLE fpo_interval (r fpo_intervalrange);
+CREATE RULE fpo_interval_rule AS ON INSERT TO fpo_interval DO ALSO
+  DELETE FROM fpo_interval
+    FOR PORTION OF r FROM (INTERVAL '1' HOUR) TO INTERVAL '2' HOUR;
+SELECT definition FROM pg_rules WHERE rulename = 'fpo_interval_rule';
+                                                                      definition                                                                      
+------------------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE RULE fpo_interval_rule AS                                                                                                                    +
+     ON INSERT TO public.fpo_interval DO  DELETE FROM fpo_interval FOR PORTION OF r FROM ('@ 1 hour'::interval hour) TO ('@ 2 hours'::interval hour);
+(1 row)
+
+SELECT 'DROP RULE fpo_interval_rule ON fpo_interval'
+  UNION ALL
+  SELECT pg_get_ruledef(oid) FROM pg_rewrite
+    WHERE rulename = 'fpo_interval_rule' \gexec
+DROP RULE fpo_interval_rule ON fpo_interval
+CREATE RULE fpo_interval_rule AS
+    ON INSERT TO public.fpo_interval DO  DELETE FROM fpo_interval FOR PORTION OF r FROM ('@ 1 hour'::interval hour) TO ('@ 2 hours'::interval hour);
+SELECT definition FROM pg_rules WHERE rulename = 'fpo_interval_rule';
+                                                                      definition                                                                      
+------------------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE RULE fpo_interval_rule AS                                                                                                                    +
+     ON INSERT TO public.fpo_interval DO  DELETE FROM fpo_interval FOR PORTION OF r FROM ('@ 1 hour'::interval hour) TO ('@ 2 hours'::interval hour);
+(1 row)
+
+DROP TABLE fpo_interval;
+DROP TYPE fpo_intervalrange;
+-- UPDATE/DELETE FOR PORTION OF on a GENERATED VIRTUAL range column:
+CREATE TABLE fpo_gen_virtual (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) VIRTUAL
+);
+INSERT INTO fpo_gen_virtual VALUES (1);
+DELETE FROM fpo_gen_virtual FOR PORTION OF b FROM 1 TO 2; -- fails
+ERROR:  cannot use generated column "b" in FOR PORTION OF
+UPDATE fpo_gen_virtual FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+ERROR:  column "b" can only be updated to DEFAULT
+DETAIL:  Column "b" is a generated column.
+DROP TABLE fpo_gen_virtual;
+-- UPDATE/DELETE FOR PORTION OF on a GENERATED STORED range column:
+CREATE TABLE fpo_gen_stored (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) STORED
+);
+INSERT INTO fpo_gen_stored VALUES (1);
+DELETE FROM fpo_gen_stored FOR PORTION OF b FROM 1 TO 2; -- fails
+ERROR:  cannot use generated column "b" in FOR PORTION OF
+UPDATE fpo_gen_stored FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+ERROR:  column "b" can only be updated to DEFAULT
+DETAIL:  Column "b" is a generated column.
+DROP TABLE fpo_gen_stored;
+-- FOR PORTION OF a generated column reached through an updatable view.
+-- The view hides that b is generated during parse analysis, so the check
+-- must happen later (in the planner), after the view is rewritten to its
+-- underlying table.
+CREATE TABLE fpo_gen_view (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) STORED
+);
+INSERT INTO fpo_gen_view VALUES (1);
+CREATE VIEW fpo_gen_view_v AS SELECT * FROM fpo_gen_view;
+DELETE FROM fpo_gen_view_v FOR PORTION OF b FROM 1 TO 2; -- fails
+ERROR:  cannot use generated column "b" in FOR PORTION OF
+UPDATE fpo_gen_view_v FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+ERROR:  column "b" can only be updated to DEFAULT
+DETAIL:  Column "b" is a generated column.
+DROP VIEW fpo_gen_view_v;
+DROP TABLE fpo_gen_view;
+-- A new-style SQL function is parsed at CREATE FUNCTION time, but our
+-- generated-column check is in the planner, so it sees the column's
+-- current attgenerated when the function's plan is built at run time.
+CREATE TABLE fpo_func_test (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) STORED
+);
+INSERT INTO fpo_func_test VALUES (1);
+-- Definition succeeds even though b is a generated column today.
+CREATE FUNCTION fpo_delete() RETURNS void
+  LANGUAGE SQL
+  BEGIN ATOMIC
+    DELETE FROM fpo_func_test FOR PORTION OF b FROM 1 TO 2;
+  END;
+SELECT fpo_delete(); -- fails: b is generated
+ERROR:  cannot use generated column "b" in FOR PORTION OF
+CONTEXT:  SQL function "fpo_delete" statement 1
+-- Drop the generation expression and the same function now succeeds.
+ALTER TABLE fpo_func_test ALTER COLUMN b DROP EXPRESSION;
+SELECT fpo_delete();
+ fpo_delete 
+------------
+ 
+(1 row)
+
+TABLE fpo_func_test ORDER BY a, b;
+ a | b 
+---+---
+(0 rows)
+
+DROP FUNCTION fpo_delete();
+DROP TABLE fpo_func_test;
+-- UPDATE/DELETE FOR PORTION OF with table inheritance
+-- Leftover rows must stay in the child table, preserving child-specific columns.
+CREATE TABLE fpo_inh_parent (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE fpo_inh_child (
+  description text
+) INHERITS (fpo_inh_parent);
+-- Update targets the parent; the matching row lives in the child.
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+-- All three rows should be in the child, with description preserved.
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+   tableoid    |  id   |        valid_at         | name  
+---------------+-------+-------------------------+-------
+ fpo_inh_child | [1,2) | [2018-01-01,2018-04-01) | one
+ fpo_inh_child | [1,2) | [2018-04-01,2018-10-01) | one^1
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one
+(3 rows)
+
+SELECT * FROM fpo_inh_child ORDER BY valid_at;
+  id   |        valid_at         | name  | description 
+-------+-------------------------+-------+-------------
+ [1,2) | [2018-01-01,2018-04-01) | one   | initial
+ [1,2) | [2018-04-01,2018-10-01) | one^1 | initial
+ [1,2) | [2018-10-01,2019-01-01) | one   | initial
+(3 rows)
+
+-- No rows should have leaked into the parent.
+SELECT * FROM ONLY fpo_inh_parent ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+-- Same test for DELETE instead of UPDATE:
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+-- Both rows should be in the child, with description preserved.
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+   tableoid    |  id   |        valid_at         | name 
+---------------+-------+-------------------------+------
+ fpo_inh_child | [1,2) | [2018-01-01,2018-04-01) | one
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one
+(2 rows)
+
+SELECT * FROM fpo_inh_child ORDER BY valid_at;
+  id   |        valid_at         | name | description 
+-------+-------------------------+------+-------------
+ [1,2) | [2018-01-01,2018-04-01) | one  | initial
+ [1,2) | [2018-10-01,2019-01-01) | one  | initial
+(2 rows)
+
+-- No rows should have leaked into the parent.
+SELECT * FROM ONLY fpo_inh_parent ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+-- Statement-level INSERT triggers for the leftovers must fire on the table
+-- the leftovers are actually inserted into, i.e. the child, just as they
+-- would for a plain INSERT into that child.
+CREATE FUNCTION fpo_inh_stmt_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% % % on %', TG_NAME, TG_WHEN, TG_OP, TG_TABLE_NAME;
+  RETURN NULL;
+END;
+$$;
+CREATE TRIGGER fpo_inh_parent_bs BEFORE INSERT ON fpo_inh_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_parent_as AFTER INSERT ON fpo_inh_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_child_bs BEFORE INSERT ON fpo_inh_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_child_as AFTER INSERT ON fpo_inh_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+-- control: a plain INSERT into the child fires only the child's triggers
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+-- One firing per leftover, all of them on the child.
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+   tableoid    |  id   |        valid_at         | name  
+---------------+-------+-------------------------+-------
+ fpo_inh_child | [1,2) | [2018-01-01,2018-04-01) | one
+ fpo_inh_child | [1,2) | [2018-04-01,2018-10-01) | one^1
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one
+(3 rows)
+
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+   tableoid    |  id   |        valid_at         | name 
+---------------+-------+-------------------------+------
+ fpo_inh_child | [1,2) | [2018-01-01,2018-04-01) | one
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one
+(2 rows)
+
+-- The transition table for those inserts belongs to the child too, and holds
+-- the child's rows in the child's own format, including its extra columns.
+CREATE FUNCTION fpo_inh_transition_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% on %: %', TG_NAME, TG_TABLE_NAME,
+    (SELECT string_agg(new_table::text, ', ' ORDER BY new_table::text)
+       FROM new_table);
+  RETURN NULL;
+END;
+$$;
+CREATE TRIGGER fpo_inh_parent_tt AFTER INSERT ON fpo_inh_parent
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
+CREATE TRIGGER fpo_inh_child_tt AFTER INSERT ON fpo_inh_child
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+-- control: a plain INSERT into the child reports description = 'initial'
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-01-01,2019-01-01)",one,initial)
+-- The leftovers must report it too: they are child rows and they keep it.
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-01-01,2018-04-01)",one,initial)
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-10-01,2019-01-01)",one,initial)
+SELECT tableoid::regclass, * FROM fpo_inh_child ORDER BY valid_at;
+   tableoid    |  id   |        valid_at         | name  | description 
+---------------+-------+-------------------------+-------+-------------
+ fpo_inh_child | [1,2) | [2018-01-01,2018-04-01) | one   | initial
+ fpo_inh_child | [1,2) | [2018-04-01,2018-10-01) | one^1 | initial
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one   | initial
+(3 rows)
+
+-- DELETE inserts its leftovers the same way.
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-01-01,2019-01-01)",one,initial)
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-01-01,2018-04-01)",one,initial)
+NOTICE:  fpo_inh_child_bs BEFORE INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_as AFTER INSERT on fpo_inh_child
+NOTICE:  fpo_inh_child_tt on fpo_inh_child: ("[1,2)","[2018-10-01,2019-01-01)",one,initial)
+SELECT tableoid::regclass, * FROM fpo_inh_child ORDER BY valid_at;
+   tableoid    |  id   |        valid_at         | name | description 
+---------------+-------+-------------------------+------+-------------
+ fpo_inh_child | [1,2) | [2018-01-01,2018-04-01) | one  | initial
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one  | initial
+(2 rows)
+
+DROP TABLE fpo_inh_parent CASCADE;
+NOTICE:  drop cascades to table fpo_inh_child
+-- By contrast, leftovers in a partitioned table are inserted through the
+-- root to get tuple routing, so the root is where their statement-level
+-- triggers belong, exactly as for a plain INSERT into the root.
+CREATE TABLE fpo_part_parent (
+  id int4range,
+  valid_at daterange,
+  name text
+) PARTITION BY RANGE (id);
+CREATE TABLE fpo_part_child PARTITION OF fpo_part_parent
+  FOR VALUES FROM ('[1,1]') TO ('[9,9]');
+CREATE TRIGGER fpo_part_parent_bs BEFORE INSERT ON fpo_part_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_parent_as AFTER INSERT ON fpo_part_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_child_bs BEFORE INSERT ON fpo_part_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_child_as AFTER INSERT ON fpo_part_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+-- and so does their transition table, which still receives the rows converted
+-- to the root's format.
+CREATE FUNCTION fpo_part_transition_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% on %: %', TG_NAME, TG_TABLE_NAME,
+    (SELECT string_agg(new_table::text, ', ' ORDER BY new_table::text)
+       FROM new_table);
+  RETURN NULL;
+END;
+$$;
+CREATE TRIGGER fpo_part_parent_tt AFTER INSERT ON fpo_part_parent
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_part_transition_trigger();
+INSERT INTO fpo_part_parent VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one');
+NOTICE:  fpo_part_parent_bs BEFORE INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_as AFTER INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_tt on fpo_part_parent: ("[1,2)","[2018-01-01,2019-01-01)",one)
+UPDATE fpo_part_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+NOTICE:  fpo_part_parent_bs BEFORE INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_as AFTER INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_tt on fpo_part_parent: ("[1,2)","[2018-01-01,2018-04-01)",one)
+NOTICE:  fpo_part_parent_bs BEFORE INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_as AFTER INSERT on fpo_part_parent
+NOTICE:  fpo_part_parent_tt on fpo_part_parent: ("[1,2)","[2018-10-01,2019-01-01)",one)
+SELECT tableoid::regclass, * FROM fpo_part_parent ORDER BY valid_at;
+    tableoid    |  id   |        valid_at         | name  
+----------------+-------+-------------------------+-------
+ fpo_part_child | [1,2) | [2018-01-01,2018-04-01) | one
+ fpo_part_child | [1,2) | [2018-04-01,2018-10-01) | one^1
+ fpo_part_child | [1,2) | [2018-10-01,2019-01-01) | one
+(3 rows)
+
+DROP FUNCTION fpo_part_transition_trigger();
+ERROR:  cannot drop function fpo_part_transition_trigger() because other objects depend on it
+DETAIL:  trigger fpo_part_parent_tt on table fpo_part_parent depends on function fpo_part_transition_trigger()
+HINT:  Use DROP ... CASCADE to drop the dependent objects too.
+DROP TABLE fpo_part_parent;
+DROP FUNCTION fpo_inh_stmt_trigger();
+-- UPDATE FOR PORTION OF with multiple inheritance
+-- Leftover rows must stay in the child table, even if the range column's
+-- attnum differs between the target parent and child.
+CREATE TABLE temporal_parent (
+  id int,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE other_parent (
+  prefix text,
+  note text
+);
+CREATE TABLE mi_child () INHERITS (other_parent, temporal_parent);
+-- The leftovers' transition table must use the child's format even when the
+-- child's columns are in a different order from the parent's.
+CREATE TRIGGER mi_child_tt AFTER INSERT ON mi_child
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
+-- attnum of the range column is different in temporal_parent and mi_child
+SELECT attnum, attname
+  FROM pg_attribute
+  WHERE attrelid = 'temporal_parent'::regclass
+    AND attnum > 0 AND NOT attisdropped
+  ORDER BY attnum;
+ attnum | attname  
+--------+----------
+      1 | id
+      2 | valid_at
+      3 | name
+(3 rows)
+
+SELECT attnum, attname
+  FROM pg_attribute
+  WHERE attrelid = 'mi_child'::regclass
+    AND attnum > 0 AND NOT attisdropped
+  ORDER BY attnum;
+ attnum | attname  
+--------+----------
+      1 | prefix
+      2 | note
+      3 | id
+      4 | valid_at
+      5 | name
+(5 rows)
+
+INSERT INTO mi_child (prefix, note, id, valid_at, name) VALUES
+  ('pfx', 'memo', 1, daterange('2000-01-01', '2010-01-01'), 'old');
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2000-01-01,2010-01-01)",old)
+UPDATE temporal_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  SET name = 'new'
+  WHERE id = 1;
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2000-01-01,2001-01-01)",old)
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2002-01-01,2010-01-01)",old)
+SELECT tableoid::regclass, * FROM temporal_parent ORDER BY valid_at;
+ tableoid | id |        valid_at         | name 
+----------+----+-------------------------+------
+ mi_child |  1 | [2000-01-01,2001-01-01) | old
+ mi_child |  1 | [2001-01-01,2002-01-01) | new
+ mi_child |  1 | [2002-01-01,2010-01-01) | old
+(3 rows)
+
+SELECT * FROM mi_child ORDER BY valid_at;
+ prefix | note | id |        valid_at         | name 
+--------+------+----+-------------------------+------
+ pfx    | memo |  1 | [2000-01-01,2001-01-01) | old
+ pfx    | memo |  1 | [2001-01-01,2002-01-01) | new
+ pfx    | memo |  1 | [2002-01-01,2010-01-01) | old
+(3 rows)
+
+SELECT * FROM ONLY temporal_parent ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+TRUNCATE mi_child, other_parent, temporal_parent;
+INSERT INTO mi_child (prefix, note, id, valid_at, name) VALUES
+  ('pfx', 'memo', 1, daterange('2000-01-01', '2010-01-01'), 'old');
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2000-01-01,2010-01-01)",old)
+DELETE FROM temporal_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  WHERE id = 1;
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2000-01-01,2001-01-01)",old)
+NOTICE:  mi_child_tt on mi_child: (pfx,memo,1,"[2002-01-01,2010-01-01)",old)
+SELECT tableoid::regclass, * FROM temporal_parent ORDER BY valid_at;
+ tableoid | id |        valid_at         | name 
+----------+----+-------------------------+------
+ mi_child |  1 | [2000-01-01,2001-01-01) | old
+ mi_child |  1 | [2002-01-01,2010-01-01) | old
+(2 rows)
+
+SELECT * FROM mi_child ORDER BY valid_at;
+ prefix | note | id |        valid_at         | name 
+--------+------+----+-------------------------+------
+ pfx    | memo |  1 | [2000-01-01,2001-01-01) | old
+ pfx    | memo |  1 | [2002-01-01,2010-01-01) | old
+(2 rows)
+
+SELECT * FROM ONLY temporal_parent ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+DROP TABLE temporal_parent CASCADE;
+NOTICE:  drop cascades to table mi_child
+DROP FUNCTION fpo_inh_transition_trigger();
+-- UPDATE FOR PORTION OF with generated columns
+-- The generated column depends on the range column, so it must be
+-- recomputed when FOR PORTION OF narrows the range.
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
+  range_lenv int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | range_len | range_lenv 
+----+----------+-----------+------------
+  1 | [10,100) |        90 |         90
+(1 row)
+
+-- After the FOR PORTION OF (FPO) update, all three resulting rows
+-- (leftover-before, updated, and leftover-after) must contain the correct
+-- values for range_len and range_lenv.
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | range_len | range_lenv 
+----+----------+-----------+------------
+  1 | [10,30)  |        20 |         20
+  2 | [30,70)  |        40 |         40
+  1 | [70,100) |        30 |         30
+(3 rows)
+
+-- Also test with a generated column that references both a SET column
+-- and the range column.
+DROP TABLE fpo_generated;
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  id_plus_len int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at)) STORED,
+  id_plus_lenv int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | id_plus_len | id_plus_lenv 
+----+----------+-------------+--------------
+  1 | [10,100) |          91 |           91
+(1 row)
+
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | id_plus_len | id_plus_lenv 
+----+----------+-------------+--------------
+  1 | [10,30)  |          21 |           21
+  2 | [30,70)  |          42 |           42
+  1 | [70,100) |          31 |           31
+(3 rows)
+
+DROP TABLE fpo_generated;
+-- Test that UPDATE OF colname triggers fire if colname is valid_at:
+CREATE TABLE fpo_update_of_trigger (
+  id int,
+  valid_at int4range
+);
+INSERT INTO fpo_update_of_trigger (id, valid_at) VALUES (1, '[10,100)');
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_row2
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+UPDATE fpo_update_of_trigger
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+NOTICE:  fpo_before_row2: BEFORE UPDATE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row1: BEFORE UPDATE ROW:
+NOTICE:    old: [10,100)
+NOTICE:    new: [30,70)
+DROP TABLE fpo_update_of_trigger;
+-- CURSORs
+CREATE TABLE fpo_cursed (
+  id int,
+  valid_at int4range
+);
+INSERT INTO fpo_cursed (id, valid_at) VALUES (1, '[10,100)');
+-- UPDATE FOR PORTION OF is not permitted with a CURSOR:
+BEGIN;
+DECLARE fpo_cur CURSOR FOR SELECT * FROM fpo_cursed;
+FETCH NEXT FROM fpo_cur;
+ id | valid_at 
+----+----------
+  1 | [10,100)
+(1 row)
+
+UPDATE fpo_cursed
+  FOR PORTION OF valid_at FROM 5 TO 6
+  SET id = 2
+  WHERE CURRENT OF fpo_cur;
+ERROR:  WHERE CURRENT OF with FOR PORTION OF is not implemented
+ROLLBACK;
+-- DELETE FOR PORTION OF is not permitted with a CURSOR:
+BEGIN;
+DECLARE fpo_cur CURSOR FOR SELECT * FROM fpo_cursed;
+FETCH NEXT FROM fpo_cur;
+ id | valid_at 
+----+----------
+  1 | [10,100)
+(1 row)
+
+DELETE FROM fpo_cursed
+  FOR PORTION OF valid_at FROM 8 TO 9
+  WHERE CURRENT OF fpo_cur;
+ERROR:  WHERE CURRENT OF with FOR PORTION OF is not implemented
+ROLLBACK;
+SELECT * FROM fpo_cursed;
+ id | valid_at 
+----+----------
+  1 | [10,100)
+(1 row)
+
+DROP TABLE fpo_cursed;
+-- UPDATE/DELETE FOR PORTION OF leftover rows must satisfy RLS INSERT checks.
+CREATE ROLE regress_fpo_rls;
+CREATE TABLE fpo_rls (
+  id int,
+  valid_at int4range
+);
+ALTER TABLE fpo_rls ENABLE ROW LEVEL SECURITY;
+CREATE POLICY fpo_rls_select ON fpo_rls
+  FOR SELECT TO regress_fpo_rls
+  USING (true);
+CREATE POLICY fpo_rls_update ON fpo_rls
+  FOR UPDATE TO regress_fpo_rls
+  USING (lower(valid_at) < 50)
+  WITH CHECK (lower(valid_at) < 50);
+CREATE POLICY fpo_rls_delete ON fpo_rls
+  FOR DELETE TO regress_fpo_rls
+  USING (lower(valid_at) < 50);
+CREATE POLICY fpo_rls_insert ON fpo_rls
+  FOR INSERT TO regress_fpo_rls
+  WITH CHECK (lower(valid_at) < 50);
+GRANT SELECT, UPDATE, DELETE ON fpo_rls TO regress_fpo_rls;
+INSERT INTO fpo_rls VALUES (1, '[10,100)');
+SET ROLE regress_fpo_rls;
+UPDATE fpo_rls
+  FOR PORTION OF valid_at FROM 30 TO 100
+  SET id = 2;
+RESET ROLE;
+SELECT * FROM fpo_rls ORDER BY valid_at;
+ id | valid_at 
+----+----------
+  1 | [10,30)
+  2 | [30,100)
+(2 rows)
+
+TRUNCATE fpo_rls;
+INSERT INTO fpo_rls VALUES (1, '[10,100)');
+SET ROLE regress_fpo_rls;
+DELETE FROM fpo_rls
+  FOR PORTION OF valid_at FROM 30 TO 100;
+RESET ROLE;
+SELECT * FROM fpo_rls ORDER BY valid_at;
+ id | valid_at 
+----+----------
+  1 | [10,30)
+(1 row)
+
+TRUNCATE fpo_rls;
+INSERT INTO fpo_rls VALUES (1, '[10,100)');
+SET ROLE regress_fpo_rls;
+UPDATE fpo_rls
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+ERROR:  new row violates row-level security policy for table "fpo_rls"
+RESET ROLE;
+SELECT * FROM fpo_rls ORDER BY valid_at;
+ id | valid_at 
+----+----------
+  1 | [10,100)
+(1 row)
+
+TRUNCATE fpo_rls;
+INSERT INTO fpo_rls VALUES (1, '[10,100)');
+SET ROLE regress_fpo_rls;
+DELETE FROM fpo_rls
+  FOR PORTION OF valid_at FROM 30 TO 70;
+ERROR:  new row violates row-level security policy for table "fpo_rls"
+RESET ROLE;
+SELECT * FROM fpo_rls ORDER BY valid_at;
+ id | valid_at 
+----+----------
+  1 | [10,100)
+(1 row)
+
+DROP TABLE fpo_rls;
+DROP ROLE regress_fpo_rls;
+--
+-- Parameters in the FOR PORTION OF bounds
+--
+CREATE TABLE fpo_param (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO fpo_param (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one');
+-- A parameter of unspecified type in an ordinary expression gets its type
+-- resolved from context.  This is the control case for the FROM/TO bounds
+-- below: it builds exactly the same daterange the FROM/TO form does.
+PREPARE fpo_param_control AS
+  UPDATE fpo_param SET name = 'ctl' WHERE valid_at && daterange($1, $2);
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_control';
+ parameter_types 
+-----------------
+ {date,date}
+(1 row)
+
+-- The (portion) form resolves the parameter type from the range column.
+PREPARE fpo_param_portion AS
+  UPDATE fpo_param FOR PORTION OF valid_at ($1) SET name = 'portion';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_portion';
+ parameter_types 
+-----------------
+ {daterange}
+(1 row)
+
+-- The FROM/TO form should likewise resolve its bounds to the range's
+-- subtype, so that clients need not spell out the parameter types.
+PREPARE fpo_param_update AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO $2 SET name = 'upd';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_update';
+ parameter_types 
+-----------------
+ {date,date}
+(1 row)
+
+PREPARE fpo_param_delete AS
+  DELETE FROM fpo_param FOR PORTION OF valid_at FROM $1 TO $2;
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_delete';
+ parameter_types 
+-----------------
+ {date,date}
+(1 row)
+
+-- Only one bound parameterized.
+PREPARE fpo_param_one AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO '2003-01-01'
+    SET name = 'one-bound';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_one';
+ parameter_types 
+-----------------
+ {date}
+(1 row)
+
+-- A parameter used as both bounds must still resolve to one type.
+PREPARE fpo_param_same AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO $1 SET name = 'same';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_same';
+ parameter_types 
+-----------------
+ {date}
+(1 row)
+
+EXECUTE fpo_param_update('2002-01-01', '2003-01-01');
+SELECT * FROM fpo_param ORDER BY valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [1,2) | [2000-01-01,2002-01-01) | one
+ [1,2) | [2002-01-01,2003-01-01) | upd
+ [1,2) | [2003-01-01,2010-01-01) | one
+(3 rows)
+
+DEALLOCATE fpo_param_control;
+DEALLOCATE fpo_param_portion;
+DEALLOCATE fpo_param_update;
+DEALLOCATE fpo_param_delete;
+DEALLOCATE fpo_param_one;
+DEALLOCATE fpo_param_same;
+-- The bounds we keep for deparsing are the coerced ones, so an untyped NULL
+-- bound renders with the range's subtype instead of "unknown".
+CREATE TABLE fpo_param_src (id int);
+CREATE RULE fpo_param_r AS ON DELETE TO fpo_param_src DO INSTEAD
+  DELETE FROM fpo_param FOR PORTION OF valid_at FROM NULL TO '2001-01-01';
+SELECT definition FROM pg_rules WHERE rulename = 'fpo_param_r';
+                                                                 definition                                                                 
+--------------------------------------------------------------------------------------------------------------------------------------------
+ CREATE RULE fpo_param_r AS                                                                                                                +
+     ON DELETE TO public.fpo_param_src DO INSTEAD  DELETE FROM fpo_param FOR PORTION OF valid_at FROM (NULL::date) TO ('2001-01-01'::date);
+(1 row)
+
+DROP TABLE fpo_param_src;
+DROP TABLE fpo_param;
+--
+-- Names in the FOR PORTION OF bounds
+--
+-- A bound may not read the row being changed, but a name in that position
+-- doesn't necessarily mean a column: it may belong to a PL/pgSQL variable or
+-- to a parameter of the SQL-bodied function we are part of.
+--
+CREATE TABLE fpo_name (valid_at int4range, name text);
+INSERT INTO fpo_name VALUES ('[1,100)', 'one');
+-- Columns of the target relation are still rejected, whether they are named
+-- directly or reached through a whole-row reference.
+UPDATE fpo_name FOR PORTION OF valid_at FROM lower(valid_at) TO 5
+  SET name = 'nope';
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 1: ...PDATE fpo_name FOR PORTION OF valid_at FROM lower(valid_at) ...
+                                                             ^
+UPDATE fpo_name FOR PORTION OF valid_at (valid_at) SET name = 'nope';
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 1: UPDATE fpo_name FOR PORTION OF valid_at (valid_at) SET name ...
+                                                 ^
+DELETE FROM fpo_name FOR PORTION OF valid_at ((ROW(fpo_name.*)).f1);
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 1: ...ELETE FROM fpo_name FOR PORTION OF valid_at ((ROW(fpo_name.*...
+                                                             ^
+DELETE FROM fpo_name FOR PORTION OF valid_at FROM (ROW(fpo_name.*)).f2 TO 5;
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 1: ...E FROM fpo_name FOR PORTION OF valid_at FROM (ROW(fpo_name.*...
+                                                             ^
+-- PL/pgSQL variables may be bounds, under either #variable_conflict rule.
+CREATE FUNCTION fpo_name_plpgsql(lo int, hi int) RETURNS void
+LANGUAGE plpgsql AS $$
+BEGIN
+  UPDATE fpo_name FOR PORTION OF valid_at FROM lo TO hi SET name = 'plpgsql';
+END $$;
+SELECT fpo_name_plpgsql(10, 20);
+ fpo_name_plpgsql 
+------------------
+ 
+(1 row)
+
+CREATE FUNCTION fpo_name_plpgsql_var(lo int, hi int) RETURNS void
+LANGUAGE plpgsql AS $$
+#variable_conflict use_variable
+BEGIN
+  UPDATE fpo_name FOR PORTION OF valid_at FROM lo TO hi SET name = 'variable';
+END $$;
+SELECT fpo_name_plpgsql_var(30, 40);
+ fpo_name_plpgsql_var 
+----------------------
+ 
+(1 row)
+
+SELECT * FROM fpo_name ORDER BY valid_at;
+ valid_at |   name   
+----------+----------
+ [1,10)   | one
+ [10,20)  | plpgsql
+ [20,30)  | one
+ [30,40)  | variable
+ [40,100) | one
+(5 rows)
+
+-- A PL/pgSQL variable that shares its name with a column of the target is
+-- still ambiguous, just as it would be anywhere else in the statement.
+CREATE FUNCTION fpo_name_plpgsql_ambig(valid_at int) RETURNS void
+LANGUAGE plpgsql AS $$
+BEGIN
+  UPDATE fpo_name FOR PORTION OF valid_at FROM valid_at TO 50 SET name = 'amb';
+END $$;
+SELECT fpo_name_plpgsql_ambig(45);
+ERROR:  column reference "valid_at" is ambiguous
+LINE 1: UPDATE fpo_name FOR PORTION OF valid_at FROM valid_at TO 50 ...
+                                                     ^
+DETAIL:  It could refer to either a PL/pgSQL variable or a table column.
+QUERY:  UPDATE fpo_name FOR PORTION OF valid_at FROM valid_at TO 50 SET name = 'amb'
+CONTEXT:  PL/pgSQL function fpo_name_plpgsql_ambig(integer) line 3 at SQL statement
+-- Parameters of a SQL-bodied function may be bounds too.  They deparse by
+-- name, so pg_get_functiondef() output has to be re-loadable.
+CREATE FUNCTION fpo_name_sql(lo int, hi int) RETURNS void
+LANGUAGE SQL
+BEGIN ATOMIC
+  DELETE FROM fpo_name FOR PORTION OF valid_at FROM $1 TO $2;
+END;
+SELECT pg_get_functiondef('fpo_name_sql(int,int)'::regprocedure);
+                                     pg_get_functiondef                                     
+--------------------------------------------------------------------------------------------
+ CREATE OR REPLACE FUNCTION public.fpo_name_sql(lo integer, hi integer)                    +
+  RETURNS void                                                                             +
+  LANGUAGE sql                                                                             +
+ BEGIN ATOMIC                                                                              +
+  DELETE FROM fpo_name FOR PORTION OF valid_at FROM (fpo_name_sql.lo) TO (fpo_name_sql.hi);+
+ END                                                                                       +
+ 
+(1 row)
+
+SELECT pg_get_functiondef('fpo_name_sql(int,int)'::regprocedure) \gexec
+CREATE OR REPLACE FUNCTION public.fpo_name_sql(lo integer, hi integer)
+ RETURNS void
+ LANGUAGE sql
+BEGIN ATOMIC
+ DELETE FROM fpo_name FOR PORTION OF valid_at FROM (fpo_name_sql.lo) TO (fpo_name_sql.hi);
+END
+
+SELECT fpo_name_sql(60, 70);
+ fpo_name_sql 
+--------------
+ 
+(1 row)
+
+SELECT * FROM fpo_name ORDER BY valid_at;
+ valid_at |   name   
+----------+----------
+ [1,10)   | one
+ [10,20)  | plpgsql
+ [20,30)  | one
+ [30,40)  | variable
+ [40,60)  | one
+ [70,100) | one
+(6 rows)
+
+DROP FUNCTION fpo_name_plpgsql;
+DROP FUNCTION fpo_name_plpgsql_var;
+DROP FUNCTION fpo_name_plpgsql_ambig;
+DROP FUNCTION fpo_name_sql;
+DROP TABLE fpo_name;
+-- The bounds see UPDATE's FROM list, as they see DELETE's USING list, so a
+-- name resolves in them as it does in the rest of the statement, and a
+-- column of another table is rejected like any other column.
+CREATE TABLE fpo_from (id int, valid_at int4range, name text);
+INSERT INTO fpo_from VALUES (1, '[1,100)', 'a');
+CREATE TABLE fpo_from_src (lo int, hi int);
+INSERT INTO fpo_from_src VALUES (5, 6);
+UPDATE fpo_from FOR PORTION OF valid_at FROM fpo_from_src.lo TO fpo_from_src.hi
+  SET name = 'x' FROM fpo_from_src; -- fails
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 1: UPDATE fpo_from FOR PORTION OF valid_at FROM fpo_from_src.lo...
+                                                     ^
+UPDATE fpo_from FOR PORTION OF valid_at FROM lo TO hi
+  SET name = 'x' FROM fpo_from_src; -- fails
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 1: UPDATE fpo_from FOR PORTION OF valid_at FROM lo TO hi
+                                                     ^
+DELETE FROM fpo_from FOR PORTION OF valid_at FROM lo TO hi
+  USING fpo_from_src; -- fails
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 1: DELETE FROM fpo_from FOR PORTION OF valid_at FROM lo TO hi
+                                                          ^
+-- In PL/pgSQL, a variable named like a FROM/USING column is ambiguous in
+-- the bounds of both.
+CREATE FUNCTION fpo_from_upd(lo int, hi int) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+  UPDATE fpo_from FOR PORTION OF valid_at FROM lo TO hi SET name = 'u'
+    FROM fpo_from_src WHERE fpo_from_src.lo = 5;
+END $$;
+CREATE FUNCTION fpo_from_del(lo int, hi int) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+  DELETE FROM fpo_from FOR PORTION OF valid_at FROM lo TO hi
+    USING fpo_from_src WHERE fpo_from_src.lo = 5;
+END $$;
+SELECT fpo_from_upd(50, 60); -- fails
+ERROR:  column reference "lo" is ambiguous
+LINE 1: UPDATE fpo_from FOR PORTION OF valid_at FROM lo TO hi SET na...
+                                                     ^
+DETAIL:  It could refer to either a PL/pgSQL variable or a table column.
+QUERY:  UPDATE fpo_from FOR PORTION OF valid_at FROM lo TO hi SET name = 'u'
+    FROM fpo_from_src WHERE fpo_from_src.lo = 5
+CONTEXT:  PL/pgSQL function fpo_from_upd(integer,integer) line 3 at SQL statement
+SELECT fpo_from_del(70, 80); -- fails
+ERROR:  column reference "lo" is ambiguous
+LINE 1: DELETE FROM fpo_from FOR PORTION OF valid_at FROM lo TO hi
+                                                          ^
+DETAIL:  It could refer to either a PL/pgSQL variable or a table column.
+QUERY:  DELETE FROM fpo_from FOR PORTION OF valid_at FROM lo TO hi
+    USING fpo_from_src WHERE fpo_from_src.lo = 5
+CONTEXT:  PL/pgSQL function fpo_from_del(integer,integer) line 3 at SQL statement
+-- In a SQL function, the column hides the parameter, in the bounds as in SET.
+CREATE FUNCTION fpo_from_sql(lo int) RETURNS void LANGUAGE sql AS $$
+  UPDATE fpo_from FOR PORTION OF valid_at FROM lo TO lo + 1 SET name = 'u' || lo
+    FROM fpo_from_src WHERE fpo_from.id = 1
+$$; -- fails
+ERROR:  cannot use column reference in FOR PORTION OF expression
+LINE 2:   UPDATE fpo_from FOR PORTION OF valid_at FROM lo TO lo + 1 ...
+                                                       ^
+SELECT * FROM fpo_from ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+  1 | [1,100)  | a
+(1 row)
+
+DROP FUNCTION fpo_from_upd, fpo_from_del;
+DROP TABLE fpo_from, fpo_from_src;
+--
+-- EXPLAIN without ANALYZE must only plan the statement, so it must not
+-- evaluate the FOR PORTION OF target.
+--
+CREATE TABLE fpo_explain (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO fpo_explain (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one');
+-- GENERIC_PLAN exists precisely so that a statement containing parameter
+-- placeholders can be planned without supplying any parameter values.  The
+-- hand-written equivalent qual is the control case.
+EXPLAIN (COSTS OFF, GENERIC_PLAN)
+  UPDATE fpo_explain SET name = 'q' WHERE valid_at && $1::daterange;
+            QUERY PLAN            
+----------------------------------
+ Update on fpo_explain
+   ->  Seq Scan on fpo_explain
+         Filter: (valid_at && $1)
+(3 rows)
+
+EXPLAIN (COSTS OFF, GENERIC_PLAN)
+  UPDATE fpo_explain FOR PORTION OF valid_at ($1::daterange) SET name = 'q';
+            QUERY PLAN            
+----------------------------------
+ Update on fpo_explain
+   ->  Seq Scan on fpo_explain
+         Filter: (valid_at && $1)
+(3 rows)
+
+EXPLAIN (COSTS OFF, GENERIC_PLAN)
+  DELETE FROM fpo_explain FOR PORTION OF valid_at ($1::daterange);
+            QUERY PLAN            
+----------------------------------
+ Delete on fpo_explain
+   ->  Seq Scan on fpo_explain
+         Filter: (valid_at && $1)
+(3 rows)
+
+-- A null target is a run-time error, so plain EXPLAIN should still report
+-- the plan.
+EXPLAIN (COSTS OFF)
+  UPDATE fpo_explain FOR PORTION OF valid_at (NULL::daterange) SET name = 'q';
+              QUERY PLAN               
+---------------------------------------
+ Update on fpo_explain
+   ->  Result
+         Replaces: Scan on fpo_explain
+         One-Time Filter: false
+(4 rows)
+
+EXPLAIN (COSTS OFF)
+  DELETE FROM fpo_explain FOR PORTION OF valid_at (NULL::daterange);
+              QUERY PLAN               
+---------------------------------------
+ Delete on fpo_explain
+   ->  Result
+         Replaces: Scan on fpo_explain
+         One-Time Filter: false
+(4 rows)
+
+-- Actually running it is still an error.
+UPDATE fpo_explain FOR PORTION OF valid_at (NULL::daterange) SET name = 'q';
+ERROR:  FOR PORTION OF target must not be null
+LINE 1: UPDATE fpo_explain FOR PORTION OF valid_at (NULL::daterange)...
+                                                    ^
+DELETE FROM fpo_explain FOR PORTION OF valid_at (NULL::daterange);
+ERROR:  FOR PORTION OF target must not be null
+LINE 1: DELETE FROM fpo_explain FOR PORTION OF valid_at (NULL::dater...
+                                                         ^
+-- EXPLAIN ANALYZE does execute, so the null check must still fire there.
+EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
+  UPDATE fpo_explain FOR PORTION OF valid_at (NULL::daterange) SET name = 'q';
+ERROR:  FOR PORTION OF target must not be null
+LINE 2:   UPDATE fpo_explain FOR PORTION OF valid_at (NULL::daterang...
+                                                      ^
+UPDATE fpo_explain FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = 'q';
+SELECT * FROM fpo_explain ORDER BY valid_at;
+  id   |        valid_at         | name 
+-------+-------------------------+------
+ [1,2) | [2000-01-01,2002-01-01) | one
+ [1,2) | [2002-01-01,2003-01-01) | q
+ [1,2) | [2003-01-01,2010-01-01) | one
+(3 rows)
+
+DROP TABLE fpo_explain;
+-- Bounds may not contain aggregates, window functions, or set-returning functions:
+CREATE TABLE fpo_bounds (id int, valid_at int4range);
+INSERT INTO fpo_bounds VALUES (1, '[1,100)');
+-- aggregate:
+UPDATE fpo_bounds FOR PORTION OF valid_at FROM count(*) TO 10 SET id = 2;
+ERROR:  aggregate functions are not allowed in FOR PORTION OF expressions
+LINE 1: UPDATE fpo_bounds FOR PORTION OF valid_at FROM count(*) TO 1...
+                                                       ^
+-- window function:
+UPDATE fpo_bounds FOR PORTION OF valid_at FROM row_number() OVER () TO 10 SET id = 2;
+ERROR:  window functions are not allowed in FOR PORTION OF expressions
+LINE 1: UPDATE fpo_bounds FOR PORTION OF valid_at FROM row_number() ...
+                                                       ^
+-- set-returning function:
+UPDATE fpo_bounds FOR PORTION OF valid_at FROM generate_series(1, 2) TO 10 SET id = 2;
+ERROR:  set-returning functions are not allowed in FOR PORTION OF expressions
+LINE 1: UPDATE fpo_bounds FOR PORTION OF valid_at FROM generate_seri...
+                                                       ^
+DROP TABLE fpo_bounds;
+-- EXPLAIN of FOR PORTION OF (plan shape should be stable):
+CREATE TABLE fpo_explain (id int, valid_at daterange, name text);
+INSERT INTO fpo_explain VALUES (1, '[2000-01-01,2010-01-01)', 'a');
+EXPLAIN (COSTS OFF) UPDATE fpo_explain
+  FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  SET name = 'b';
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Update on fpo_explain
+   ->  Seq Scan on fpo_explain
+         Filter: (valid_at && '[2001-01-01,2002-01-01)'::daterange)
+(3 rows)
+
+EXPLAIN (COSTS OFF) DELETE FROM fpo_explain
+  FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01';
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Delete on fpo_explain
+   ->  Seq Scan on fpo_explain
+         Filter: (valid_at && '[2001-01-01,2002-01-01)'::daterange)
+(3 rows)
+
+DROP TABLE fpo_explain;
+-- Parameterized bounds via PREPARE/EXECUTE must keep working once a generic plan
+-- is chosen (parameters are allowed in bounds even though volatile functions
+-- are not):
+CREATE TABLE fpo_prepared (id int, valid_at int4range, name text);
+INSERT INTO fpo_prepared VALUES (1, '[1,100)', 'a');
+PREPARE fpo_upd (int, int) AS
+  UPDATE fpo_prepared FOR PORTION OF valid_at FROM $1 TO $2 SET name = 'b'
+  WHERE id = 1;
+EXECUTE fpo_upd(10, 20);
+EXECUTE fpo_upd(30, 40);
+EXECUTE fpo_upd(50, 60);
+EXECUTE fpo_upd(70, 80);
+EXECUTE fpo_upd(85, 90);
+EXECUTE fpo_upd(92, 95);
+SELECT valid_at, name FROM fpo_prepared ORDER BY valid_at;
+ valid_at | name 
+----------+------
+ [1,10)   | a
+ [10,20)  | b
+ [20,30)  | a
+ [30,40)  | b
+ [40,50)  | a
+ [50,60)  | b
+ [60,70)  | a
+ [70,80)  | b
+ [80,85)  | a
+ [85,90)  | b
+ [90,92)  | a
+ [92,95)  | b
+ [95,100) | a
+(13 rows)
+
+DEALLOCATE fpo_upd;
+DROP TABLE fpo_prepared;
+-- Leftover rows must copy the original row's values for identity and
+-- default-valued columns; those defaults must NOT re-fire for leftovers:
+CREATE SEQUENCE fpo_seq;
+CREATE TABLE fpo_defaults (
+  id int GENERATED ALWAYS AS IDENTITY,
+  seqval int DEFAULT nextval('fpo_seq'),
+  valid_at int4range,
+  name text
+);
+INSERT INTO fpo_defaults (valid_at, name) VALUES ('[1,100)', 'a');
+UPDATE fpo_defaults FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+-- All three rows keep id=1 and seqval=1 (defaults did not re-fire):
+SELECT id, seqval, valid_at, name FROM fpo_defaults ORDER BY valid_at;
+ id | seqval | valid_at | name 
+----+--------+----------+------
+  1 |      1 | [1,40)   | a
+  1 |      1 | [40,60)  | b
+  1 |      1 | [60,100) | a
+(3 rows)
+
+-- The sequence did not advance past the single explicit insert:
+SELECT last_value FROM fpo_seq;
+ last_value 
+------------
+          1
+(1 row)
+
+DROP TABLE fpo_defaults;
+DROP SEQUENCE fpo_seq;
+-- A large (toasted) value must be preserved intact in leftovers:
+CREATE TABLE fpo_toast (id int, valid_at int4range, big text);
+INSERT INTO fpo_toast VALUES (1, '[1,100)', repeat('x', 100000));
+UPDATE fpo_toast FOR PORTION OF valid_at FROM 40 TO 60 SET id = 2;
+SELECT id, valid_at, length(big) FROM fpo_toast ORDER BY valid_at;
+ id | valid_at | length 
+----+----------+--------
+  1 | [1,40)   | 100000
+  2 | [40,60)  | 100000
+  1 | [60,100) | 100000
+(3 rows)
+
+DROP TABLE fpo_toast;
+-- FOR PORTION OF is not supported inside MERGE:
+CREATE TABLE fpo_merge (id int, valid_at int4range, name text);
+CREATE TABLE fpo_merge_src (id int);
+MERGE INTO fpo_merge t
+USING fpo_merge_src s ON t.id = s.id
+WHEN MATCHED THEN
+  UPDATE FOR PORTION OF valid_at FROM 1 TO 2 SET name = 'x';
+ERROR:  syntax error at or near "FOR"
+LINE 4:   UPDATE FOR PORTION OF valid_at FROM 1 TO 2 SET name = 'x';
+                 ^
+DROP TABLE fpo_merge, fpo_merge_src;
+-- Subtransaction rollback undoes a FOR PORTION OF, including its leftovers:
+CREATE TABLE fpo_savepoint (id int, valid_at int4range, name text);
+INSERT INTO fpo_savepoint VALUES (1, '[1,100)', 'a');
+BEGIN;
+SAVEPOINT sp;
+UPDATE fpo_savepoint FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+SELECT valid_at, name FROM fpo_savepoint ORDER BY valid_at;
+ valid_at | name 
+----------+------
+ [1,40)   | a
+ [40,60)  | b
+ [60,100) | a
+(3 rows)
+
+ROLLBACK TO SAVEPOINT sp;
+SELECT valid_at, name FROM fpo_savepoint ORDER BY valid_at;
+ valid_at | name 
+----------+------
+ [1,100)  | a
+(1 row)
+
+COMMIT;
+DROP TABLE fpo_savepoint;
+-- If a leftover insert fails, the whole statement rolls back and the target
+-- row is not left truncated:
+CREATE TABLE fpo_atomic (id int, valid_at int4range, name text);
+INSERT INTO fpo_atomic VALUES (1, '[1,100)', 'a');
+CREATE FUNCTION fpo_reject_insert() RETURNS trigger LANGUAGE plpgsql AS
+  $$ BEGIN RAISE EXCEPTION 'no leftovers allowed'; END $$;
+CREATE TRIGGER fpo_atomic_trg BEFORE INSERT ON fpo_atomic
+  FOR EACH ROW EXECUTE PROCEDURE fpo_reject_insert();
+UPDATE fpo_atomic FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+ERROR:  no leftovers allowed
+CONTEXT:  PL/pgSQL function fpo_reject_insert() line 1 at RAISE
+-- The original row is intact (not truncated to [1,40)):
+SELECT * FROM fpo_atomic ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+  1 | [1,100)  | a
+(1 row)
+
+DROP TABLE fpo_atomic;
+DROP FUNCTION fpo_reject_insert();
+-- Degenerate targets: no matching row, target entirely outside, target covering:
+CREATE TABLE fpo_edge (id int, valid_at int4range, name text);
+INSERT INTO fpo_edge VALUES (1, '[10,20)', 'a');
+-- WHERE matches nothing: no change, no leftovers:
+UPDATE fpo_edge FOR PORTION OF valid_at FROM 12 TO 15 SET name = 'b' WHERE id = 999;
+SELECT * FROM fpo_edge ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+  1 | [10,20)  | a
+(1 row)
+
+-- Target range entirely outside the row's range: no change, no leftovers:
+UPDATE fpo_edge FOR PORTION OF valid_at FROM 100 TO 200 SET name = 'b';
+SELECT * FROM fpo_edge ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+  1 | [10,20)  | a
+(1 row)
+
+-- Target range fully covers the row: whole-row update, no leftovers:
+UPDATE fpo_edge FOR PORTION OF valid_at FROM 0 TO 50 SET name = 'c';
+SELECT * FROM fpo_edge ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+  1 | [10,20)  | c
+(1 row)
+
+DROP TABLE fpo_edge;
+-- FOR PORTION OF after DROP COLUMN and after a dropped-then-readded column
+-- (leftover tuple construction must handle attnum gaps):
+CREATE TABLE fpo_dropcol (id int, junk int, valid_at int4range, name text);
+INSERT INTO fpo_dropcol VALUES (1, 42, '[1,100)', 'a');
+ALTER TABLE fpo_dropcol DROP COLUMN junk;
+UPDATE fpo_dropcol FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+SELECT id, valid_at, name FROM fpo_dropcol ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+  1 | [1,40)   | a
+  1 | [40,60)  | b
+  1 | [60,100) | a
+(3 rows)
+
+ALTER TABLE fpo_dropcol ADD COLUMN extra text DEFAULT 'z';
+UPDATE fpo_dropcol FOR PORTION OF valid_at FROM 10 TO 20 SET name = 'c';
+SELECT id, valid_at, name, extra FROM fpo_dropcol ORDER BY valid_at;
+ id | valid_at | name | extra 
+----+----------+------+-------
+  1 | [1,10)   | a    | z
+  1 | [10,20)  | c    | z
+  1 | [20,40)  | a    | z
+  1 | [40,60)  | b    | z
+  1 | [60,100) | a    | z
+(5 rows)
+
+DROP TABLE fpo_dropcol;
+-- FOR PORTION OF applied directly to a partition (not the root):
+CREATE TABLE fpo_proot (id int, valid_at int4range, name text) PARTITION BY LIST (id);
+CREATE TABLE fpo_pchild PARTITION OF fpo_proot FOR VALUES IN (1);
+INSERT INTO fpo_proot VALUES (1, '[1,100)', 'a');
+UPDATE fpo_pchild FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+SELECT * FROM fpo_proot ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+  1 | [1,40)   | a
+  1 | [40,60)  | b
+  1 | [60,100) | a
+(3 rows)
+
+DROP TABLE fpo_proot;
+-- A leftover that routes to no partition raises an error (and the whole
+-- statement rolls back).  Partition by the range's lower bound so that narrowing
+-- valid_at can push a leftover out of every existing partition.
+CREATE TABLE fpo_part_err (id int, valid_at int4range, name text)
+  PARTITION BY RANGE (lower(valid_at));
+CREATE TABLE fpo_part_err_low PARTITION OF fpo_part_err
+  FOR VALUES FROM (MINVALUE) TO (50);
+INSERT INTO fpo_part_err VALUES (1, '[10,100)', 'a');
+-- The leftover [60,100) has lower bound 60, which no partition covers:
+UPDATE fpo_part_err FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+ERROR:  no partition of relation "fpo_part_err" found for row
+DETAIL:  Partition key of the failing row contains (lower(valid_at)) = (60).
+-- The original row is intact (statement rolled back atomically):
+SELECT * FROM fpo_part_err ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+  1 | [10,100) | a
+(1 row)
+
+DROP TABLE fpo_part_err;
+-- FOR PORTION OF against a table referenced by a temporal foreign key (NO ACTION).
+-- UPDATE preserves total coverage via leftovers, so the FK stays satisfied even
+-- when the target overlaps a range the child needs; DELETE removes coverage and
+-- so is rejected when the child depends on it.
+CREATE TABLE fpo_fk_parent (
+  id int4range,
+  valid_at daterange,
+  note text,
+  PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+CREATE TABLE fpo_fk_child (
+  id int4range,
+  valid_at daterange,
+  parent_id int4range,
+  FOREIGN KEY (parent_id, PERIOD valid_at) REFERENCES fpo_fk_parent (id, PERIOD valid_at)
+);
+INSERT INTO fpo_fk_parent VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a');
+INSERT INTO fpo_fk_child VALUES ('[1,2)', '[2000-01-01,2005-01-01)', '[1,2)');
+-- ok: leftovers restore the coverage the child needs:
+UPDATE fpo_fk_parent
+  FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  SET note = 'b';
+SELECT * FROM fpo_fk_parent ORDER BY valid_at;
+  id   |        valid_at         | note 
+-------+-------------------------+------
+ [1,2) | [2000-01-01,2001-01-01) | a
+ [1,2) | [2001-01-01,2002-01-01) | b
+ [1,2) | [2002-01-01,2010-01-01) | a
+(3 rows)
+
+-- error: DELETE removes coverage the child depends on:
+DELETE FROM fpo_fk_parent
+  FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01';
+ERROR:  update or delete on table "fpo_fk_parent" violates foreign key constraint "fpo_fk_child_parent_id_valid_at_fkey" on table "fpo_fk_child"
+DETAIL:  Key (id, valid_at)=([1,2), [2002-01-01,2010-01-01)) is still referenced from table "fpo_fk_child".
+-- ok: DELETE outside the child's needed range:
+DELETE FROM fpo_fk_parent
+  FOR PORTION OF valid_at FROM '2008-01-01' TO '2009-01-01';
+SELECT * FROM fpo_fk_parent ORDER BY valid_at;
+  id   |        valid_at         | note 
+-------+-------------------------+------
+ [1,2) | [2000-01-01,2001-01-01) | a
+ [1,2) | [2001-01-01,2002-01-01) | b
+ [1,2) | [2002-01-01,2008-01-01) | a
+ [1,2) | [2009-01-01,2010-01-01) | a
+(4 rows)
+
+DROP TABLE fpo_fk_child, fpo_fk_parent;
+-- FOR PORTION OF on the *referencing* side of a temporal foreign key: leftovers
+-- only narrow the child's own validity, so they always still satisfy the FK.
+CREATE TABLE fpo_ref_parent (
+  id int4range,
+  valid_at daterange,
+  PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+CREATE TABLE fpo_ref_child (
+  id int4range,
+  valid_at daterange,
+  parent_id int4range,
+  note text,
+  FOREIGN KEY (parent_id, PERIOD valid_at) REFERENCES fpo_ref_parent (id, PERIOD valid_at)
+);
+INSERT INTO fpo_ref_parent VALUES ('[1,2)', '[2000-01-01,2010-01-01)');
+INSERT INTO fpo_ref_child VALUES ('[1,2)', '[2000-01-01,2008-01-01)', '[1,2)', 'a');
+-- UPDATE FOR PORTION OF the child: leftovers still reference covered parent ranges:
+UPDATE fpo_ref_child
+  FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET note = 'b';
+SELECT id, valid_at, parent_id, note FROM fpo_ref_child ORDER BY valid_at;
+  id   |        valid_at         | parent_id | note 
+-------+-------------------------+-----------+------
+ [1,2) | [2000-01-01,2002-01-01) | [1,2)     | a
+ [1,2) | [2002-01-01,2003-01-01) | [1,2)     | b
+ [1,2) | [2003-01-01,2008-01-01) | [1,2)     | a
+(3 rows)
+
+-- DELETE FOR PORTION OF the child: removes part of the child's validity (FK-safe):
+DELETE FROM fpo_ref_child
+  FOR PORTION OF valid_at FROM '2004-01-01' TO '2005-01-01';
+SELECT id, valid_at, parent_id, note FROM fpo_ref_child ORDER BY valid_at;
+  id   |        valid_at         | parent_id | note 
+-------+-------------------------+-----------+------
+ [1,2) | [2000-01-01,2002-01-01) | [1,2)     | a
+ [1,2) | [2002-01-01,2003-01-01) | [1,2)     | b
+ [1,2) | [2003-01-01,2004-01-01) | [1,2)     | a
+ [1,2) | [2005-01-01,2008-01-01) | [1,2)     | a
+(4 rows)
+
+DROP TABLE fpo_ref_child, fpo_ref_parent;
+-- A NOT NULL non-range column is copied into leftovers (not nulled out):
+CREATE TABLE fpo_notnull (id int, valid_at int4range, name text NOT NULL);
+INSERT INTO fpo_notnull VALUES (1, '[1,100)', 'keep');
+UPDATE fpo_notnull FOR PORTION OF valid_at FROM 40 TO 60 SET id = 2;
+SELECT * FROM fpo_notnull ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+  1 | [1,40)   | keep
+  2 | [40,60)  | keep
+  1 | [60,100) | keep
+(3 rows)
+
+DROP TABLE fpo_notnull;
+-- A non-default collation on a non-range column is preserved in leftovers:
+CREATE TABLE fpo_collate (id int, valid_at int4range, name text COLLATE "C");
+INSERT INTO fpo_collate VALUES (1, '[1,100)', 'Xyz');
+UPDATE fpo_collate FOR PORTION OF valid_at FROM 40 TO 60 SET id = 2;
+SELECT id, valid_at, name FROM fpo_collate ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+  1 | [1,40)   | Xyz
+  2 | [40,60)  | Xyz
+  1 | [60,100) | Xyz
+(3 rows)
+
+DROP TABLE fpo_collate;
+-- EXPLAIN ANALYZE executes the FOR PORTION OF (including its leftovers):
+CREATE TABLE fpo_ea (id int, valid_at daterange, name text);
+INSERT INTO fpo_ea VALUES (1, '[2000-01-01,2010-01-01)', 'a');
+EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
+  UPDATE fpo_ea FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' SET name = 'b';
+                             QUERY PLAN                             
+--------------------------------------------------------------------
+ Update on fpo_ea (actual rows=0.00 loops=1)
+   ->  Seq Scan on fpo_ea (actual rows=1.00 loops=1)
+         Filter: (valid_at && '[2002-01-01,2003-01-01)'::daterange)
+(3 rows)
+
+SELECT valid_at, name FROM fpo_ea ORDER BY valid_at;
+        valid_at         | name 
+-------------------------+------
+ [2000-01-01,2002-01-01) | a
+ [2002-01-01,2003-01-01) | b
+ [2003-01-01,2010-01-01) | a
+(3 rows)
+
+DROP TABLE fpo_ea;
+-- RLS USING visibility: FOR PORTION OF only sees and splits rows the USING
+-- policy makes visible; rows hidden by USING are left untouched.  Runs as a
+-- non-superuser role, since superusers bypass RLS.  The leftovers keep the
+-- tenant column, so they satisfy the policy's implied WITH CHECK.
+CREATE ROLE regress_fpo_rls_using;
+CREATE TABLE fpo_rls_using (id int, valid_at int4range, tenant name, name text);
+INSERT INTO fpo_rls_using VALUES
+  (1, '[1,100)', 'regress_fpo_rls_using', 'a'),
+  (2, '[1,100)', 'other', 'b');
+ALTER TABLE fpo_rls_using ENABLE ROW LEVEL SECURITY;
+CREATE POLICY fpo_rls_using_pol ON fpo_rls_using USING (tenant = current_user);
+GRANT SELECT, INSERT, UPDATE ON fpo_rls_using TO regress_fpo_rls_using;
+SET ROLE regress_fpo_rls_using;
+-- Only the row whose tenant = current_user is visible, so only it is split:
+UPDATE fpo_rls_using FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'a2';
+SELECT id, valid_at, tenant, name FROM fpo_rls_using ORDER BY id, valid_at;
+ id | valid_at |        tenant         | name 
+----+----------+-----------------------+------
+  1 | [1,40)   | regress_fpo_rls_using | a
+  1 | [40,60)  | regress_fpo_rls_using | a2
+  1 | [60,100) | regress_fpo_rls_using | a
+(3 rows)
+
+RESET ROLE;
+-- As the RLS-bypassing owner: the 'other' row was never seen or touched:
+SELECT id, valid_at, tenant, name FROM fpo_rls_using ORDER BY id, valid_at;
+ id | valid_at |        tenant         | name 
+----+----------+-----------------------+------
+  1 | [1,40)   | regress_fpo_rls_using | a
+  1 | [40,60)  | regress_fpo_rls_using | a2
+  1 | [60,100) | regress_fpo_rls_using | a
+  2 | [1,100)  | other                 | b
+(4 rows)
+
+DROP TABLE fpo_rls_using;
+DROP ROLE regress_fpo_rls_using;
+--
+-- A user-defined default GiST operator class whose opcintype is a concrete
+-- range (or multirange) type, rather than the polymorphic anyrange the
+-- built-in range_ops uses.
+--
+CREATE TYPE fpo_range AS RANGE (
+  subtype = date,
+  multirange_type_name = fpo_multirange
+);
+CREATE FUNCTION fpo_range_overlaps(fpo_range, fpo_range) RETURNS boolean
+  LANGUAGE sql IMMUTABLE STRICT
+  AS $$ SELECT $1 OPERATOR(pg_catalog.&&) $2 $$;
+CREATE OPERATOR && (
+  leftarg = fpo_range, rightarg = fpo_range,
+  procedure = fpo_range_overlaps, commutator = &&,
+  restrict = rangesel, join = areajoinsel
+);
+CREATE OPERATOR CLASS fpo_range_ops
+  DEFAULT FOR TYPE fpo_range USING gist AS
+  OPERATOR 3 &&(fpo_range, fpo_range),
+  FUNCTION 1 range_gist_consistent(internal, anyrange, smallint, oid, internal),
+  FUNCTION 2 range_gist_union(internal, internal),
+  FUNCTION 5 range_gist_penalty(internal, internal, internal),
+  FUNCTION 6 range_gist_picksplit(internal, internal),
+  FUNCTION 7 range_gist_same(anyrange, anyrange, internal);
+-- gistutil.c looks this one up with get_opfamily_proc(opfamily, ANYOID,
+-- ANYOID, GIST_TRANSLATE_CMPTYPE_PROC), so it must be registered that way.
+ALTER OPERATOR FAMILY fpo_range_ops USING gist
+  ADD FUNCTION 12 ("any", "any") gist_translate_cmptype_common(integer);
+CREATE FUNCTION fpo_multirange_overlaps(fpo_multirange, fpo_multirange)
+  RETURNS boolean LANGUAGE sql IMMUTABLE STRICT
+  AS $$ SELECT $1 OPERATOR(pg_catalog.&&) $2 $$;
+CREATE OPERATOR && (
+  leftarg = fpo_multirange, rightarg = fpo_multirange,
+  procedure = fpo_multirange_overlaps, commutator = &&,
+  restrict = multirangesel, join = areajoinsel
+);
+CREATE OPERATOR CLASS fpo_multirange_ops
+  DEFAULT FOR TYPE fpo_multirange USING gist AS
+  OPERATOR 3 &&(fpo_multirange, fpo_multirange),
+  FUNCTION 1 multirange_gist_consistent(internal, anymultirange, smallint, oid, internal),
+  FUNCTION 2 range_gist_union(internal, internal),
+  FUNCTION 3 multirange_gist_compress(internal),
+  FUNCTION 5 range_gist_penalty(internal, internal, internal),
+  FUNCTION 6 range_gist_picksplit(internal, internal),
+  FUNCTION 7 range_gist_same(anyrange, anyrange, internal);
+ALTER OPERATOR FAMILY fpo_multirange_ops USING gist
+  ADD FUNCTION 12 ("any", "any") gist_translate_cmptype_common(integer);
+CREATE TABLE fpo_opc (
+  id int,
+  valid_at fpo_range,
+  valid_ats fpo_multirange,
+  name text
+);
+-- The opclasses really are usable for indexing.
+CREATE INDEX fpo_opc_r_idx ON fpo_opc USING gist (valid_at);
+CREATE INDEX fpo_opc_mr_idx ON fpo_opc USING gist (valid_ats);
+INSERT INTO fpo_opc VALUES
+  (1, fpo_range('2000-01-01', '2010-01-01'),
+      fpo_multirange(fpo_range('2000-01-01', '2010-01-01')), 'one');
+-- control: an ordinary overlaps qual against the same opclass
+UPDATE fpo_opc SET name = 'ctl'
+  WHERE valid_at && fpo_range('2002-01-01', '2003-01-01');
+SELECT * FROM fpo_opc ORDER BY valid_at;
+ id |        valid_at         |         valid_ats         | name 
+----+-------------------------+---------------------------+------
+  1 | [2000-01-01,2010-01-01) | {[2000-01-01,2010-01-01)} | ctl
+(1 row)
+
+UPDATE fpo_opc FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = 'upd';
+SELECT id, valid_at, name FROM fpo_opc ORDER BY valid_at;
+ id |        valid_at         | name 
+----+-------------------------+------
+  1 | [2000-01-01,2002-01-01) | ctl
+  1 | [2002-01-01,2003-01-01) | upd
+  1 | [2003-01-01,2010-01-01) | ctl
+(3 rows)
+
+DELETE FROM fpo_opc FOR PORTION OF valid_at FROM '2005-01-01' TO '2006-01-01';
+SELECT id, valid_at, name FROM fpo_opc ORDER BY valid_at;
+ id |        valid_at         | name 
+----+-------------------------+------
+  1 | [2000-01-01,2002-01-01) | ctl
+  1 | [2002-01-01,2003-01-01) | upd
+  1 | [2003-01-01,2005-01-01) | ctl
+  1 | [2006-01-01,2010-01-01) | ctl
+(4 rows)
+
+DELETE FROM fpo_opc;
+INSERT INTO fpo_opc VALUES
+  (1, fpo_range('2000-01-01', '2010-01-01'),
+      fpo_multirange(fpo_range('2000-01-01', '2010-01-01')), 'one');
+UPDATE fpo_opc
+  FOR PORTION OF valid_ats
+    (fpo_multirange(fpo_range('2002-01-01', '2003-01-01')))
+  SET name = 'upd';
+SELECT id, valid_ats, name FROM fpo_opc ORDER BY valid_ats;
+ id |                     valid_ats                     | name 
+----+---------------------------------------------------+------
+  1 | {[2000-01-01,2002-01-01),[2003-01-01,2010-01-01)} | one
+  1 | {[2002-01-01,2003-01-01)}                         | upd
+(2 rows)
+
+DELETE FROM fpo_opc
+  FOR PORTION OF valid_ats
+    (fpo_multirange(fpo_range('2005-01-01', '2006-01-01')));
+SELECT id, valid_ats, name FROM fpo_opc ORDER BY valid_ats;
+ id |                                 valid_ats                                 | name 
+----+---------------------------------------------------------------------------+------
+  1 | {[2000-01-01,2002-01-01),[2003-01-01,2005-01-01),[2006-01-01,2010-01-01)} | one
+  1 | {[2002-01-01,2003-01-01)}                                                 | upd
+(2 rows)
+
+DROP TABLE fpo_opc;
+DROP OPERATOR CLASS fpo_multirange_ops USING gist;
+DROP OPERATOR && (fpo_multirange, fpo_multirange);
+DROP FUNCTION fpo_multirange_overlaps(fpo_multirange, fpo_multirange);
+DROP OPERATOR CLASS fpo_range_ops USING gist;
+DROP OPERATOR && (fpo_range, fpo_range);
+DROP FUNCTION fpo_range_overlaps(fpo_range, fpo_range);
+DROP TYPE fpo_range;
+--
+-- Diagnostics when a trigger on the leftover INSERTs changes rows the
+-- statement has not reached yet.
+--
+-- Leftovers are inserted as the scan proceeds and fire their own triggers, so
+-- an AFTER row trigger can trip the "already modified by an operation
+-- triggered by the current command" check.  The stock hint only mentions
+-- BEFORE triggers, so we add a detail naming the other possibility.
+--
+CREATE TABLE fpo_selfmod (
+  id int,
+  valid_at daterange,
+  name text,
+  touched int NOT NULL DEFAULT 0
+);
+INSERT INTO fpo_selfmod (id, valid_at, name) VALUES
+  (1, daterange('2000-01-01', '2010-01-01'), 'one'),
+  (2, daterange('2000-01-01', '2010-01-01'), 'two');
+CREATE FUNCTION fpo_touch_siblings() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  IF pg_trigger_depth() > 1 THEN
+    RETURN NULL;
+  END IF;
+  UPDATE fpo_selfmod SET touched = touched + 1 WHERE id <> NEW.id;
+  RETURN NULL;
+END;
+$$;
+CREATE TRIGGER fpo_selfmod_ai AFTER INSERT ON fpo_selfmod
+  FOR EACH ROW EXECUTE PROCEDURE fpo_touch_siblings();
+UPDATE fpo_selfmod FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = name || '!';
+ERROR:  tuple to be updated was already modified by an operation triggered by the current command
+DETAIL:  FOR PORTION OF inserts temporal leftovers while the statement is still running, and those inserts fire their own triggers.
+HINT:  Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows.
+DELETE FROM fpo_selfmod FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01';
+ERROR:  tuple to be deleted was already modified by an operation triggered by the current command
+DETAIL:  FOR PORTION OF inserts temporal leftovers while the statement is still running, and those inserts fire their own triggers.
+HINT:  Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows.
+-- The same detail appears for the check in GetTupleForTrigger(), which a
+-- BEFORE trigger reaches.  There the stock hint is the right advice, so it is
+-- still what we suggest.
+CREATE FUNCTION fpo_touch_siblings_bu() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  IF pg_trigger_depth() = 1 THEN
+    UPDATE fpo_selfmod SET touched = touched + 1 WHERE id <> NEW.id;
+  END IF;
+  RETURN NEW;
+END;
+$$;
+CREATE TRIGGER fpo_selfmod_bu BEFORE UPDATE ON fpo_selfmod
+  FOR EACH ROW EXECUTE PROCEDURE fpo_touch_siblings_bu();
+UPDATE fpo_selfmod FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = name || '!';
+ERROR:  tuple to be updated was already modified by an operation triggered by the current command
+DETAIL:  FOR PORTION OF inserts temporal leftovers while the statement is still running, and those inserts fire their own triggers.
+HINT:  Consider using an AFTER trigger instead of a BEFORE trigger to propagate changes to other rows.
+DROP TRIGGER fpo_selfmod_bu ON fpo_selfmod;
+DROP FUNCTION fpo_touch_siblings_bu();
+-- Deferring the trigger works.
+DROP TRIGGER fpo_selfmod_ai ON fpo_selfmod;
+CREATE CONSTRAINT TRIGGER fpo_selfmod_ai AFTER INSERT ON fpo_selfmod
+  DEFERRABLE INITIALLY DEFERRED
+  FOR EACH ROW EXECUTE PROCEDURE fpo_touch_siblings();
+BEGIN;
+UPDATE fpo_selfmod FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = name || '!';
+COMMIT;
+SELECT * FROM fpo_selfmod ORDER BY id, valid_at;
+ id |        valid_at         | name | touched 
+----+-------------------------+------+---------
+  1 | [2000-01-01,2002-01-01) | one  |       2
+  1 | [2002-01-01,2003-01-01) | one! |       2
+  1 | [2003-01-01,2010-01-01) | one  |       2
+  2 | [2000-01-01,2002-01-01) | two  |       2
+  2 | [2002-01-01,2003-01-01) | two! |       2
+  2 | [2003-01-01,2010-01-01) | two  |       2
+(6 rows)
+
+DROP TABLE fpo_selfmod;
+DROP FUNCTION fpo_touch_siblings();
+--
+-- WITH CHECK OPTION on a view
+--
+-- The row the statement produced is checked first, so a violation names the
+-- row the user asked for and not an untouched leftover.
+--
+CREATE TABLE fpo_wco (
+  id int,
+  valid_at daterange,
+  name text
+);
+INSERT INTO fpo_wco VALUES
+  (1, daterange('2000-01-01', '2010-01-01'), 'keepme');
+CREATE VIEW fpo_wco_v AS
+  SELECT * FROM fpo_wco WHERE valid_at @> '2005-01-01'::date
+  WITH CHECK OPTION;
+-- Both the updated row and the leftovers fall outside the view.  The updated
+-- row is the one the user wrote, so that is the one to report.
+UPDATE fpo_wco_v FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = 'changed';
+ERROR:  new row violates check option for view "fpo_wco_v"
+DETAIL:  Failing row contains (1, [2002-01-01,2003-01-01), changed).
+-- Here the updated row still satisfies the view and only a leftover does not,
+-- so the leftover is correctly the one reported.
+UPDATE fpo_wco_v FOR PORTION OF valid_at FROM '2004-01-01' TO '2006-01-01'
+  SET name = 'changed';
+ERROR:  new row violates check option for view "fpo_wco_v"
+DETAIL:  Failing row contains (1, [2000-01-01,2004-01-01), keepme).
+-- Nothing was written by either statement.
+SELECT * FROM fpo_wco ORDER BY valid_at;
+ id |        valid_at         |  name  
+----+-------------------------+--------
+  1 | [2000-01-01,2010-01-01) | keepme
+(1 row)
+
+-- A portion covering the whole row leaves no leftovers and is accepted.
+UPDATE fpo_wco_v FOR PORTION OF valid_at FROM '2000-01-01' TO '2010-01-01'
+  SET name = 'changed';
+SELECT * FROM fpo_wco ORDER BY valid_at;
+ id |        valid_at         |  name   
+----+-------------------------+---------
+  1 | [2000-01-01,2010-01-01) | changed
+(1 row)
+
+DROP VIEW fpo_wco_v;
+DROP TABLE fpo_wco;
+--
+-- Assigning the range column through a view that exposes it twice
+--
+-- transformUpdateTargetList() compares the names the user wrote, so it does
+-- not notice that two view columns are the same base column.  The rewriter
+-- must still say why the assignment is rejected.
+--
+CREATE TABLE fpo_dup (
+  id int,
+  valid_at daterange,
+  name text
+);
+INSERT INTO fpo_dup VALUES
+  (1, daterange('2000-01-01', '2010-01-01'), 'one');
+CREATE VIEW fpo_dup_v AS
+  SELECT id, valid_at AS a, valid_at AS b, name, name AS c, name AS d
+  FROM fpo_dup;
+UPDATE fpo_dup_v FOR PORTION OF a FROM '2002-01-01' TO '2003-01-01'
+  SET b = daterange('1990-01-01', '1991-01-01');
+ERROR:  cannot update column "b" because it is used in FOR PORTION OF
+DETAIL:  Columns "b" and "a" are the same column of relation "fpo_dup".
+-- Naming the same view column in both places is caught in parse analysis, as
+-- it is for a table.
+UPDATE fpo_dup_v FOR PORTION OF a FROM '2002-01-01' TO '2003-01-01'
+  SET a = daterange('1990-01-01', '1991-01-01');
+ERROR:  cannot update column "a" because it is used in FOR PORTION OF
+LINE 2:   SET a = daterange('1990-01-01', '1991-01-01');
+              ^
+-- Two view columns aliasing a column that is not the FOR PORTION OF column
+-- collide with each other, not with the clause, so they still get the generic
+-- error: the new check must not fire too eagerly.
+UPDATE fpo_dup_v FOR PORTION OF a FROM '2002-01-01' TO '2003-01-01'
+  SET c = 'x', d = 'y';
+ERROR:  multiple assignments to same column "name"
+-- Not assigning the range column through the view still works.
+UPDATE fpo_dup_v FOR PORTION OF a FROM '2002-01-01' TO '2003-01-01'
+  SET name = 'one^1';
+SELECT * FROM fpo_dup ORDER BY valid_at;
+ id |        valid_at         | name  
+----+-------------------------+-------
+  1 | [2000-01-01,2002-01-01) | one
+  1 | [2002-01-01,2003-01-01) | one^1
+  1 | [2003-01-01,2010-01-01) | one
+(3 rows)
+
+DROP VIEW fpo_dup_v;
+DROP TABLE fpo_dup;
+RESET datestyle;
diff --git a/src/test/regress/expected/privileges.out b/src/test/regress/expected/privileges.out
index 9b7153c9cd5..11989f75423 100644
--- a/src/test/regress/expected/privileges.out
+++ b/src/test/regress/expected/privileges.out
@@ -1154,6 +1154,44 @@ ERROR:  null value in column "b" of relation "errtst_part_2" violates not-null c
 DETAIL:  Failing row contains (a, b, c) = (aaaa, null, ccc).
 SET SESSION AUTHORIZATION regress_priv_user1;
 DROP TABLE errtst;
+-- test column-level privileges on the range used in FOR PORTION OF
+SET SESSION AUTHORIZATION regress_priv_user1;
+CREATE TABLE t1 (
+  c1 int4range,
+  valid_at tsrange,
+	CONSTRAINT t1pk PRIMARY KEY (c1, valid_at WITHOUT OVERLAPS)
+);
+-- UPDATE requires select and update permission on the valid_at column:
+GRANT SELECT (c1) ON t1 TO regress_priv_user2;
+GRANT UPDATE (c1) ON t1 TO regress_priv_user2;
+GRANT SELECT (c1, valid_at) ON t1 TO regress_priv_user3;
+GRANT UPDATE (c1) ON t1 TO regress_priv_user3;
+GRANT SELECT (c1) ON t1 TO regress_priv_user4;
+GRANT UPDATE (c1, valid_at) ON t1 TO regress_priv_user4;
+GRANT SELECT (c1, valid_at) ON t1 TO regress_priv_user5;
+GRANT UPDATE (c1, valid_at) ON t1 TO regress_priv_user5;
+SET SESSION AUTHORIZATION regress_priv_user2;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+ERROR:  permission denied for table t1
+SET SESSION AUTHORIZATION regress_priv_user3;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+ERROR:  permission denied for table t1
+SET SESSION AUTHORIZATION regress_priv_user4;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+ERROR:  permission denied for table t1
+SET SESSION AUTHORIZATION regress_priv_user5;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+SET SESSION AUTHORIZATION regress_priv_user1;
+-- DELETE requires select permission on the valid_at column:
+GRANT DELETE ON t1 TO regress_priv_user2;
+GRANT DELETE ON t1 TO regress_priv_user3;
+SET SESSION AUTHORIZATION regress_priv_user2;
+DELETE FROM t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01';
+ERROR:  permission denied for table t1
+SET SESSION AUTHORIZATION regress_priv_user3;
+DELETE FROM t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01';
+SET SESSION AUTHORIZATION regress_priv_user1;
+DROP TABLE t1;
 -- test column-level privileges when involved with DELETE
 SET SESSION AUTHORIZATION regress_priv_user1;
 ALTER TABLE atest6 ADD COLUMN three integer;
diff --git a/src/test/regress/expected/rowsecurity.out b/src/test/regress/expected/rowsecurity.out
index 72d76405639..041e7a61c34 100644
--- a/src/test/regress/expected/rowsecurity.out
+++ b/src/test/regress/expected/rowsecurity.out
@@ -68,6 +68,20 @@ CREATE POLICY upd_pol ON rls_test_tgt FOR UPDATE
   WITH CHECK (rls_test_policy_fn('UPDATE CHECK on rls_test_tgt', rls_test_tgt));
 CREATE POLICY del_pol ON rls_test_tgt FOR DELETE
   USING (rls_test_policy_fn('DELETE USING on rls_test_tgt', rls_test_tgt));
+-- setup temporal target table (for FOR PORTION OF operations)
+CREATE TABLE rls_test_fpo_tgt (a int, b text, valid_at int4range);
+ALTER TABLE rls_test_fpo_tgt ENABLE ROW LEVEL SECURITY;
+GRANT SELECT, UPDATE, DELETE ON rls_test_fpo_tgt TO public;
+INSERT INTO rls_test_fpo_tgt VALUES (1, 'fpo a', '[1,10)');
+CREATE POLICY sel_pol ON rls_test_fpo_tgt FOR SELECT
+  USING (rls_test_policy_fn('SELECT USING on rls_test_fpo_tgt', rls_test_fpo_tgt));
+CREATE POLICY ins_pol ON rls_test_fpo_tgt FOR INSERT
+  WITH CHECK (rls_test_policy_fn('INSERT CHECK on rls_test_fpo_tgt', rls_test_fpo_tgt));
+CREATE POLICY upd_pol ON rls_test_fpo_tgt FOR UPDATE
+  USING (rls_test_policy_fn('UPDATE USING on rls_test_fpo_tgt', rls_test_fpo_tgt))
+  WITH CHECK (rls_test_policy_fn('UPDATE CHECK on rls_test_fpo_tgt', rls_test_fpo_tgt));
+CREATE POLICY del_pol ON rls_test_fpo_tgt FOR DELETE
+  USING (rls_test_policy_fn('DELETE USING on rls_test_fpo_tgt', rls_test_fpo_tgt));
 -- test policies applied to regress_rls_bob
 SET SESSION AUTHORIZATION regress_rls_bob;
 -- SELECT, COPY ... TO, and TABLE should only apply SELECT USING policy clause
@@ -155,6 +169,19 @@ NOTICE:  SELECT USING on rls_test_tgt.(1,"tgt d","TGT D")
  1 | tgt d | TGT D
 (1 row)
 
+-- UPDATE ... FOR PORTION OF should also apply SELECT USING policy clauses to
+-- the old and new rows, and INSERT CHECK policy clauses to leftover rows.
+BEGIN;
+UPDATE rls_test_fpo_tgt
+  FOR PORTION OF valid_at FROM 3 TO 7
+  SET b = 'fpo b';
+NOTICE:  UPDATE USING on rls_test_fpo_tgt.(1,"fpo a","[1,10)")
+NOTICE:  SELECT USING on rls_test_fpo_tgt.(1,"fpo a","[1,10)")
+NOTICE:  UPDATE CHECK on rls_test_fpo_tgt.(1,"fpo b","[3,7)")
+NOTICE:  SELECT USING on rls_test_fpo_tgt.(1,"fpo b","[3,7)")
+NOTICE:  INSERT CHECK on rls_test_fpo_tgt.(1,"fpo a","[1,3)")
+NOTICE:  INSERT CHECK on rls_test_fpo_tgt.(1,"fpo a","[7,10)")
+ROLLBACK;
 -- DELETE without WHERE or RETURNING should only apply DELETE USING policy clause
 BEGIN; DELETE FROM rls_test_tgt; ROLLBACK;
 NOTICE:  DELETE USING on rls_test_tgt.(1,"tgt d","TGT D")
@@ -170,6 +197,16 @@ NOTICE:  SELECT USING on rls_test_tgt.(1,"tgt d","TGT D")
  1 | tgt d | TGT D
 (1 row)
 
+-- DELETE ... FOR PORTION OF should also apply SELECT USING policy clauses to
+-- the old row, and INSERT CHECK policy clauses to leftover rows.
+BEGIN;
+DELETE FROM rls_test_fpo_tgt
+  FOR PORTION OF valid_at FROM 3 TO 7;
+NOTICE:  DELETE USING on rls_test_fpo_tgt.(1,"fpo a","[1,10)")
+NOTICE:  SELECT USING on rls_test_fpo_tgt.(1,"fpo a","[1,10)")
+NOTICE:  INSERT CHECK on rls_test_fpo_tgt.(1,"fpo a","[1,3)")
+NOTICE:  INSERT CHECK on rls_test_fpo_tgt.(1,"fpo a","[7,10)")
+ROLLBACK;
 -- INSERT ... ON CONFLICT DO NOTHING with an arbiter clause should apply
 -- INSERT CHECK and SELECT USING policy clauses (to new value, whether it
 -- conflicts or not)
@@ -332,7 +369,7 @@ NOTICE:  DELETE USING on rls_test_tgt.(1,"tgt c","TGT C")
 
 -- Tidy up
 RESET SESSION AUTHORIZATION;
-DROP TABLE rls_test_src, rls_test_tgt;
+DROP TABLE rls_test_src, rls_test_tgt, rls_test_fpo_tgt;
 DROP FUNCTION rls_test_tgt_set_c;
 DROP FUNCTION rls_test_policy_fn;
 -- BASIC Row-Level Security Scenario
diff --git a/src/test/regress/expected/updatable_views.out b/src/test/regress/expected/updatable_views.out
index 9cea538b8e8..81818b680d6 100644
--- a/src/test/regress/expected/updatable_views.out
+++ b/src/test/regress/expected/updatable_views.out
@@ -3722,6 +3722,153 @@ select * from uv_iocu_tab;
 
 drop view uv_iocu_view;
 drop table uv_iocu_tab;
+-- Check UPDATE FOR PORTION OF works correctly
+create table uv_fpo_tab (id int4range, valid_at tsrange, b float,
+    constraint pk_uv_fpo_tab primary key (id, valid_at without overlaps));
+insert into uv_fpo_tab values ('[1,1]', '[2020-01-01, 2030-01-01)', 0);
+create view uv_fpo_view as
+    select b, b+1 as c, valid_at, id, '2.0'::text as two from uv_fpo_tab;
+insert into uv_fpo_view (id, valid_at, b) values ('[1,1]', '[2010-01-01, 2020-01-01)', 1);
+select * from uv_fpo_view order by id, valid_at;
+ b | c |                        valid_at                         |  id   | two 
+---+---+---------------------------------------------------------+-------+-----
+ 1 | 2 | ["Fri Jan 01 00:00:00 2010","Wed Jan 01 00:00:00 2020") | [1,2) | 2.0
+ 0 | 1 | ["Wed Jan 01 00:00:00 2020","Tue Jan 01 00:00:00 2030") | [1,2) | 2.0
+(2 rows)
+
+update uv_fpo_view for portion of valid_at from '2015-01-01' to '2020-01-01' set b = 2 where id = '[1,1]';
+select * from uv_fpo_view order by id, valid_at;
+ b | c |                        valid_at                         |  id   | two 
+---+---+---------------------------------------------------------+-------+-----
+ 1 | 2 | ["Fri Jan 01 00:00:00 2010","Thu Jan 01 00:00:00 2015") | [1,2) | 2.0
+ 2 | 3 | ["Thu Jan 01 00:00:00 2015","Wed Jan 01 00:00:00 2020") | [1,2) | 2.0
+ 0 | 1 | ["Wed Jan 01 00:00:00 2020","Tue Jan 01 00:00:00 2030") | [1,2) | 2.0
+(3 rows)
+
+delete from uv_fpo_view for portion of valid_at from '2017-01-01' to '2022-01-01' where id = '[1,1]';
+select * from uv_fpo_view order by id, valid_at;
+ b | c |                        valid_at                         |  id   | two 
+---+---+---------------------------------------------------------+-------+-----
+ 1 | 2 | ["Fri Jan 01 00:00:00 2010","Thu Jan 01 00:00:00 2015") | [1,2) | 2.0
+ 2 | 3 | ["Thu Jan 01 00:00:00 2015","Sun Jan 01 00:00:00 2017") | [1,2) | 2.0
+ 0 | 1 | ["Sat Jan 01 00:00:00 2022","Tue Jan 01 00:00:00 2030") | [1,2) | 2.0
+(3 rows)
+
+-- UPDATE/DELETE FOR PORTION fails if the column is not updatable
+-- (e.g. a computed expression, not a base column):
+create view uv_fpo_view_nonupd as
+  select id, '[1,20]'::int4range as valid_at, b
+  from uv_fpo_tab;
+-- Updating fails:
+update uv_fpo_view_nonupd for portion of valid_at from 1 to 10 set b = 2;
+ERROR:  cannot update column "valid_at" of view "uv_fpo_view_nonupd"
+DETAIL:  View columns that are not columns of their base relation are not updatable.
+-- Deleting fails:
+delete from uv_fpo_view_nonupd for portion of valid_at from 1 to 10;
+ERROR:  cannot delete from view "uv_fpo_view_nonupd" using FOR PORTION OF "valid_at"
+DETAIL:  View columns that are not columns of their base relation are not updatable.
+drop view uv_fpo_view_nonupd;
+-- WITH CHECK OPTION must be enforced on temporal leftovers, i.e. the rows
+-- FOR PORTION OF inserts to preserve the untouched parts of the target row.
+-- This applies to DELETE as well as UPDATE.
+create table uv_fpo_wco_tab (id int4range, valid_at daterange, b int);
+insert into uv_fpo_wco_tab values ('[1,1]', '[2020-01-01,2030-01-01)', 0);
+create view uv_fpo_wco_view as
+  select * from uv_fpo_wco_tab
+  where valid_at && daterange('2024-01-01', '2025-01-01')
+  with check option;
+-- The leftovers fall outside the view, so both commands fail:
+update uv_fpo_wco_view for portion of valid_at from '2024-01-01' to '2025-01-01' set b = 1;
+ERROR:  new row violates check option for view "uv_fpo_wco_view"
+DETAIL:  Failing row contains ([1,2), [01-01-2020,01-01-2024), 0).
+delete from uv_fpo_wco_view for portion of valid_at from '2024-01-01' to '2025-01-01';
+ERROR:  new row violates check option for view "uv_fpo_wco_view"
+DETAIL:  Failing row contains ([1,2), [01-01-2020,01-01-2024), 0).
+-- The base table is unchanged:
+select * from uv_fpo_wco_tab order by valid_at;
+  id   |        valid_at         | b 
+-------+-------------------------+---
+ [1,2) | [01-01-2020,01-01-2030) | 0
+(1 row)
+
+-- Leftovers that still satisfy the view are allowed:
+delete from uv_fpo_wco_view for portion of valid_at from '2024-03-01' to '2024-06-01';
+update uv_fpo_wco_view for portion of valid_at from '2024-07-01' to '2024-08-01' set b = 1;
+select * from uv_fpo_wco_tab order by valid_at;
+  id   |        valid_at         | b 
+-------+-------------------------+---
+ [1,2) | [01-01-2020,03-01-2024) | 0
+ [1,2) | [06-01-2024,07-01-2024) | 0
+ [1,2) | [07-01-2024,08-01-2024) | 1
+ [1,2) | [08-01-2024,01-01-2030) | 0
+(4 rows)
+
+-- Without WITH CHECK OPTION the leftovers may leave the view:
+create view uv_fpo_nowco_view as
+  select * from uv_fpo_wco_tab
+  where valid_at && daterange('2024-01-01', '2025-01-01');
+delete from uv_fpo_nowco_view for portion of valid_at from '2024-01-01' to '2024-02-01';
+update uv_fpo_nowco_view for portion of valid_at from '2024-09-01' to '2026-01-01' set b = 2;
+select * from uv_fpo_wco_tab order by valid_at;
+  id   |        valid_at         | b 
+-------+-------------------------+---
+ [1,2) | [01-01-2020,01-01-2024) | 0
+ [1,2) | [02-01-2024,03-01-2024) | 0
+ [1,2) | [06-01-2024,07-01-2024) | 0
+ [1,2) | [07-01-2024,08-01-2024) | 1
+ [1,2) | [08-01-2024,09-01-2024) | 0
+ [1,2) | [09-01-2024,01-01-2026) | 2
+ [1,2) | [01-01-2026,01-01-2030) | 0
+(7 rows)
+
+drop view uv_fpo_wco_view;
+drop view uv_fpo_nowco_view;
+drop table uv_fpo_wco_tab;
+-- On a partitioned table a leftover can be routed to a partition that is not
+-- one of the plan's result relations.  WITH CHECK OPTION must be enforced
+-- there too.
+create table uv_fpo_wco_part (id int4range, valid_at daterange, b int)
+  partition by range (lower(valid_at));
+create table uv_fpo_wco_part1 partition of uv_fpo_wco_part
+  for values from (minvalue) to ('2000-06-01');
+create table uv_fpo_wco_part2 partition of uv_fpo_wco_part
+  for values from ('2000-06-01') to (maxvalue);
+insert into uv_fpo_wco_part values ('[1,1]', '[2000-01-01,2004-01-01)', 0);
+-- This view accepts both leftovers.  The where clause prunes away part2, but
+-- the second leftover is routed there anyway:
+create view uv_fpo_wco_part_view as
+  select * from uv_fpo_wco_part where b = 0 with check option;
+delete from uv_fpo_wco_part_view
+  for portion of valid_at from '2000-03-01' to '2000-07-01'
+  where lower(valid_at) < '2000-06-01';
+select tableoid::regclass, * from uv_fpo_wco_part order by valid_at;
+     tableoid     |  id   |        valid_at         | b 
+------------------+-------+-------------------------+---
+ uv_fpo_wco_part1 | [1,2) | [01-01-2000,03-01-2000) | 0
+ uv_fpo_wco_part2 | [1,2) | [07-01-2000,01-01-2004) | 0
+(2 rows)
+
+-- This view rejects the leftover that lands in part2:
+delete from uv_fpo_wco_part;
+insert into uv_fpo_wco_part values ('[1,1]', '[2000-01-01,2004-01-01)', 0);
+create view uv_fpo_wco_part_view2 as
+  select * from uv_fpo_wco_part where lower(valid_at) < '2000-02-01'
+  with check option;
+delete from uv_fpo_wco_part_view2
+  for portion of valid_at from '2000-03-01' to '2000-07-01'
+  where lower(valid_at) < '2000-06-01';
+ERROR:  new row violates check option for view "uv_fpo_wco_part_view2"
+DETAIL:  Failing row contains ([1,2), [07-01-2000,01-01-2004), 0).
+-- The base table is unchanged:
+select tableoid::regclass, * from uv_fpo_wco_part order by valid_at;
+     tableoid     |  id   |        valid_at         | b 
+------------------+-------+-------------------------+---
+ uv_fpo_wco_part1 | [1,2) | [01-01-2000,01-01-2004) | 0
+(1 row)
+
+drop view uv_fpo_wco_part_view;
+drop view uv_fpo_wco_part_view2;
+drop table uv_fpo_wco_part;
 -- Test whole-row references to the view
 create table uv_iocu_tab (a int unique, b text);
 create view uv_iocu_view as
@@ -4119,3 +4266,132 @@ select * from base_tab order by a;
 
 drop view base_tab_view;
 drop table base_tab;
+-- FOR PORTION OF is not supported on views with INSTEAD OF triggers
+create view uv_fpo_instead_view as select id, valid_at, b from uv_fpo_tab;
+create function uv_fpo_instead_trig() returns trigger language plpgsql as
+$$ begin return null; end $$;
+create trigger uv_fpo_instead_upd_trig
+  instead of update on uv_fpo_instead_view
+  for each row execute function uv_fpo_instead_trig();
+create trigger uv_fpo_instead_del_trig
+  instead of delete on uv_fpo_instead_view
+  for each row execute function uv_fpo_instead_trig();
+update uv_fpo_instead_view
+  for portion of valid_at from '2015-01-01' to '2020-01-01'
+  set b = 99 where id = '[1,1]'; -- error
+ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
+delete from uv_fpo_instead_view
+  for portion of valid_at from '2017-01-01' to '2022-01-01'
+  where id = '[1,1]'; -- error
+ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
+-- The check does not depend on which rows match, so it errors even when
+-- no rows do.
+update uv_fpo_instead_view
+  for portion of valid_at from '2015-01-01' to '2020-01-01'
+  set b = 99 where id = '[9,9]'; -- error, even with no matching rows
+ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
+delete from uv_fpo_instead_view
+  for portion of valid_at from '2017-01-01' to '2022-01-01'
+  where id = '[9,9]'; -- error, even with no matching rows
+ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
+drop view uv_fpo_instead_view;
+drop function uv_fpo_instead_trig();
+-- FOR PORTION OF is not supported on views with DO ALSO rules either.  The
+-- rule action is built before we narrow the view's query, so it would see an
+-- unnarrowed NEW range and would not be limited to the targeted rows.
+create table uv_fpo_also_log (tag text, valid_at tsrange, b float);
+create view uv_fpo_also_view as select id, valid_at, b from uv_fpo_tab;
+create rule uv_fpo_also_upd as on update to uv_fpo_also_view
+  do also insert into uv_fpo_also_log values ('upd', new.valid_at, new.b);
+create rule uv_fpo_also_del as on delete to uv_fpo_also_view
+  do also insert into uv_fpo_also_log values ('del', old.valid_at, old.b);
+update uv_fpo_also_view
+  for portion of valid_at from '2015-01-01' to '2020-01-01'
+  set b = 99 where id = '[1,1]'; -- error
+ERROR:  views with DO ALSO rules do not support FOR PORTION OF
+delete from uv_fpo_also_view
+  for portion of valid_at from '2017-01-01' to '2022-01-01'
+  where id = '[1,1]'; -- error
+ERROR:  views with DO ALSO rules do not support FOR PORTION OF
+-- Without FOR PORTION OF the rules work as always.
+update uv_fpo_also_view set b = 98 where id = '[1,1]';
+delete from uv_fpo_also_view where id = '[1,1]';
+select * from uv_fpo_also_log order by tag, valid_at;
+ tag |                        valid_at                         | b  
+-----+---------------------------------------------------------+----
+ del | ["Fri Jan 01 00:00:00 2010","Thu Jan 01 00:00:00 2015") | 98
+ del | ["Thu Jan 01 00:00:00 2015","Sun Jan 01 00:00:00 2017") | 98
+ del | ["Sat Jan 01 00:00:00 2022","Tue Jan 01 00:00:00 2030") | 98
+ upd | ["Fri Jan 01 00:00:00 2010","Thu Jan 01 00:00:00 2015") | 98
+ upd | ["Thu Jan 01 00:00:00 2015","Sun Jan 01 00:00:00 2017") | 98
+ upd | ["Sat Jan 01 00:00:00 2022","Tue Jan 01 00:00:00 2030") | 98
+(6 rows)
+
+drop view uv_fpo_also_view;
+drop table uv_fpo_also_log;
+-- A DO ALSO rule on the base table is fine: the rule action is built from a
+-- query that already carries the FOR PORTION OF restriction.
+create table uv_fpo_also_tab (id int4range, valid_at tsrange, b float);
+insert into uv_fpo_also_tab values ('[1,1]', '[2000-01-01,2030-01-01)', 0);
+create table uv_fpo_also_tab_log (valid_at tsrange, b float);
+create rule uv_fpo_also_tab_upd as on update to uv_fpo_also_tab
+  do also insert into uv_fpo_also_tab_log values (new.valid_at, new.b);
+update uv_fpo_also_tab
+  for portion of valid_at from '2010-01-01' to '2020-01-01'
+  set b = 99 where id = '[1,1]';
+select * from uv_fpo_also_tab order by valid_at;
+  id   |                        valid_at                         | b  
+-------+---------------------------------------------------------+----
+ [1,2) | ["Sat Jan 01 00:00:00 2000","Fri Jan 01 00:00:00 2010") |  0
+ [1,2) | ["Fri Jan 01 00:00:00 2010","Wed Jan 01 00:00:00 2020") | 99
+ [1,2) | ["Wed Jan 01 00:00:00 2020","Tue Jan 01 00:00:00 2030") |  0
+(3 rows)
+
+select * from uv_fpo_also_tab_log order by valid_at;
+                        valid_at                         | b  
+---------------------------------------------------------+----
+ ["Fri Jan 01 00:00:00 2010","Wed Jan 01 00:00:00 2020") | 99
+(1 row)
+
+drop table uv_fpo_also_tab, uv_fpo_also_tab_log;
+-- Forbid INSTEAD OF triggers with FOR PORTION OF even if the FOR PORTION OF
+-- statement is parsed before the trigger exists.
+-- This can happen in at least a couple ways: a rewrite rule or a BEGIN ATOMIC function.
+create function uv_fpo_instead_trig() returns trigger language plpgsql as
+$$ begin return new; end $$;
+-- via a rewrite rule
+create table uv_fpo_rule_tab (id int4range, valid_at tsrange, b float);
+insert into uv_fpo_rule_tab values ('[1,1]', '[2000-01-01, 2030-01-01)', 0);
+create view uv_fpo_rule_view as select id, valid_at, b from uv_fpo_rule_tab;
+create rule uv_fpo_rule as on insert to uv_fpo_rule_tab do also
+  update uv_fpo_rule_view
+    for portion of valid_at from '2010-01-01' to '2020-01-01'
+    set b = 99 where id = '[1,1]';
+create trigger uv_fpo_rule_instead
+  instead of update on uv_fpo_rule_view
+  for each row execute function uv_fpo_instead_trig();
+-- Would crash if we checked at parse-time:
+insert into uv_fpo_rule_tab values ('[2,2]', '[2000-01-01,2030-01-01)', 0);
+ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
+drop table uv_fpo_rule_tab cascade;
+NOTICE:  drop cascades to view uv_fpo_rule_view
+-- via a BEGIN ATOMIC function body
+create table uv_fpo_atomic_tab (id int4range, valid_at tsrange, b float);
+insert into uv_fpo_atomic_tab values ('[1,1]', '[2000-01-01, 2030-01-01)', 0);
+create view uv_fpo_atomic_view as select id, valid_at, b from uv_fpo_atomic_tab;
+create function uv_fpo_atomic_fn() returns void language sql begin atomic
+  update uv_fpo_atomic_view
+    for portion of valid_at from '2010-01-01' to '2020-01-01'
+    set b = 99 where id = '[1,1]';
+end;
+create trigger uv_fpo_atomic_instead
+  instead of update on uv_fpo_atomic_view
+  for each row execute function uv_fpo_instead_trig();
+-- Would crash if we checked at parse-time:
+select uv_fpo_atomic_fn();
+ERROR:  views with INSTEAD OF triggers do not support FOR PORTION OF
+CONTEXT:  SQL function "uv_fpo_atomic_fn" statement 1
+drop function uv_fpo_atomic_fn();
+drop table uv_fpo_atomic_tab cascade;
+NOTICE:  drop cascades to view uv_fpo_atomic_view
+drop function uv_fpo_instead_trig();
diff --git a/src/test/regress/expected/without_overlaps.out b/src/test/regress/expected/without_overlaps.out
index 8e3f6eee0b0..de2f8bc4786 100644
--- a/src/test/regress/expected/without_overlaps.out
+++ b/src/test/regress/expected/without_overlaps.out
@@ -2,7 +2,7 @@
 --
 -- We leave behind several tables to test pg_dump etc:
 -- temporal_rng, temporal_rng2,
--- temporal_fk_rng2rng.
+-- temporal_fk_rng2rng, temporal_fk2_rng2rng.
 SET datestyle TO ISO, YMD;
 --
 -- test input parser
@@ -931,6 +931,36 @@ INSERT INTO temporal3 (id, valid_at, id2, name)
   ('[1,2)', daterange('2000-01-01', '2010-01-01'), '[7,8)', 'foo'),
   ('[2,3)', daterange('2000-01-01', '2010-01-01'), '[9,10)', 'bar')
 ;
+UPDATE temporal3 FOR PORTION OF valid_at FROM '2000-05-01' TO '2000-07-01'
+  SET name = name || '1';
+UPDATE temporal3 FOR PORTION OF valid_at FROM '2000-04-01' TO '2000-06-01'
+  SET name = name || '2'
+  WHERE id = '[2,3)';
+SELECT * FROM temporal3 ORDER BY id, valid_at;
+  id   |        valid_at         |  id2   | name  
+-------+-------------------------+--------+-------
+ [1,2) | [2000-01-01,2000-05-01) | [7,8)  | foo
+ [1,2) | [2000-05-01,2000-07-01) | [7,8)  | foo1
+ [1,2) | [2000-07-01,2010-01-01) | [7,8)  | foo
+ [2,3) | [2000-01-01,2000-04-01) | [9,10) | bar
+ [2,3) | [2000-04-01,2000-05-01) | [9,10) | bar2
+ [2,3) | [2000-05-01,2000-06-01) | [9,10) | bar12
+ [2,3) | [2000-06-01,2000-07-01) | [9,10) | bar1
+ [2,3) | [2000-07-01,2010-01-01) | [9,10) | bar
+(8 rows)
+
+-- conflicting id only:
+INSERT INTO temporal3 (id, valid_at, id2, name)
+  VALUES
+  ('[1,2)', daterange('2005-01-01', '2006-01-01'), '[8,9)', 'foo3');
+ERROR:  conflicting key value violates exclusion constraint "temporal3_pk"
+DETAIL:  Key (id, valid_at)=([1,2), [2005-01-01,2006-01-01)) conflicts with existing key (id, valid_at)=([1,2), [2000-07-01,2010-01-01)).
+-- conflicting id2 only:
+INSERT INTO temporal3 (id, valid_at, id2, name)
+  VALUES
+  ('[3,4)', daterange('2005-01-01', '2010-01-01'), '[9,10)', 'bar3');
+ERROR:  conflicting key value violates exclusion constraint "temporal3_uniq"
+DETAIL:  Key (id2, valid_at)=([9,10), [2005-01-01,2010-01-01)) conflicts with existing key (id2, valid_at)=([9,10), [2000-07-01,2010-01-01)).
 DROP TABLE temporal3;
 --
 -- test changing the PK's dependencies
@@ -962,26 +992,43 @@ INSERT INTO temporal_partitioned (id, valid_at, name) VALUES
   ('[1,2)', daterange('2000-01-01', '2000-02-01'), 'one'),
   ('[1,2)', daterange('2000-02-01', '2000-03-01'), 'one'),
   ('[3,4)', daterange('2000-01-01', '2010-01-01'), 'three');
-SELECT * FROM temporal_partitioned ORDER BY id, valid_at;
-  id   |        valid_at         | name  
--------+-------------------------+-------
- [1,2) | [2000-01-01,2000-02-01) | one
- [1,2) | [2000-02-01,2000-03-01) | one
- [3,4) | [2000-01-01,2010-01-01) | three
+SELECT tableoid::regclass, * FROM temporal_partitioned ORDER BY id, valid_at;
+ tableoid |  id   |        valid_at         | name  
+----------+-------+-------------------------+-------
+ tp1      | [1,2) | [2000-01-01,2000-02-01) | one
+ tp1      | [1,2) | [2000-02-01,2000-03-01) | one
+ tp2      | [3,4) | [2000-01-01,2010-01-01) | three
 (3 rows)
 
-SELECT * FROM tp1 ORDER BY id, valid_at;
-  id   |        valid_at         | name 
--------+-------------------------+------
- [1,2) | [2000-01-01,2000-02-01) | one
- [1,2) | [2000-02-01,2000-03-01) | one
-(2 rows)
-
-SELECT * FROM tp2 ORDER BY id, valid_at;
-  id   |        valid_at         | name  
--------+-------------------------+-------
- [3,4) | [2000-01-01,2010-01-01) | three
-(1 row)
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-01-15' TO '2000-02-15'
+  SET name = 'one2'
+  WHERE id = '[1,2)';
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-02-20' TO '2000-02-25'
+  SET id = '[4,5)'
+  WHERE name = 'one';
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET id = '[2,3)'
+  WHERE name = 'three';
+DELETE FROM temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-01-15' TO '2000-02-15'
+  WHERE id = '[3,4)';
+SELECT tableoid::regclass, * FROM temporal_partitioned ORDER BY id, valid_at;
+ tableoid |  id   |        valid_at         | name  
+----------+-------+-------------------------+-------
+ tp1      | [1,2) | [2000-01-01,2000-01-15) | one
+ tp1      | [1,2) | [2000-01-15,2000-02-01) | one2
+ tp1      | [1,2) | [2000-02-01,2000-02-15) | one2
+ tp1      | [1,2) | [2000-02-15,2000-02-20) | one
+ tp1      | [1,2) | [2000-02-25,2000-03-01) | one
+ tp1      | [2,3) | [2002-01-01,2003-01-01) | three
+ tp2      | [3,4) | [2000-01-01,2000-01-15) | three
+ tp2      | [3,4) | [2000-02-15,2002-01-01) | three
+ tp2      | [3,4) | [2003-01-01,2010-01-01) | three
+ tp2      | [4,5) | [2000-02-20,2000-02-25) | one
+(10 rows)
 
 DROP TABLE temporal_partitioned;
 -- temporal UNIQUE:
@@ -997,26 +1044,43 @@ INSERT INTO temporal_partitioned (id, valid_at, name) VALUES
   ('[1,2)', daterange('2000-01-01', '2000-02-01'), 'one'),
   ('[1,2)', daterange('2000-02-01', '2000-03-01'), 'one'),
   ('[3,4)', daterange('2000-01-01', '2010-01-01'), 'three');
-SELECT * FROM temporal_partitioned ORDER BY id, valid_at;
-  id   |        valid_at         | name  
--------+-------------------------+-------
- [1,2) | [2000-01-01,2000-02-01) | one
- [1,2) | [2000-02-01,2000-03-01) | one
- [3,4) | [2000-01-01,2010-01-01) | three
+SELECT tableoid::regclass, * FROM temporal_partitioned ORDER BY id, valid_at;
+ tableoid |  id   |        valid_at         | name  
+----------+-------+-------------------------+-------
+ tp1      | [1,2) | [2000-01-01,2000-02-01) | one
+ tp1      | [1,2) | [2000-02-01,2000-03-01) | one
+ tp2      | [3,4) | [2000-01-01,2010-01-01) | three
 (3 rows)
 
-SELECT * FROM tp1 ORDER BY id, valid_at;
-  id   |        valid_at         | name 
--------+-------------------------+------
- [1,2) | [2000-01-01,2000-02-01) | one
- [1,2) | [2000-02-01,2000-03-01) | one
-(2 rows)
-
-SELECT * FROM tp2 ORDER BY id, valid_at;
-  id   |        valid_at         | name  
--------+-------------------------+-------
- [3,4) | [2000-01-01,2010-01-01) | three
-(1 row)
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-01-15' TO '2000-02-15'
+  SET name = 'one2'
+  WHERE id = '[1,2)';
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-02-20' TO '2000-02-25'
+  SET id = '[4,5)'
+  WHERE name = 'one';
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET id = '[2,3)'
+  WHERE name = 'three';
+DELETE FROM temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-01-15' TO '2000-02-15'
+  WHERE id = '[3,4)';
+SELECT tableoid::regclass, * FROM temporal_partitioned ORDER BY id, valid_at;
+ tableoid |  id   |        valid_at         | name  
+----------+-------+-------------------------+-------
+ tp1      | [1,2) | [2000-01-01,2000-01-15) | one
+ tp1      | [1,2) | [2000-01-15,2000-02-01) | one2
+ tp1      | [1,2) | [2000-02-01,2000-02-15) | one2
+ tp1      | [1,2) | [2000-02-15,2000-02-20) | one
+ tp1      | [1,2) | [2000-02-25,2000-03-01) | one
+ tp1      | [2,3) | [2002-01-01,2003-01-01) | three
+ tp2      | [3,4) | [2000-01-01,2000-01-15) | three
+ tp2      | [3,4) | [2000-02-15,2002-01-01) | three
+ tp2      | [3,4) | [2003-01-01,2010-01-01) | three
+ tp2      | [4,5) | [2000-02-20,2000-02-25) | one
+(10 rows)
 
 DROP TABLE temporal_partitioned;
 -- ALTER TABLE REPLICA IDENTITY
@@ -1797,6 +1861,33 @@ UPDATE temporal_rng SET id = '[7,8)'
   WHERE id = '[5,6)' AND valid_at = daterange('2018-01-01', '2018-02-01');
 ERROR:  update or delete on table "temporal_rng" violates foreign key constraint "temporal_fk_rng2rng_fk" on table "temporal_fk_rng2rng"
 DETAIL:  Key (id, valid_at)=([5,6), [2018-01-01,2018-02-01)) is still referenced from table "temporal_fk_rng2rng".
+-- changing an unreferenced part is okay:
+UPDATE temporal_rng
+  FOR PORTION OF valid_at FROM '2018-01-02' TO '2018-01-03'
+  SET id = '[7,8)'
+  WHERE id = '[5,6)';
+-- changing just a part fails:
+UPDATE temporal_rng
+  FOR PORTION OF valid_at FROM '2018-01-05' TO '2018-01-10'
+  SET id = '[7,8)'
+  WHERE id = '[5,6)';
+ERROR:  update or delete on table "temporal_rng" violates foreign key constraint "temporal_fk_rng2rng_fk" on table "temporal_fk_rng2rng"
+DETAIL:  Key (id, valid_at)=([5,6), [2018-01-03,2018-02-01)) is still referenced from table "temporal_fk_rng2rng".
+SELECT * FROM temporal_rng WHERE id in ('[5,6)', '[7,8)') ORDER BY id, valid_at;
+  id   |        valid_at         
+-------+-------------------------
+ [5,6) | [2016-02-01,2016-03-01)
+ [5,6) | [2018-01-01,2018-01-02)
+ [5,6) | [2018-01-03,2018-02-01)
+ [7,8) | [2018-01-02,2018-01-03)
+(4 rows)
+
+SELECT * FROM temporal_fk_rng2rng WHERE id in ('[3,4)') ORDER BY id, valid_at;
+  id   |        valid_at         | parent_id 
+-------+-------------------------+-----------
+ [3,4) | [2018-01-05,2018-01-10) | [5,6)
+(1 row)
+
 -- then delete the objecting FK record and the same PK update succeeds:
 DELETE FROM temporal_fk_rng2rng WHERE id = '[3,4)';
 UPDATE temporal_rng SET valid_at = daterange('2016-01-01', '2016-02-01')
@@ -1844,6 +1935,42 @@ BEGIN;
 COMMIT;
 ERROR:  update or delete on table "temporal_rng" violates foreign key constraint "temporal_fk_rng2rng_fk" on table "temporal_fk_rng2rng"
 DETAIL:  Key (id, valid_at)=([5,6), [2018-01-01,2018-02-01)) is still referenced from table "temporal_fk_rng2rng".
+-- deleting an unreferenced part is okay:
+DELETE FROM temporal_rng
+FOR PORTION OF valid_at FROM '2018-01-02' TO '2018-01-03'
+WHERE id = '[5,6)';
+SELECT * FROM temporal_rng WHERE id in ('[5,6)', '[7,8)') ORDER BY id, valid_at;
+  id   |        valid_at         
+-------+-------------------------
+ [5,6) | [2018-01-01,2018-01-02)
+ [5,6) | [2018-01-03,2018-02-01)
+(2 rows)
+
+SELECT * FROM temporal_fk_rng2rng WHERE id in ('[3,4)') ORDER BY id, valid_at;
+  id   |        valid_at         | parent_id 
+-------+-------------------------+-----------
+ [3,4) | [2018-01-05,2018-01-10) | [5,6)
+(1 row)
+
+-- deleting just a part fails:
+DELETE FROM temporal_rng
+FOR PORTION OF valid_at FROM '2018-01-05' TO '2018-01-10'
+WHERE id = '[5,6)';
+ERROR:  update or delete on table "temporal_rng" violates foreign key constraint "temporal_fk_rng2rng_fk" on table "temporal_fk_rng2rng"
+DETAIL:  Key (id, valid_at)=([5,6), [2018-01-03,2018-02-01)) is still referenced from table "temporal_fk_rng2rng".
+SELECT * FROM temporal_rng WHERE id in ('[5,6)', '[7,8)') ORDER BY id, valid_at;
+  id   |        valid_at         
+-------+-------------------------
+ [5,6) | [2018-01-01,2018-01-02)
+ [5,6) | [2018-01-03,2018-02-01)
+(2 rows)
+
+SELECT * FROM temporal_fk_rng2rng WHERE id in ('[3,4)') ORDER BY id, valid_at;
+  id   |        valid_at         | parent_id 
+-------+-------------------------+-----------
+ [3,4) | [2018-01-05,2018-01-10) | [5,6)
+(1 row)
+
 -- then delete the objecting FK record and the same PK delete succeeds:
 DELETE FROM temporal_fk_rng2rng WHERE id = '[3,4)';
 DELETE FROM temporal_rng WHERE id = '[5,6)' AND valid_at = daterange('2018-01-01', '2018-02-01');
@@ -1860,11 +1987,12 @@ ALTER TABLE temporal_fk_rng2rng
   ON DELETE RESTRICT;
 ERROR:  unsupported ON DELETE action for foreign key constraint using PERIOD
 --
--- test ON UPDATE/DELETE options
+-- rng2rng test ON UPDATE/DELETE options
 --
 -- test FK referenced updates CASCADE
+TRUNCATE temporal_rng, temporal_fk_rng2rng;
 INSERT INTO temporal_rng (id, valid_at) VALUES ('[6,7)', daterange('2018-01-01', '2021-01-01'));
-INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[4,5)', daterange('2018-01-01', '2021-01-01'), '[6,7)');
+INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[100,101)', daterange('2018-01-01', '2021-01-01'), '[6,7)');
 ALTER TABLE temporal_fk_rng2rng
   ADD CONSTRAINT temporal_fk_rng2rng_fk
     FOREIGN KEY (parent_id, PERIOD valid_at)
@@ -1872,8 +2000,9 @@ ALTER TABLE temporal_fk_rng2rng
     ON DELETE CASCADE ON UPDATE CASCADE;
 ERROR:  unsupported ON UPDATE action for foreign key constraint using PERIOD
 -- test FK referenced updates SET NULL
-INSERT INTO temporal_rng (id, valid_at) VALUES ('[9,10)', daterange('2018-01-01', '2021-01-01'));
-INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[6,7)', daterange('2018-01-01', '2021-01-01'), '[9,10)');
+TRUNCATE temporal_rng, temporal_fk_rng2rng;
+INSERT INTO temporal_rng (id, valid_at) VALUES ('[6,7)', daterange('2018-01-01', '2021-01-01'));
+INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[100,101)', daterange('2018-01-01', '2021-01-01'), '[6,7)');
 ALTER TABLE temporal_fk_rng2rng
   ADD CONSTRAINT temporal_fk_rng2rng_fk
     FOREIGN KEY (parent_id, PERIOD valid_at)
@@ -1881,9 +2010,10 @@ ALTER TABLE temporal_fk_rng2rng
     ON DELETE SET NULL ON UPDATE SET NULL;
 ERROR:  unsupported ON UPDATE action for foreign key constraint using PERIOD
 -- test FK referenced updates SET DEFAULT
+TRUNCATE temporal_rng, temporal_fk_rng2rng;
 INSERT INTO temporal_rng (id, valid_at) VALUES ('[-1,-1]', daterange(null, null));
-INSERT INTO temporal_rng (id, valid_at) VALUES ('[12,13)', daterange('2018-01-01', '2021-01-01'));
-INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[8,9)', daterange('2018-01-01', '2021-01-01'), '[12,13)');
+INSERT INTO temporal_rng (id, valid_at) VALUES ('[6,7)', daterange('2018-01-01', '2021-01-01'));
+INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[100,101)', daterange('2018-01-01', '2021-01-01'), '[6,7)');
 ALTER TABLE temporal_fk_rng2rng
   ALTER COLUMN parent_id SET DEFAULT '[-1,-1]',
   ADD CONSTRAINT temporal_fk_rng2rng_fk
@@ -2253,6 +2383,22 @@ UPDATE temporal_mltrng SET id = '[7,8)'
   WHERE id = '[5,6)' AND valid_at = datemultirange(daterange('2018-01-01', '2018-02-01'));
 ERROR:  update or delete on table "temporal_mltrng" violates foreign key constraint "temporal_fk_mltrng2mltrng_fk" on table "temporal_fk_mltrng2mltrng"
 DETAIL:  Key (id, valid_at)=([5,6), {[2018-01-01,2018-02-01)}) is still referenced from table "temporal_fk_mltrng2mltrng".
+-- changing an unreferenced part is okay:
+UPDATE temporal_mltrng
+  FOR PORTION OF valid_at (datemultirange(daterange('2018-01-02', '2018-01-03')))
+  SET id = '[7,8)'
+  WHERE id = '[5,6)';
+-- changing just a part fails:
+UPDATE temporal_mltrng
+  FOR PORTION OF valid_at (datemultirange(daterange('2018-01-05', '2018-01-10')))
+  SET id = '[7,8)'
+  WHERE id = '[5,6)';
+ERROR:  update or delete on table "temporal_mltrng" violates foreign key constraint "temporal_fk_mltrng2mltrng_fk" on table "temporal_fk_mltrng2mltrng"
+DETAIL:  Key (id, valid_at)=([5,6), {[2018-01-01,2018-01-02),[2018-01-03,2018-02-01)}) is still referenced from table "temporal_fk_mltrng2mltrng".
+-- then delete the objecting FK record and the same PK update succeeds:
+DELETE FROM temporal_fk_mltrng2mltrng WHERE id = '[3,4)';
+UPDATE temporal_mltrng SET id = '[7,8)'
+  WHERE id = '[5,6)' AND valid_at = datemultirange(daterange('2018-01-01', '2018-02-01'));
 --
 -- test FK referenced updates RESTRICT
 --
@@ -2295,6 +2441,19 @@ BEGIN;
 COMMIT;
 ERROR:  update or delete on table "temporal_mltrng" violates foreign key constraint "temporal_fk_mltrng2mltrng_fk" on table "temporal_fk_mltrng2mltrng"
 DETAIL:  Key (id, valid_at)=([5,6), {[2018-01-01,2018-02-01)}) is still referenced from table "temporal_fk_mltrng2mltrng".
+-- deleting an unreferenced part is okay:
+DELETE FROM temporal_mltrng
+FOR PORTION OF valid_at (datemultirange(daterange('2018-01-02', '2018-01-03')))
+WHERE id = '[5,6)';
+-- deleting just a part fails:
+DELETE FROM temporal_mltrng
+FOR PORTION OF valid_at (datemultirange(daterange('2018-01-05', '2018-01-10')))
+WHERE id = '[5,6)';
+ERROR:  update or delete on table "temporal_mltrng" violates foreign key constraint "temporal_fk_mltrng2mltrng_fk" on table "temporal_fk_mltrng2mltrng"
+DETAIL:  Key (id, valid_at)=([5,6), {[2018-01-01,2018-01-02),[2018-01-03,2018-02-01)}) is still referenced from table "temporal_fk_mltrng2mltrng".
+-- then delete the objecting FK record and the same PK delete succeeds:
+DELETE FROM temporal_fk_mltrng2mltrng WHERE id = '[3,4)';
+DELETE FROM temporal_mltrng WHERE id = '[5,6)' AND valid_at = datemultirange(daterange('2018-01-01', '2018-02-01'));
 --
 -- FK between partitioned tables: ranges
 --
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 75063f87a4a..ba01fb8a450 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -48,7 +48,7 @@ test: create_index create_index_spgist create_view index_including index_includi
 # ----------
 # Another group of parallel tests
 # ----------
-test: create_aggregate create_function_sql create_cast constraints triggers select inherit typed_table vacuum drop_if_exists updatable_views roleattributes create_am hash_func errors infinite_recurse
+test: create_aggregate create_function_sql create_cast constraints triggers select inherit typed_table vacuum drop_if_exists updatable_views roleattributes create_am hash_func errors infinite_recurse for_portion_of
 
 # ----------
 # sanity_check does a vacuum, affecting the sort order of SELECT *
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
new file mode 100644
index 00000000000..7cc97f448f8
--- /dev/null
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -0,0 +1,2750 @@
+-- Tests for UPDATE/DELETE FOR PORTION OF
+
+SET datestyle TO ISO, YMD;
+
+-- Works on non-PK columns
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text NOT NULL
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-02,2020-01-01)', 'one');
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01'
+  SET name = 'one^1';
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-01-15' TO '2019-01-20';
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+
+-- With a table alias with AS
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-02-01' TO '2019-02-03' AS t
+  SET name = 'one^2';
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-02-03' TO '2019-02-04' AS t;
+
+-- With a table alias without AS
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-02-04' TO '2019-02-05' t
+  SET name = 'one^3';
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-02-05' TO '2019-02-06' t;
+
+-- UPDATE with FROM
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-03-01' to '2019-03-02'
+  SET name = 'one^4'
+  FROM (SELECT '[1,2)'::int4range) AS t2(id)
+  WHERE for_portion_of_test.id = t2.id;
+
+-- DELETE with USING
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2019-03-02' TO '2019-03-03'
+  USING (SELECT '[1,2)'::int4range) AS t2(id)
+  WHERE for_portion_of_test.id = t2.id;
+
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+
+-- Works on more than one range
+DROP TABLE for_portion_of_test;
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid1_at daterange,
+  valid2_at daterange,
+  name text NOT NULL
+);
+INSERT INTO for_portion_of_test (id, valid1_at, valid2_at, name) VALUES
+  ('[1,2)', '[2018-01-02,2018-02-03)', '[2015-01-01,2025-01-01)', 'one');
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid1_at FROM '2018-01-15' TO NULL
+  SET name = 'foo';
+SELECT * FROM for_portion_of_test ORDER BY id, valid1_at, valid2_at;
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid2_at FROM '2018-01-15' TO NULL
+  SET name = 'bar';
+SELECT * FROM for_portion_of_test ORDER BY id, valid1_at, valid2_at;
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid1_at FROM '2018-01-20' TO NULL;
+SELECT * FROM for_portion_of_test ORDER BY id, valid1_at, valid2_at;
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid2_at FROM '2018-01-20' TO NULL;
+SELECT * FROM for_portion_of_test ORDER BY id, valid1_at, valid2_at;
+
+-- Test with NULLs in the scalar/range key columns.
+-- This won't happen if there is a PRIMARY KEY or UNIQUE constraint
+-- but FOR PORTION OF shouldn't require that.
+DROP TABLE for_portion_of_test;
+CREATE UNLOGGED TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', NULL, '1 null'),
+  ('[1,2)', '(,)', '1 unbounded'),
+  ('[1,2)', 'empty', '1 empty'),
+  (NULL, NULL, NULL),
+  (NULL, daterange('2018-01-01', '2019-01-01'), 'null key');
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO NULL
+  SET name = 'NULL to NULL';
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+
+DROP TABLE for_portion_of_test;
+
+--
+-- UPDATE tests
+--
+
+CREATE TABLE for_portion_of_test (
+  id int4range NOT NULL,
+  valid_at daterange NOT NULL,
+  name text NOT NULL,
+  CONSTRAINT for_portion_of_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-02,2018-02-03)', 'one'),
+  ('[1,2)', '[2018-02-03,2018-03-03)', 'one'),
+  ('[1,2)', '[2018-03-03,2018-04-04)', 'one'),
+  ('[2,3)', '[2018-01-01,2018-01-05)', 'two'),
+  ('[3,4)', '[2018-01-01,)', 'three'),
+  ('[4,5)', '(,2018-04-01)', 'four'),
+  ('[5,6)', '(,)', 'five')
+  ;
+\set QUIET false
+
+-- Updating with a missing column fails
+UPDATE for_portion_of_test
+  FOR PORTION OF invalid_at FROM '2018-06-01' TO NULL
+  SET name = 'foo'
+  WHERE id = '[5,6)';
+
+-- Updating the range fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO NULL
+  SET valid_at = '[1990-01-01,1999-01-01)'
+  WHERE id = '[5,6)';
+
+-- The wrong start type fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM 1 TO '2020-01-01'
+  SET name = 'nope'
+  WHERE id = '[3,4)';
+
+-- The wrong end type fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2000-01-01' TO 4
+  SET name = 'nope'
+  WHERE id = '[3,4)';
+
+-- Updating with timestamps reversed fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO '2018-01-01'
+  SET name = 'three^1'
+  WHERE id = '[3,4)';
+
+-- Updating with a subquery fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM (SELECT '2018-01-01') TO '2018-06-01'
+  SET name = 'nope'
+  WHERE id = '[3,4)';
+
+-- Updating with a column fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM lower(valid_at) TO NULL
+  SET name = 'nope'
+  WHERE id = '[3,4)';
+-- and so does a whole-row reference, which takes a different path
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at ((ROW(for_portion_of_test.*)).f2)
+  SET name = 'nope'
+  WHERE id = '[3,4)';
+
+-- Updating with timestamps equal does nothing
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-04-01'
+  SET name = 'three^0'
+  WHERE id = '[3,4)';
+
+-- Updating a finite/open portion with a finite/open target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO NULL
+  SET name = 'three^1'
+  WHERE id = '[3,4)';
+SELECT * FROM for_portion_of_test WHERE id = '[3,4)' ORDER BY id, valid_at;
+
+-- Updating a finite/open portion with an open/finite target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-03-01'
+  SET name = 'three^2'
+  WHERE id = '[3,4)';
+SELECT * FROM for_portion_of_test WHERE id = '[3,4)' ORDER BY id, valid_at;
+
+-- Updating an open/finite portion with an open/finite target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-02-01'
+  SET name = 'four^1'
+  WHERE id = '[4,5)';
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+
+-- Updating an open/finite portion with a finite/open target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2017-01-01' TO NULL
+  SET name = 'four^2'
+  WHERE id = '[4,5)';
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+
+-- Updating a finite/finite portion with an exact fit
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2017-01-01' TO '2018-02-01'
+  SET name = 'four^3'
+  WHERE id = '[4,5)';
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+
+-- Updating an enclosed span
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO NULL
+  SET name = 'two^2'
+  WHERE id = '[2,3)';
+SELECT * FROM for_portion_of_test WHERE id = '[2,3)' ORDER BY id, valid_at;
+
+-- Updating an open/open portion with a finite/finite target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-01-01' TO '2019-01-01'
+  SET name = 'five^1'
+  WHERE id = '[5,6)';
+SELECT * FROM for_portion_of_test WHERE id = '[5,6)' ORDER BY id, valid_at;
+
+-- Updating an enclosed span with separate protruding spans
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2017-01-01' TO '2020-01-01'
+  SET name = 'five^2'
+  WHERE id = '[5,6)';
+SELECT * FROM for_portion_of_test WHERE id = '[5,6)' ORDER BY id, valid_at;
+
+-- Updating multiple enclosed spans
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO NULL
+  SET name = 'one^2'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+
+-- Updating with a direct target
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at (daterange('2018-03-10', '2018-03-15'))
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+
+-- Updating with a direct target, coerced from a string
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at ('[2018-03-15,2018-03-17)')
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+
+-- Updating with a direct target of the wrong range subtype fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at (int4range(1, 4))
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+
+-- Updating with a direct target of a non-rangetype fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at (4)
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+
+-- Updating with a direct target of NULL fails
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at (NULL)
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+
+-- Updating with a direct target of empty does nothing
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at ('empty')
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+
+-- Updating the non-range part of the PK:
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-15' TO NULL
+  SET id = '[6,7)'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test WHERE id IN ('[1,2)', '[6,7)') ORDER BY id, valid_at;
+
+-- UPDATE with no WHERE clause
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2030-01-01' TO NULL
+  SET name = name || '*';
+
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+\set QUIET true
+
+-- Updating with a shift/reduce conflict
+-- (requires a tsrange column)
+CREATE UNLOGGED TABLE for_portion_of_test2 (
+  id int4range,
+  valid_at tsrange,
+  name text
+);
+INSERT INTO for_portion_of_test2 (id, valid_at, name) VALUES
+  ('[1,2)', '[2000-01-01,2020-01-01)', 'one');
+-- updates [2011-03-01 01:02:00, 2012-01-01) (note 2 minutes)
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at
+    FROM '2011-03-01'::timestamp + INTERVAL '1:02:03' HOUR TO MINUTE
+    TO '2012-01-01'
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+
+-- TO is used for the bound but not the INTERVAL:
+-- syntax error
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at
+    FROM '2013-03-01'::timestamp + INTERVAL '1:02:03' HOUR
+    TO '2014-01-01'
+  SET name = 'one^2'
+  WHERE id = '[1,2)';
+
+-- adding parens fixes it
+-- updates [2015-03-01 01:00:00, 2016-01-01) (no minutes)
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at
+    FROM ('2015-03-01'::timestamp + INTERVAL '1:02:03' HOUR)
+    TO '2016-01-01'
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+DROP TABLE for_portion_of_test2;
+
+-- UPDATE FOR PORTION OF in a CTE:
+-- The outer query sees the table how it was before the updates,
+-- and with no leftovers yet,
+-- but it also sees the new values via the RETURNING clause.
+-- (We test RETURNING more directly, without a CTE, below.)
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[10,11)', '[2018-01-01,2020-01-01)', 'ten');
+WITH update_apr AS (
+  UPDATE for_portion_of_test
+    FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-05-01'
+    SET name = 'Apr 2018'
+    WHERE id = '[10,11)'
+    RETURNING id, valid_at, name
+)
+SELECT *
+  FROM for_portion_of_test AS t, update_apr
+  WHERE t.id = update_apr.id;
+SELECT * FROM for_portion_of_test WHERE id = '[10,11)' ORDER BY id, valid_at;
+
+-- UPDATE FOR PORTION OF with current_date
+-- (We take care not to make the expectation depend on the timestamp.)
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[99,100)', '[2000-01-01,)', 'foo');
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM current_date TO null
+  SET name = 'bar'
+  WHERE id = '[99,100)';
+SELECT name, lower(valid_at) FROM for_portion_of_test
+  WHERE id = '[99,100)' AND valid_at @> current_date - 1;
+SELECT name, upper(valid_at) FROM for_portion_of_test
+  WHERE id = '[99,100)' AND valid_at @> current_date + 1;
+
+-- UPDATE FOR PORTION OF with clock_timestamp()
+-- fails because the function is volatile:
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM clock_timestamp()::date TO null
+  SET name = 'baz'
+  WHERE id = '[99,100)';
+
+-- clean up:
+DELETE FROM for_portion_of_test WHERE id = '[99,100)';
+
+-- Not visible to UPDATE:
+-- Tuples updated/inserted within the CTE are not visible to the main query yet,
+-- but neither are old tuples the CTE changed:
+-- (This is the same behavior as without FOR PORTION OF.)
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[11,12)', '[2018-01-01,2020-01-01)', 'eleven');
+WITH update_apr AS (
+  UPDATE for_portion_of_test
+    FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-05-01'
+    SET name = 'Apr 2018'
+    WHERE id = '[11,12)'
+    RETURNING id, valid_at, name
+)
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  AS t
+  SET name = 'May 2018'
+  FROM update_apr AS j
+  WHERE t.id = j.id;
+SELECT * FROM for_portion_of_test WHERE id = '[11,12)' ORDER BY id, valid_at;
+DELETE FROM for_portion_of_test WHERE id IN ('[10,11)', '[11,12)');
+
+-- UPDATE FOR PORTION OF in a PL/pgSQL function
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[10,11)', '[2018-01-01,2020-01-01)', 'ten');
+CREATE FUNCTION fpo_update(_id int4range, _target_from date, _target_til date)
+RETURNS void LANGUAGE plpgsql AS
+$$
+BEGIN
+  UPDATE for_portion_of_test
+    FOR PORTION OF valid_at FROM $2 TO $3
+    SET name = concat(_target_from::text, ' to ', _target_til::text)
+    WHERE id = $1;
+END;
+$$;
+SELECT fpo_update('[10,11)', '2015-01-01', '2019-01-01');
+SELECT * FROM for_portion_of_test WHERE id = '[10,11)';
+
+-- UPDATE FOR PORTION OF in a compiled SQL function
+CREATE FUNCTION fpo_update()
+RETURNS text
+BEGIN ATOMIC
+  UPDATE for_portion_of_test
+    FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01'
+    SET name = 'one^1'
+    RETURNING name;
+END;
+\sf+ fpo_update()
+CREATE OR REPLACE function fpo_update()
+RETURNS text
+BEGIN ATOMIC
+  UPDATE for_portion_of_test
+    FOR PORTION OF valid_at (daterange('2018-01-15', '2020-01-01') * daterange('2019-01-01', '2022-01-01'))
+    SET name = 'one^1'
+    RETURNING name;
+END;
+\sf+ fpo_update()
+DROP FUNCTION fpo_update();
+
+DROP TABLE for_portion_of_test;
+
+--
+-- DELETE tests
+--
+
+CREATE TABLE for_portion_of_test (
+  id int4range NOT NULL,
+  valid_at daterange NOT NULL,
+  name text NOT NULL,
+  CONSTRAINT for_portion_of_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-02,2018-02-03)', 'one'),
+  ('[1,2)', '[2018-02-03,2018-03-03)', 'one'),
+  ('[1,2)', '[2018-03-03,2018-04-04)', 'one'),
+  ('[2,3)', '[2018-01-01,2018-01-05)', 'two'),
+  ('[3,4)', '[2018-01-01,)', 'three'),
+  ('[4,5)', '(,2018-04-01)', 'four'),
+  ('[5,6)', '(,)', 'five'),
+  ('[6,7)', '[2018-01-01,)', 'six'),
+  ('[7,8)', '(,2018-04-01)', 'seven'),
+  ('[8,9)', '[2018-01-02,2018-02-03)', 'eight'),
+  ('[8,9)', '[2018-02-03,2018-03-03)', 'eight'),
+  ('[8,9)', '[2018-03-03,2018-04-04)', 'eight')
+  ;
+\set QUIET false
+
+-- Deleting with a missing column fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF invalid_at FROM '2018-06-01' TO NULL
+  WHERE id = '[5,6)';
+
+-- The wrong start type fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM 1 TO '2020-01-01'
+  WHERE id = '[3,4)';
+
+-- The wrong end type fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2000-01-01' TO 4
+  WHERE id = '[3,4)';
+
+-- Deleting with timestamps reversed fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO '2018-01-01'
+  WHERE id = '[3,4)';
+
+-- Deleting with a subquery fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM (SELECT '2018-01-01') TO '2018-06-01'
+  WHERE id = '[3,4)';
+
+-- Deleting with a column fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM lower(valid_at) TO NULL
+  WHERE id = '[3,4)';
+-- and so does a whole-row reference, which takes a different path
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at ((ROW(for_portion_of_test.*)).f2)
+  WHERE id = '[3,4)';
+
+-- Deleting with timestamps equal does nothing
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-04-01'
+  WHERE id = '[3,4)';
+
+-- Deleting a finite/open portion with a finite/open target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-06-01' TO NULL
+  WHERE id = '[3,4)';
+SELECT * FROM for_portion_of_test WHERE id = '[3,4)' ORDER BY id, valid_at;
+
+-- Deleting a finite/open portion with an open/finite target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-03-01'
+  WHERE id = '[6,7)';
+SELECT * FROM for_portion_of_test WHERE id = '[6,7)' ORDER BY id, valid_at;
+
+-- Deleting an open/finite portion with an open/finite target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-02-01'
+  WHERE id = '[4,5)';
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+
+-- Deleting an open/finite portion with a finite/open target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2017-01-01' TO NULL
+  WHERE id = '[7,8)';
+SELECT * FROM for_portion_of_test WHERE id = '[7,8)' ORDER BY id, valid_at;
+
+-- Deleting a finite/finite portion with an exact fit
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-01' TO '2018-04-01'
+  WHERE id = '[4,5)';
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+
+-- Deleting an enclosed span
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO NULL
+  WHERE id = '[2,3)';
+SELECT * FROM for_portion_of_test WHERE id = '[2,3)' ORDER BY id, valid_at;
+
+-- Deleting an open/open portion with a finite/finite target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-01-01' TO '2019-01-01'
+  WHERE id = '[5,6)';
+SELECT * FROM for_portion_of_test WHERE id = '[5,6)' ORDER BY id, valid_at;
+
+-- Deleting an enclosed span with separate protruding spans
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-03' TO '2018-03-03'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+
+-- Deleting multiple enclosed spans
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO NULL
+  WHERE id = '[8,9)';
+SELECT * FROM for_portion_of_test WHERE id = '[8,9)' ORDER BY id, valid_at;
+
+-- Deleting with a direct target
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at (daterange('2018-03-10', '2018-03-15'))
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+
+-- Deleting with a direct target, coerced from a string
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at ('[2018-03-15,2018-03-17)')
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+
+-- Deleting with a direct target of the wrong range subtype fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at (int4range(1, 4))
+  WHERE id = '[1,2)';
+
+-- Deleting with a direct target of a non-rangetype fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at (4)
+  WHERE id = '[1,2)';
+
+-- Deleting with a direct target of NULL fails
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at (NULL)
+  WHERE id = '[1,2)';
+
+-- Deleting with a direct target of empty does nothing
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at ('empty')
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+
+-- DELETE with no WHERE clause
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2030-01-01' TO NULL;
+
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+\set QUIET true
+
+-- UPDATE ... RETURNING returns only the updated values
+-- (not the inserted side values, which are added by a separate "statement"):
+CREATE FUNCTION fpo_returning_row(text)
+RETURNS text LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE 'RETURNING %', $1;
+  RETURN $1;
+END;
+$$;
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-01' TO '2018-02-15'
+  SET name = 'three^3'
+  WHERE id = '[3,4)'
+  RETURNING *, fpo_returning_row(for_portion_of_test::text);
+
+-- UPDATE ... RETURNING supports NEW and OLD valid_at
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-10' TO '2018-02-20'
+  SET name = 'three^4'
+  WHERE id = '[3,4)'
+  RETURNING OLD.name, NEW.name, OLD.valid_at, NEW.valid_at;
+
+-- DELETE FOR PORTION OF with current_date
+-- (We take care not to make the expectation depend on the timestamp.)
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[99,100)', '[2000-01-01,)', 'foo');
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM current_date TO null
+  WHERE id = '[99,100)';
+SELECT name, lower(valid_at) FROM for_portion_of_test
+  WHERE id = '[99,100)' AND valid_at @> current_date - 1;
+SELECT name, upper(valid_at) FROM for_portion_of_test
+  WHERE id = '[99,100)' AND valid_at @> current_date + 1;
+
+-- DELETE FOR PORTION OF with clock_timestamp()
+-- fails because the function is volatile:
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM clock_timestamp()::date TO null
+  WHERE id = '[99,100)';
+
+-- clean up:
+DELETE FROM for_portion_of_test WHERE id = '[99,100)';
+
+-- DELETE ... RETURNING returns the deleted values, regardless of bounds
+-- (not the inserted side values, which are added by a separate "statement"):
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-02-02' TO '2018-02-03'
+  WHERE id = '[3,4)'
+  RETURNING *, fpo_returning_row(for_portion_of_test::text);
+
+-- DELETE FOR PORTION OF in a PL/pgSQL function
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[10,11)', '[2018-01-01,2020-01-01)', 'ten');
+CREATE FUNCTION fpo_delete(_id int4range, _target_from date, _target_til date)
+RETURNS void LANGUAGE plpgsql AS
+$$
+BEGIN
+  DELETE FROM for_portion_of_test
+    FOR PORTION OF valid_at FROM $2 TO $3
+    WHERE id = $1;
+END;
+$$;
+SELECT fpo_delete('[10,11)', '2015-01-01', '2019-01-01');
+SELECT * FROM for_portion_of_test WHERE id = '[10,11)';
+DELETE FROM for_portion_of_test WHERE id IN ('[10,11)');
+
+-- DELETE FOR PORTION OF in a compiled SQL function
+CREATE FUNCTION fpo_delete()
+RETURNS text
+BEGIN ATOMIC
+  DELETE FROM for_portion_of_test
+    FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01'
+    RETURNING name;
+END;
+\sf+ fpo_delete()
+CREATE OR REPLACE function fpo_delete()
+RETURNS text
+BEGIN ATOMIC
+  DELETE FROM for_portion_of_test
+    FOR PORTION OF valid_at (daterange('2018-01-15', '2020-01-01') * daterange('2019-01-01', '2022-01-01'))
+    RETURNING name;
+END;
+\sf+ fpo_delete()
+DROP FUNCTION fpo_delete();
+
+
+-- test domains and CHECK constraints
+
+-- With a domain on a rangetype
+CREATE DOMAIN daterange_d AS daterange CHECK (upper(VALUE) <> '2005-05-05'::date);
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at daterange_d,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '[2000-01-01,2020-01-01)', 'one'),
+  (2, '[2000-01-01,2020-01-01)', 'two');
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '[2000-01-01,2005-05-05)', 'nope');
+-- UPDATE works:
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2010-01-01' TO '2010-01-05'
+  SET name = 'one^1'
+  WHERE id = 1;
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('[2010-01-07,2010-01-09)')
+  SET name = 'one^2'
+  WHERE id = 1;
+SELECT * FROM for_portion_of_test2 WHERE id = 1 ORDER BY valid_at;
+-- The target is allowed to violate the domain:
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '1999-01-01' TO '2005-05-05'
+  SET name = 'miss'
+  WHERE id = -1;
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('[1999-01-01,2005-05-05)')
+  SET name = 'miss'
+  WHERE id = -1;
+-- test the updated row violating the domain
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '1999-01-01' TO '2005-05-05'
+  SET name = 'one^3'
+  WHERE id = 1;
+-- test inserts violating the domain
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2005-05-05' TO '2010-01-01'
+  SET name = 'one^3'
+  WHERE id = 1;
+-- test updated row violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (upper(valid_at) <> '2001-01-11');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-11'
+  SET name = 'one^3'
+  WHERE id = 1;
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+-- test inserts violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (lower(valid_at) <> '2002-02-02');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-02-02'
+  SET name = 'one^3'
+  WHERE id = 1;
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+SELECT * FROM for_portion_of_test2 WHERE id = 1 ORDER BY valid_at;
+-- DELETE works:
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2010-01-01' TO '2010-01-05'
+  WHERE id = 2;
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('[2010-01-07,2010-01-09)')
+  WHERE id = 2;
+SELECT * FROM for_portion_of_test2 WHERE id = 2 ORDER BY valid_at;
+-- The target is allowed to violate the domain:
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at FROM '1999-01-01' TO '2005-05-05'
+  WHERE id = -1;
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('[1999-01-01,2005-05-05)')
+  WHERE id = -1;
+-- test inserts violating the domain
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2005-05-05' TO '2010-01-01'
+  WHERE id = 2;
+-- test inserts violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (lower(valid_at) <> '2002-02-02');
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-02-02'
+  WHERE id = 2;
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+SELECT * FROM for_portion_of_test2 WHERE id = 2 ORDER BY valid_at;
+DROP TABLE for_portion_of_test2;
+
+-- The domain is checked for every leftover, not just the first one.  The
+-- lookup is cached across rows, so make sure a violation is still caught on a
+-- row after one that passed.
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at daterange_d,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '[2006-01-01,2020-01-01)', 'one'),
+  (2, '[2000-01-01,2020-01-01)', 'two'),
+  (3, '[2000-01-01,2020-01-01)', 'three');
+
+-- Every row's leftovers are fine here: several rows, several checks.
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2010-01-01' TO '2011-01-01'
+  SET name = name || '!';
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+
+-- Now the first row's leftovers pass but the second row's violate the domain.
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2005-05-05' TO '2007-01-01'
+  SET name = 'nope';
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+DROP TABLE for_portion_of_test2;
+
+-- With a domain on a multirangetype
+CREATE FUNCTION multirange_lowers(mr anymultirange) RETURNS anyarray LANGUAGE sql AS $$
+  SELECT array_agg(lower(r)) FROM UNNEST(mr) u(r);
+$$;
+CREATE FUNCTION multirange_uppers(mr anymultirange) RETURNS anyarray LANGUAGE sql AS $$
+  SELECT array_agg(upper(r)) FROM UNNEST(mr) u(r);
+$$;
+CREATE DOMAIN datemultirange_d AS datemultirange CHECK (NOT '2005-05-05'::date = ANY (multirange_uppers(VALUE)));
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at datemultirange_d,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '{[2000-01-01,2020-01-01)}', 'one'),
+  (2, '{[2000-01-01,2020-01-01)}', 'two');
+INSERT INTO for_portion_of_test2 VALUES
+  (1, '{[2000-01-01,2005-05-05)}', 'nope');
+-- UPDATE works:
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2010-01-07,2010-01-09)}')
+  SET name = 'one^2'
+  WHERE id = 1;
+SELECT * FROM for_portion_of_test2 WHERE id = 1 ORDER BY valid_at;
+-- The target is allowed to violate the domain:
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[1999-01-01,2005-05-05)}')
+  SET name = 'miss'
+  WHERE id = -1;
+-- test the updated row violating the domain
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[1999-01-01,2005-05-05)}')
+  SET name = 'one^3'
+  WHERE id = 1;
+-- test inserts violating the domain
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2005-05-05,2010-01-01)}')
+  SET name = 'one^3'
+  WHERE id = 1;
+-- test updated row violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (upper(valid_at) <> '2001-01-11');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2000-01-01,2001-01-11)}')
+  SET name = 'one^3'
+  WHERE id = 1;
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+-- test inserts violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (NOT '2002-02-02'::date = ANY (multirange_lowers(valid_at)));
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2001-01-01,2002-02-02)}')
+  SET name = 'one^3'
+  WHERE id = 1;
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+SELECT * FROM for_portion_of_test2 WHERE id = 1 ORDER BY valid_at;
+-- DELETE works:
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2010-01-07,2010-01-09)}')
+  WHERE id = 2;
+SELECT * FROM for_portion_of_test2 WHERE id = 2 ORDER BY valid_at;
+-- The target is allowed to violate the domain:
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[1999-01-01,2005-05-05)}')
+  WHERE id = -1;
+-- test inserts violating the domain
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2005-05-05,2010-01-01)}')
+  WHERE id = 2;
+-- test inserts violating CHECK constraints
+ALTER TABLE for_portion_of_test2
+  ADD CONSTRAINT fpo2_check CHECK (NOT '2002-02-02'::date = ANY (multirange_lowers(valid_at)));
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2001-01-01,2002-02-02)}')
+  WHERE id = 2;
+ALTER TABLE for_portion_of_test2 DROP CONSTRAINT fpo2_check;
+SELECT * FROM for_portion_of_test2 WHERE id = 2 ORDER BY valid_at;
+DROP TABLE for_portion_of_test2;
+
+-- test on non-range/multirange columns
+
+-- With a direct target and a scalar column
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at date,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES (1, '2020-01-01', 'one');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('2010-01-01')
+  SET name = 'one^1';
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('2010-01-01');
+DROP TABLE for_portion_of_test2;
+
+-- With a direct target and a non-{,multi}range gistable column without overlaps
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at point,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES (1, '0,0', 'one');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('1,1')
+  SET name = 'one^1';
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('1,1');
+DROP TABLE for_portion_of_test2;
+
+-- With a direct target and a non-{,multi}range column with overlaps
+CREATE TABLE for_portion_of_test2 (
+  id integer,
+  valid_at box,
+  name text
+);
+INSERT INTO for_portion_of_test2 VALUES (1, '0,0,4,4', 'one');
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('1,1,2,2')
+  SET name = 'one^1';
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('1,1,2,2');
+DROP TABLE for_portion_of_test2;
+
+-- test that we run triggers on the UPDATE/DELETEd row and the INSERTed rows
+
+CREATE FUNCTION dump_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '%: % % %:',
+    TG_NAME, TG_WHEN, TG_OP, TG_LEVEL;
+
+  IF TG_ARGV[0] THEN
+    RAISE NOTICE '  old: %', (SELECT string_agg(old_table::text, '\n       ') FROM old_table);
+  ELSE
+    RAISE NOTICE '  old: %', OLD.valid_at;
+  END IF;
+  IF TG_ARGV[1] THEN
+    RAISE NOTICE '  new: %', (SELECT string_agg(new_table::text, '\n       ') FROM new_table);
+  ELSE
+    RAISE NOTICE '  new: %', NEW.valid_at;
+  END IF;
+
+  IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
+    RETURN NEW;
+  ELSIF TG_OP = 'DELETE' THEN
+    RETURN OLD;
+  END IF;
+END;
+$$;
+
+-- statement triggers:
+
+CREATE TRIGGER fpo_before_stmt
+  BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE TRIGGER fpo_before_stmt1
+  BEFORE UPDATE OF valid_at ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE TRIGGER fpo_after_insert_stmt
+  AFTER INSERT ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE TRIGGER fpo_after_update_stmt
+  AFTER UPDATE ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE TRIGGER fpo_after_delete_stmt
+  AFTER DELETE ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+
+-- row triggers:
+
+CREATE TRIGGER fpo_before_row
+  BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE TRIGGER fpo_after_insert_row
+  AFTER INSERT ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE TRIGGER fpo_after_update_row
+  AFTER UPDATE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE TRIGGER fpo_after_delete_row
+  AFTER DELETE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2021-01-01' TO '2022-01-01'
+  SET name = 'five^3'
+  WHERE id = '[5,6)';
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2023-01-01' TO '2024-01-01'
+  WHERE id = '[5,6)';
+
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+
+-- Triggers with a custom transition table name:
+
+DROP TABLE for_portion_of_test;
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO for_portion_of_test VALUES ('[1,2)', '[2018-01-01,2020-01-01)', 'one');
+
+-- statement triggers:
+
+CREATE TRIGGER fpo_before_stmt
+  BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE TRIGGER fpo_after_insert_stmt
+  AFTER INSERT ON for_portion_of_test
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, true);
+
+CREATE TRIGGER fpo_after_update_stmt
+  AFTER UPDATE ON for_portion_of_test
+  REFERENCING NEW TABLE AS new_table OLD TABLE AS old_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(true, true);
+
+CREATE TRIGGER fpo_after_delete_stmt
+  AFTER DELETE ON for_portion_of_test
+  REFERENCING OLD TABLE AS old_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(true, false);
+
+-- row triggers:
+
+CREATE TRIGGER fpo_before_row
+  BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE TRIGGER fpo_after_insert_row
+  AFTER INSERT ON for_portion_of_test
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, true);
+
+CREATE TRIGGER fpo_after_update_row
+  AFTER UPDATE ON for_portion_of_test
+  REFERENCING OLD TABLE AS old_table NEW TABLE AS new_table
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(true, true);
+
+CREATE TRIGGER fpo_after_delete_row
+  AFTER DELETE ON for_portion_of_test
+  REFERENCING OLD TABLE AS old_table
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(true, false);
+
+BEGIN;
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01'
+  SET name = '2018-01-15_to_2019-01-01';
+ROLLBACK;
+
+BEGIN;
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-01-21';
+ROLLBACK;
+
+BEGIN;
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-01-02'
+  SET name = 'NULL_to_2018-01-01';
+ROLLBACK;
+
+-- Deferred triggers
+-- (must be CONSTRAINT triggers thus AFTER ROW with no transition tables)
+
+DROP TABLE for_portion_of_test;
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-01,2020-01-01)', 'one');
+
+CREATE CONSTRAINT TRIGGER fpo_after_insert_row
+  AFTER INSERT ON for_portion_of_test
+  DEFERRABLE INITIALLY DEFERRED
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE CONSTRAINT TRIGGER fpo_after_update_row
+  AFTER UPDATE ON for_portion_of_test
+  DEFERRABLE INITIALLY DEFERRED
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE CONSTRAINT TRIGGER fpo_after_delete_row
+  AFTER DELETE ON for_portion_of_test
+  DEFERRABLE INITIALLY DEFERRED
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+
+BEGIN;
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-01-15' TO '2019-01-01'
+  SET name = '2018-01-15_to_2019-01-01';
+COMMIT;
+
+BEGIN;
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-01-21';
+COMMIT;
+
+BEGIN;
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM NULL TO '2018-01-02'
+  SET name = 'NULL_to_2018-01-01';
+COMMIT;
+
+SELECT * FROM for_portion_of_test;
+
+-- test FOR PORTION OF from triggers during FOR PORTION OF:
+
+DROP TABLE for_portion_of_test;
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-01,2020-01-01)', 'one'),
+  ('[2,3)', '[2018-01-01,2020-01-01)', 'two'),
+  ('[3,4)', '[2018-01-01,2020-01-01)', 'three'),
+  ('[4,5)', '[2018-01-01,2020-01-01)', 'four');
+
+CREATE FUNCTION trg_fpo_update()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  IF pg_trigger_depth() = 1 THEN
+    UPDATE for_portion_of_test
+      FOR PORTION OF valid_at FROM '2018-02-01' TO '2018-03-01'
+      SET name = CONCAT(name, '^')
+      WHERE id = OLD.id;
+  END IF;
+  RETURN CASE WHEN 'TG_OP' = 'DELETE' THEN OLD ELSE NEW END;
+END;
+$$;
+
+CREATE FUNCTION trg_fpo_delete()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  IF pg_trigger_depth() = 1 THEN
+    DELETE FROM for_portion_of_test
+      FOR PORTION OF valid_at FROM '2018-03-01' TO '2018-04-01'
+      WHERE id = OLD.id;
+  END IF;
+  RETURN CASE WHEN 'TG_OP' = 'DELETE' THEN OLD ELSE NEW END;
+END;
+$$;
+
+-- UPDATE FOR PORTION OF from a trigger fired by UPDATE FOR PORTION OF
+
+CREATE TRIGGER fpo_after_update_row
+  AFTER UPDATE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE trg_fpo_update();
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  SET name = CONCAT(name, '*')
+  WHERE id = '[1,2)';
+
+SELECT * FROM for_portion_of_test WHERE id = '[1,2)' ORDER BY id, valid_at;
+
+DROP TRIGGER fpo_after_update_row ON for_portion_of_test;
+
+-- UPDATE FOR PORTION OF from a trigger fired by DELETE FOR PORTION OF
+
+CREATE TRIGGER fpo_after_delete_row
+  AFTER DELETE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE trg_fpo_update();
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  WHERE id = '[2,3)';
+
+SELECT * FROM for_portion_of_test WHERE id = '[2,3)' ORDER BY id, valid_at;
+
+DROP TRIGGER fpo_after_delete_row ON for_portion_of_test;
+
+-- DELETE FOR PORTION OF from a trigger fired by UPDATE FOR PORTION OF
+
+CREATE TRIGGER fpo_after_update_row
+  AFTER UPDATE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE trg_fpo_delete();
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  SET name = CONCAT(name, '*')
+  WHERE id = '[3,4)';
+
+SELECT * FROM for_portion_of_test WHERE id = '[3,4)' ORDER BY id, valid_at;
+
+DROP TRIGGER fpo_after_update_row ON for_portion_of_test;
+
+-- DELETE FOR PORTION OF from a trigger fired by DELETE FOR PORTION OF
+
+CREATE TRIGGER fpo_after_delete_row
+  AFTER DELETE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE trg_fpo_delete();
+
+DELETE FROM for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  WHERE id = '[4,5)';
+
+SELECT * FROM for_portion_of_test WHERE id = '[4,5)' ORDER BY id, valid_at;
+
+DROP TRIGGER fpo_after_delete_row ON for_portion_of_test;
+
+-- Test that a tuple-modifying BEFORE INSERT ROW trigger acts
+-- consistently on both temporal leftovers.
+-- When FOR PORTION OF splits a row into two leftovers, both triggers
+-- should get the original row's values.
+
+DROP TABLE for_portion_of_test;
+CREATE TABLE for_portion_of_test (
+  id int,
+  valid_at daterange,
+  name text
+);
+
+CREATE FUNCTION fpo_append_name_suffix()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  NEW.name := NEW.name || '+insert';
+  RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER fpo_before_insert_row
+  BEFORE INSERT ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE fpo_append_name_suffix();
+
+INSERT INTO for_portion_of_test VALUES (1, '[2020-01-01,2020-12-31)', 'foo');
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2020-04-01' TO '2020-08-01'
+  SET name = 'bar'
+  WHERE id = 1;
+
+-- Both leftovers should have the same name: 'foo+insert+insert'.
+SELECT * FROM for_portion_of_test ORDER BY valid_at;
+
+DROP FUNCTION fpo_append_name_suffix CASCADE;
+DROP TABLE for_portion_of_test;
+
+-- A BEFORE UPDATE trigger that changes the application-time column is allowed,
+-- even if the results are senseless.
+-- Note this is likely to cause a primary key violation.
+
+CREATE TABLE for_portion_of_test (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+
+CREATE FUNCTION trg_fpo_change_valid_at()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  NEW.valid_at = daterange('2018-01-01', '2019-01-01');
+  RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER fpo_before_update_row
+  BEFORE UPDATE ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE trg_fpo_change_valid_at();
+
+INSERT INTO for_portion_of_test VALUES ('[1,2)', '[2010-01-01,2020-01-01)', 'foo');
+
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  SET name = CONCAT(name, '!')
+  WHERE id = '[1,2)';
+
+SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
+
+-- A primary key should reject anything invalid:
+TRUNCATE for_portion_of_test;
+ALTER TABLE for_portion_of_test
+  ADD CONSTRAINT for_portion_of_test_key
+  PRIMARY KEY (id, valid_at WITHOUT OVERLAPS);
+INSERT INTO for_portion_of_test VALUES ('[1,2)', '[2010-01-01,2020-01-01)', 'foo');
+UPDATE for_portion_of_test
+  FOR PORTION OF valid_at FROM '2018-05-01' TO '2018-06-01'
+  SET name = CONCAT(name, '!')
+  WHERE id = '[1,2)';
+
+DROP TRIGGER fpo_before_update_row ON for_portion_of_test;
+DROP FUNCTION trg_fpo_change_valid_at();
+DROP TABLE for_portion_of_test;
+
+-- Test with multiranges
+
+CREATE TABLE for_portion_of_test2 (
+  id int4range NOT NULL,
+  valid_at datemultirange NOT NULL,
+  name text NOT NULL,
+  CONSTRAINT for_portion_of_test2_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+INSERT INTO for_portion_of_test2 (id, valid_at, name) VALUES
+  ('[1,2)', datemultirange(daterange('2018-01-02', '2018-02-03)'), daterange('2018-02-04', '2018-03-03')), 'one'),
+  ('[1,2)', datemultirange(daterange('2018-03-03', '2018-04-04)')), 'one'),
+  ('[2,3)', datemultirange(daterange('2018-01-01', '2018-05-01)')), 'two'),
+  ('[3,4)', datemultirange(daterange('2018-01-01', null)), 'three');
+  ;
+
+-- Updating with FROM/TO
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2000-01-01' TO '2010-01-01'
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+-- Updating with multirange
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at (datemultirange(daterange('2018-01-10', '2018-02-10'), daterange('2018-03-05', '2018-05-01')))
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test2 WHERE id = '[1,2)' ORDER BY valid_at;
+-- Updating with string coercion
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2018-03-05,2018-03-10)}')
+  SET name = 'one^2'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test2 WHERE id = '[1,2)' ORDER BY valid_at;
+-- Updating with the wrong range subtype fails
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{[1,4)}'::int4multirange)
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+-- Updating with a non-multirangetype fails
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at (4)
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+-- Updating with NULL fails
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at (NULL)
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+-- Updating with empty does nothing
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at ('{}')
+  SET name = 'one^3'
+  WHERE id = '[1,2)';
+SELECT * FROM for_portion_of_test2 WHERE id = '[1,2)' ORDER BY valid_at;
+
+-- Deleting with FROM/TO
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2000-01-01' TO '2010-01-01'
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+-- Deleting with multirange
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at (datemultirange(daterange('2018-01-15', '2018-02-15'), daterange('2018-03-01', '2018-03-15')))
+  WHERE id = '[2,3)';
+SELECT * FROM for_portion_of_test2 WHERE id = '[2,3)' ORDER BY valid_at;
+-- Deleting with string coercion
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[2018-03-05,2018-03-20)}')
+  WHERE id = '[2,3)';
+SELECT * FROM for_portion_of_test2 WHERE id = '[2,3)' ORDER BY valid_at;
+-- Deleting with the wrong range subtype fails
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{[1,4)}'::int4multirange)
+  WHERE id = '[2,3)';
+-- Deleting with a non-multirangetype fails
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at (4)
+  WHERE id = '[2,3)';
+-- Deleting with NULL fails
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at (NULL)
+  WHERE id = '[2,3)';
+-- Deleting with empty does nothing
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at ('{}')
+  WHERE id = '[2,3)';
+
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+
+DROP TABLE for_portion_of_test2;
+
+-- Test with a custom range type
+
+CREATE TYPE mydaterange AS range(subtype=date);
+
+CREATE TABLE for_portion_of_test2 (
+  id int4range NOT NULL,
+  valid_at mydaterange NOT NULL,
+  name text NOT NULL,
+  CONSTRAINT for_portion_of_test2_pk PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+INSERT INTO for_portion_of_test2 (id, valid_at, name) VALUES
+  ('[1,2)', '[2018-01-02,2018-02-03)', 'one'),
+  ('[1,2)', '[2018-02-03,2018-03-03)', 'one'),
+  ('[1,2)', '[2018-03-03,2018-04-04)', 'one'),
+  ('[2,3)', '[2018-01-01,2018-05-01)', 'two'),
+  ('[3,4)', '[2018-01-01,)', 'three');
+  ;
+
+UPDATE for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2018-01-10' TO '2018-02-10'
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+
+DELETE FROM for_portion_of_test2
+  FOR PORTION OF valid_at FROM '2018-01-15' TO '2018-02-15'
+  WHERE id = '[2,3)';
+
+SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
+
+DROP TABLE for_portion_of_test2;
+DROP TYPE mydaterange;
+
+-- Test FOR PORTION OF against a partitioned table.
+-- Include a stored generated column to test updatedCols column mapping.
+-- temporal_partitioned_1 has the same attnums as the root
+-- temporal_partitioned_3 has the different attnums from the root
+-- temporal_partitioned_5 has the different attnums too, but reversed
+
+CREATE TABLE temporal_partitioned (
+  id int4range,
+  valid_at daterange,
+  name text,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
+  CONSTRAINT temporal_paritioned_uq UNIQUE (id, valid_at WITHOUT OVERLAPS)
+) PARTITION BY LIST (id);
+CREATE TABLE temporal_partitioned_1 PARTITION OF temporal_partitioned FOR VALUES IN ('[1,2)', '[2,3)');
+CREATE TABLE temporal_partitioned_3 PARTITION OF temporal_partitioned FOR VALUES IN ('[3,4)', '[4,5)');
+CREATE TABLE temporal_partitioned_5 PARTITION OF temporal_partitioned FOR VALUES IN ('[5,6)', '[6,7)');
+
+ALTER TABLE temporal_partitioned DETACH PARTITION temporal_partitioned_3;
+ALTER TABLE temporal_partitioned_3 DROP COLUMN id, DROP COLUMN valid_at CASCADE;
+ALTER TABLE temporal_partitioned_3 ADD COLUMN id int4range NOT NULL, ADD COLUMN valid_at daterange NOT NULL;
+ALTER TABLE temporal_partitioned_3 ADD COLUMN range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED;
+ALTER TABLE temporal_partitioned ATTACH PARTITION temporal_partitioned_3 FOR VALUES IN ('[3,4)', '[4,5)');
+
+ALTER TABLE temporal_partitioned DETACH PARTITION temporal_partitioned_5;
+ALTER TABLE temporal_partitioned_5 DROP COLUMN id, DROP COLUMN valid_at CASCADE;
+ALTER TABLE temporal_partitioned_5 ADD COLUMN valid_at daterange NOT NULL, ADD COLUMN id int4range NOT NULL;
+ALTER TABLE temporal_partitioned_5 ADD COLUMN range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED;
+ALTER TABLE temporal_partitioned ATTACH PARTITION temporal_partitioned_5 FOR VALUES IN ('[5,6)', '[6,7)');
+
+INSERT INTO temporal_partitioned (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one'),
+  ('[3,4)', daterange('2000-01-01', '2010-01-01'), 'three'),
+  ('[5,6)', daterange('2000-01-01', '2010-01-01'), 'five');
+
+SELECT * FROM temporal_partitioned;
+
+-- Update without moving within partition 1
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-04-01'
+  SET name = 'one^1'
+  WHERE id = '[1,2)';
+
+-- Update without moving within partition 3
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-04-01'
+  SET name = 'three^1'
+  WHERE id = '[3,4)';
+
+-- Update without moving within partition 5
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-04-01'
+  SET name = 'five^1'
+  WHERE id = '[5,6)';
+
+-- Move from partition 1 to partition 3
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01'
+  SET name = 'one^2',
+      id = '[4,5)'
+  WHERE id = '[1,2)'
+  RETURNING id, valid_at, name, fpo_returning_row(temporal_partitioned::text);
+
+-- Move from partition 3 to partition 1
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01'
+  SET name = 'three^2',
+      id = '[2,3)'
+  WHERE id = '[3,4)';
+
+-- Move from partition 5 to partition 3
+UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01'
+  SET name = 'five^2',
+      id = '[3,4)'
+  WHERE id = '[5,6)';
+
+-- Update all partitions at once (each with leftovers)
+
+SELECT *, upper(valid_at) - lower(valid_at) FROM temporal_partitioned ORDER BY id, valid_at;
+SELECT * FROM temporal_partitioned_1 ORDER BY id, valid_at;
+SELECT * FROM temporal_partitioned_3 ORDER BY id, valid_at;
+SELECT * FROM temporal_partitioned_5 ORDER BY id, valid_at;
+
+DROP FUNCTION fpo_returning_row;
+DROP TABLE temporal_partitioned;
+
+-- Test FOR PORTION OF when the partition key depends on the range column.
+-- Then a leftover can belong to a partition that is not one of the plan's
+-- result relations, and we must build a new ResultRelInfo for it.
+
+CREATE TABLE temporal_partitioned_by_valid_at (
+  id int4range,
+  valid_at daterange,
+  name text
+) PARTITION BY RANGE (lower(valid_at));
+CREATE TABLE temporal_partitioned_early
+  PARTITION OF temporal_partitioned_by_valid_at
+  FOR VALUES FROM (MINVALUE) TO ('2000-06-01');
+CREATE TABLE temporal_partitioned_late
+  PARTITION OF temporal_partitioned_by_valid_at
+  FOR VALUES FROM ('2000-06-01') TO (MAXVALUE);
+
+INSERT INTO temporal_partitioned_by_valid_at (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one');
+
+-- The WHERE clause prunes away the late partition, but the second leftover
+-- belongs there.  The leftovers must not appear in the RETURNING output.
+DELETE FROM temporal_partitioned_by_valid_at
+  FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-07-01'
+  WHERE lower(valid_at) < '2000-06-01'
+  RETURNING id, valid_at, name;
+SELECT tableoid::regclass, * FROM temporal_partitioned_by_valid_at
+  ORDER BY id, valid_at;
+
+-- The same thing for UPDATE
+DELETE FROM temporal_partitioned_by_valid_at;
+INSERT INTO temporal_partitioned_by_valid_at (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one');
+UPDATE temporal_partitioned_by_valid_at
+  FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-07-01'
+  SET name = 'one^1'
+  WHERE lower(valid_at) < '2000-06-01'
+  RETURNING id, valid_at, name;
+SELECT tableoid::regclass, * FROM temporal_partitioned_by_valid_at
+  ORDER BY id, valid_at;
+
+DROP TABLE temporal_partitioned_by_valid_at;
+
+-- UPDATE/DELETE FOR PORTION OF with RULEs
+CREATE TABLE fpo_rule (f1 bigint, f2 int4range);
+INSERT INTO fpo_rule VALUES (1, '[1, 11)');
+
+CREATE RULE fpo_rule1 AS ON INSERT TO fpo_rule
+  DO INSTEAD UPDATE fpo_rule FOR PORTION OF f2 FROM 1 TO 4 SET f1 = 2;
+INSERT INTO fpo_rule VALUES (1, '[1, 11)');
+SELECT * FROM fpo_rule ORDER BY f1;
+
+CREATE RULE fpo_rule2 AS ON INSERT TO fpo_rule
+  DO INSTEAD DELETE FROM fpo_rule FOR PORTION OF f2 FROM 1 TO 4;
+INSERT INTO fpo_rule VALUES (1, '[1, 11)');
+SELECT * FROM fpo_rule ORDER BY f1;
+
+CREATE RULE fpo_rule3 AS ON DELETE TO fpo_rule
+  DO INSTEAD UPDATE fpo_rule FOR PORTION OF f2 FROM 1 TO 8 SET f1 = 2;
+DELETE FROM fpo_rule FOR PORTION OF f2 FROM 1 TO 5;
+SELECT * FROM fpo_rule ORDER BY f1;
+
+DROP RULE fpo_rule3 ON fpo_rule;
+CREATE RULE fpo_rule4 AS ON UPDATE TO fpo_rule
+  DO INSTEAD DELETE FROM fpo_rule FOR PORTION OF f2 FROM 6 TO 9;
+UPDATE fpo_rule FOR PORTION OF f2 FROM 4 TO 9 SET f1 = 12;
+SELECT * FROM fpo_rule ORDER BY f1;
+
+DROP RULE fpo_rule4 ON fpo_rule;
+CREATE RULE fpo_rule5 AS ON UPDATE TO fpo_rule
+  DO ALSO DELETE FROM fpo_rule FOR PORTION OF f2 FROM 4 TO 6;
+UPDATE fpo_rule FOR PORTION OF f2 FROM 9 TO 10 SET f1 = 3;
+SELECT * FROM fpo_rule ORDER BY f1;
+
+DROP TABLE fpo_rule;
+
+-- Deparsing FOR PORTION OF must use the range column's current name,
+-- not the name it had when the rule was created.
+CREATE TABLE fpo_rename (f1 bigint, f2 int4range);
+CREATE TABLE fpo_rename_src (x int);
+CREATE RULE fpo_rename_rule1 AS ON UPDATE TO fpo_rename_src
+  DO INSTEAD UPDATE fpo_rename FOR PORTION OF f2 FROM 3 TO 6 SET f1 = 2;
+CREATE RULE fpo_rename_rule2 AS ON DELETE TO fpo_rename_src
+  DO INSTEAD DELETE FROM fpo_rename FOR PORTION OF f2 (int4range(3, 6));
+
+\d+ fpo_rename_src
+ALTER TABLE fpo_rename RENAME COLUMN f1 TO ff1;
+ALTER TABLE fpo_rename RENAME COLUMN f2 TO ff2;
+\d+ fpo_rename_src
+
+DROP TABLE fpo_rename, fpo_rename_src;
+
+-- Deparsed FROM/TO bounds must be re-loadable.  A bound ending in an interval
+-- type name with a field qualifier would otherwise run into the following TO,
+-- since "interval hour to minute" is a type name too.
+CREATE TYPE fpo_intervalrange AS RANGE (subtype = interval);
+CREATE TABLE fpo_interval (r fpo_intervalrange);
+CREATE RULE fpo_interval_rule AS ON INSERT TO fpo_interval DO ALSO
+  DELETE FROM fpo_interval
+    FOR PORTION OF r FROM (INTERVAL '1' HOUR) TO INTERVAL '2' HOUR;
+SELECT definition FROM pg_rules WHERE rulename = 'fpo_interval_rule';
+SELECT 'DROP RULE fpo_interval_rule ON fpo_interval'
+  UNION ALL
+  SELECT pg_get_ruledef(oid) FROM pg_rewrite
+    WHERE rulename = 'fpo_interval_rule' \gexec
+SELECT definition FROM pg_rules WHERE rulename = 'fpo_interval_rule';
+
+DROP TABLE fpo_interval;
+DROP TYPE fpo_intervalrange;
+
+-- UPDATE/DELETE FOR PORTION OF on a GENERATED VIRTUAL range column:
+CREATE TABLE fpo_gen_virtual (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) VIRTUAL
+);
+INSERT INTO fpo_gen_virtual VALUES (1);
+DELETE FROM fpo_gen_virtual FOR PORTION OF b FROM 1 TO 2; -- fails
+UPDATE fpo_gen_virtual FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+DROP TABLE fpo_gen_virtual;
+
+-- UPDATE/DELETE FOR PORTION OF on a GENERATED STORED range column:
+CREATE TABLE fpo_gen_stored (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) STORED
+);
+INSERT INTO fpo_gen_stored VALUES (1);
+DELETE FROM fpo_gen_stored FOR PORTION OF b FROM 1 TO 2; -- fails
+UPDATE fpo_gen_stored FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+DROP TABLE fpo_gen_stored;
+
+-- FOR PORTION OF a generated column reached through an updatable view.
+-- The view hides that b is generated during parse analysis, so the check
+-- must happen later (in the planner), after the view is rewritten to its
+-- underlying table.
+CREATE TABLE fpo_gen_view (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) STORED
+);
+INSERT INTO fpo_gen_view VALUES (1);
+CREATE VIEW fpo_gen_view_v AS SELECT * FROM fpo_gen_view;
+DELETE FROM fpo_gen_view_v FOR PORTION OF b FROM 1 TO 2; -- fails
+UPDATE fpo_gen_view_v FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+DROP VIEW fpo_gen_view_v;
+DROP TABLE fpo_gen_view;
+
+-- A new-style SQL function is parsed at CREATE FUNCTION time, but our
+-- generated-column check is in the planner, so it sees the column's
+-- current attgenerated when the function's plan is built at run time.
+CREATE TABLE fpo_func_test (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) STORED
+);
+INSERT INTO fpo_func_test VALUES (1);
+-- Definition succeeds even though b is a generated column today.
+CREATE FUNCTION fpo_delete() RETURNS void
+  LANGUAGE SQL
+  BEGIN ATOMIC
+    DELETE FROM fpo_func_test FOR PORTION OF b FROM 1 TO 2;
+  END;
+SELECT fpo_delete(); -- fails: b is generated
+-- Drop the generation expression and the same function now succeeds.
+ALTER TABLE fpo_func_test ALTER COLUMN b DROP EXPRESSION;
+SELECT fpo_delete();
+TABLE fpo_func_test ORDER BY a, b;
+DROP FUNCTION fpo_delete();
+DROP TABLE fpo_func_test;
+
+-- UPDATE/DELETE FOR PORTION OF with table inheritance
+-- Leftover rows must stay in the child table, preserving child-specific columns.
+CREATE TABLE fpo_inh_parent (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE fpo_inh_child (
+  description text
+) INHERITS (fpo_inh_parent);
+
+-- Update targets the parent; the matching row lives in the child.
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+-- All three rows should be in the child, with description preserved.
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+SELECT * FROM fpo_inh_child ORDER BY valid_at;
+-- No rows should have leaked into the parent.
+SELECT * FROM ONLY fpo_inh_parent ORDER BY valid_at;
+
+-- Same test for DELETE instead of UPDATE:
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+-- Both rows should be in the child, with description preserved.
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+SELECT * FROM fpo_inh_child ORDER BY valid_at;
+-- No rows should have leaked into the parent.
+SELECT * FROM ONLY fpo_inh_parent ORDER BY valid_at;
+
+-- Statement-level INSERT triggers for the leftovers must fire on the table
+-- the leftovers are actually inserted into, i.e. the child, just as they
+-- would for a plain INSERT into that child.
+CREATE FUNCTION fpo_inh_stmt_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% % % on %', TG_NAME, TG_WHEN, TG_OP, TG_TABLE_NAME;
+  RETURN NULL;
+END;
+$$;
+
+CREATE TRIGGER fpo_inh_parent_bs BEFORE INSERT ON fpo_inh_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_parent_as AFTER INSERT ON fpo_inh_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_child_bs BEFORE INSERT ON fpo_inh_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_inh_child_as AFTER INSERT ON fpo_inh_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+-- control: a plain INSERT into the child fires only the child's triggers
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+
+-- One firing per leftover, all of them on the child.
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+
+-- The transition table for those inserts belongs to the child too, and holds
+-- the child's rows in the child's own format, including its extra columns.
+CREATE FUNCTION fpo_inh_transition_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% on %: %', TG_NAME, TG_TABLE_NAME,
+    (SELECT string_agg(new_table::text, ', ' ORDER BY new_table::text)
+       FROM new_table);
+  RETURN NULL;
+END;
+$$;
+
+CREATE TRIGGER fpo_inh_parent_tt AFTER INSERT ON fpo_inh_parent
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
+CREATE TRIGGER fpo_inh_child_tt AFTER INSERT ON fpo_inh_child
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
+
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+-- control: a plain INSERT into the child reports description = 'initial'
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+-- The leftovers must report it too: they are child rows and they keep it.
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+SELECT tableoid::regclass, * FROM fpo_inh_child ORDER BY valid_at;
+
+-- DELETE inserts its leftovers the same way.
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+SELECT tableoid::regclass, * FROM fpo_inh_child ORDER BY valid_at;
+
+DROP TABLE fpo_inh_parent CASCADE;
+
+-- By contrast, leftovers in a partitioned table are inserted through the
+-- root to get tuple routing, so the root is where their statement-level
+-- triggers belong, exactly as for a plain INSERT into the root.
+CREATE TABLE fpo_part_parent (
+  id int4range,
+  valid_at daterange,
+  name text
+) PARTITION BY RANGE (id);
+CREATE TABLE fpo_part_child PARTITION OF fpo_part_parent
+  FOR VALUES FROM ('[1,1]') TO ('[9,9]');
+
+CREATE TRIGGER fpo_part_parent_bs BEFORE INSERT ON fpo_part_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_parent_as AFTER INSERT ON fpo_part_parent
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_child_bs BEFORE INSERT ON fpo_part_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+CREATE TRIGGER fpo_part_child_as AFTER INSERT ON fpo_part_child
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_stmt_trigger();
+
+-- and so does their transition table, which still receives the rows converted
+-- to the root's format.
+CREATE FUNCTION fpo_part_transition_trigger()
+RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  RAISE NOTICE '% on %: %', TG_NAME, TG_TABLE_NAME,
+    (SELECT string_agg(new_table::text, ', ' ORDER BY new_table::text)
+       FROM new_table);
+  RETURN NULL;
+END;
+$$;
+CREATE TRIGGER fpo_part_parent_tt AFTER INSERT ON fpo_part_parent
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_part_transition_trigger();
+
+INSERT INTO fpo_part_parent VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one');
+UPDATE fpo_part_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+SELECT tableoid::regclass, * FROM fpo_part_parent ORDER BY valid_at;
+DROP FUNCTION fpo_part_transition_trigger();
+
+DROP TABLE fpo_part_parent;
+DROP FUNCTION fpo_inh_stmt_trigger();
+
+-- UPDATE FOR PORTION OF with multiple inheritance
+-- Leftover rows must stay in the child table, even if the range column's
+-- attnum differs between the target parent and child.
+CREATE TABLE temporal_parent (
+  id int,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE other_parent (
+  prefix text,
+  note text
+);
+CREATE TABLE mi_child () INHERITS (other_parent, temporal_parent);
+
+-- The leftovers' transition table must use the child's format even when the
+-- child's columns are in a different order from the parent's.
+CREATE TRIGGER mi_child_tt AFTER INSERT ON mi_child
+  REFERENCING NEW TABLE AS new_table
+  FOR EACH STATEMENT EXECUTE PROCEDURE fpo_inh_transition_trigger();
+
+-- attnum of the range column is different in temporal_parent and mi_child
+SELECT attnum, attname
+  FROM pg_attribute
+  WHERE attrelid = 'temporal_parent'::regclass
+    AND attnum > 0 AND NOT attisdropped
+  ORDER BY attnum;
+SELECT attnum, attname
+  FROM pg_attribute
+  WHERE attrelid = 'mi_child'::regclass
+    AND attnum > 0 AND NOT attisdropped
+  ORDER BY attnum;
+
+INSERT INTO mi_child (prefix, note, id, valid_at, name) VALUES
+  ('pfx', 'memo', 1, daterange('2000-01-01', '2010-01-01'), 'old');
+
+UPDATE temporal_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  SET name = 'new'
+  WHERE id = 1;
+
+SELECT tableoid::regclass, * FROM temporal_parent ORDER BY valid_at;
+SELECT * FROM mi_child ORDER BY valid_at;
+SELECT * FROM ONLY temporal_parent ORDER BY valid_at;
+
+TRUNCATE mi_child, other_parent, temporal_parent;
+INSERT INTO mi_child (prefix, note, id, valid_at, name) VALUES
+  ('pfx', 'memo', 1, daterange('2000-01-01', '2010-01-01'), 'old');
+
+DELETE FROM temporal_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  WHERE id = 1;
+
+SELECT tableoid::regclass, * FROM temporal_parent ORDER BY valid_at;
+SELECT * FROM mi_child ORDER BY valid_at;
+SELECT * FROM ONLY temporal_parent ORDER BY valid_at;
+
+DROP TABLE temporal_parent CASCADE;
+DROP FUNCTION fpo_inh_transition_trigger();
+
+-- UPDATE FOR PORTION OF with generated columns
+-- The generated column depends on the range column, so it must be
+-- recomputed when FOR PORTION OF narrows the range.
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
+  range_lenv int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+-- After the FOR PORTION OF (FPO) update, all three resulting rows
+-- (leftover-before, updated, and leftover-after) must contain the correct
+-- values for range_len and range_lenv.
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+-- Also test with a generated column that references both a SET column
+-- and the range column.
+DROP TABLE fpo_generated;
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  id_plus_len int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at)) STORED,
+  id_plus_lenv int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at))
+);
+
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+SELECT * FROM fpo_generated ORDER BY valid_at;
+DROP TABLE fpo_generated;
+
+-- Test that UPDATE OF colname triggers fire if colname is valid_at:
+CREATE TABLE fpo_update_of_trigger (
+  id int,
+  valid_at int4range
+);
+INSERT INTO fpo_update_of_trigger (id, valid_at) VALUES (1, '[10,100)');
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_row2
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+UPDATE fpo_update_of_trigger
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+DROP TABLE fpo_update_of_trigger;
+
+-- CURSORs
+CREATE TABLE fpo_cursed (
+  id int,
+  valid_at int4range
+);
+INSERT INTO fpo_cursed (id, valid_at) VALUES (1, '[10,100)');
+
+-- UPDATE FOR PORTION OF is not permitted with a CURSOR:
+BEGIN;
+DECLARE fpo_cur CURSOR FOR SELECT * FROM fpo_cursed;
+FETCH NEXT FROM fpo_cur;
+UPDATE fpo_cursed
+  FOR PORTION OF valid_at FROM 5 TO 6
+  SET id = 2
+  WHERE CURRENT OF fpo_cur;
+ROLLBACK;
+
+-- DELETE FOR PORTION OF is not permitted with a CURSOR:
+BEGIN;
+DECLARE fpo_cur CURSOR FOR SELECT * FROM fpo_cursed;
+FETCH NEXT FROM fpo_cur;
+DELETE FROM fpo_cursed
+  FOR PORTION OF valid_at FROM 8 TO 9
+  WHERE CURRENT OF fpo_cur;
+ROLLBACK;
+SELECT * FROM fpo_cursed;
+DROP TABLE fpo_cursed;
+
+-- UPDATE/DELETE FOR PORTION OF leftover rows must satisfy RLS INSERT checks.
+CREATE ROLE regress_fpo_rls;
+CREATE TABLE fpo_rls (
+  id int,
+  valid_at int4range
+);
+ALTER TABLE fpo_rls ENABLE ROW LEVEL SECURITY;
+CREATE POLICY fpo_rls_select ON fpo_rls
+  FOR SELECT TO regress_fpo_rls
+  USING (true);
+CREATE POLICY fpo_rls_update ON fpo_rls
+  FOR UPDATE TO regress_fpo_rls
+  USING (lower(valid_at) < 50)
+  WITH CHECK (lower(valid_at) < 50);
+CREATE POLICY fpo_rls_delete ON fpo_rls
+  FOR DELETE TO regress_fpo_rls
+  USING (lower(valid_at) < 50);
+CREATE POLICY fpo_rls_insert ON fpo_rls
+  FOR INSERT TO regress_fpo_rls
+  WITH CHECK (lower(valid_at) < 50);
+GRANT SELECT, UPDATE, DELETE ON fpo_rls TO regress_fpo_rls;
+
+INSERT INTO fpo_rls VALUES (1, '[10,100)');
+SET ROLE regress_fpo_rls;
+UPDATE fpo_rls
+  FOR PORTION OF valid_at FROM 30 TO 100
+  SET id = 2;
+RESET ROLE;
+SELECT * FROM fpo_rls ORDER BY valid_at;
+
+TRUNCATE fpo_rls;
+INSERT INTO fpo_rls VALUES (1, '[10,100)');
+SET ROLE regress_fpo_rls;
+DELETE FROM fpo_rls
+  FOR PORTION OF valid_at FROM 30 TO 100;
+RESET ROLE;
+SELECT * FROM fpo_rls ORDER BY valid_at;
+
+TRUNCATE fpo_rls;
+INSERT INTO fpo_rls VALUES (1, '[10,100)');
+SET ROLE regress_fpo_rls;
+UPDATE fpo_rls
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+RESET ROLE;
+SELECT * FROM fpo_rls ORDER BY valid_at;
+
+TRUNCATE fpo_rls;
+INSERT INTO fpo_rls VALUES (1, '[10,100)');
+SET ROLE regress_fpo_rls;
+DELETE FROM fpo_rls
+  FOR PORTION OF valid_at FROM 30 TO 70;
+RESET ROLE;
+SELECT * FROM fpo_rls ORDER BY valid_at;
+
+DROP TABLE fpo_rls;
+DROP ROLE regress_fpo_rls;
+
+--
+-- Parameters in the FOR PORTION OF bounds
+--
+
+CREATE TABLE fpo_param (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO fpo_param (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one');
+
+-- A parameter of unspecified type in an ordinary expression gets its type
+-- resolved from context.  This is the control case for the FROM/TO bounds
+-- below: it builds exactly the same daterange the FROM/TO form does.
+PREPARE fpo_param_control AS
+  UPDATE fpo_param SET name = 'ctl' WHERE valid_at && daterange($1, $2);
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_control';
+
+-- The (portion) form resolves the parameter type from the range column.
+PREPARE fpo_param_portion AS
+  UPDATE fpo_param FOR PORTION OF valid_at ($1) SET name = 'portion';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_portion';
+
+-- The FROM/TO form should likewise resolve its bounds to the range's
+-- subtype, so that clients need not spell out the parameter types.
+PREPARE fpo_param_update AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO $2 SET name = 'upd';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_update';
+
+PREPARE fpo_param_delete AS
+  DELETE FROM fpo_param FOR PORTION OF valid_at FROM $1 TO $2;
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_delete';
+
+-- Only one bound parameterized.
+PREPARE fpo_param_one AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO '2003-01-01'
+    SET name = 'one-bound';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_one';
+
+-- A parameter used as both bounds must still resolve to one type.
+PREPARE fpo_param_same AS
+  UPDATE fpo_param FOR PORTION OF valid_at FROM $1 TO $1 SET name = 'same';
+SELECT parameter_types FROM pg_prepared_statements
+  WHERE name = 'fpo_param_same';
+
+EXECUTE fpo_param_update('2002-01-01', '2003-01-01');
+SELECT * FROM fpo_param ORDER BY valid_at;
+
+DEALLOCATE fpo_param_control;
+DEALLOCATE fpo_param_portion;
+DEALLOCATE fpo_param_update;
+DEALLOCATE fpo_param_delete;
+DEALLOCATE fpo_param_one;
+DEALLOCATE fpo_param_same;
+
+-- The bounds we keep for deparsing are the coerced ones, so an untyped NULL
+-- bound renders with the range's subtype instead of "unknown".
+CREATE TABLE fpo_param_src (id int);
+CREATE RULE fpo_param_r AS ON DELETE TO fpo_param_src DO INSTEAD
+  DELETE FROM fpo_param FOR PORTION OF valid_at FROM NULL TO '2001-01-01';
+SELECT definition FROM pg_rules WHERE rulename = 'fpo_param_r';
+
+DROP TABLE fpo_param_src;
+DROP TABLE fpo_param;
+
+--
+-- Names in the FOR PORTION OF bounds
+--
+-- A bound may not read the row being changed, but a name in that position
+-- doesn't necessarily mean a column: it may belong to a PL/pgSQL variable or
+-- to a parameter of the SQL-bodied function we are part of.
+--
+
+CREATE TABLE fpo_name (valid_at int4range, name text);
+INSERT INTO fpo_name VALUES ('[1,100)', 'one');
+
+-- Columns of the target relation are still rejected, whether they are named
+-- directly or reached through a whole-row reference.
+UPDATE fpo_name FOR PORTION OF valid_at FROM lower(valid_at) TO 5
+  SET name = 'nope';
+UPDATE fpo_name FOR PORTION OF valid_at (valid_at) SET name = 'nope';
+DELETE FROM fpo_name FOR PORTION OF valid_at ((ROW(fpo_name.*)).f1);
+DELETE FROM fpo_name FOR PORTION OF valid_at FROM (ROW(fpo_name.*)).f2 TO 5;
+
+-- PL/pgSQL variables may be bounds, under either #variable_conflict rule.
+CREATE FUNCTION fpo_name_plpgsql(lo int, hi int) RETURNS void
+LANGUAGE plpgsql AS $$
+BEGIN
+  UPDATE fpo_name FOR PORTION OF valid_at FROM lo TO hi SET name = 'plpgsql';
+END $$;
+SELECT fpo_name_plpgsql(10, 20);
+
+CREATE FUNCTION fpo_name_plpgsql_var(lo int, hi int) RETURNS void
+LANGUAGE plpgsql AS $$
+#variable_conflict use_variable
+BEGIN
+  UPDATE fpo_name FOR PORTION OF valid_at FROM lo TO hi SET name = 'variable';
+END $$;
+SELECT fpo_name_plpgsql_var(30, 40);
+SELECT * FROM fpo_name ORDER BY valid_at;
+
+-- A PL/pgSQL variable that shares its name with a column of the target is
+-- still ambiguous, just as it would be anywhere else in the statement.
+CREATE FUNCTION fpo_name_plpgsql_ambig(valid_at int) RETURNS void
+LANGUAGE plpgsql AS $$
+BEGIN
+  UPDATE fpo_name FOR PORTION OF valid_at FROM valid_at TO 50 SET name = 'amb';
+END $$;
+SELECT fpo_name_plpgsql_ambig(45);
+
+-- Parameters of a SQL-bodied function may be bounds too.  They deparse by
+-- name, so pg_get_functiondef() output has to be re-loadable.
+CREATE FUNCTION fpo_name_sql(lo int, hi int) RETURNS void
+LANGUAGE SQL
+BEGIN ATOMIC
+  DELETE FROM fpo_name FOR PORTION OF valid_at FROM $1 TO $2;
+END;
+SELECT pg_get_functiondef('fpo_name_sql(int,int)'::regprocedure);
+SELECT pg_get_functiondef('fpo_name_sql(int,int)'::regprocedure) \gexec
+SELECT fpo_name_sql(60, 70);
+SELECT * FROM fpo_name ORDER BY valid_at;
+
+DROP FUNCTION fpo_name_plpgsql;
+DROP FUNCTION fpo_name_plpgsql_var;
+DROP FUNCTION fpo_name_plpgsql_ambig;
+DROP FUNCTION fpo_name_sql;
+DROP TABLE fpo_name;
+
+-- The bounds see UPDATE's FROM list, as they see DELETE's USING list, so a
+-- name resolves in them as it does in the rest of the statement, and a
+-- column of another table is rejected like any other column.
+CREATE TABLE fpo_from (id int, valid_at int4range, name text);
+INSERT INTO fpo_from VALUES (1, '[1,100)', 'a');
+CREATE TABLE fpo_from_src (lo int, hi int);
+INSERT INTO fpo_from_src VALUES (5, 6);
+UPDATE fpo_from FOR PORTION OF valid_at FROM fpo_from_src.lo TO fpo_from_src.hi
+  SET name = 'x' FROM fpo_from_src; -- fails
+UPDATE fpo_from FOR PORTION OF valid_at FROM lo TO hi
+  SET name = 'x' FROM fpo_from_src; -- fails
+DELETE FROM fpo_from FOR PORTION OF valid_at FROM lo TO hi
+  USING fpo_from_src; -- fails
+-- In PL/pgSQL, a variable named like a FROM/USING column is ambiguous in
+-- the bounds of both.
+CREATE FUNCTION fpo_from_upd(lo int, hi int) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+  UPDATE fpo_from FOR PORTION OF valid_at FROM lo TO hi SET name = 'u'
+    FROM fpo_from_src WHERE fpo_from_src.lo = 5;
+END $$;
+CREATE FUNCTION fpo_from_del(lo int, hi int) RETURNS void LANGUAGE plpgsql AS $$
+BEGIN
+  DELETE FROM fpo_from FOR PORTION OF valid_at FROM lo TO hi
+    USING fpo_from_src WHERE fpo_from_src.lo = 5;
+END $$;
+SELECT fpo_from_upd(50, 60); -- fails
+SELECT fpo_from_del(70, 80); -- fails
+-- In a SQL function, the column hides the parameter, in the bounds as in SET.
+CREATE FUNCTION fpo_from_sql(lo int) RETURNS void LANGUAGE sql AS $$
+  UPDATE fpo_from FOR PORTION OF valid_at FROM lo TO lo + 1 SET name = 'u' || lo
+    FROM fpo_from_src WHERE fpo_from.id = 1
+$$; -- fails
+SELECT * FROM fpo_from ORDER BY valid_at;
+DROP FUNCTION fpo_from_upd, fpo_from_del;
+DROP TABLE fpo_from, fpo_from_src;
+
+--
+-- EXPLAIN without ANALYZE must only plan the statement, so it must not
+-- evaluate the FOR PORTION OF target.
+--
+
+CREATE TABLE fpo_explain (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+INSERT INTO fpo_explain (id, valid_at, name) VALUES
+  ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one');
+
+-- GENERIC_PLAN exists precisely so that a statement containing parameter
+-- placeholders can be planned without supplying any parameter values.  The
+-- hand-written equivalent qual is the control case.
+EXPLAIN (COSTS OFF, GENERIC_PLAN)
+  UPDATE fpo_explain SET name = 'q' WHERE valid_at && $1::daterange;
+EXPLAIN (COSTS OFF, GENERIC_PLAN)
+  UPDATE fpo_explain FOR PORTION OF valid_at ($1::daterange) SET name = 'q';
+EXPLAIN (COSTS OFF, GENERIC_PLAN)
+  DELETE FROM fpo_explain FOR PORTION OF valid_at ($1::daterange);
+
+-- A null target is a run-time error, so plain EXPLAIN should still report
+-- the plan.
+EXPLAIN (COSTS OFF)
+  UPDATE fpo_explain FOR PORTION OF valid_at (NULL::daterange) SET name = 'q';
+EXPLAIN (COSTS OFF)
+  DELETE FROM fpo_explain FOR PORTION OF valid_at (NULL::daterange);
+
+-- Actually running it is still an error.
+UPDATE fpo_explain FOR PORTION OF valid_at (NULL::daterange) SET name = 'q';
+DELETE FROM fpo_explain FOR PORTION OF valid_at (NULL::daterange);
+
+-- EXPLAIN ANALYZE does execute, so the null check must still fire there.
+EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
+  UPDATE fpo_explain FOR PORTION OF valid_at (NULL::daterange) SET name = 'q';
+
+UPDATE fpo_explain FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = 'q';
+SELECT * FROM fpo_explain ORDER BY valid_at;
+
+DROP TABLE fpo_explain;
+
+-- Bounds may not contain aggregates, window functions, or set-returning functions:
+CREATE TABLE fpo_bounds (id int, valid_at int4range);
+INSERT INTO fpo_bounds VALUES (1, '[1,100)');
+-- aggregate:
+UPDATE fpo_bounds FOR PORTION OF valid_at FROM count(*) TO 10 SET id = 2;
+-- window function:
+UPDATE fpo_bounds FOR PORTION OF valid_at FROM row_number() OVER () TO 10 SET id = 2;
+-- set-returning function:
+UPDATE fpo_bounds FOR PORTION OF valid_at FROM generate_series(1, 2) TO 10 SET id = 2;
+DROP TABLE fpo_bounds;
+
+-- EXPLAIN of FOR PORTION OF (plan shape should be stable):
+CREATE TABLE fpo_explain (id int, valid_at daterange, name text);
+INSERT INTO fpo_explain VALUES (1, '[2000-01-01,2010-01-01)', 'a');
+EXPLAIN (COSTS OFF) UPDATE fpo_explain
+  FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  SET name = 'b';
+EXPLAIN (COSTS OFF) DELETE FROM fpo_explain
+  FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01';
+DROP TABLE fpo_explain;
+
+-- Parameterized bounds via PREPARE/EXECUTE must keep working once a generic plan
+-- is chosen (parameters are allowed in bounds even though volatile functions
+-- are not):
+CREATE TABLE fpo_prepared (id int, valid_at int4range, name text);
+INSERT INTO fpo_prepared VALUES (1, '[1,100)', 'a');
+PREPARE fpo_upd (int, int) AS
+  UPDATE fpo_prepared FOR PORTION OF valid_at FROM $1 TO $2 SET name = 'b'
+  WHERE id = 1;
+EXECUTE fpo_upd(10, 20);
+EXECUTE fpo_upd(30, 40);
+EXECUTE fpo_upd(50, 60);
+EXECUTE fpo_upd(70, 80);
+EXECUTE fpo_upd(85, 90);
+EXECUTE fpo_upd(92, 95);
+SELECT valid_at, name FROM fpo_prepared ORDER BY valid_at;
+DEALLOCATE fpo_upd;
+DROP TABLE fpo_prepared;
+
+-- Leftover rows must copy the original row's values for identity and
+-- default-valued columns; those defaults must NOT re-fire for leftovers:
+CREATE SEQUENCE fpo_seq;
+CREATE TABLE fpo_defaults (
+  id int GENERATED ALWAYS AS IDENTITY,
+  seqval int DEFAULT nextval('fpo_seq'),
+  valid_at int4range,
+  name text
+);
+INSERT INTO fpo_defaults (valid_at, name) VALUES ('[1,100)', 'a');
+UPDATE fpo_defaults FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+-- All three rows keep id=1 and seqval=1 (defaults did not re-fire):
+SELECT id, seqval, valid_at, name FROM fpo_defaults ORDER BY valid_at;
+-- The sequence did not advance past the single explicit insert:
+SELECT last_value FROM fpo_seq;
+DROP TABLE fpo_defaults;
+DROP SEQUENCE fpo_seq;
+
+-- A large (toasted) value must be preserved intact in leftovers:
+CREATE TABLE fpo_toast (id int, valid_at int4range, big text);
+INSERT INTO fpo_toast VALUES (1, '[1,100)', repeat('x', 100000));
+UPDATE fpo_toast FOR PORTION OF valid_at FROM 40 TO 60 SET id = 2;
+SELECT id, valid_at, length(big) FROM fpo_toast ORDER BY valid_at;
+DROP TABLE fpo_toast;
+
+-- FOR PORTION OF is not supported inside MERGE:
+CREATE TABLE fpo_merge (id int, valid_at int4range, name text);
+CREATE TABLE fpo_merge_src (id int);
+MERGE INTO fpo_merge t
+USING fpo_merge_src s ON t.id = s.id
+WHEN MATCHED THEN
+  UPDATE FOR PORTION OF valid_at FROM 1 TO 2 SET name = 'x';
+DROP TABLE fpo_merge, fpo_merge_src;
+
+-- Subtransaction rollback undoes a FOR PORTION OF, including its leftovers:
+CREATE TABLE fpo_savepoint (id int, valid_at int4range, name text);
+INSERT INTO fpo_savepoint VALUES (1, '[1,100)', 'a');
+BEGIN;
+SAVEPOINT sp;
+UPDATE fpo_savepoint FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+SELECT valid_at, name FROM fpo_savepoint ORDER BY valid_at;
+ROLLBACK TO SAVEPOINT sp;
+SELECT valid_at, name FROM fpo_savepoint ORDER BY valid_at;
+COMMIT;
+DROP TABLE fpo_savepoint;
+
+-- If a leftover insert fails, the whole statement rolls back and the target
+-- row is not left truncated:
+CREATE TABLE fpo_atomic (id int, valid_at int4range, name text);
+INSERT INTO fpo_atomic VALUES (1, '[1,100)', 'a');
+CREATE FUNCTION fpo_reject_insert() RETURNS trigger LANGUAGE plpgsql AS
+  $$ BEGIN RAISE EXCEPTION 'no leftovers allowed'; END $$;
+CREATE TRIGGER fpo_atomic_trg BEFORE INSERT ON fpo_atomic
+  FOR EACH ROW EXECUTE PROCEDURE fpo_reject_insert();
+UPDATE fpo_atomic FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+-- The original row is intact (not truncated to [1,40)):
+SELECT * FROM fpo_atomic ORDER BY valid_at;
+DROP TABLE fpo_atomic;
+DROP FUNCTION fpo_reject_insert();
+
+-- Degenerate targets: no matching row, target entirely outside, target covering:
+CREATE TABLE fpo_edge (id int, valid_at int4range, name text);
+INSERT INTO fpo_edge VALUES (1, '[10,20)', 'a');
+-- WHERE matches nothing: no change, no leftovers:
+UPDATE fpo_edge FOR PORTION OF valid_at FROM 12 TO 15 SET name = 'b' WHERE id = 999;
+SELECT * FROM fpo_edge ORDER BY valid_at;
+-- Target range entirely outside the row's range: no change, no leftovers:
+UPDATE fpo_edge FOR PORTION OF valid_at FROM 100 TO 200 SET name = 'b';
+SELECT * FROM fpo_edge ORDER BY valid_at;
+-- Target range fully covers the row: whole-row update, no leftovers:
+UPDATE fpo_edge FOR PORTION OF valid_at FROM 0 TO 50 SET name = 'c';
+SELECT * FROM fpo_edge ORDER BY valid_at;
+DROP TABLE fpo_edge;
+
+-- FOR PORTION OF after DROP COLUMN and after a dropped-then-readded column
+-- (leftover tuple construction must handle attnum gaps):
+CREATE TABLE fpo_dropcol (id int, junk int, valid_at int4range, name text);
+INSERT INTO fpo_dropcol VALUES (1, 42, '[1,100)', 'a');
+ALTER TABLE fpo_dropcol DROP COLUMN junk;
+UPDATE fpo_dropcol FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+SELECT id, valid_at, name FROM fpo_dropcol ORDER BY valid_at;
+ALTER TABLE fpo_dropcol ADD COLUMN extra text DEFAULT 'z';
+UPDATE fpo_dropcol FOR PORTION OF valid_at FROM 10 TO 20 SET name = 'c';
+SELECT id, valid_at, name, extra FROM fpo_dropcol ORDER BY valid_at;
+DROP TABLE fpo_dropcol;
+
+-- FOR PORTION OF applied directly to a partition (not the root):
+CREATE TABLE fpo_proot (id int, valid_at int4range, name text) PARTITION BY LIST (id);
+CREATE TABLE fpo_pchild PARTITION OF fpo_proot FOR VALUES IN (1);
+INSERT INTO fpo_proot VALUES (1, '[1,100)', 'a');
+UPDATE fpo_pchild FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+SELECT * FROM fpo_proot ORDER BY valid_at;
+DROP TABLE fpo_proot;
+
+-- A leftover that routes to no partition raises an error (and the whole
+-- statement rolls back).  Partition by the range's lower bound so that narrowing
+-- valid_at can push a leftover out of every existing partition.
+CREATE TABLE fpo_part_err (id int, valid_at int4range, name text)
+  PARTITION BY RANGE (lower(valid_at));
+CREATE TABLE fpo_part_err_low PARTITION OF fpo_part_err
+  FOR VALUES FROM (MINVALUE) TO (50);
+INSERT INTO fpo_part_err VALUES (1, '[10,100)', 'a');
+-- The leftover [60,100) has lower bound 60, which no partition covers:
+UPDATE fpo_part_err FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'b';
+-- The original row is intact (statement rolled back atomically):
+SELECT * FROM fpo_part_err ORDER BY valid_at;
+DROP TABLE fpo_part_err;
+
+-- FOR PORTION OF against a table referenced by a temporal foreign key (NO ACTION).
+-- UPDATE preserves total coverage via leftovers, so the FK stays satisfied even
+-- when the target overlaps a range the child needs; DELETE removes coverage and
+-- so is rejected when the child depends on it.
+CREATE TABLE fpo_fk_parent (
+  id int4range,
+  valid_at daterange,
+  note text,
+  PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+CREATE TABLE fpo_fk_child (
+  id int4range,
+  valid_at daterange,
+  parent_id int4range,
+  FOREIGN KEY (parent_id, PERIOD valid_at) REFERENCES fpo_fk_parent (id, PERIOD valid_at)
+);
+INSERT INTO fpo_fk_parent VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a');
+INSERT INTO fpo_fk_child VALUES ('[1,2)', '[2000-01-01,2005-01-01)', '[1,2)');
+-- ok: leftovers restore the coverage the child needs:
+UPDATE fpo_fk_parent
+  FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  SET note = 'b';
+SELECT * FROM fpo_fk_parent ORDER BY valid_at;
+-- error: DELETE removes coverage the child depends on:
+DELETE FROM fpo_fk_parent
+  FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01';
+-- ok: DELETE outside the child's needed range:
+DELETE FROM fpo_fk_parent
+  FOR PORTION OF valid_at FROM '2008-01-01' TO '2009-01-01';
+SELECT * FROM fpo_fk_parent ORDER BY valid_at;
+DROP TABLE fpo_fk_child, fpo_fk_parent;
+
+-- FOR PORTION OF on the *referencing* side of a temporal foreign key: leftovers
+-- only narrow the child's own validity, so they always still satisfy the FK.
+CREATE TABLE fpo_ref_parent (
+  id int4range,
+  valid_at daterange,
+  PRIMARY KEY (id, valid_at WITHOUT OVERLAPS)
+);
+CREATE TABLE fpo_ref_child (
+  id int4range,
+  valid_at daterange,
+  parent_id int4range,
+  note text,
+  FOREIGN KEY (parent_id, PERIOD valid_at) REFERENCES fpo_ref_parent (id, PERIOD valid_at)
+);
+INSERT INTO fpo_ref_parent VALUES ('[1,2)', '[2000-01-01,2010-01-01)');
+INSERT INTO fpo_ref_child VALUES ('[1,2)', '[2000-01-01,2008-01-01)', '[1,2)', 'a');
+-- UPDATE FOR PORTION OF the child: leftovers still reference covered parent ranges:
+UPDATE fpo_ref_child
+  FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET note = 'b';
+SELECT id, valid_at, parent_id, note FROM fpo_ref_child ORDER BY valid_at;
+-- DELETE FOR PORTION OF the child: removes part of the child's validity (FK-safe):
+DELETE FROM fpo_ref_child
+  FOR PORTION OF valid_at FROM '2004-01-01' TO '2005-01-01';
+SELECT id, valid_at, parent_id, note FROM fpo_ref_child ORDER BY valid_at;
+DROP TABLE fpo_ref_child, fpo_ref_parent;
+
+-- A NOT NULL non-range column is copied into leftovers (not nulled out):
+CREATE TABLE fpo_notnull (id int, valid_at int4range, name text NOT NULL);
+INSERT INTO fpo_notnull VALUES (1, '[1,100)', 'keep');
+UPDATE fpo_notnull FOR PORTION OF valid_at FROM 40 TO 60 SET id = 2;
+SELECT * FROM fpo_notnull ORDER BY valid_at;
+DROP TABLE fpo_notnull;
+
+-- A non-default collation on a non-range column is preserved in leftovers:
+CREATE TABLE fpo_collate (id int, valid_at int4range, name text COLLATE "C");
+INSERT INTO fpo_collate VALUES (1, '[1,100)', 'Xyz');
+UPDATE fpo_collate FOR PORTION OF valid_at FROM 40 TO 60 SET id = 2;
+SELECT id, valid_at, name FROM fpo_collate ORDER BY valid_at;
+DROP TABLE fpo_collate;
+
+-- EXPLAIN ANALYZE executes the FOR PORTION OF (including its leftovers):
+CREATE TABLE fpo_ea (id int, valid_at daterange, name text);
+INSERT INTO fpo_ea VALUES (1, '[2000-01-01,2010-01-01)', 'a');
+EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
+  UPDATE fpo_ea FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' SET name = 'b';
+SELECT valid_at, name FROM fpo_ea ORDER BY valid_at;
+DROP TABLE fpo_ea;
+
+-- RLS USING visibility: FOR PORTION OF only sees and splits rows the USING
+-- policy makes visible; rows hidden by USING are left untouched.  Runs as a
+-- non-superuser role, since superusers bypass RLS.  The leftovers keep the
+-- tenant column, so they satisfy the policy's implied WITH CHECK.
+CREATE ROLE regress_fpo_rls_using;
+CREATE TABLE fpo_rls_using (id int, valid_at int4range, tenant name, name text);
+INSERT INTO fpo_rls_using VALUES
+  (1, '[1,100)', 'regress_fpo_rls_using', 'a'),
+  (2, '[1,100)', 'other', 'b');
+ALTER TABLE fpo_rls_using ENABLE ROW LEVEL SECURITY;
+CREATE POLICY fpo_rls_using_pol ON fpo_rls_using USING (tenant = current_user);
+GRANT SELECT, INSERT, UPDATE ON fpo_rls_using TO regress_fpo_rls_using;
+SET ROLE regress_fpo_rls_using;
+-- Only the row whose tenant = current_user is visible, so only it is split:
+UPDATE fpo_rls_using FOR PORTION OF valid_at FROM 40 TO 60 SET name = 'a2';
+SELECT id, valid_at, tenant, name FROM fpo_rls_using ORDER BY id, valid_at;
+RESET ROLE;
+-- As the RLS-bypassing owner: the 'other' row was never seen or touched:
+SELECT id, valid_at, tenant, name FROM fpo_rls_using ORDER BY id, valid_at;
+DROP TABLE fpo_rls_using;
+DROP ROLE regress_fpo_rls_using;
+
+--
+-- A user-defined default GiST operator class whose opcintype is a concrete
+-- range (or multirange) type, rather than the polymorphic anyrange the
+-- built-in range_ops uses.
+--
+
+CREATE TYPE fpo_range AS RANGE (
+  subtype = date,
+  multirange_type_name = fpo_multirange
+);
+
+CREATE FUNCTION fpo_range_overlaps(fpo_range, fpo_range) RETURNS boolean
+  LANGUAGE sql IMMUTABLE STRICT
+  AS $$ SELECT $1 OPERATOR(pg_catalog.&&) $2 $$;
+CREATE OPERATOR && (
+  leftarg = fpo_range, rightarg = fpo_range,
+  procedure = fpo_range_overlaps, commutator = &&,
+  restrict = rangesel, join = areajoinsel
+);
+CREATE OPERATOR CLASS fpo_range_ops
+  DEFAULT FOR TYPE fpo_range USING gist AS
+  OPERATOR 3 &&(fpo_range, fpo_range),
+  FUNCTION 1 range_gist_consistent(internal, anyrange, smallint, oid, internal),
+  FUNCTION 2 range_gist_union(internal, internal),
+  FUNCTION 5 range_gist_penalty(internal, internal, internal),
+  FUNCTION 6 range_gist_picksplit(internal, internal),
+  FUNCTION 7 range_gist_same(anyrange, anyrange, internal);
+-- gistutil.c looks this one up with get_opfamily_proc(opfamily, ANYOID,
+-- ANYOID, GIST_TRANSLATE_CMPTYPE_PROC), so it must be registered that way.
+ALTER OPERATOR FAMILY fpo_range_ops USING gist
+  ADD FUNCTION 12 ("any", "any") gist_translate_cmptype_common(integer);
+
+CREATE FUNCTION fpo_multirange_overlaps(fpo_multirange, fpo_multirange)
+  RETURNS boolean LANGUAGE sql IMMUTABLE STRICT
+  AS $$ SELECT $1 OPERATOR(pg_catalog.&&) $2 $$;
+CREATE OPERATOR && (
+  leftarg = fpo_multirange, rightarg = fpo_multirange,
+  procedure = fpo_multirange_overlaps, commutator = &&,
+  restrict = multirangesel, join = areajoinsel
+);
+CREATE OPERATOR CLASS fpo_multirange_ops
+  DEFAULT FOR TYPE fpo_multirange USING gist AS
+  OPERATOR 3 &&(fpo_multirange, fpo_multirange),
+  FUNCTION 1 multirange_gist_consistent(internal, anymultirange, smallint, oid, internal),
+  FUNCTION 2 range_gist_union(internal, internal),
+  FUNCTION 3 multirange_gist_compress(internal),
+  FUNCTION 5 range_gist_penalty(internal, internal, internal),
+  FUNCTION 6 range_gist_picksplit(internal, internal),
+  FUNCTION 7 range_gist_same(anyrange, anyrange, internal);
+ALTER OPERATOR FAMILY fpo_multirange_ops USING gist
+  ADD FUNCTION 12 ("any", "any") gist_translate_cmptype_common(integer);
+
+CREATE TABLE fpo_opc (
+  id int,
+  valid_at fpo_range,
+  valid_ats fpo_multirange,
+  name text
+);
+-- The opclasses really are usable for indexing.
+CREATE INDEX fpo_opc_r_idx ON fpo_opc USING gist (valid_at);
+CREATE INDEX fpo_opc_mr_idx ON fpo_opc USING gist (valid_ats);
+
+INSERT INTO fpo_opc VALUES
+  (1, fpo_range('2000-01-01', '2010-01-01'),
+      fpo_multirange(fpo_range('2000-01-01', '2010-01-01')), 'one');
+
+-- control: an ordinary overlaps qual against the same opclass
+UPDATE fpo_opc SET name = 'ctl'
+  WHERE valid_at && fpo_range('2002-01-01', '2003-01-01');
+SELECT * FROM fpo_opc ORDER BY valid_at;
+
+UPDATE fpo_opc FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = 'upd';
+SELECT id, valid_at, name FROM fpo_opc ORDER BY valid_at;
+DELETE FROM fpo_opc FOR PORTION OF valid_at FROM '2005-01-01' TO '2006-01-01';
+SELECT id, valid_at, name FROM fpo_opc ORDER BY valid_at;
+
+DELETE FROM fpo_opc;
+INSERT INTO fpo_opc VALUES
+  (1, fpo_range('2000-01-01', '2010-01-01'),
+      fpo_multirange(fpo_range('2000-01-01', '2010-01-01')), 'one');
+
+UPDATE fpo_opc
+  FOR PORTION OF valid_ats
+    (fpo_multirange(fpo_range('2002-01-01', '2003-01-01')))
+  SET name = 'upd';
+SELECT id, valid_ats, name FROM fpo_opc ORDER BY valid_ats;
+DELETE FROM fpo_opc
+  FOR PORTION OF valid_ats
+    (fpo_multirange(fpo_range('2005-01-01', '2006-01-01')));
+SELECT id, valid_ats, name FROM fpo_opc ORDER BY valid_ats;
+
+DROP TABLE fpo_opc;
+DROP OPERATOR CLASS fpo_multirange_ops USING gist;
+DROP OPERATOR && (fpo_multirange, fpo_multirange);
+DROP FUNCTION fpo_multirange_overlaps(fpo_multirange, fpo_multirange);
+DROP OPERATOR CLASS fpo_range_ops USING gist;
+DROP OPERATOR && (fpo_range, fpo_range);
+DROP FUNCTION fpo_range_overlaps(fpo_range, fpo_range);
+DROP TYPE fpo_range;
+
+--
+-- Diagnostics when a trigger on the leftover INSERTs changes rows the
+-- statement has not reached yet.
+--
+-- Leftovers are inserted as the scan proceeds and fire their own triggers, so
+-- an AFTER row trigger can trip the "already modified by an operation
+-- triggered by the current command" check.  The stock hint only mentions
+-- BEFORE triggers, so we add a detail naming the other possibility.
+--
+
+CREATE TABLE fpo_selfmod (
+  id int,
+  valid_at daterange,
+  name text,
+  touched int NOT NULL DEFAULT 0
+);
+INSERT INTO fpo_selfmod (id, valid_at, name) VALUES
+  (1, daterange('2000-01-01', '2010-01-01'), 'one'),
+  (2, daterange('2000-01-01', '2010-01-01'), 'two');
+
+CREATE FUNCTION fpo_touch_siblings() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  IF pg_trigger_depth() > 1 THEN
+    RETURN NULL;
+  END IF;
+  UPDATE fpo_selfmod SET touched = touched + 1 WHERE id <> NEW.id;
+  RETURN NULL;
+END;
+$$;
+
+CREATE TRIGGER fpo_selfmod_ai AFTER INSERT ON fpo_selfmod
+  FOR EACH ROW EXECUTE PROCEDURE fpo_touch_siblings();
+
+UPDATE fpo_selfmod FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = name || '!';
+DELETE FROM fpo_selfmod FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01';
+
+-- The same detail appears for the check in GetTupleForTrigger(), which a
+-- BEFORE trigger reaches.  There the stock hint is the right advice, so it is
+-- still what we suggest.
+CREATE FUNCTION fpo_touch_siblings_bu() RETURNS TRIGGER LANGUAGE plpgsql AS
+$$
+BEGIN
+  IF pg_trigger_depth() = 1 THEN
+    UPDATE fpo_selfmod SET touched = touched + 1 WHERE id <> NEW.id;
+  END IF;
+  RETURN NEW;
+END;
+$$;
+CREATE TRIGGER fpo_selfmod_bu BEFORE UPDATE ON fpo_selfmod
+  FOR EACH ROW EXECUTE PROCEDURE fpo_touch_siblings_bu();
+UPDATE fpo_selfmod FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = name || '!';
+DROP TRIGGER fpo_selfmod_bu ON fpo_selfmod;
+DROP FUNCTION fpo_touch_siblings_bu();
+
+-- Deferring the trigger works.
+DROP TRIGGER fpo_selfmod_ai ON fpo_selfmod;
+CREATE CONSTRAINT TRIGGER fpo_selfmod_ai AFTER INSERT ON fpo_selfmod
+  DEFERRABLE INITIALLY DEFERRED
+  FOR EACH ROW EXECUTE PROCEDURE fpo_touch_siblings();
+
+BEGIN;
+UPDATE fpo_selfmod FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = name || '!';
+COMMIT;
+SELECT * FROM fpo_selfmod ORDER BY id, valid_at;
+
+DROP TABLE fpo_selfmod;
+DROP FUNCTION fpo_touch_siblings();
+
+--
+-- WITH CHECK OPTION on a view
+--
+-- The row the statement produced is checked first, so a violation names the
+-- row the user asked for and not an untouched leftover.
+--
+
+CREATE TABLE fpo_wco (
+  id int,
+  valid_at daterange,
+  name text
+);
+INSERT INTO fpo_wco VALUES
+  (1, daterange('2000-01-01', '2010-01-01'), 'keepme');
+
+CREATE VIEW fpo_wco_v AS
+  SELECT * FROM fpo_wco WHERE valid_at @> '2005-01-01'::date
+  WITH CHECK OPTION;
+
+-- Both the updated row and the leftovers fall outside the view.  The updated
+-- row is the one the user wrote, so that is the one to report.
+UPDATE fpo_wco_v FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET name = 'changed';
+
+-- Here the updated row still satisfies the view and only a leftover does not,
+-- so the leftover is correctly the one reported.
+UPDATE fpo_wco_v FOR PORTION OF valid_at FROM '2004-01-01' TO '2006-01-01'
+  SET name = 'changed';
+
+-- Nothing was written by either statement.
+SELECT * FROM fpo_wco ORDER BY valid_at;
+
+-- A portion covering the whole row leaves no leftovers and is accepted.
+UPDATE fpo_wco_v FOR PORTION OF valid_at FROM '2000-01-01' TO '2010-01-01'
+  SET name = 'changed';
+SELECT * FROM fpo_wco ORDER BY valid_at;
+
+DROP VIEW fpo_wco_v;
+DROP TABLE fpo_wco;
+
+--
+-- Assigning the range column through a view that exposes it twice
+--
+-- transformUpdateTargetList() compares the names the user wrote, so it does
+-- not notice that two view columns are the same base column.  The rewriter
+-- must still say why the assignment is rejected.
+--
+
+CREATE TABLE fpo_dup (
+  id int,
+  valid_at daterange,
+  name text
+);
+INSERT INTO fpo_dup VALUES
+  (1, daterange('2000-01-01', '2010-01-01'), 'one');
+
+CREATE VIEW fpo_dup_v AS
+  SELECT id, valid_at AS a, valid_at AS b, name, name AS c, name AS d
+  FROM fpo_dup;
+
+UPDATE fpo_dup_v FOR PORTION OF a FROM '2002-01-01' TO '2003-01-01'
+  SET b = daterange('1990-01-01', '1991-01-01');
+
+-- Naming the same view column in both places is caught in parse analysis, as
+-- it is for a table.
+UPDATE fpo_dup_v FOR PORTION OF a FROM '2002-01-01' TO '2003-01-01'
+  SET a = daterange('1990-01-01', '1991-01-01');
+
+-- Two view columns aliasing a column that is not the FOR PORTION OF column
+-- collide with each other, not with the clause, so they still get the generic
+-- error: the new check must not fire too eagerly.
+UPDATE fpo_dup_v FOR PORTION OF a FROM '2002-01-01' TO '2003-01-01'
+  SET c = 'x', d = 'y';
+
+-- Not assigning the range column through the view still works.
+UPDATE fpo_dup_v FOR PORTION OF a FROM '2002-01-01' TO '2003-01-01'
+  SET name = 'one^1';
+SELECT * FROM fpo_dup ORDER BY valid_at;
+
+DROP VIEW fpo_dup_v;
+DROP TABLE fpo_dup;
+
+RESET datestyle;
diff --git a/src/test/regress/sql/privileges.sql b/src/test/regress/sql/privileges.sql
index 0113dfa5dab..b4c3cb1d919 100644
--- a/src/test/regress/sql/privileges.sql
+++ b/src/test/regress/sql/privileges.sql
@@ -791,6 +791,41 @@ UPDATE errtst SET a = 'aaaa', b = NULL WHERE a = 'aaa';
 SET SESSION AUTHORIZATION regress_priv_user1;
 DROP TABLE errtst;
 
+-- test column-level privileges on the range used in FOR PORTION OF
+SET SESSION AUTHORIZATION regress_priv_user1;
+CREATE TABLE t1 (
+  c1 int4range,
+  valid_at tsrange,
+	CONSTRAINT t1pk PRIMARY KEY (c1, valid_at WITHOUT OVERLAPS)
+);
+-- UPDATE requires select and update permission on the valid_at column:
+GRANT SELECT (c1) ON t1 TO regress_priv_user2;
+GRANT UPDATE (c1) ON t1 TO regress_priv_user2;
+GRANT SELECT (c1, valid_at) ON t1 TO regress_priv_user3;
+GRANT UPDATE (c1) ON t1 TO regress_priv_user3;
+GRANT SELECT (c1) ON t1 TO regress_priv_user4;
+GRANT UPDATE (c1, valid_at) ON t1 TO regress_priv_user4;
+GRANT SELECT (c1, valid_at) ON t1 TO regress_priv_user5;
+GRANT UPDATE (c1, valid_at) ON t1 TO regress_priv_user5;
+SET SESSION AUTHORIZATION regress_priv_user2;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+SET SESSION AUTHORIZATION regress_priv_user3;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+SET SESSION AUTHORIZATION regress_priv_user4;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+SET SESSION AUTHORIZATION regress_priv_user5;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+SET SESSION AUTHORIZATION regress_priv_user1;
+-- DELETE requires select permission on the valid_at column:
+GRANT DELETE ON t1 TO regress_priv_user2;
+GRANT DELETE ON t1 TO regress_priv_user3;
+SET SESSION AUTHORIZATION regress_priv_user2;
+DELETE FROM t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01';
+SET SESSION AUTHORIZATION regress_priv_user3;
+DELETE FROM t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01';
+SET SESSION AUTHORIZATION regress_priv_user1;
+DROP TABLE t1;
+
 -- test column-level privileges when involved with DELETE
 SET SESSION AUTHORIZATION regress_priv_user1;
 ALTER TABLE atest6 ADD COLUMN three integer;
diff --git a/src/test/regress/sql/rowsecurity.sql b/src/test/regress/sql/rowsecurity.sql
index 2bdb0c70c5c..ecb55dfe9cf 100644
--- a/src/test/regress/sql/rowsecurity.sql
+++ b/src/test/regress/sql/rowsecurity.sql
@@ -85,6 +85,22 @@ CREATE POLICY upd_pol ON rls_test_tgt FOR UPDATE
 CREATE POLICY del_pol ON rls_test_tgt FOR DELETE
   USING (rls_test_policy_fn('DELETE USING on rls_test_tgt', rls_test_tgt));
 
+-- setup temporal target table (for FOR PORTION OF operations)
+CREATE TABLE rls_test_fpo_tgt (a int, b text, valid_at int4range);
+ALTER TABLE rls_test_fpo_tgt ENABLE ROW LEVEL SECURITY;
+GRANT SELECT, UPDATE, DELETE ON rls_test_fpo_tgt TO public;
+INSERT INTO rls_test_fpo_tgt VALUES (1, 'fpo a', '[1,10)');
+
+CREATE POLICY sel_pol ON rls_test_fpo_tgt FOR SELECT
+  USING (rls_test_policy_fn('SELECT USING on rls_test_fpo_tgt', rls_test_fpo_tgt));
+CREATE POLICY ins_pol ON rls_test_fpo_tgt FOR INSERT
+  WITH CHECK (rls_test_policy_fn('INSERT CHECK on rls_test_fpo_tgt', rls_test_fpo_tgt));
+CREATE POLICY upd_pol ON rls_test_fpo_tgt FOR UPDATE
+  USING (rls_test_policy_fn('UPDATE USING on rls_test_fpo_tgt', rls_test_fpo_tgt))
+  WITH CHECK (rls_test_policy_fn('UPDATE CHECK on rls_test_fpo_tgt', rls_test_fpo_tgt));
+CREATE POLICY del_pol ON rls_test_fpo_tgt FOR DELETE
+  USING (rls_test_policy_fn('DELETE USING on rls_test_fpo_tgt', rls_test_fpo_tgt));
+
 -- test policies applied to regress_rls_bob
 SET SESSION AUTHORIZATION regress_rls_bob;
 
@@ -114,6 +130,14 @@ UPDATE rls_test_tgt SET b = 'tgt b';
 UPDATE rls_test_tgt SET b = 'tgt c' WHERE a = 1;
 UPDATE rls_test_tgt SET b = 'tgt d' RETURNING *;
 
+-- UPDATE ... FOR PORTION OF should also apply SELECT USING policy clauses to
+-- the old and new rows, and INSERT CHECK policy clauses to leftover rows.
+BEGIN;
+UPDATE rls_test_fpo_tgt
+  FOR PORTION OF valid_at FROM 3 TO 7
+  SET b = 'fpo b';
+ROLLBACK;
+
 -- DELETE without WHERE or RETURNING should only apply DELETE USING policy clause
 BEGIN; DELETE FROM rls_test_tgt; ROLLBACK;
 
@@ -121,6 +145,13 @@ BEGIN; DELETE FROM rls_test_tgt; ROLLBACK;
 BEGIN; DELETE FROM rls_test_tgt WHERE a = 1; ROLLBACK;
 DELETE FROM rls_test_tgt RETURNING *;
 
+-- DELETE ... FOR PORTION OF should also apply SELECT USING policy clauses to
+-- the old row, and INSERT CHECK policy clauses to leftover rows.
+BEGIN;
+DELETE FROM rls_test_fpo_tgt
+  FOR PORTION OF valid_at FROM 3 TO 7;
+ROLLBACK;
+
 -- INSERT ... ON CONFLICT DO NOTHING with an arbiter clause should apply
 -- INSERT CHECK and SELECT USING policy clauses (to new value, whether it
 -- conflicts or not)
@@ -192,7 +223,7 @@ MERGE INTO rls_test_tgt t USING rls_test_src s ON t.a = s.a
 
 -- Tidy up
 RESET SESSION AUTHORIZATION;
-DROP TABLE rls_test_src, rls_test_tgt;
+DROP TABLE rls_test_src, rls_test_tgt, rls_test_fpo_tgt;
 DROP FUNCTION rls_test_tgt_set_c;
 DROP FUNCTION rls_test_policy_fn;
 
diff --git a/src/test/regress/sql/updatable_views.sql b/src/test/regress/sql/updatable_views.sql
index 1635adde2d4..a0603557257 100644
--- a/src/test/regress/sql/updatable_views.sql
+++ b/src/test/regress/sql/updatable_views.sql
@@ -1889,6 +1889,93 @@ select * from uv_iocu_tab;
 drop view uv_iocu_view;
 drop table uv_iocu_tab;
 
+-- Check UPDATE FOR PORTION OF works correctly
+create table uv_fpo_tab (id int4range, valid_at tsrange, b float,
+    constraint pk_uv_fpo_tab primary key (id, valid_at without overlaps));
+insert into uv_fpo_tab values ('[1,1]', '[2020-01-01, 2030-01-01)', 0);
+create view uv_fpo_view as
+    select b, b+1 as c, valid_at, id, '2.0'::text as two from uv_fpo_tab;
+
+insert into uv_fpo_view (id, valid_at, b) values ('[1,1]', '[2010-01-01, 2020-01-01)', 1);
+select * from uv_fpo_view order by id, valid_at;
+update uv_fpo_view for portion of valid_at from '2015-01-01' to '2020-01-01' set b = 2 where id = '[1,1]';
+select * from uv_fpo_view order by id, valid_at;
+delete from uv_fpo_view for portion of valid_at from '2017-01-01' to '2022-01-01' where id = '[1,1]';
+select * from uv_fpo_view order by id, valid_at;
+
+-- UPDATE/DELETE FOR PORTION fails if the column is not updatable
+-- (e.g. a computed expression, not a base column):
+create view uv_fpo_view_nonupd as
+  select id, '[1,20]'::int4range as valid_at, b
+  from uv_fpo_tab;
+-- Updating fails:
+update uv_fpo_view_nonupd for portion of valid_at from 1 to 10 set b = 2;
+-- Deleting fails:
+delete from uv_fpo_view_nonupd for portion of valid_at from 1 to 10;
+drop view uv_fpo_view_nonupd;
+
+-- WITH CHECK OPTION must be enforced on temporal leftovers, i.e. the rows
+-- FOR PORTION OF inserts to preserve the untouched parts of the target row.
+-- This applies to DELETE as well as UPDATE.
+create table uv_fpo_wco_tab (id int4range, valid_at daterange, b int);
+insert into uv_fpo_wco_tab values ('[1,1]', '[2020-01-01,2030-01-01)', 0);
+create view uv_fpo_wco_view as
+  select * from uv_fpo_wco_tab
+  where valid_at && daterange('2024-01-01', '2025-01-01')
+  with check option;
+-- The leftovers fall outside the view, so both commands fail:
+update uv_fpo_wco_view for portion of valid_at from '2024-01-01' to '2025-01-01' set b = 1;
+delete from uv_fpo_wco_view for portion of valid_at from '2024-01-01' to '2025-01-01';
+-- The base table is unchanged:
+select * from uv_fpo_wco_tab order by valid_at;
+-- Leftovers that still satisfy the view are allowed:
+delete from uv_fpo_wco_view for portion of valid_at from '2024-03-01' to '2024-06-01';
+update uv_fpo_wco_view for portion of valid_at from '2024-07-01' to '2024-08-01' set b = 1;
+select * from uv_fpo_wco_tab order by valid_at;
+-- Without WITH CHECK OPTION the leftovers may leave the view:
+create view uv_fpo_nowco_view as
+  select * from uv_fpo_wco_tab
+  where valid_at && daterange('2024-01-01', '2025-01-01');
+delete from uv_fpo_nowco_view for portion of valid_at from '2024-01-01' to '2024-02-01';
+update uv_fpo_nowco_view for portion of valid_at from '2024-09-01' to '2026-01-01' set b = 2;
+select * from uv_fpo_wco_tab order by valid_at;
+drop view uv_fpo_wco_view;
+drop view uv_fpo_nowco_view;
+drop table uv_fpo_wco_tab;
+
+-- On a partitioned table a leftover can be routed to a partition that is not
+-- one of the plan's result relations.  WITH CHECK OPTION must be enforced
+-- there too.
+create table uv_fpo_wco_part (id int4range, valid_at daterange, b int)
+  partition by range (lower(valid_at));
+create table uv_fpo_wco_part1 partition of uv_fpo_wco_part
+  for values from (minvalue) to ('2000-06-01');
+create table uv_fpo_wco_part2 partition of uv_fpo_wco_part
+  for values from ('2000-06-01') to (maxvalue);
+insert into uv_fpo_wco_part values ('[1,1]', '[2000-01-01,2004-01-01)', 0);
+-- This view accepts both leftovers.  The where clause prunes away part2, but
+-- the second leftover is routed there anyway:
+create view uv_fpo_wco_part_view as
+  select * from uv_fpo_wco_part where b = 0 with check option;
+delete from uv_fpo_wco_part_view
+  for portion of valid_at from '2000-03-01' to '2000-07-01'
+  where lower(valid_at) < '2000-06-01';
+select tableoid::regclass, * from uv_fpo_wco_part order by valid_at;
+-- This view rejects the leftover that lands in part2:
+delete from uv_fpo_wco_part;
+insert into uv_fpo_wco_part values ('[1,1]', '[2000-01-01,2004-01-01)', 0);
+create view uv_fpo_wco_part_view2 as
+  select * from uv_fpo_wco_part where lower(valid_at) < '2000-02-01'
+  with check option;
+delete from uv_fpo_wco_part_view2
+  for portion of valid_at from '2000-03-01' to '2000-07-01'
+  where lower(valid_at) < '2000-06-01';
+-- The base table is unchanged:
+select tableoid::regclass, * from uv_fpo_wco_part order by valid_at;
+drop view uv_fpo_wco_part_view;
+drop view uv_fpo_wco_part_view2;
+drop table uv_fpo_wco_part;
+
 -- Test whole-row references to the view
 create table uv_iocu_tab (a int unique, b text);
 create view uv_iocu_view as
@@ -2123,3 +2210,116 @@ values (1, 2, default, 5, 4, default, 3), (10, 11, 'C value', 14, 13, 100, 12);
 select * from base_tab order by a;
 drop view base_tab_view;
 drop table base_tab;
+
+-- FOR PORTION OF is not supported on views with INSTEAD OF triggers
+create view uv_fpo_instead_view as select id, valid_at, b from uv_fpo_tab;
+
+create function uv_fpo_instead_trig() returns trigger language plpgsql as
+$$ begin return null; end $$;
+
+create trigger uv_fpo_instead_upd_trig
+  instead of update on uv_fpo_instead_view
+  for each row execute function uv_fpo_instead_trig();
+create trigger uv_fpo_instead_del_trig
+  instead of delete on uv_fpo_instead_view
+  for each row execute function uv_fpo_instead_trig();
+
+update uv_fpo_instead_view
+  for portion of valid_at from '2015-01-01' to '2020-01-01'
+  set b = 99 where id = '[1,1]'; -- error
+
+delete from uv_fpo_instead_view
+  for portion of valid_at from '2017-01-01' to '2022-01-01'
+  where id = '[1,1]'; -- error
+
+-- The check does not depend on which rows match, so it errors even when
+-- no rows do.
+update uv_fpo_instead_view
+  for portion of valid_at from '2015-01-01' to '2020-01-01'
+  set b = 99 where id = '[9,9]'; -- error, even with no matching rows
+
+delete from uv_fpo_instead_view
+  for portion of valid_at from '2017-01-01' to '2022-01-01'
+  where id = '[9,9]'; -- error, even with no matching rows
+
+drop view uv_fpo_instead_view;
+drop function uv_fpo_instead_trig();
+
+-- FOR PORTION OF is not supported on views with DO ALSO rules either.  The
+-- rule action is built before we narrow the view's query, so it would see an
+-- unnarrowed NEW range and would not be limited to the targeted rows.
+create table uv_fpo_also_log (tag text, valid_at tsrange, b float);
+create view uv_fpo_also_view as select id, valid_at, b from uv_fpo_tab;
+create rule uv_fpo_also_upd as on update to uv_fpo_also_view
+  do also insert into uv_fpo_also_log values ('upd', new.valid_at, new.b);
+create rule uv_fpo_also_del as on delete to uv_fpo_also_view
+  do also insert into uv_fpo_also_log values ('del', old.valid_at, old.b);
+
+update uv_fpo_also_view
+  for portion of valid_at from '2015-01-01' to '2020-01-01'
+  set b = 99 where id = '[1,1]'; -- error
+
+delete from uv_fpo_also_view
+  for portion of valid_at from '2017-01-01' to '2022-01-01'
+  where id = '[1,1]'; -- error
+
+-- Without FOR PORTION OF the rules work as always.
+update uv_fpo_also_view set b = 98 where id = '[1,1]';
+delete from uv_fpo_also_view where id = '[1,1]';
+select * from uv_fpo_also_log order by tag, valid_at;
+
+drop view uv_fpo_also_view;
+drop table uv_fpo_also_log;
+
+-- A DO ALSO rule on the base table is fine: the rule action is built from a
+-- query that already carries the FOR PORTION OF restriction.
+create table uv_fpo_also_tab (id int4range, valid_at tsrange, b float);
+insert into uv_fpo_also_tab values ('[1,1]', '[2000-01-01,2030-01-01)', 0);
+create table uv_fpo_also_tab_log (valid_at tsrange, b float);
+create rule uv_fpo_also_tab_upd as on update to uv_fpo_also_tab
+  do also insert into uv_fpo_also_tab_log values (new.valid_at, new.b);
+update uv_fpo_also_tab
+  for portion of valid_at from '2010-01-01' to '2020-01-01'
+  set b = 99 where id = '[1,1]';
+select * from uv_fpo_also_tab order by valid_at;
+select * from uv_fpo_also_tab_log order by valid_at;
+drop table uv_fpo_also_tab, uv_fpo_also_tab_log;
+
+-- Forbid INSTEAD OF triggers with FOR PORTION OF even if the FOR PORTION OF
+-- statement is parsed before the trigger exists.
+-- This can happen in at least a couple ways: a rewrite rule or a BEGIN ATOMIC function.
+create function uv_fpo_instead_trig() returns trigger language plpgsql as
+$$ begin return new; end $$;
+
+-- via a rewrite rule
+create table uv_fpo_rule_tab (id int4range, valid_at tsrange, b float);
+insert into uv_fpo_rule_tab values ('[1,1]', '[2000-01-01, 2030-01-01)', 0);
+create view uv_fpo_rule_view as select id, valid_at, b from uv_fpo_rule_tab;
+create rule uv_fpo_rule as on insert to uv_fpo_rule_tab do also
+  update uv_fpo_rule_view
+    for portion of valid_at from '2010-01-01' to '2020-01-01'
+    set b = 99 where id = '[1,1]';
+create trigger uv_fpo_rule_instead
+  instead of update on uv_fpo_rule_view
+  for each row execute function uv_fpo_instead_trig();
+-- Would crash if we checked at parse-time:
+insert into uv_fpo_rule_tab values ('[2,2]', '[2000-01-01,2030-01-01)', 0);
+drop table uv_fpo_rule_tab cascade;
+
+-- via a BEGIN ATOMIC function body
+create table uv_fpo_atomic_tab (id int4range, valid_at tsrange, b float);
+insert into uv_fpo_atomic_tab values ('[1,1]', '[2000-01-01, 2030-01-01)', 0);
+create view uv_fpo_atomic_view as select id, valid_at, b from uv_fpo_atomic_tab;
+create function uv_fpo_atomic_fn() returns void language sql begin atomic
+  update uv_fpo_atomic_view
+    for portion of valid_at from '2010-01-01' to '2020-01-01'
+    set b = 99 where id = '[1,1]';
+end;
+create trigger uv_fpo_atomic_instead
+  instead of update on uv_fpo_atomic_view
+  for each row execute function uv_fpo_instead_trig();
+-- Would crash if we checked at parse-time:
+select uv_fpo_atomic_fn();
+drop function uv_fpo_atomic_fn();
+drop table uv_fpo_atomic_tab cascade;
+drop function uv_fpo_instead_trig();
diff --git a/src/test/regress/sql/without_overlaps.sql b/src/test/regress/sql/without_overlaps.sql
index 46bdd7856a4..4833b8ac5f0 100644
--- a/src/test/regress/sql/without_overlaps.sql
+++ b/src/test/regress/sql/without_overlaps.sql
@@ -2,7 +2,7 @@
 --
 -- We leave behind several tables to test pg_dump etc:
 -- temporal_rng, temporal_rng2,
--- temporal_fk_rng2rng.
+-- temporal_fk_rng2rng, temporal_fk2_rng2rng.
 
 SET datestyle TO ISO, YMD;
 
@@ -663,6 +663,20 @@ INSERT INTO temporal3 (id, valid_at, id2, name)
   ('[1,2)', daterange('2000-01-01', '2010-01-01'), '[7,8)', 'foo'),
   ('[2,3)', daterange('2000-01-01', '2010-01-01'), '[9,10)', 'bar')
 ;
+UPDATE temporal3 FOR PORTION OF valid_at FROM '2000-05-01' TO '2000-07-01'
+  SET name = name || '1';
+UPDATE temporal3 FOR PORTION OF valid_at FROM '2000-04-01' TO '2000-06-01'
+  SET name = name || '2'
+  WHERE id = '[2,3)';
+SELECT * FROM temporal3 ORDER BY id, valid_at;
+-- conflicting id only:
+INSERT INTO temporal3 (id, valid_at, id2, name)
+  VALUES
+  ('[1,2)', daterange('2005-01-01', '2006-01-01'), '[8,9)', 'foo3');
+-- conflicting id2 only:
+INSERT INTO temporal3 (id, valid_at, id2, name)
+  VALUES
+  ('[3,4)', daterange('2005-01-01', '2010-01-01'), '[9,10)', 'bar3');
 DROP TABLE temporal3;
 
 --
@@ -698,9 +712,23 @@ INSERT INTO temporal_partitioned (id, valid_at, name) VALUES
   ('[1,2)', daterange('2000-01-01', '2000-02-01'), 'one'),
   ('[1,2)', daterange('2000-02-01', '2000-03-01'), 'one'),
   ('[3,4)', daterange('2000-01-01', '2010-01-01'), 'three');
-SELECT * FROM temporal_partitioned ORDER BY id, valid_at;
-SELECT * FROM tp1 ORDER BY id, valid_at;
-SELECT * FROM tp2 ORDER BY id, valid_at;
+SELECT tableoid::regclass, * FROM temporal_partitioned ORDER BY id, valid_at;
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-01-15' TO '2000-02-15'
+  SET name = 'one2'
+  WHERE id = '[1,2)';
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-02-20' TO '2000-02-25'
+  SET id = '[4,5)'
+  WHERE name = 'one';
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET id = '[2,3)'
+  WHERE name = 'three';
+DELETE FROM temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-01-15' TO '2000-02-15'
+  WHERE id = '[3,4)';
+SELECT tableoid::regclass, * FROM temporal_partitioned ORDER BY id, valid_at;
 DROP TABLE temporal_partitioned;
 
 -- temporal UNIQUE:
@@ -716,9 +744,23 @@ INSERT INTO temporal_partitioned (id, valid_at, name) VALUES
   ('[1,2)', daterange('2000-01-01', '2000-02-01'), 'one'),
   ('[1,2)', daterange('2000-02-01', '2000-03-01'), 'one'),
   ('[3,4)', daterange('2000-01-01', '2010-01-01'), 'three');
-SELECT * FROM temporal_partitioned ORDER BY id, valid_at;
-SELECT * FROM tp1 ORDER BY id, valid_at;
-SELECT * FROM tp2 ORDER BY id, valid_at;
+SELECT tableoid::regclass, * FROM temporal_partitioned ORDER BY id, valid_at;
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-01-15' TO '2000-02-15'
+  SET name = 'one2'
+  WHERE id = '[1,2)';
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-02-20' TO '2000-02-25'
+  SET id = '[4,5)'
+  WHERE name = 'one';
+UPDATE  temporal_partitioned
+  FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01'
+  SET id = '[2,3)'
+  WHERE name = 'three';
+DELETE FROM temporal_partitioned
+  FOR PORTION OF valid_at FROM '2000-01-15' TO '2000-02-15'
+  WHERE id = '[3,4)';
+SELECT tableoid::regclass, * FROM temporal_partitioned ORDER BY id, valid_at;
 DROP TABLE temporal_partitioned;
 
 -- ALTER TABLE REPLICA IDENTITY
@@ -1322,6 +1364,18 @@ COMMIT;
 -- changing the scalar part fails:
 UPDATE temporal_rng SET id = '[7,8)'
   WHERE id = '[5,6)' AND valid_at = daterange('2018-01-01', '2018-02-01');
+-- changing an unreferenced part is okay:
+UPDATE temporal_rng
+  FOR PORTION OF valid_at FROM '2018-01-02' TO '2018-01-03'
+  SET id = '[7,8)'
+  WHERE id = '[5,6)';
+-- changing just a part fails:
+UPDATE temporal_rng
+  FOR PORTION OF valid_at FROM '2018-01-05' TO '2018-01-10'
+  SET id = '[7,8)'
+  WHERE id = '[5,6)';
+SELECT * FROM temporal_rng WHERE id in ('[5,6)', '[7,8)') ORDER BY id, valid_at;
+SELECT * FROM temporal_fk_rng2rng WHERE id in ('[3,4)') ORDER BY id, valid_at;
 -- then delete the objecting FK record and the same PK update succeeds:
 DELETE FROM temporal_fk_rng2rng WHERE id = '[3,4)';
 UPDATE temporal_rng SET valid_at = daterange('2016-01-01', '2016-02-01')
@@ -1369,6 +1423,18 @@ BEGIN;
 
   DELETE FROM temporal_rng WHERE id = '[5,6)' AND valid_at = daterange('2018-01-01', '2018-02-01');
 COMMIT;
+-- deleting an unreferenced part is okay:
+DELETE FROM temporal_rng
+FOR PORTION OF valid_at FROM '2018-01-02' TO '2018-01-03'
+WHERE id = '[5,6)';
+SELECT * FROM temporal_rng WHERE id in ('[5,6)', '[7,8)') ORDER BY id, valid_at;
+SELECT * FROM temporal_fk_rng2rng WHERE id in ('[3,4)') ORDER BY id, valid_at;
+-- deleting just a part fails:
+DELETE FROM temporal_rng
+FOR PORTION OF valid_at FROM '2018-01-05' TO '2018-01-10'
+WHERE id = '[5,6)';
+SELECT * FROM temporal_rng WHERE id in ('[5,6)', '[7,8)') ORDER BY id, valid_at;
+SELECT * FROM temporal_fk_rng2rng WHERE id in ('[3,4)') ORDER BY id, valid_at;
 -- then delete the objecting FK record and the same PK delete succeeds:
 DELETE FROM temporal_fk_rng2rng WHERE id = '[3,4)';
 DELETE FROM temporal_rng WHERE id = '[5,6)' AND valid_at = daterange('2018-01-01', '2018-02-01');
@@ -1387,12 +1453,13 @@ ALTER TABLE temporal_fk_rng2rng
   ON DELETE RESTRICT;
 
 --
--- test ON UPDATE/DELETE options
+-- rng2rng test ON UPDATE/DELETE options
 --
 
 -- test FK referenced updates CASCADE
+TRUNCATE temporal_rng, temporal_fk_rng2rng;
 INSERT INTO temporal_rng (id, valid_at) VALUES ('[6,7)', daterange('2018-01-01', '2021-01-01'));
-INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[4,5)', daterange('2018-01-01', '2021-01-01'), '[6,7)');
+INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[100,101)', daterange('2018-01-01', '2021-01-01'), '[6,7)');
 ALTER TABLE temporal_fk_rng2rng
   ADD CONSTRAINT temporal_fk_rng2rng_fk
     FOREIGN KEY (parent_id, PERIOD valid_at)
@@ -1400,8 +1467,9 @@ ALTER TABLE temporal_fk_rng2rng
     ON DELETE CASCADE ON UPDATE CASCADE;
 
 -- test FK referenced updates SET NULL
-INSERT INTO temporal_rng (id, valid_at) VALUES ('[9,10)', daterange('2018-01-01', '2021-01-01'));
-INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[6,7)', daterange('2018-01-01', '2021-01-01'), '[9,10)');
+TRUNCATE temporal_rng, temporal_fk_rng2rng;
+INSERT INTO temporal_rng (id, valid_at) VALUES ('[6,7)', daterange('2018-01-01', '2021-01-01'));
+INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[100,101)', daterange('2018-01-01', '2021-01-01'), '[6,7)');
 ALTER TABLE temporal_fk_rng2rng
   ADD CONSTRAINT temporal_fk_rng2rng_fk
     FOREIGN KEY (parent_id, PERIOD valid_at)
@@ -1409,9 +1477,10 @@ ALTER TABLE temporal_fk_rng2rng
     ON DELETE SET NULL ON UPDATE SET NULL;
 
 -- test FK referenced updates SET DEFAULT
+TRUNCATE temporal_rng, temporal_fk_rng2rng;
 INSERT INTO temporal_rng (id, valid_at) VALUES ('[-1,-1]', daterange(null, null));
-INSERT INTO temporal_rng (id, valid_at) VALUES ('[12,13)', daterange('2018-01-01', '2021-01-01'));
-INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[8,9)', daterange('2018-01-01', '2021-01-01'), '[12,13)');
+INSERT INTO temporal_rng (id, valid_at) VALUES ('[6,7)', daterange('2018-01-01', '2021-01-01'));
+INSERT INTO temporal_fk_rng2rng (id, valid_at, parent_id) VALUES ('[100,101)', daterange('2018-01-01', '2021-01-01'), '[6,7)');
 ALTER TABLE temporal_fk_rng2rng
   ALTER COLUMN parent_id SET DEFAULT '[-1,-1]',
   ADD CONSTRAINT temporal_fk_rng2rng_fk
@@ -1747,6 +1816,20 @@ BEGIN;
   WHERE id = '[5,6)' AND valid_at = datemultirange(daterange('2018-01-01', '2018-02-01'));
 COMMIT;
 -- changing the scalar part fails:
+UPDATE temporal_mltrng SET id = '[7,8)'
+  WHERE id = '[5,6)' AND valid_at = datemultirange(daterange('2018-01-01', '2018-02-01'));
+-- changing an unreferenced part is okay:
+UPDATE temporal_mltrng
+  FOR PORTION OF valid_at (datemultirange(daterange('2018-01-02', '2018-01-03')))
+  SET id = '[7,8)'
+  WHERE id = '[5,6)';
+-- changing just a part fails:
+UPDATE temporal_mltrng
+  FOR PORTION OF valid_at (datemultirange(daterange('2018-01-05', '2018-01-10')))
+  SET id = '[7,8)'
+  WHERE id = '[5,6)';
+-- then delete the objecting FK record and the same PK update succeeds:
+DELETE FROM temporal_fk_mltrng2mltrng WHERE id = '[3,4)';
 UPDATE temporal_mltrng SET id = '[7,8)'
   WHERE id = '[5,6)' AND valid_at = datemultirange(daterange('2018-01-01', '2018-02-01'));
 
@@ -1791,6 +1874,17 @@ BEGIN;
 
   DELETE FROM temporal_mltrng WHERE id = '[5,6)' AND valid_at = datemultirange(daterange('2018-01-01', '2018-02-01'));
 COMMIT;
+-- deleting an unreferenced part is okay:
+DELETE FROM temporal_mltrng
+FOR PORTION OF valid_at (datemultirange(daterange('2018-01-02', '2018-01-03')))
+WHERE id = '[5,6)';
+-- deleting just a part fails:
+DELETE FROM temporal_mltrng
+FOR PORTION OF valid_at (datemultirange(daterange('2018-01-05', '2018-01-10')))
+WHERE id = '[5,6)';
+-- then delete the objecting FK record and the same PK delete succeeds:
+DELETE FROM temporal_fk_mltrng2mltrng WHERE id = '[3,4)';
+DELETE FROM temporal_mltrng WHERE id = '[5,6)' AND valid_at = datemultirange(daterange('2018-01-01', '2018-02-01'));
 
 --
 -- FK between partitioned tables: ranges
diff --git a/src/test/subscription/t/034_temporal.pl b/src/test/subscription/t/034_temporal.pl
index d3a6d6b237f..ebff9a57bb9 100644
--- a/src/test/subscription/t/034_temporal.pl
+++ b/src/test/subscription/t/034_temporal.pl
@@ -137,6 +137,7 @@ is( $stderr,
 	qq(psql:<stdin>:1: ERROR:  cannot update table "temporal_no_key" because it does not have a replica identity and publishes updates
 HINT:  To enable updating the table, set REPLICA IDENTITY using ALTER TABLE.),
 	"can't UPDATE temporal_no_key DEFAULT");
+# No need to test again with FOR PORTION OF
 
 ($result, $stdout, $stderr) = $node_publisher->psql('postgres',
 	"DELETE FROM temporal_no_key WHERE id = '[3,4)'");
@@ -144,6 +145,13 @@ is( $stderr,
 	qq(psql:<stdin>:1: ERROR:  cannot delete from table "temporal_no_key" because it does not have a replica identity and publishes deletes
 HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.),
 	"can't DELETE temporal_no_key DEFAULT");
+($result, $stdout, $stderr) = $node_publisher->psql('postgres',
+	"DELETE FROM temporal_no_key FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'"
+);
+is( $stderr,
+	qq(psql:<stdin>:1: ERROR:  cannot delete from table "temporal_no_key" because it does not have a replica identity and publishes deletes
+HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.),
+	"can't DELETE FOR PORTION OF temporal_no_key DEFAULT");
 
 $node_publisher->wait_for_catchup('sub1');
 
@@ -165,16 +173,24 @@ $node_publisher->safe_psql(
 
 $node_publisher->safe_psql('postgres',
 	"UPDATE temporal_pk SET a = 'b' WHERE id = '[2,3)'");
+$node_publisher->safe_psql('postgres',
+	"UPDATE temporal_pk FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'"
+);
 
 $node_publisher->safe_psql('postgres',
 	"DELETE FROM temporal_pk WHERE id = '[3,4)'");
+$node_publisher->safe_psql('postgres',
+	"DELETE FROM temporal_pk FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'"
+);
 
 $node_publisher->wait_for_catchup('sub1');
 
 $result = $node_subscriber->safe_psql('postgres',
 	"SELECT * FROM temporal_pk ORDER BY id, valid_at");
 is( $result, qq{[1,2)|[2000-01-01,2010-01-01)|a
-[2,3)|[2000-01-01,2010-01-01)|b
+[2,3)|[2000-01-01,2001-01-01)|b
+[2,3)|[2001-01-01,2002-01-01)|c
+[2,3)|[2003-01-01,2010-01-01)|b
 [4,5)|[2000-01-01,2010-01-01)|a}, 'replicated temporal_pk DEFAULT');
 
 # replicate with a unique key:
@@ -192,6 +208,7 @@ is( $stderr,
 	qq(psql:<stdin>:1: ERROR:  cannot update table "temporal_unique" because it does not have a replica identity and publishes updates
 HINT:  To enable updating the table, set REPLICA IDENTITY using ALTER TABLE.),
 	"can't UPDATE temporal_unique DEFAULT");
+# No need to test again with FOR PORTION OF
 
 ($result, $stdout, $stderr) = $node_publisher->psql('postgres',
 	"DELETE FROM temporal_unique WHERE id = '[3,4)'");
@@ -199,6 +216,13 @@ is( $stderr,
 	qq(psql:<stdin>:1: ERROR:  cannot delete from table "temporal_unique" because it does not have a replica identity and publishes deletes
 HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.),
 	"can't DELETE temporal_unique DEFAULT");
+($result, $stdout, $stderr) = $node_publisher->psql('postgres',
+	"DELETE FROM temporal_unique FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'"
+);
+is( $stderr,
+	qq(psql:<stdin>:1: ERROR:  cannot delete from table "temporal_unique" because it does not have a replica identity and publishes deletes
+HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.),
+	"can't DELETE FOR PORTION OF temporal_unique DEFAULT");
 
 $node_publisher->wait_for_catchup('sub1');
 
@@ -287,16 +311,24 @@ $node_publisher->safe_psql(
 
 $node_publisher->safe_psql('postgres',
 	"UPDATE temporal_no_key SET a = 'b' WHERE id = '[2,3)'");
+$node_publisher->safe_psql('postgres',
+	"UPDATE temporal_no_key FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'"
+);
 
 $node_publisher->safe_psql('postgres',
 	"DELETE FROM temporal_no_key WHERE id = '[3,4)'");
+$node_publisher->safe_psql('postgres',
+	"DELETE FROM temporal_no_key FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'"
+);
 
 $node_publisher->wait_for_catchup('sub1');
 
 $result = $node_subscriber->safe_psql('postgres',
 	"SELECT * FROM temporal_no_key ORDER BY id, valid_at");
 is( $result, qq{[1,2)|[2000-01-01,2010-01-01)|a
-[2,3)|[2000-01-01,2010-01-01)|b
+[2,3)|[2000-01-01,2001-01-01)|b
+[2,3)|[2001-01-01,2002-01-01)|c
+[2,3)|[2003-01-01,2010-01-01)|b
 [4,5)|[2000-01-01,2010-01-01)|a}, 'replicated temporal_no_key FULL');
 
 # replicate with a primary key:
@@ -310,16 +342,24 @@ $node_publisher->safe_psql(
 
 $node_publisher->safe_psql('postgres',
 	"UPDATE temporal_pk SET a = 'b' WHERE id = '[2,3)'");
+$node_publisher->safe_psql('postgres',
+	"UPDATE temporal_pk FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'"
+);
 
 $node_publisher->safe_psql('postgres',
 	"DELETE FROM temporal_pk WHERE id = '[3,4)'");
+$node_publisher->safe_psql('postgres',
+	"DELETE FROM temporal_pk FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'"
+);
 
 $node_publisher->wait_for_catchup('sub1');
 
 $result = $node_subscriber->safe_psql('postgres',
 	"SELECT * FROM temporal_pk ORDER BY id, valid_at");
 is( $result, qq{[1,2)|[2000-01-01,2010-01-01)|a
-[2,3)|[2000-01-01,2010-01-01)|b
+[2,3)|[2000-01-01,2001-01-01)|b
+[2,3)|[2001-01-01,2002-01-01)|c
+[2,3)|[2003-01-01,2010-01-01)|b
 [4,5)|[2000-01-01,2010-01-01)|a}, 'replicated temporal_pk FULL');
 
 # replicate with a unique key:
@@ -333,16 +373,24 @@ $node_publisher->safe_psql(
 
 $node_publisher->safe_psql('postgres',
 	"UPDATE temporal_unique SET a = 'b' WHERE id = '[2,3)'");
+$node_publisher->safe_psql('postgres',
+	"UPDATE temporal_unique FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'"
+);
 
 $node_publisher->safe_psql('postgres',
 	"DELETE FROM temporal_unique WHERE id = '[3,4)'");
+$node_publisher->safe_psql('postgres',
+	"DELETE FROM temporal_unique FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'"
+);
 
 $node_publisher->wait_for_catchup('sub1');
 
 $result = $node_subscriber->safe_psql('postgres',
 	"SELECT * FROM temporal_unique ORDER BY id, valid_at");
 is( $result, qq{[1,2)|[2000-01-01,2010-01-01)|a
-[2,3)|[2000-01-01,2010-01-01)|b
+[2,3)|[2000-01-01,2001-01-01)|b
+[2,3)|[2001-01-01,2002-01-01)|c
+[2,3)|[2003-01-01,2010-01-01)|b
 [4,5)|[2000-01-01,2010-01-01)|a}, 'replicated temporal_unique FULL');
 
 # cleanup
@@ -425,16 +473,24 @@ $node_publisher->safe_psql(
 
 $node_publisher->safe_psql('postgres',
 	"UPDATE temporal_pk SET a = 'b' WHERE id = '[2,3)'");
+$node_publisher->safe_psql('postgres',
+	"UPDATE temporal_pk FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'"
+);
 
 $node_publisher->safe_psql('postgres',
 	"DELETE FROM temporal_pk WHERE id = '[3,4)'");
+$node_publisher->safe_psql('postgres',
+	"DELETE FROM temporal_pk FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'"
+);
 
 $node_publisher->wait_for_catchup('sub1');
 
 $result = $node_subscriber->safe_psql('postgres',
 	"SELECT * FROM temporal_pk ORDER BY id, valid_at");
 is( $result, qq{[1,2)|[2000-01-01,2010-01-01)|a
-[2,3)|[2000-01-01,2010-01-01)|b
+[2,3)|[2000-01-01,2001-01-01)|b
+[2,3)|[2001-01-01,2002-01-01)|c
+[2,3)|[2003-01-01,2010-01-01)|b
 [4,5)|[2000-01-01,2010-01-01)|a}, 'replicated temporal_pk USING INDEX');
 
 # replicate with a unique key:
@@ -448,16 +504,24 @@ $node_publisher->safe_psql(
 
 $node_publisher->safe_psql('postgres',
 	"UPDATE temporal_unique SET a = 'b' WHERE id = '[2,3)'");
+$node_publisher->safe_psql('postgres',
+	"UPDATE temporal_unique FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01' SET a = 'c' WHERE id = '[2,3)'"
+);
 
 $node_publisher->safe_psql('postgres',
 	"DELETE FROM temporal_unique WHERE id = '[3,4)'");
+$node_publisher->safe_psql('postgres',
+	"DELETE FROM temporal_unique FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'"
+);
 
 $node_publisher->wait_for_catchup('sub1');
 
 $result = $node_subscriber->safe_psql('postgres',
 	"SELECT * FROM temporal_unique ORDER BY id, valid_at");
 is( $result, qq{[1,2)|[2000-01-01,2010-01-01)|a
-[2,3)|[2000-01-01,2010-01-01)|b
+[2,3)|[2000-01-01,2001-01-01)|b
+[2,3)|[2001-01-01,2002-01-01)|c
+[2,3)|[2003-01-01,2010-01-01)|b
 [4,5)|[2000-01-01,2010-01-01)|a}, 'replicated temporal_unique USING INDEX');
 
 # cleanup
@@ -543,6 +607,7 @@ is( $stderr,
 	qq(psql:<stdin>:1: ERROR:  cannot update table "temporal_no_key" because it does not have a replica identity and publishes updates
 HINT:  To enable updating the table, set REPLICA IDENTITY using ALTER TABLE.),
 	"can't UPDATE temporal_no_key NOTHING");
+# No need to test again with FOR PORTION OF
 
 ($result, $stdout, $stderr) = $node_publisher->psql('postgres',
 	"DELETE FROM temporal_no_key WHERE id = '[3,4)'");
@@ -550,6 +615,13 @@ is( $stderr,
 	qq(psql:<stdin>:1: ERROR:  cannot delete from table "temporal_no_key" because it does not have a replica identity and publishes deletes
 HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.),
 	"can't DELETE temporal_no_key NOTHING");
+($result, $stdout, $stderr) = $node_publisher->psql('postgres',
+	"DELETE FROM temporal_no_key FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'"
+);
+is( $stderr,
+	qq(psql:<stdin>:1: ERROR:  cannot delete from table "temporal_no_key" because it does not have a replica identity and publishes deletes
+HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.),
+	"can't DELETE temporal_no_key NOTHING");
 
 $node_publisher->wait_for_catchup('sub1');
 
@@ -575,6 +647,7 @@ is( $stderr,
 	qq(psql:<stdin>:1: ERROR:  cannot update table "temporal_pk" because it does not have a replica identity and publishes updates
 HINT:  To enable updating the table, set REPLICA IDENTITY using ALTER TABLE.),
 	"can't UPDATE temporal_pk NOTHING");
+# No need to test again with FOR PORTION OF
 
 ($result, $stdout, $stderr) = $node_publisher->psql('postgres',
 	"DELETE FROM temporal_pk WHERE id = '[3,4)'");
@@ -582,6 +655,13 @@ is( $stderr,
 	qq(psql:<stdin>:1: ERROR:  cannot delete from table "temporal_pk" because it does not have a replica identity and publishes deletes
 HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.),
 	"can't DELETE temporal_pk NOTHING");
+($result, $stdout, $stderr) = $node_publisher->psql('postgres',
+	"DELETE FROM temporal_pk FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'"
+);
+is( $stderr,
+	qq(psql:<stdin>:1: ERROR:  cannot delete from table "temporal_pk" because it does not have a replica identity and publishes deletes
+HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.),
+	"can't DELETE temporal_pk NOTHING");
 
 $node_publisher->wait_for_catchup('sub1');
 
@@ -607,6 +687,7 @@ is( $stderr,
 	qq(psql:<stdin>:1: ERROR:  cannot update table "temporal_unique" because it does not have a replica identity and publishes updates
 HINT:  To enable updating the table, set REPLICA IDENTITY using ALTER TABLE.),
 	"can't UPDATE temporal_unique NOTHING");
+# No need to test again with FOR PORTION OF
 
 ($result, $stdout, $stderr) = $node_publisher->psql('postgres',
 	"DELETE FROM temporal_unique WHERE id = '[3,4)'");
@@ -614,6 +695,13 @@ is( $stderr,
 	qq(psql:<stdin>:1: ERROR:  cannot delete from table "temporal_unique" because it does not have a replica identity and publishes deletes
 HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.),
 	"can't DELETE temporal_unique NOTHING");
+($result, $stdout, $stderr) = $node_publisher->psql('postgres',
+	"DELETE FROM temporal_unique FOR PORTION OF valid_at FROM '2002-01-01' TO '2003-01-01' WHERE id = '[2,3)'"
+);
+is( $stderr,
+	qq(psql:<stdin>:1: ERROR:  cannot delete from table "temporal_unique" because it does not have a replica identity and publishes deletes
+HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.),
+	"can't DELETE FOR PORTION OF temporal_unique NOTHING");
 
 $node_publisher->wait_for_catchup('sub1');
 
@@ -628,7 +716,6 @@ is( $result, qq{[1,2)|[2000-01-01,2010-01-01)|a
 
 drop_everything();
 
-
 # #################################
 # Test with a lossy REPLICA IDENTITY index (multirange key)
 #
@@ -699,4 +786,93 @@ $node_subscriber->safe_psql('postgres', "DROP TABLE temporal_mltrng");
 $node_publisher->safe_psql('postgres', "DROP PUBLICATION pub1");
 $node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION sub1");
 
+# #################################
+# Test column lists and row filters interacting with FOR PORTION OF leftovers
+# #################################
+
+# A FOR PORTION OF update/delete emits the truncated row as an UPDATE/DELETE and
+# each temporal leftover as an INSERT.  The leftover INSERTs must honor the
+# publication's column list and row filter like any other INSERT.
+
+# --- Column list: leftovers replicate only the listed columns ---
+
+$node_publisher->safe_psql('postgres',
+	"CREATE TABLE temporal_collist (id int4range, valid_at daterange, a text, b text, PRIMARY KEY (id, valid_at WITHOUT OVERLAPS))"
+);
+$node_subscriber->safe_psql('postgres',
+	"CREATE TABLE temporal_collist (id int4range, valid_at daterange, a text, b text, PRIMARY KEY (id, valid_at WITHOUT OVERLAPS))"
+);
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO temporal_collist VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a', 'secret')"
+);
+
+# The publication replicates only (id, valid_at, a); b is excluded.  The column
+# list includes the replica identity columns, as required.
+$node_publisher->safe_psql('postgres',
+	"CREATE PUBLICATION pub_collist FOR TABLE temporal_collist (id, valid_at, a)");
+$node_subscriber->safe_psql('postgres',
+	"CREATE SUBSCRIPTION sub_collist CONNECTION '$publisher_connstr' PUBLICATION pub_collist"
+);
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'sub_collist');
+
+$node_publisher->safe_psql('postgres',
+	"UPDATE temporal_collist FOR PORTION OF valid_at FROM '2004-01-01' TO '2006-01-01' SET a = 'x' WHERE id = '[1,2)'"
+);
+$node_publisher->wait_for_catchup('sub_collist');
+
+# b is never replicated, so it is NULL on the subscriber for the truncated row
+# and both leftovers:
+$result = $node_subscriber->safe_psql('postgres',
+	"SELECT id, valid_at, a, b FROM temporal_collist ORDER BY valid_at");
+is( $result, qq{[1,2)|[2000-01-01,2004-01-01)|a|
+[1,2)|[2004-01-01,2006-01-01)|x|
+[1,2)|[2006-01-01,2010-01-01)|a|},
+	'column list applies to FOR PORTION OF leftovers');
+
+$node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION sub_collist");
+$node_publisher->safe_psql('postgres', "DROP PUBLICATION pub_collist");
+$node_publisher->safe_psql('postgres', "DROP TABLE temporal_collist");
+$node_subscriber->safe_psql('postgres', "DROP TABLE temporal_collist");
+
+# --- Row filter: leftovers are subject to the publication row filter ---
+
+$node_publisher->safe_psql('postgres',
+	"CREATE TABLE temporal_rowfilter (id int4range, valid_at daterange, a text, PRIMARY KEY (id, valid_at WITHOUT OVERLAPS))"
+);
+$node_subscriber->safe_psql('postgres',
+	"CREATE TABLE temporal_rowfilter (id int4range, valid_at daterange, a text, PRIMARY KEY (id, valid_at WITHOUT OVERLAPS))"
+);
+$node_publisher->safe_psql('postgres',
+	"INSERT INTO temporal_rowfilter VALUES ('[1,2)', '[2000-01-01,2010-01-01)', 'a')"
+);
+
+# Only rows whose validity starts before 2005 are replicated.  valid_at is part
+# of the replica identity, so it may appear in the row filter.
+$node_publisher->safe_psql('postgres',
+	"CREATE PUBLICATION pub_rowfilter FOR TABLE temporal_rowfilter WHERE (lower(valid_at) < '2005-01-01')"
+);
+$node_subscriber->safe_psql('postgres',
+	"CREATE SUBSCRIPTION sub_rowfilter CONNECTION '$publisher_connstr' PUBLICATION pub_rowfilter"
+);
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'sub_rowfilter');
+
+# FOR PORTION OF splits [2000,2010) into leftovers [2000,2004) and [2006,2010)
+# around the truncated [2004,2006).  The [2006,2010) leftover starts in 2006, so
+# it fails the row filter and is not replicated.
+$node_publisher->safe_psql('postgres',
+	"UPDATE temporal_rowfilter FOR PORTION OF valid_at FROM '2004-01-01' TO '2006-01-01' SET a = 'x' WHERE id = '[1,2)'"
+);
+$node_publisher->wait_for_catchup('sub_rowfilter');
+
+$result = $node_subscriber->safe_psql('postgres',
+	"SELECT id, valid_at, a FROM temporal_rowfilter ORDER BY valid_at");
+is( $result, qq{[1,2)|[2000-01-01,2004-01-01)|a
+[1,2)|[2004-01-01,2006-01-01)|x},
+	'row filter applies to FOR PORTION OF leftovers');
+
+$node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION sub_rowfilter");
+$node_publisher->safe_psql('postgres', "DROP PUBLICATION pub_rowfilter");
+$node_publisher->safe_psql('postgres', "DROP TABLE temporal_rowfilter");
+$node_subscriber->safe_psql('postgres', "DROP TABLE temporal_rowfilter");
+
 done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index b964e5ee907..1c0a71b57c7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -878,6 +878,9 @@ ForBothState
 ForEachState
 ForFiveState
 ForFourState
+ForPortionOfClause
+ForPortionOfExpr
+ForPortionOfState
 ForThreeState
 ForeignAsyncConfigureWait_function
 ForeignAsyncNotify_function
-- 
2.47.3

