pg.ddx.io  pgsql-hackers@postgresql.org mailing list archive  
help / color / mirror / Atom feed
Show expression of virtual columns in error messages
16+ messages / 5 participants
[nested] [flat]

* Show expression of virtual columns in error messages
@ 2026-02-03 14:06 Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  0 siblings, 1 reply; 16+ messages in thread

From: Matheus Alcantara @ 2026-02-03 14:06 UTC (permalink / raw)
  To: pgsql-hackers@lists.postgresql.org

Hi,

When a constraint violation occurs on a table with virtual generated
columns, the "Failing row contains" error message currently displays the
literal string "virtual" as a placeholder:

  CREATE TABLE t (a int, b int, c int GENERATED ALWAYS AS (a * 2) VIRTUAL CHECK (c < 10));
  INSERT INTO t VALUES (5, 10);

  ERROR:  new row for relation "t" violates check constraint "t_c_check"
  DETAIL:  Failing row contains (5, 10, virtual).

This isn't very helpful for debugging, especially when the user needs to
understand why the check constraint failed or if multiple virtual
generated columns are involved on the original statement.

The attached patch changes this behavior to show the virtual column
expression instead:

  ERROR:  new row for relation "t" violates check constraint "t_c_check"
  DETAIL:  Failing row contains (5, 10, a * 2).

An alternative approach would be to compute and display the actual value
of the virtual column (e.g., "10" instead of "a * 2"). However, I chose
to show the expression because:

1. It's simpler to implement (no need to evaluate the expression)
2. It shows the user *how* the value is computed, which may be more
   useful for understanding why a constraint failed
3. It avoids potential issues with expression evaluation in error paths

Thoughts?

--
Matheus Alcantara
EDB: https://www.enterprisedb.com
From e615b0b5afefaca6442a28ce23029a94fc55f922 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v1] Show expression of virtual columns in error messages

Previously, when a constraint violation occurred on a table with virtual
generated columns, the "Failing row contains" error message would display
the literal string "virtual" as a placeholder for those columns. This was
not helpful for debugging.

Now, the generation expression is shown instead, making it easier to
understand what value would be computed for the virtual column.

For example, instead of:
  Failing row contains (5, 10, virtual).

The error message now shows:
  Failing row contains (5, 10, a * 2).

This required changing ExecBuildSlotValueDescription() to accept a
Relation instead of just an Oid, so that build_generation_expression()
can be called to retrieve the column's generation expression.
---
 src/backend/executor/execMain.c               | 30 ++++++++++++-------
 src/backend/replication/logical/conflict.c    |  7 ++---
 src/include/executor/executor.h               |  2 +-
 .../regress/expected/generated_virtual.out    | 18 +++++------
 src/test/regress/expected/partition_merge.out |  2 +-
 5 files changed, 33 insertions(+), 26 deletions(-)

diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index bfd3ebc601e..6208f6530ba 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -61,6 +61,7 @@
 #include "utils/lsyscache.h"
 #include "utils/partcache.h"
 #include "utils/rls.h"
+#include "utils/ruleutils.h"
 #include "utils/snapmgr.h"
 
 
@@ -1914,7 +1915,7 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 							TupleTableSlot *slot,
 							EState *estate)
 {
-	Oid			root_relid;
+	Relation	root_rel;
 	TupleDesc	tupdesc;
 	char	   *val_desc;
 	Bitmapset  *modifiedCols;
@@ -1931,8 +1932,8 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 		TupleDesc	old_tupdesc;
 		AttrMap    *map;
 
-		root_relid = RelationGetRelid(rootrel->ri_RelationDesc);
-		tupdesc = RelationGetDescr(rootrel->ri_RelationDesc);
+		root_rel = rootrel->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 
 		old_tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
 		/* a reverse map */
@@ -1950,13 +1951,13 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 	}
 	else
 	{
-		root_relid = RelationGetRelid(resultRelInfo->ri_RelationDesc);
-		tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
+		root_rel = resultRelInfo->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 	}
 
-	val_desc = ExecBuildSlotValueDescription(root_relid,
+	val_desc = ExecBuildSlotValueDescription(root_rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2068,7 +2069,7 @@ ExecConstraints(ResultRelInfo *resultRelInfo,
 			else
 				modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 										 ExecGetUpdatedCols(resultRelInfo, estate));
-			val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+			val_desc = ExecBuildSlotValueDescription(rel,
 													 slot,
 													 tupdesc,
 													 modifiedCols,
@@ -2205,7 +2206,7 @@ ReportNotNullViolationError(ResultRelInfo *resultRelInfo, TupleTableSlot *slot,
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 
-	val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+	val_desc = ExecBuildSlotValueDescription(rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2313,7 +2314,7 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 					else
 						modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 												 ExecGetUpdatedCols(resultRelInfo, estate));
-					val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+					val_desc = ExecBuildSlotValueDescription(rel,
 															 slot,
 															 tupdesc,
 															 modifiedCols,
@@ -2392,12 +2393,13 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
  * columns they are.
  */
 char *
-ExecBuildSlotValueDescription(Oid reloid,
+ExecBuildSlotValueDescription(Relation rel,
 							  TupleTableSlot *slot,
 							  TupleDesc tupdesc,
 							  Bitmapset *modifiedCols,
 							  int maxfieldlen)
 {
+	Oid			reloid = RelationGetRelid(rel);
 	StringInfoData buf;
 	StringInfoData collist;
 	bool		write_comma = false;
@@ -2477,7 +2479,13 @@ ExecBuildSlotValueDescription(Oid reloid,
 		if (table_perm || column_perm)
 		{
 			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
+			{
+				Node	   *genexpr = build_generation_expression(rel, att->attnum);
+				List	   *dpcontext = deparse_context_for(RelationGetRelationName(rel),
+															reloid);
+
+				val = deparse_expression(genexpr, dpcontext, false, false);
+			}
 			else if (slot->tts_isnull[i])
 				val = "null";
 			else
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index ca71a81c7bf..478c0a223fc 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -432,7 +432,6 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 			   Oid indexoid)
 {
 	Relation	localrel = relinfo->ri_RelationDesc;
-	Oid			relid = RelationGetRelid(localrel);
 	TupleDesc	tupdesc = RelationGetDescr(localrel);
 	char	   *desc = NULL;
 
@@ -461,7 +460,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 * The 'modifiedCols' only applies to the new tuple, hence we pass
 		 * NULL for the local row.
 		 */
-		desc = ExecBuildSlotValueDescription(relid, localslot, tupdesc,
+		desc = ExecBuildSlotValueDescription(localrel, localslot, tupdesc,
 											 NULL, 64);
 
 		if (desc)
@@ -481,7 +480,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 */
 		modifiedCols = bms_union(ExecGetInsertedCols(relinfo, estate),
 								 ExecGetUpdatedCols(relinfo, estate));
-		desc = ExecBuildSlotValueDescription(relid, remoteslot,
+		desc = ExecBuildSlotValueDescription(localrel, remoteslot,
 											 tupdesc, modifiedCols,
 											 64);
 
@@ -510,7 +509,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		if (OidIsValid(replica_index))
 			desc = build_index_value_desc(estate, localrel, searchslot, replica_index);
 		else
-			desc = ExecBuildSlotValueDescription(relid, searchslot, tupdesc, NULL, 64);
+			desc = ExecBuildSlotValueDescription(localrel, searchslot, tupdesc, NULL, 64);
 
 		if (desc)
 		{
diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h
index 55a7d930d26..2ffb97d48ca 100644
--- a/src/include/executor/executor.h
+++ b/src/include/executor/executor.h
@@ -269,7 +269,7 @@ extern void ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 										TupleTableSlot *slot, EState *estate);
 extern void ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 								 TupleTableSlot *slot, EState *estate);
-extern char *ExecBuildSlotValueDescription(Oid reloid, TupleTableSlot *slot,
+extern char *ExecBuildSlotValueDescription(Relation rel, TupleTableSlot *slot,
 										   TupleDesc tupdesc,
 										   Bitmapset *modifiedCols,
 										   int maxfieldlen);
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 249e68be654..cadc51c5288 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -638,7 +638,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, (a * 2)).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint (currently not supported)
 ERROR:  ALTER TABLE / SET EXPRESSION is not supported for virtual generated columns in tables with check constraints
 DETAIL:  Column "b" of relation "gtest20" is a virtual generated column.
@@ -666,18 +666,18 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, (a * 2)).
 -- not-null constraints
 CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL);
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(a, 0)).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(a, 0)).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -687,17 +687,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(a, 0)).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(a, 0)).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, NULLIF(a, 0)).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- not-null constraint with partitioned table
@@ -712,10 +712,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, (NULLIF(f1, 1) + NULLIF(f2, 10))).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, (NULLIF(f1, 1) + NULLIF(f2, 10))).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 925fe4f570a..b8d21a5a7fa 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1073,7 +1073,7 @@ INSERT INTO t VALUES (16);
 -- ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, (i + (tableoid)::integer)).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
-- 
2.51.2



Attachments:

  [text/plain] v1-0001-Show-expression-of-virtual-columns-in-error-messa.patch (12.5K, ../../DG5DV8SED62G.2WFJB46D7WIT8@gmail.com/2-v1-0001-Show-expression-of-virtual-columns-in-error-messa.patch)
  download | inline diff:
From e615b0b5afefaca6442a28ce23029a94fc55f922 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v1] Show expression of virtual columns in error messages

Previously, when a constraint violation occurred on a table with virtual
generated columns, the "Failing row contains" error message would display
the literal string "virtual" as a placeholder for those columns. This was
not helpful for debugging.

Now, the generation expression is shown instead, making it easier to
understand what value would be computed for the virtual column.

For example, instead of:
  Failing row contains (5, 10, virtual).

The error message now shows:
  Failing row contains (5, 10, a * 2).

This required changing ExecBuildSlotValueDescription() to accept a
Relation instead of just an Oid, so that build_generation_expression()
can be called to retrieve the column's generation expression.
---
 src/backend/executor/execMain.c               | 30 ++++++++++++-------
 src/backend/replication/logical/conflict.c    |  7 ++---
 src/include/executor/executor.h               |  2 +-
 .../regress/expected/generated_virtual.out    | 18 +++++------
 src/test/regress/expected/partition_merge.out |  2 +-
 5 files changed, 33 insertions(+), 26 deletions(-)

diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index bfd3ebc601e..6208f6530ba 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -61,6 +61,7 @@
 #include "utils/lsyscache.h"
 #include "utils/partcache.h"
 #include "utils/rls.h"
+#include "utils/ruleutils.h"
 #include "utils/snapmgr.h"
 
 
@@ -1914,7 +1915,7 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 							TupleTableSlot *slot,
 							EState *estate)
 {
-	Oid			root_relid;
+	Relation	root_rel;
 	TupleDesc	tupdesc;
 	char	   *val_desc;
 	Bitmapset  *modifiedCols;
@@ -1931,8 +1932,8 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 		TupleDesc	old_tupdesc;
 		AttrMap    *map;
 
-		root_relid = RelationGetRelid(rootrel->ri_RelationDesc);
-		tupdesc = RelationGetDescr(rootrel->ri_RelationDesc);
+		root_rel = rootrel->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 
 		old_tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
 		/* a reverse map */
@@ -1950,13 +1951,13 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 	}
 	else
 	{
-		root_relid = RelationGetRelid(resultRelInfo->ri_RelationDesc);
-		tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
+		root_rel = resultRelInfo->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 	}
 
-	val_desc = ExecBuildSlotValueDescription(root_relid,
+	val_desc = ExecBuildSlotValueDescription(root_rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2068,7 +2069,7 @@ ExecConstraints(ResultRelInfo *resultRelInfo,
 			else
 				modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 										 ExecGetUpdatedCols(resultRelInfo, estate));
-			val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+			val_desc = ExecBuildSlotValueDescription(rel,
 													 slot,
 													 tupdesc,
 													 modifiedCols,
@@ -2205,7 +2206,7 @@ ReportNotNullViolationError(ResultRelInfo *resultRelInfo, TupleTableSlot *slot,
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 
-	val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+	val_desc = ExecBuildSlotValueDescription(rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2313,7 +2314,7 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 					else
 						modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 												 ExecGetUpdatedCols(resultRelInfo, estate));
-					val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+					val_desc = ExecBuildSlotValueDescription(rel,
 															 slot,
 															 tupdesc,
 															 modifiedCols,
@@ -2392,12 +2393,13 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
  * columns they are.
  */
 char *
-ExecBuildSlotValueDescription(Oid reloid,
+ExecBuildSlotValueDescription(Relation rel,
 							  TupleTableSlot *slot,
 							  TupleDesc tupdesc,
 							  Bitmapset *modifiedCols,
 							  int maxfieldlen)
 {
+	Oid			reloid = RelationGetRelid(rel);
 	StringInfoData buf;
 	StringInfoData collist;
 	bool		write_comma = false;
@@ -2477,7 +2479,13 @@ ExecBuildSlotValueDescription(Oid reloid,
 		if (table_perm || column_perm)
 		{
 			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
+			{
+				Node	   *genexpr = build_generation_expression(rel, att->attnum);
+				List	   *dpcontext = deparse_context_for(RelationGetRelationName(rel),
+															reloid);
+
+				val = deparse_expression(genexpr, dpcontext, false, false);
+			}
 			else if (slot->tts_isnull[i])
 				val = "null";
 			else
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index ca71a81c7bf..478c0a223fc 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -432,7 +432,6 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 			   Oid indexoid)
 {
 	Relation	localrel = relinfo->ri_RelationDesc;
-	Oid			relid = RelationGetRelid(localrel);
 	TupleDesc	tupdesc = RelationGetDescr(localrel);
 	char	   *desc = NULL;
 
@@ -461,7 +460,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 * The 'modifiedCols' only applies to the new tuple, hence we pass
 		 * NULL for the local row.
 		 */
-		desc = ExecBuildSlotValueDescription(relid, localslot, tupdesc,
+		desc = ExecBuildSlotValueDescription(localrel, localslot, tupdesc,
 											 NULL, 64);
 
 		if (desc)
@@ -481,7 +480,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 */
 		modifiedCols = bms_union(ExecGetInsertedCols(relinfo, estate),
 								 ExecGetUpdatedCols(relinfo, estate));
-		desc = ExecBuildSlotValueDescription(relid, remoteslot,
+		desc = ExecBuildSlotValueDescription(localrel, remoteslot,
 											 tupdesc, modifiedCols,
 											 64);
 
@@ -510,7 +509,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		if (OidIsValid(replica_index))
 			desc = build_index_value_desc(estate, localrel, searchslot, replica_index);
 		else
-			desc = ExecBuildSlotValueDescription(relid, searchslot, tupdesc, NULL, 64);
+			desc = ExecBuildSlotValueDescription(localrel, searchslot, tupdesc, NULL, 64);
 
 		if (desc)
 		{
diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h
index 55a7d930d26..2ffb97d48ca 100644
--- a/src/include/executor/executor.h
+++ b/src/include/executor/executor.h
@@ -269,7 +269,7 @@ extern void ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 										TupleTableSlot *slot, EState *estate);
 extern void ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 								 TupleTableSlot *slot, EState *estate);
-extern char *ExecBuildSlotValueDescription(Oid reloid, TupleTableSlot *slot,
+extern char *ExecBuildSlotValueDescription(Relation rel, TupleTableSlot *slot,
 										   TupleDesc tupdesc,
 										   Bitmapset *modifiedCols,
 										   int maxfieldlen);
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 249e68be654..cadc51c5288 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -638,7 +638,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, (a * 2)).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint (currently not supported)
 ERROR:  ALTER TABLE / SET EXPRESSION is not supported for virtual generated columns in tables with check constraints
 DETAIL:  Column "b" of relation "gtest20" is a virtual generated column.
@@ -666,18 +666,18 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, (a * 2)).
 -- not-null constraints
 CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL);
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(a, 0)).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(a, 0)).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -687,17 +687,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(a, 0)).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(a, 0)).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, NULLIF(a, 0)).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- not-null constraint with partitioned table
@@ -712,10 +712,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, (NULLIF(f1, 1) + NULLIF(f2, 10))).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, (NULLIF(f1, 1) + NULLIF(f2, 10))).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 925fe4f570a..b8d21a5a7fa 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1073,7 +1073,7 @@ INSERT INTO t VALUES (16);
 -- ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, (i + (tableoid)::integer)).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
-- 
2.51.2



^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
@ 2026-02-04 14:57 ` Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  0 siblings, 1 reply; 16+ messages in thread

From: Peter Eisentraut @ 2026-02-04 14:57 UTC (permalink / raw)
  To: Matheus Alcantara <matheusssilv97@gmail.com>; pgsql-hackers@lists.postgresql.org

On 03.02.26 15:06, Matheus Alcantara wrote:
> The attached patch changes this behavior to show the virtual column
> expression instead:
> 
>    ERROR:  new row for relation "t" violates check constraint "t_c_check"
>    DETAIL:  Failing row contains (5, 10, a * 2).

Could be useful, but in this context you don't know which column is "a"?





^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
@ 2026-02-04 17:54   ` Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  0 siblings, 1 reply; 16+ messages in thread

From: Matheus Alcantara @ 2026-02-04 17:54 UTC (permalink / raw)
  To: Peter Eisentraut <peter@eisentraut.org>; Matheus Alcantara <matheusssilv97@gmail.com>; pgsql-hackers@lists.postgresql.org

On Wed Feb 4, 2026 at 11:57 AM -03, Peter Eisentraut wrote:
> On 03.02.26 15:06, Matheus Alcantara wrote:
>> The attached patch changes this behavior to show the virtual column
>> expression instead:
>> 
>>    ERROR:  new row for relation "t" violates check constraint "t_c_check"
>>    DETAIL:  Failing row contains (5, 10, a * 2).
>
> Could be useful, but in this context you don't know which column is "a"?

I agree but we at least show more information to the user. The user may
still need to check the table definition to understand the constraint or
to get the column ordering of the table to understand what the value of
"a" is being used to compute the expression. 

If the statement declare the columns that the values are referecing this
could be easier to know (e.g INSERT INTO t(a, b) VALUES (5, 10)) but the
user may still need to check the table definition to understand the
constraint.

I see this patch as a way to at least give more information to user
about the error.

Another possibility would be to get the actual values of "a" for example
and show it on the error message, e.g:

    ERROR:  new row for relation "t" violates check constraint "t_c_check"
    DETAIL:  Failing row contains (5, 10, 5 * 2).

--
Matheus Alcantara
EDB: https://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
@ 2026-02-05 02:45     ` Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  0 siblings, 1 reply; 16+ messages in thread

From: Yugo Nagata @ 2026-02-05 02:45 UTC (permalink / raw)
  To: Matheus Alcantara <matheusssilv97@gmail.com>; +Cc: Peter Eisentraut <peter@eisentraut.org>; pgsql-hackers@lists.postgresql.org

On Wed, 04 Feb 2026 14:54:11 -0300
"Matheus Alcantara" <matheusssilv97@gmail.com> wrote:

> On Wed Feb 4, 2026 at 11:57 AM -03, Peter Eisentraut wrote:
> > On 03.02.26 15:06, Matheus Alcantara wrote:
> >> The attached patch changes this behavior to show the virtual column
> >> expression instead:
> >> 
> >>    ERROR:  new row for relation "t" violates check constraint "t_c_check"
> >>    DETAIL:  Failing row contains (5, 10, a * 2).
> >
> > Could be useful, but in this context you don't know which column is "a"?
> 
> I agree but we at least show more information to the user. The user may
> still need to check the table definition to understand the constraint or
> to get the column ordering of the table to understand what the value of
> "a" is being used to compute the expression. 
> 
> If the statement declare the columns that the values are referecing this
> could be easier to know (e.g INSERT INTO t(a, b) VALUES (5, 10)) but the
> user may still need to check the table definition to understand the
> constraint.
> 
> I see this patch as a way to at least give more information to user
> about the error.
> 
> Another possibility would be to get the actual values of "a" for example
> and show it on the error message, e.g:
> 
>     ERROR:  new row for relation "t" violates check constraint "t_c_check"
>     DETAIL:  Failing row contains (5, 10, 5 * 2).

That would indeed be more useful. One way to achieve this might be to
modify deparse_context and get_variable() so that a Var is displayed as its
actual value.

Another possibility would be to include column names in the DETAIL message,
for example:

 ERROR:  new row for relation "t" violates check constraint "t_c_check"
 DETAIL:  Failing row contains (a, b, c)=(5, 10, a * 2).

Although this would change the existing message format, including column
names could generally provide users with more information about the error.

Regards,
Yugo Nagata

-- 
Yugo Nagata <nagata@sraoss.co.jp>





^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
@ 2026-02-05 18:51       ` Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-06 03:54         ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  0 siblings, 1 reply; 16+ messages in thread

From: Matheus Alcantara @ 2026-02-05 18:51 UTC (permalink / raw)
  To: Yugo Nagata <nagata@sraoss.co.jp>; +Cc: Peter Eisentraut <peter@eisentraut.org>; pgsql-hackers@lists.postgresql.org

On Wed Feb 4, 2026 at 11:45 PM -03, Yugo Nagata wrote:
>> Another possibility would be to get the actual values of "a" for example
>> and show it on the error message, e.g:
>> 
>>     ERROR:  new row for relation "t" violates check constraint "t_c_check"
>>     DETAIL:  Failing row contains (5, 10, 5 * 2).
>
> That would indeed be more useful. One way to achieve this might be to
> modify deparse_context and get_variable() so that a Var is displayed as its
> actual value.
>
I'm not sure if I understand how modifying deparse_context_for() could
help on this.

What I did was to use the expression_tree_mutator API to mutate the
virtual column expression to replace any Var reference with the value
into the TupleTableSlot. Please see the attached v2 version.

> Another possibility would be to include column names in the DETAIL message,
> for example:
>
>  ERROR:  new row for relation "t" violates check constraint "t_c_check"
>  DETAIL:  Failing row contains (a, b, c)=(5, 10, a * 2).
>
> Although this would change the existing message format, including column
> names could generally provide users with more information about the error.
>
I think that this could make the error output too verbose when there is
a lot of columns involved on the statement.

--
Matheus Alcantara
EDB: https://www.enterprisedb.com
From 76b5b36ba3b103e7c501de0bd94e9bb1afcdcf75 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v2] Show expression of virtual columns in error messages

Previously, when a constraint violation occurred on a table with virtual
generated columns, the "Failing row contains" error message would display
the literal string "virtual" as a placeholder for those columns. This was
not helpful for debugging.

Now, the generation expression is shown instead, making it easier to
understand what value would be computed for the virtual column.

For example, instead of:
  Failing row contains (5, 10, virtual).

The error message now shows:
  Failing row contains (5, 10, a * 2).

This required changing ExecBuildSlotValueDescription() to accept a
Relation instead of just an Oid, so that build_generation_expression()
can be called to retrieve the column's generation expression.
---
 src/backend/executor/execMain.c               | 97 ++++++++++++++++---
 src/backend/replication/logical/conflict.c    |  7 +-
 src/include/executor/executor.h               |  2 +-
 .../regress/expected/generated_virtual.out    | 18 ++--
 src/test/regress/expected/partition_merge.out |  2 +-
 src/tools/pgindent/typedefs.list              |  1 +
 6 files changed, 101 insertions(+), 26 deletions(-)

diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index bfd3ebc601e..b82c500ba90 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -51,6 +51,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -61,8 +63,18 @@
 #include "utils/lsyscache.h"
 #include "utils/partcache.h"
 #include "utils/rls.h"
+#include "utils/ruleutils.h"
 #include "utils/snapmgr.h"
 
+/*
+ * Context for substitute_actual_values_mutator
+ */
+typedef struct
+{
+	TupleTableSlot *slot;
+	TupleDesc	tupdesc;
+} substitute_actual_values_context;
+
 
 /* Hooks for plugins to get control in ExecutorStart/Run/Finish/End */
 ExecutorStart_hook_type ExecutorStart_hook = NULL;
@@ -93,6 +105,9 @@ static void ReportNotNullViolationError(ResultRelInfo *resultRelInfo,
 										TupleTableSlot *slot,
 										EState *estate, int attnum);
 
+static Node *substitute_actual_values_mutator(Node *node,
+											  substitute_actual_values_context *context);
+
 /* end of local decls */
 
 
@@ -1914,7 +1929,7 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 							TupleTableSlot *slot,
 							EState *estate)
 {
-	Oid			root_relid;
+	Relation	root_rel;
 	TupleDesc	tupdesc;
 	char	   *val_desc;
 	Bitmapset  *modifiedCols;
@@ -1931,8 +1946,8 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 		TupleDesc	old_tupdesc;
 		AttrMap    *map;
 
-		root_relid = RelationGetRelid(rootrel->ri_RelationDesc);
-		tupdesc = RelationGetDescr(rootrel->ri_RelationDesc);
+		root_rel = rootrel->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 
 		old_tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
 		/* a reverse map */
@@ -1950,13 +1965,13 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 	}
 	else
 	{
-		root_relid = RelationGetRelid(resultRelInfo->ri_RelationDesc);
-		tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
+		root_rel = resultRelInfo->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 	}
 
-	val_desc = ExecBuildSlotValueDescription(root_relid,
+	val_desc = ExecBuildSlotValueDescription(root_rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2068,7 +2083,7 @@ ExecConstraints(ResultRelInfo *resultRelInfo,
 			else
 				modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 										 ExecGetUpdatedCols(resultRelInfo, estate));
-			val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+			val_desc = ExecBuildSlotValueDescription(rel,
 													 slot,
 													 tupdesc,
 													 modifiedCols,
@@ -2205,7 +2220,7 @@ ReportNotNullViolationError(ResultRelInfo *resultRelInfo, TupleTableSlot *slot,
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 
-	val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+	val_desc = ExecBuildSlotValueDescription(rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2313,7 +2328,7 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 					else
 						modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 												 ExecGetUpdatedCols(resultRelInfo, estate));
-					val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+					val_desc = ExecBuildSlotValueDescription(rel,
 															 slot,
 															 tupdesc,
 															 modifiedCols,
@@ -2392,12 +2407,13 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
  * columns they are.
  */
 char *
-ExecBuildSlotValueDescription(Oid reloid,
+ExecBuildSlotValueDescription(Relation rel,
 							  TupleTableSlot *slot,
 							  TupleDesc tupdesc,
 							  Bitmapset *modifiedCols,
 							  int maxfieldlen)
 {
+	Oid			reloid = RelationGetRelid(rel);
 	StringInfoData buf;
 	StringInfoData collist;
 	bool		write_comma = false;
@@ -2477,7 +2493,23 @@ ExecBuildSlotValueDescription(Oid reloid,
 		if (table_perm || column_perm)
 		{
 			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
+			{
+				Node	   *genexpr = build_generation_expression(rel, att->attnum);
+				substitute_actual_values_context cxt;
+				List	   *dpcontext;
+
+				cxt.slot = slot;
+				cxt.tupdesc = tupdesc;
+				genexpr = substitute_actual_values_mutator(genexpr, &cxt);
+
+				/*
+				 * We need dpcontext for any remaining Vars that weren't
+				 * substituted (e.g system columns).
+				 */
+				dpcontext = deparse_context_for(RelationGetRelationName(rel), reloid);
+
+				val = deparse_expression(genexpr, dpcontext, false, false);
+			}
 			else if (slot->tts_isnull[i])
 				val = "null";
 			else
@@ -3241,3 +3273,46 @@ EvalPlanQualEnd(EPQState *epqstate)
 	epqstate->relsubs_done = NULL;
 	epqstate->relsubs_blocked = NULL;
 }
+
+/*
+ * Replaces Var nodes with Const nodes containing the actual values from the
+ * tuple slot.
+ *
+ * This is used to display the actual values used in virtual generated column
+ * expressions for error messages.
+ */
+static Node *
+substitute_actual_values_mutator(Node *node,
+								 substitute_actual_values_context *context)
+{
+	if (node == NULL)
+		return NULL;
+
+	if (IsA(node, Var))
+	{
+		Var		   *var = (Var *) node;
+		int			attnum = var->varattno;
+
+		if (attnum > 0 && attnum <= context->tupdesc->natts)
+		{
+			Form_pg_attribute att = TupleDescAttr(context->tupdesc, attnum - 1);
+			Datum		value;
+			bool		isnull;
+
+			value = context->slot->tts_values[attnum - 1];
+			isnull = context->slot->tts_isnull[attnum - 1];
+
+			return (Node *) makeConst(att->atttypid,
+									  att->atttypmod,
+									  att->attcollation,
+									  att->attlen,
+									  value,
+									  isnull,
+									  att->attbyval);
+		}
+	}
+
+	return expression_tree_mutator(node,
+								   substitute_actual_values_mutator,
+								   context);
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index ca71a81c7bf..478c0a223fc 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -432,7 +432,6 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 			   Oid indexoid)
 {
 	Relation	localrel = relinfo->ri_RelationDesc;
-	Oid			relid = RelationGetRelid(localrel);
 	TupleDesc	tupdesc = RelationGetDescr(localrel);
 	char	   *desc = NULL;
 
@@ -461,7 +460,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 * The 'modifiedCols' only applies to the new tuple, hence we pass
 		 * NULL for the local row.
 		 */
-		desc = ExecBuildSlotValueDescription(relid, localslot, tupdesc,
+		desc = ExecBuildSlotValueDescription(localrel, localslot, tupdesc,
 											 NULL, 64);
 
 		if (desc)
@@ -481,7 +480,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 */
 		modifiedCols = bms_union(ExecGetInsertedCols(relinfo, estate),
 								 ExecGetUpdatedCols(relinfo, estate));
-		desc = ExecBuildSlotValueDescription(relid, remoteslot,
+		desc = ExecBuildSlotValueDescription(localrel, remoteslot,
 											 tupdesc, modifiedCols,
 											 64);
 
@@ -510,7 +509,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		if (OidIsValid(replica_index))
 			desc = build_index_value_desc(estate, localrel, searchslot, replica_index);
 		else
-			desc = ExecBuildSlotValueDescription(relid, searchslot, tupdesc, NULL, 64);
+			desc = ExecBuildSlotValueDescription(localrel, searchslot, tupdesc, NULL, 64);
 
 		if (desc)
 		{
diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h
index 55a7d930d26..2ffb97d48ca 100644
--- a/src/include/executor/executor.h
+++ b/src/include/executor/executor.h
@@ -269,7 +269,7 @@ extern void ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 										TupleTableSlot *slot, EState *estate);
 extern void ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 								 TupleTableSlot *slot, EState *estate);
-extern char *ExecBuildSlotValueDescription(Oid reloid, TupleTableSlot *slot,
+extern char *ExecBuildSlotValueDescription(Relation rel, TupleTableSlot *slot,
 										   TupleDesc tupdesc,
 										   Bitmapset *modifiedCols,
 										   int maxfieldlen);
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 249e68be654..a55470fd47f 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -638,7 +638,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, (30 * 2)).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint (currently not supported)
 ERROR:  ALTER TABLE / SET EXPRESSION is not supported for virtual generated columns in tables with check constraints
 DETAIL:  Column "b" of relation "gtest20" is a virtual generated column.
@@ -666,18 +666,18 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, (NULL::integer * 2)).
 -- not-null constraints
 CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL);
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -687,17 +687,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, NULLIF(NULL::integer, 0)).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- not-null constraint with partitioned table
@@ -712,10 +712,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, (NULLIF(1, 1) + NULLIF('2'::bigint, 10))).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, (NULLIF(2, 1) + NULLIF('10'::bigint, 10))).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 925fe4f570a..ae40cb9cfcb 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1073,7 +1073,7 @@ INSERT INTO t VALUES (16);
 -- ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, (0 + (tableoid)::integer)).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 9f5ee8fd482..0bc6f86b884 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2950,6 +2950,7 @@ SubscriptingRefState
 Subscription
 SubscriptionInfo
 SubscriptionRelState
+substitute_actual_values_context
 SummarizerReadLocalXLogPrivate
 SupportRequestCost
 SupportRequestIndexCondition
-- 
2.52.0



Attachments:

  [text/plain] v2-0001-Show-expression-of-virtual-columns-in-error-messa.patch (15.2K, ../../DG796MZZAE5S.224STEF6SZTBZ@gmail.com/2-v2-0001-Show-expression-of-virtual-columns-in-error-messa.patch)
  download | inline diff:
From 76b5b36ba3b103e7c501de0bd94e9bb1afcdcf75 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v2] Show expression of virtual columns in error messages

Previously, when a constraint violation occurred on a table with virtual
generated columns, the "Failing row contains" error message would display
the literal string "virtual" as a placeholder for those columns. This was
not helpful for debugging.

Now, the generation expression is shown instead, making it easier to
understand what value would be computed for the virtual column.

For example, instead of:
  Failing row contains (5, 10, virtual).

The error message now shows:
  Failing row contains (5, 10, a * 2).

This required changing ExecBuildSlotValueDescription() to accept a
Relation instead of just an Oid, so that build_generation_expression()
can be called to retrieve the column's generation expression.
---
 src/backend/executor/execMain.c               | 97 ++++++++++++++++---
 src/backend/replication/logical/conflict.c    |  7 +-
 src/include/executor/executor.h               |  2 +-
 .../regress/expected/generated_virtual.out    | 18 ++--
 src/test/regress/expected/partition_merge.out |  2 +-
 src/tools/pgindent/typedefs.list              |  1 +
 6 files changed, 101 insertions(+), 26 deletions(-)

diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index bfd3ebc601e..b82c500ba90 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -51,6 +51,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -61,8 +63,18 @@
 #include "utils/lsyscache.h"
 #include "utils/partcache.h"
 #include "utils/rls.h"
+#include "utils/ruleutils.h"
 #include "utils/snapmgr.h"
 
+/*
+ * Context for substitute_actual_values_mutator
+ */
+typedef struct
+{
+	TupleTableSlot *slot;
+	TupleDesc	tupdesc;
+} substitute_actual_values_context;
+
 
 /* Hooks for plugins to get control in ExecutorStart/Run/Finish/End */
 ExecutorStart_hook_type ExecutorStart_hook = NULL;
@@ -93,6 +105,9 @@ static void ReportNotNullViolationError(ResultRelInfo *resultRelInfo,
 										TupleTableSlot *slot,
 										EState *estate, int attnum);
 
+static Node *substitute_actual_values_mutator(Node *node,
+											  substitute_actual_values_context *context);
+
 /* end of local decls */
 
 
@@ -1914,7 +1929,7 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 							TupleTableSlot *slot,
 							EState *estate)
 {
-	Oid			root_relid;
+	Relation	root_rel;
 	TupleDesc	tupdesc;
 	char	   *val_desc;
 	Bitmapset  *modifiedCols;
@@ -1931,8 +1946,8 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 		TupleDesc	old_tupdesc;
 		AttrMap    *map;
 
-		root_relid = RelationGetRelid(rootrel->ri_RelationDesc);
-		tupdesc = RelationGetDescr(rootrel->ri_RelationDesc);
+		root_rel = rootrel->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 
 		old_tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
 		/* a reverse map */
@@ -1950,13 +1965,13 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 	}
 	else
 	{
-		root_relid = RelationGetRelid(resultRelInfo->ri_RelationDesc);
-		tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
+		root_rel = resultRelInfo->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 	}
 
-	val_desc = ExecBuildSlotValueDescription(root_relid,
+	val_desc = ExecBuildSlotValueDescription(root_rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2068,7 +2083,7 @@ ExecConstraints(ResultRelInfo *resultRelInfo,
 			else
 				modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 										 ExecGetUpdatedCols(resultRelInfo, estate));
-			val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+			val_desc = ExecBuildSlotValueDescription(rel,
 													 slot,
 													 tupdesc,
 													 modifiedCols,
@@ -2205,7 +2220,7 @@ ReportNotNullViolationError(ResultRelInfo *resultRelInfo, TupleTableSlot *slot,
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 
-	val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+	val_desc = ExecBuildSlotValueDescription(rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2313,7 +2328,7 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 					else
 						modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 												 ExecGetUpdatedCols(resultRelInfo, estate));
-					val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+					val_desc = ExecBuildSlotValueDescription(rel,
 															 slot,
 															 tupdesc,
 															 modifiedCols,
@@ -2392,12 +2407,13 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
  * columns they are.
  */
 char *
-ExecBuildSlotValueDescription(Oid reloid,
+ExecBuildSlotValueDescription(Relation rel,
 							  TupleTableSlot *slot,
 							  TupleDesc tupdesc,
 							  Bitmapset *modifiedCols,
 							  int maxfieldlen)
 {
+	Oid			reloid = RelationGetRelid(rel);
 	StringInfoData buf;
 	StringInfoData collist;
 	bool		write_comma = false;
@@ -2477,7 +2493,23 @@ ExecBuildSlotValueDescription(Oid reloid,
 		if (table_perm || column_perm)
 		{
 			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
+			{
+				Node	   *genexpr = build_generation_expression(rel, att->attnum);
+				substitute_actual_values_context cxt;
+				List	   *dpcontext;
+
+				cxt.slot = slot;
+				cxt.tupdesc = tupdesc;
+				genexpr = substitute_actual_values_mutator(genexpr, &cxt);
+
+				/*
+				 * We need dpcontext for any remaining Vars that weren't
+				 * substituted (e.g system columns).
+				 */
+				dpcontext = deparse_context_for(RelationGetRelationName(rel), reloid);
+
+				val = deparse_expression(genexpr, dpcontext, false, false);
+			}
 			else if (slot->tts_isnull[i])
 				val = "null";
 			else
@@ -3241,3 +3273,46 @@ EvalPlanQualEnd(EPQState *epqstate)
 	epqstate->relsubs_done = NULL;
 	epqstate->relsubs_blocked = NULL;
 }
+
+/*
+ * Replaces Var nodes with Const nodes containing the actual values from the
+ * tuple slot.
+ *
+ * This is used to display the actual values used in virtual generated column
+ * expressions for error messages.
+ */
+static Node *
+substitute_actual_values_mutator(Node *node,
+								 substitute_actual_values_context *context)
+{
+	if (node == NULL)
+		return NULL;
+
+	if (IsA(node, Var))
+	{
+		Var		   *var = (Var *) node;
+		int			attnum = var->varattno;
+
+		if (attnum > 0 && attnum <= context->tupdesc->natts)
+		{
+			Form_pg_attribute att = TupleDescAttr(context->tupdesc, attnum - 1);
+			Datum		value;
+			bool		isnull;
+
+			value = context->slot->tts_values[attnum - 1];
+			isnull = context->slot->tts_isnull[attnum - 1];
+
+			return (Node *) makeConst(att->atttypid,
+									  att->atttypmod,
+									  att->attcollation,
+									  att->attlen,
+									  value,
+									  isnull,
+									  att->attbyval);
+		}
+	}
+
+	return expression_tree_mutator(node,
+								   substitute_actual_values_mutator,
+								   context);
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index ca71a81c7bf..478c0a223fc 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -432,7 +432,6 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 			   Oid indexoid)
 {
 	Relation	localrel = relinfo->ri_RelationDesc;
-	Oid			relid = RelationGetRelid(localrel);
 	TupleDesc	tupdesc = RelationGetDescr(localrel);
 	char	   *desc = NULL;
 
@@ -461,7 +460,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 * The 'modifiedCols' only applies to the new tuple, hence we pass
 		 * NULL for the local row.
 		 */
-		desc = ExecBuildSlotValueDescription(relid, localslot, tupdesc,
+		desc = ExecBuildSlotValueDescription(localrel, localslot, tupdesc,
 											 NULL, 64);
 
 		if (desc)
@@ -481,7 +480,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 */
 		modifiedCols = bms_union(ExecGetInsertedCols(relinfo, estate),
 								 ExecGetUpdatedCols(relinfo, estate));
-		desc = ExecBuildSlotValueDescription(relid, remoteslot,
+		desc = ExecBuildSlotValueDescription(localrel, remoteslot,
 											 tupdesc, modifiedCols,
 											 64);
 
@@ -510,7 +509,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		if (OidIsValid(replica_index))
 			desc = build_index_value_desc(estate, localrel, searchslot, replica_index);
 		else
-			desc = ExecBuildSlotValueDescription(relid, searchslot, tupdesc, NULL, 64);
+			desc = ExecBuildSlotValueDescription(localrel, searchslot, tupdesc, NULL, 64);
 
 		if (desc)
 		{
diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h
index 55a7d930d26..2ffb97d48ca 100644
--- a/src/include/executor/executor.h
+++ b/src/include/executor/executor.h
@@ -269,7 +269,7 @@ extern void ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 										TupleTableSlot *slot, EState *estate);
 extern void ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 								 TupleTableSlot *slot, EState *estate);
-extern char *ExecBuildSlotValueDescription(Oid reloid, TupleTableSlot *slot,
+extern char *ExecBuildSlotValueDescription(Relation rel, TupleTableSlot *slot,
 										   TupleDesc tupdesc,
 										   Bitmapset *modifiedCols,
 										   int maxfieldlen);
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 249e68be654..a55470fd47f 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -638,7 +638,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, (30 * 2)).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint (currently not supported)
 ERROR:  ALTER TABLE / SET EXPRESSION is not supported for virtual generated columns in tables with check constraints
 DETAIL:  Column "b" of relation "gtest20" is a virtual generated column.
@@ -666,18 +666,18 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, (NULL::integer * 2)).
 -- not-null constraints
 CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL);
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -687,17 +687,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, NULLIF(NULL::integer, 0)).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- not-null constraint with partitioned table
@@ -712,10 +712,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, (NULLIF(1, 1) + NULLIF('2'::bigint, 10))).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, (NULLIF(2, 1) + NULLIF('10'::bigint, 10))).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 925fe4f570a..ae40cb9cfcb 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1073,7 +1073,7 @@ INSERT INTO t VALUES (16);
 -- ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, (0 + (tableoid)::integer)).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 9f5ee8fd482..0bc6f86b884 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2950,6 +2950,7 @@ SubscriptingRefState
 Subscription
 SubscriptionInfo
 SubscriptionRelState
+substitute_actual_values_context
 SummarizerReadLocalXLogPrivate
 SupportRequestCost
 SupportRequestIndexCondition
-- 
2.52.0



^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
@ 2026-02-06 03:54         ` Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-06 12:25           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  0 siblings, 1 reply; 16+ messages in thread

From: Yugo Nagata @ 2026-02-06 03:54 UTC (permalink / raw)
  To: Matheus Alcantara <matheusssilv97@gmail.com>; +Cc: Peter Eisentraut <peter@eisentraut.org>; pgsql-hackers@lists.postgresql.org

On Thu, 05 Feb 2026 15:51:54 -0300
"Matheus Alcantara" <matheusssilv97@gmail.com> wrote:

> On Wed Feb 4, 2026 at 11:45 PM -03, Yugo Nagata wrote:
> >> Another possibility would be to get the actual values of "a" for example
> >> and show it on the error message, e.g:
> >> 
> >>     ERROR:  new row for relation "t" violates check constraint "t_c_check"
> >>     DETAIL:  Failing row contains (5, 10, 5 * 2).
> >
> > That would indeed be more useful. One way to achieve this might be to
> > modify deparse_context and get_variable() so that a Var is displayed as its
> > actual value.
> >
> I'm not sure if I understand how modifying deparse_context_for() could
> help on this.
> 
> What I did was to use the expression_tree_mutator API to mutate the
> virtual column expression to replace any Var reference with the value
> into the TupleTableSlot. Please see the attached v2 version.

I initially thought about having deparse_context_for() directly output
actual values for Var references, but that does not seem like a good
approach. Replacing Vars with Consts beforehand using
expression_tree_mutator, as you did, looks like a better solution.

One thing I noticed is that some expressions in virtual columns (e.g.,
NULL and negative integers) differ from those in normal columns, for
example in the following (null vs. NULL):

+DETAIL:  Failing row contains (null, NULLIF(NULL::integer, 0)).

However, this seems acceptable.

Overall, this patch looks good to me.

Regards,
Yugo Nagata

> > Another possibility would be to include column names in the DETAIL message,
> > for example:
> >
> >  ERROR:  new row for relation "t" violates check constraint "t_c_check"
> >  DETAIL:  Failing row contains (a, b, c)=(5, 10, a * 2).
> >
> > Although this would change the existing message format, including column
> > names could generally provide users with more information about the error.
> >
> I think that this could make the error output too verbose when there is
> a lot of columns involved on the statement.
> 
> --
> Matheus Alcantara
> EDB: https://www.enterprisedb.com
> 


-- 
Yugo Nagata <nagata@sraoss.co.jp>





^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-06 03:54         ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
@ 2026-02-06 12:25           ` Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:06             ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  0 siblings, 1 reply; 16+ messages in thread

From: Matheus Alcantara @ 2026-02-06 12:25 UTC (permalink / raw)
  To: Yugo Nagata <nagata@sraoss.co.jp>; +Cc: Peter Eisentraut <peter@eisentraut.org>; pgsql-hackers@lists.postgresql.org

On Fri Feb 6, 2026 at 12:54 AM -03, Yugo Nagata wrote:
>> >> Another possibility would be to get the actual values of "a" for example
>> >> and show it on the error message, e.g:
>> >> 
>> >>     ERROR:  new row for relation "t" violates check constraint "t_c_check"
>> >>     DETAIL:  Failing row contains (5, 10, 5 * 2).
>> >
>> > That would indeed be more useful. One way to achieve this might be to
>> > modify deparse_context and get_variable() so that a Var is displayed as its
>> > actual value.
>> >
>> I'm not sure if I understand how modifying deparse_context_for() could
>> help on this.
>> 
>> What I did was to use the expression_tree_mutator API to mutate the
>> virtual column expression to replace any Var reference with the value
>> into the TupleTableSlot. Please see the attached v2 version.
>
> I initially thought about having deparse_context_for() directly output
> actual values for Var references, but that does not seem like a good
> approach. Replacing Vars with Consts beforehand using
> expression_tree_mutator, as you did, looks like a better solution.
>
> One thing I noticed is that some expressions in virtual columns (e.g.,
> NULL and negative integers) differ from those in normal columns, for
> example in the following (null vs. NULL):
>
> +DETAIL:  Failing row contains (null, NULLIF(NULL::integer, 0)).
>
> However, this seems acceptable.
>
Yeah, after parsing we don't have the original string representation of
the parse node, so at deparse_expression() it has it's own style to
deparse nodes. I don't think that it's an issue and I also think that
it's accepttable. 

> Overall, this patch looks good to me.
>
Thank you for reviewing the patch!

--
Matheus Alcantara
EDB: https://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-06 03:54         ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-06 12:25           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
@ 2026-02-26 18:06             ` Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:47               ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  0 siblings, 1 reply; 16+ messages in thread

From: Matheus Alcantara @ 2026-02-26 18:06 UTC (permalink / raw)
  To: pgsql-hackers@lists.postgresql.org; +Cc: Peter Eisentraut <peter@eisentraut.org>; Yugo Nagata <nagata@sraoss.co.jp>

Hi,

Attached rebased v3 due to f80bedd52b1. No additional changes compared 
to v2.

--
Matheus Alcantara
EDB: https://www.enterprisedb.com
From 4572053a997576f08df97565674aed9515b01d50 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v3] Show expression of virtual columns in error messages

Previously, when a constraint violation occurred on a table with virtual
generated columns, the "Failing row contains" error message would display
the literal string "virtual" as a placeholder for those columns. This was
not helpful for debugging.

Now, the generation expression is shown instead, making it easier to
understand what value would be computed for the virtual column.

For example, instead of:
  Failing row contains (5, 10, virtual).

The error message now shows:
  Failing row contains (5, 10, a * 2).

This required changing ExecBuildSlotValueDescription() to accept a
Relation instead of just an Oid, so that build_generation_expression()
can be called to retrieve the column's generation expression.
---
 src/backend/executor/execMain.c               | 97 ++++++++++++++++---
 src/backend/replication/logical/conflict.c    |  7 +-
 src/include/executor/executor.h               |  2 +-
 .../regress/expected/generated_virtual.out    | 18 ++--
 src/test/regress/expected/partition_merge.out |  2 +-
 src/tools/pgindent/typedefs.list              |  1 +
 6 files changed, 101 insertions(+), 26 deletions(-)

diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index bfd3ebc601e..b82c500ba90 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -51,6 +51,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -61,8 +63,18 @@
 #include "utils/lsyscache.h"
 #include "utils/partcache.h"
 #include "utils/rls.h"
+#include "utils/ruleutils.h"
 #include "utils/snapmgr.h"
 
+/*
+ * Context for substitute_actual_values_mutator
+ */
+typedef struct
+{
+	TupleTableSlot *slot;
+	TupleDesc	tupdesc;
+} substitute_actual_values_context;
+
 
 /* Hooks for plugins to get control in ExecutorStart/Run/Finish/End */
 ExecutorStart_hook_type ExecutorStart_hook = NULL;
@@ -93,6 +105,9 @@ static void ReportNotNullViolationError(ResultRelInfo *resultRelInfo,
 										TupleTableSlot *slot,
 										EState *estate, int attnum);
 
+static Node *substitute_actual_values_mutator(Node *node,
+											  substitute_actual_values_context *context);
+
 /* end of local decls */
 
 
@@ -1914,7 +1929,7 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 							TupleTableSlot *slot,
 							EState *estate)
 {
-	Oid			root_relid;
+	Relation	root_rel;
 	TupleDesc	tupdesc;
 	char	   *val_desc;
 	Bitmapset  *modifiedCols;
@@ -1931,8 +1946,8 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 		TupleDesc	old_tupdesc;
 		AttrMap    *map;
 
-		root_relid = RelationGetRelid(rootrel->ri_RelationDesc);
-		tupdesc = RelationGetDescr(rootrel->ri_RelationDesc);
+		root_rel = rootrel->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 
 		old_tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
 		/* a reverse map */
@@ -1950,13 +1965,13 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 	}
 	else
 	{
-		root_relid = RelationGetRelid(resultRelInfo->ri_RelationDesc);
-		tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
+		root_rel = resultRelInfo->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 	}
 
-	val_desc = ExecBuildSlotValueDescription(root_relid,
+	val_desc = ExecBuildSlotValueDescription(root_rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2068,7 +2083,7 @@ ExecConstraints(ResultRelInfo *resultRelInfo,
 			else
 				modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 										 ExecGetUpdatedCols(resultRelInfo, estate));
-			val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+			val_desc = ExecBuildSlotValueDescription(rel,
 													 slot,
 													 tupdesc,
 													 modifiedCols,
@@ -2205,7 +2220,7 @@ ReportNotNullViolationError(ResultRelInfo *resultRelInfo, TupleTableSlot *slot,
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 
-	val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+	val_desc = ExecBuildSlotValueDescription(rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2313,7 +2328,7 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 					else
 						modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 												 ExecGetUpdatedCols(resultRelInfo, estate));
-					val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+					val_desc = ExecBuildSlotValueDescription(rel,
 															 slot,
 															 tupdesc,
 															 modifiedCols,
@@ -2392,12 +2407,13 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
  * columns they are.
  */
 char *
-ExecBuildSlotValueDescription(Oid reloid,
+ExecBuildSlotValueDescription(Relation rel,
 							  TupleTableSlot *slot,
 							  TupleDesc tupdesc,
 							  Bitmapset *modifiedCols,
 							  int maxfieldlen)
 {
+	Oid			reloid = RelationGetRelid(rel);
 	StringInfoData buf;
 	StringInfoData collist;
 	bool		write_comma = false;
@@ -2477,7 +2493,23 @@ ExecBuildSlotValueDescription(Oid reloid,
 		if (table_perm || column_perm)
 		{
 			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
+			{
+				Node	   *genexpr = build_generation_expression(rel, att->attnum);
+				substitute_actual_values_context cxt;
+				List	   *dpcontext;
+
+				cxt.slot = slot;
+				cxt.tupdesc = tupdesc;
+				genexpr = substitute_actual_values_mutator(genexpr, &cxt);
+
+				/*
+				 * We need dpcontext for any remaining Vars that weren't
+				 * substituted (e.g system columns).
+				 */
+				dpcontext = deparse_context_for(RelationGetRelationName(rel), reloid);
+
+				val = deparse_expression(genexpr, dpcontext, false, false);
+			}
 			else if (slot->tts_isnull[i])
 				val = "null";
 			else
@@ -3241,3 +3273,46 @@ EvalPlanQualEnd(EPQState *epqstate)
 	epqstate->relsubs_done = NULL;
 	epqstate->relsubs_blocked = NULL;
 }
+
+/*
+ * Replaces Var nodes with Const nodes containing the actual values from the
+ * tuple slot.
+ *
+ * This is used to display the actual values used in virtual generated column
+ * expressions for error messages.
+ */
+static Node *
+substitute_actual_values_mutator(Node *node,
+								 substitute_actual_values_context *context)
+{
+	if (node == NULL)
+		return NULL;
+
+	if (IsA(node, Var))
+	{
+		Var		   *var = (Var *) node;
+		int			attnum = var->varattno;
+
+		if (attnum > 0 && attnum <= context->tupdesc->natts)
+		{
+			Form_pg_attribute att = TupleDescAttr(context->tupdesc, attnum - 1);
+			Datum		value;
+			bool		isnull;
+
+			value = context->slot->tts_values[attnum - 1];
+			isnull = context->slot->tts_isnull[attnum - 1];
+
+			return (Node *) makeConst(att->atttypid,
+									  att->atttypmod,
+									  att->attcollation,
+									  att->attlen,
+									  value,
+									  isnull,
+									  att->attbyval);
+		}
+	}
+
+	return expression_tree_mutator(node,
+								   substitute_actual_values_mutator,
+								   context);
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index ca71a81c7bf..478c0a223fc 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -432,7 +432,6 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 			   Oid indexoid)
 {
 	Relation	localrel = relinfo->ri_RelationDesc;
-	Oid			relid = RelationGetRelid(localrel);
 	TupleDesc	tupdesc = RelationGetDescr(localrel);
 	char	   *desc = NULL;
 
@@ -461,7 +460,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 * The 'modifiedCols' only applies to the new tuple, hence we pass
 		 * NULL for the local row.
 		 */
-		desc = ExecBuildSlotValueDescription(relid, localslot, tupdesc,
+		desc = ExecBuildSlotValueDescription(localrel, localslot, tupdesc,
 											 NULL, 64);
 
 		if (desc)
@@ -481,7 +480,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 */
 		modifiedCols = bms_union(ExecGetInsertedCols(relinfo, estate),
 								 ExecGetUpdatedCols(relinfo, estate));
-		desc = ExecBuildSlotValueDescription(relid, remoteslot,
+		desc = ExecBuildSlotValueDescription(localrel, remoteslot,
 											 tupdesc, modifiedCols,
 											 64);
 
@@ -510,7 +509,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		if (OidIsValid(replica_index))
 			desc = build_index_value_desc(estate, localrel, searchslot, replica_index);
 		else
-			desc = ExecBuildSlotValueDescription(relid, searchslot, tupdesc, NULL, 64);
+			desc = ExecBuildSlotValueDescription(localrel, searchslot, tupdesc, NULL, 64);
 
 		if (desc)
 		{
diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h
index d46ba59895d..6cc4a96046e 100644
--- a/src/include/executor/executor.h
+++ b/src/include/executor/executor.h
@@ -269,7 +269,7 @@ extern void ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 										TupleTableSlot *slot, EState *estate);
 extern void ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 								 TupleTableSlot *slot, EState *estate);
-extern char *ExecBuildSlotValueDescription(Oid reloid, TupleTableSlot *slot,
+extern char *ExecBuildSlotValueDescription(Relation rel, TupleTableSlot *slot,
 										   TupleDesc tupdesc,
 										   Bitmapset *modifiedCols,
 										   int maxfieldlen);
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 6dab60c937b..f4e98286906 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -638,7 +638,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, (30 * 2)).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint
 ERROR:  check constraint "gtest20_b_check" of relation "gtest20" is violated by some row
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 3);  -- ok
@@ -684,18 +684,18 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, (NULL::integer * 2)).
 -- not-null constraints
 CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL);
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -705,17 +705,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, NULLIF(NULL::integer, 0)).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- not-null constraint with partitioned table
@@ -730,10 +730,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, (NULLIF(1, 1) + NULLIF('2'::bigint, 10))).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, (NULLIF(2, 1) + NULLIF('10'::bigint, 10))).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 925fe4f570a..ae40cb9cfcb 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1073,7 +1073,7 @@ INSERT INTO t VALUES (16);
 -- ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, (0 + (tableoid)::integer)).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 1a89ef94bec..a4520dfd502 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2956,6 +2956,7 @@ SubscriptingRefState
 Subscription
 SubscriptionInfo
 SubscriptionRelState
+substitute_actual_values_context
 SummarizerReadLocalXLogPrivate
 SupportRequestCost
 SupportRequestIndexCondition
-- 
2.52.0



Attachments:

  [text/plain] v3-0001-Show-expression-of-virtual-columns-in-error-messa.patch (15.2K, ../../2f55b9a4-332f-45c7-9b27-590637a2bb9a@gmail.com/2-v3-0001-Show-expression-of-virtual-columns-in-error-messa.patch)
  download | inline diff:
From 4572053a997576f08df97565674aed9515b01d50 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v3] Show expression of virtual columns in error messages

Previously, when a constraint violation occurred on a table with virtual
generated columns, the "Failing row contains" error message would display
the literal string "virtual" as a placeholder for those columns. This was
not helpful for debugging.

Now, the generation expression is shown instead, making it easier to
understand what value would be computed for the virtual column.

For example, instead of:
  Failing row contains (5, 10, virtual).

The error message now shows:
  Failing row contains (5, 10, a * 2).

This required changing ExecBuildSlotValueDescription() to accept a
Relation instead of just an Oid, so that build_generation_expression()
can be called to retrieve the column's generation expression.
---
 src/backend/executor/execMain.c               | 97 ++++++++++++++++---
 src/backend/replication/logical/conflict.c    |  7 +-
 src/include/executor/executor.h               |  2 +-
 .../regress/expected/generated_virtual.out    | 18 ++--
 src/test/regress/expected/partition_merge.out |  2 +-
 src/tools/pgindent/typedefs.list              |  1 +
 6 files changed, 101 insertions(+), 26 deletions(-)

diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index bfd3ebc601e..b82c500ba90 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -51,6 +51,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -61,8 +63,18 @@
 #include "utils/lsyscache.h"
 #include "utils/partcache.h"
 #include "utils/rls.h"
+#include "utils/ruleutils.h"
 #include "utils/snapmgr.h"
 
+/*
+ * Context for substitute_actual_values_mutator
+ */
+typedef struct
+{
+	TupleTableSlot *slot;
+	TupleDesc	tupdesc;
+} substitute_actual_values_context;
+
 
 /* Hooks for plugins to get control in ExecutorStart/Run/Finish/End */
 ExecutorStart_hook_type ExecutorStart_hook = NULL;
@@ -93,6 +105,9 @@ static void ReportNotNullViolationError(ResultRelInfo *resultRelInfo,
 										TupleTableSlot *slot,
 										EState *estate, int attnum);
 
+static Node *substitute_actual_values_mutator(Node *node,
+											  substitute_actual_values_context *context);
+
 /* end of local decls */
 
 
@@ -1914,7 +1929,7 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 							TupleTableSlot *slot,
 							EState *estate)
 {
-	Oid			root_relid;
+	Relation	root_rel;
 	TupleDesc	tupdesc;
 	char	   *val_desc;
 	Bitmapset  *modifiedCols;
@@ -1931,8 +1946,8 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 		TupleDesc	old_tupdesc;
 		AttrMap    *map;
 
-		root_relid = RelationGetRelid(rootrel->ri_RelationDesc);
-		tupdesc = RelationGetDescr(rootrel->ri_RelationDesc);
+		root_rel = rootrel->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 
 		old_tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
 		/* a reverse map */
@@ -1950,13 +1965,13 @@ ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 	}
 	else
 	{
-		root_relid = RelationGetRelid(resultRelInfo->ri_RelationDesc);
-		tupdesc = RelationGetDescr(resultRelInfo->ri_RelationDesc);
+		root_rel = resultRelInfo->ri_RelationDesc;
+		tupdesc = RelationGetDescr(root_rel);
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 	}
 
-	val_desc = ExecBuildSlotValueDescription(root_relid,
+	val_desc = ExecBuildSlotValueDescription(root_rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2068,7 +2083,7 @@ ExecConstraints(ResultRelInfo *resultRelInfo,
 			else
 				modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 										 ExecGetUpdatedCols(resultRelInfo, estate));
-			val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+			val_desc = ExecBuildSlotValueDescription(rel,
 													 slot,
 													 tupdesc,
 													 modifiedCols,
@@ -2205,7 +2220,7 @@ ReportNotNullViolationError(ResultRelInfo *resultRelInfo, TupleTableSlot *slot,
 		modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 								 ExecGetUpdatedCols(resultRelInfo, estate));
 
-	val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+	val_desc = ExecBuildSlotValueDescription(rel,
 											 slot,
 											 tupdesc,
 											 modifiedCols,
@@ -2313,7 +2328,7 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 					else
 						modifiedCols = bms_union(ExecGetInsertedCols(resultRelInfo, estate),
 												 ExecGetUpdatedCols(resultRelInfo, estate));
-					val_desc = ExecBuildSlotValueDescription(RelationGetRelid(rel),
+					val_desc = ExecBuildSlotValueDescription(rel,
 															 slot,
 															 tupdesc,
 															 modifiedCols,
@@ -2392,12 +2407,13 @@ ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
  * columns they are.
  */
 char *
-ExecBuildSlotValueDescription(Oid reloid,
+ExecBuildSlotValueDescription(Relation rel,
 							  TupleTableSlot *slot,
 							  TupleDesc tupdesc,
 							  Bitmapset *modifiedCols,
 							  int maxfieldlen)
 {
+	Oid			reloid = RelationGetRelid(rel);
 	StringInfoData buf;
 	StringInfoData collist;
 	bool		write_comma = false;
@@ -2477,7 +2493,23 @@ ExecBuildSlotValueDescription(Oid reloid,
 		if (table_perm || column_perm)
 		{
 			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
+			{
+				Node	   *genexpr = build_generation_expression(rel, att->attnum);
+				substitute_actual_values_context cxt;
+				List	   *dpcontext;
+
+				cxt.slot = slot;
+				cxt.tupdesc = tupdesc;
+				genexpr = substitute_actual_values_mutator(genexpr, &cxt);
+
+				/*
+				 * We need dpcontext for any remaining Vars that weren't
+				 * substituted (e.g system columns).
+				 */
+				dpcontext = deparse_context_for(RelationGetRelationName(rel), reloid);
+
+				val = deparse_expression(genexpr, dpcontext, false, false);
+			}
 			else if (slot->tts_isnull[i])
 				val = "null";
 			else
@@ -3241,3 +3273,46 @@ EvalPlanQualEnd(EPQState *epqstate)
 	epqstate->relsubs_done = NULL;
 	epqstate->relsubs_blocked = NULL;
 }
+
+/*
+ * Replaces Var nodes with Const nodes containing the actual values from the
+ * tuple slot.
+ *
+ * This is used to display the actual values used in virtual generated column
+ * expressions for error messages.
+ */
+static Node *
+substitute_actual_values_mutator(Node *node,
+								 substitute_actual_values_context *context)
+{
+	if (node == NULL)
+		return NULL;
+
+	if (IsA(node, Var))
+	{
+		Var		   *var = (Var *) node;
+		int			attnum = var->varattno;
+
+		if (attnum > 0 && attnum <= context->tupdesc->natts)
+		{
+			Form_pg_attribute att = TupleDescAttr(context->tupdesc, attnum - 1);
+			Datum		value;
+			bool		isnull;
+
+			value = context->slot->tts_values[attnum - 1];
+			isnull = context->slot->tts_isnull[attnum - 1];
+
+			return (Node *) makeConst(att->atttypid,
+									  att->atttypmod,
+									  att->attcollation,
+									  att->attlen,
+									  value,
+									  isnull,
+									  att->attbyval);
+		}
+	}
+
+	return expression_tree_mutator(node,
+								   substitute_actual_values_mutator,
+								   context);
+}
diff --git a/src/backend/replication/logical/conflict.c b/src/backend/replication/logical/conflict.c
index ca71a81c7bf..478c0a223fc 100644
--- a/src/backend/replication/logical/conflict.c
+++ b/src/backend/replication/logical/conflict.c
@@ -432,7 +432,6 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 			   Oid indexoid)
 {
 	Relation	localrel = relinfo->ri_RelationDesc;
-	Oid			relid = RelationGetRelid(localrel);
 	TupleDesc	tupdesc = RelationGetDescr(localrel);
 	char	   *desc = NULL;
 
@@ -461,7 +460,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 * The 'modifiedCols' only applies to the new tuple, hence we pass
 		 * NULL for the local row.
 		 */
-		desc = ExecBuildSlotValueDescription(relid, localslot, tupdesc,
+		desc = ExecBuildSlotValueDescription(localrel, localslot, tupdesc,
 											 NULL, 64);
 
 		if (desc)
@@ -481,7 +480,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		 */
 		modifiedCols = bms_union(ExecGetInsertedCols(relinfo, estate),
 								 ExecGetUpdatedCols(relinfo, estate));
-		desc = ExecBuildSlotValueDescription(relid, remoteslot,
+		desc = ExecBuildSlotValueDescription(localrel, remoteslot,
 											 tupdesc, modifiedCols,
 											 64);
 
@@ -510,7 +509,7 @@ get_tuple_desc(EState *estate, ResultRelInfo *relinfo, ConflictType type,
 		if (OidIsValid(replica_index))
 			desc = build_index_value_desc(estate, localrel, searchslot, replica_index);
 		else
-			desc = ExecBuildSlotValueDescription(relid, searchslot, tupdesc, NULL, 64);
+			desc = ExecBuildSlotValueDescription(localrel, searchslot, tupdesc, NULL, 64);
 
 		if (desc)
 		{
diff --git a/src/include/executor/executor.h b/src/include/executor/executor.h
index d46ba59895d..6cc4a96046e 100644
--- a/src/include/executor/executor.h
+++ b/src/include/executor/executor.h
@@ -269,7 +269,7 @@ extern void ExecPartitionCheckEmitError(ResultRelInfo *resultRelInfo,
 										TupleTableSlot *slot, EState *estate);
 extern void ExecWithCheckOptions(WCOKind kind, ResultRelInfo *resultRelInfo,
 								 TupleTableSlot *slot, EState *estate);
-extern char *ExecBuildSlotValueDescription(Oid reloid, TupleTableSlot *slot,
+extern char *ExecBuildSlotValueDescription(Relation rel, TupleTableSlot *slot,
 										   TupleDesc tupdesc,
 										   Bitmapset *modifiedCols,
 										   int maxfieldlen);
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 6dab60c937b..f4e98286906 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -638,7 +638,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, (30 * 2)).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint
 ERROR:  check constraint "gtest20_b_check" of relation "gtest20" is violated by some row
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 3);  -- ok
@@ -684,18 +684,18 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, (NULL::integer * 2)).
 -- not-null constraints
 CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL);
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -705,17 +705,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, NULLIF(0, 0)).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, NULLIF(NULL::integer, 0)).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- not-null constraint with partitioned table
@@ -730,10 +730,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, (NULLIF(1, 1) + NULLIF('2'::bigint, 10))).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, (NULLIF(2, 1) + NULLIF('10'::bigint, 10))).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 925fe4f570a..ae40cb9cfcb 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1073,7 +1073,7 @@ INSERT INTO t VALUES (16);
 -- ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, (0 + (tableoid)::integer)).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 1a89ef94bec..a4520dfd502 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2956,6 +2956,7 @@ SubscriptingRefState
 Subscription
 SubscriptionInfo
 SubscriptionRelState
+substitute_actual_values_context
 SummarizerReadLocalXLogPrivate
 SupportRequestCost
 SupportRequestIndexCondition
-- 
2.52.0



^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-06 03:54         ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-06 12:25           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:06             ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
@ 2026-02-26 18:47               ` Tom Lane <tgl@sss.pgh.pa.us>
  2026-02-26 20:29                 ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  0 siblings, 1 reply; 16+ messages in thread

From: Tom Lane @ 2026-02-26 18:47 UTC (permalink / raw)
  To: Matheus Alcantara <matheusssilv97@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org, Peter Eisentraut <peter@eisentraut.org>; Yugo Nagata <nagata@sraoss.co.jp>

Matheus Alcantara <matheusssilv97@gmail.com> writes:
> Attached rebased v3 due to f80bedd52b1. No additional changes compared 
> to v2.

I looked at this and frankly I think it's going in the wrong
direction.  I agree that showing "virtual" is unhelpful, but
I don't like this approach for a couple of reasons:

1. Inserting a column expression could easily make the error
message annoyingly long.

2. Adding this much complexity in an error code path doesn't
seem like a good idea.  Such paths are typically not well
tested, and if there is any bug lurking, it will result in
an error message quite removed from the user's problem.

3. It's making virtual generated columns behave (even more)
differently from stored generated columns.  I think the
general plan has been to make them act as alike as possible.

So what would comport better with the behavior of stored columns
is to show the expression's value.  I agree with you that
calculating that in the error path is a no-go, but haven't we
computed it earlier?  Or could we do so, if there are constraints
to be checked?

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-06 03:54         ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-06 12:25           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:06             ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:47               ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
@ 2026-02-26 20:29                 ` Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 22:14                   ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  0 siblings, 1 reply; 16+ messages in thread

From: Matheus Alcantara @ 2026-02-26 20:29 UTC (permalink / raw)
  To: Tom Lane <tgl@sss.pgh.pa.us>; +Cc: pgsql-hackers@lists.postgresql.org; Peter Eisentraut <peter@eisentraut.org>; Yugo Nagata <nagata@sraoss.co.jp>

On Thu Feb 26, 2026 at 3:47 PM -03, Tom Lane wrote:
> Matheus Alcantara <matheusssilv97@gmail.com> writes:
>> Attached rebased v3 due to f80bedd52b1. No additional changes compared 
>> to v2.
>
> I looked at this and frankly I think it's going in the wrong
> direction.  I agree that showing "virtual" is unhelpful, but
> I don't like this approach for a couple of reasons:
>
> 1. Inserting a column expression could easily make the error
> message annoyingly long.
>
> 2. Adding this much complexity in an error code path doesn't
> seem like a good idea.  Such paths are typically not well
> tested, and if there is any bug lurking, it will result in
> an error message quite removed from the user's problem.
>
> 3. It's making virtual generated columns behave (even more)
> differently from stored generated columns.  I think the
> general plan has been to make them act as alike as possible.
>
> So what would comport better with the behavior of stored columns
> is to show the expression's value.  I agree with you that
> calculating that in the error path is a no-go, but haven't we
> computed it earlier?  Or could we do so, if there are constraints
> to be checked?

Thanks for the feedback. After investigating the code a bit, I found
that IIUC virtual column values are actually never computed and stored
separately, they're computed by expanding the expression wherever the
column is referenced.

According to my understanding, here's how it works during constraint
checking in ExecRelCheck():

1. The constraint expression (e.g., c > 10) is loaded from the catalog
2. Virtual column references are expanded via
expand_generated_columns_in_expr(), transforming it to (a + b) > 10 
3. ExecCheck() evaluates the entire expanded expression, reading base
column values (a, b) directly from the slot and returns the if the check
was true or false.

The virtual column value (a + b) is computed as an intermediate result
during expression evaluation of a check constraint but is never stored
anywhere accessible.

So, if all of this make sense and it's correct it seems to me that we
indeed need to compute the virtual expression value on error path to
show on error message although it would not be desirable, or I'm missing
something?

--
Matheus Alcantara
EDB: https://www.enterprisedb.com





^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-06 03:54         ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-06 12:25           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:06             ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:47               ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  2026-02-26 20:29                 ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
@ 2026-02-26 22:14                   ` Tom Lane <tgl@sss.pgh.pa.us>
  2026-03-03 17:01                     ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  0 siblings, 1 reply; 16+ messages in thread

From: Tom Lane @ 2026-02-26 22:14 UTC (permalink / raw)
  To: Matheus Alcantara <matheusssilv97@gmail.com>; +Cc: pgsql-hackers@lists.postgresql.org, "Peter Eisentraut" <peter@eisentraut.org>; Yugo Nagata <nagata@sraoss.co.jp>

"Matheus Alcantara" <matheusssilv97@gmail.com> writes:
> On Thu Feb 26, 2026 at 3:47 PM -03, Tom Lane wrote:
>> 3. It's making virtual generated columns behave (even more)
>> differently from stored generated columns.  I think the
>> general plan has been to make them act as alike as possible.
>> 
>> So what would comport better with the behavior of stored columns
>> is to show the expression's value.  I agree with you that
>> calculating that in the error path is a no-go, but haven't we
>> computed it earlier?  Or could we do so, if there are constraints
>> to be checked?

> Thanks for the feedback. After investigating the code a bit, I found
> that IIUC virtual column values are actually never computed and stored
> separately, they're computed by expanding the expression wherever the
> column is referenced.

Correct: rather than storing them, we recalculate the expression
whenever the column's value is demanded.  But what I'm suggesting is
that we ought to calculate the value during INSERT/UPDATE as well,
even though it will not get written to disk.  It'd be useful to do
that for the purposes of this error message.  But I think we ought to
do it even when there are no constraints, because that ensures that
it's *possible* to calculate the value, and that there are not for
instance overflow problems.  As things stand, it's possible to create
a row that cannot be fetched:

regression=# create table foo (f1 int, f2 int generated always as (f1 * 1000000));
CREATE TABLE
regression=# insert into foo values(1);
INSERT 0 1
regression=# table foo;
 f1 |   f2    
----+---------
  1 | 1000000
(1 row)

regression=# insert into foo values(1000000);
INSERT 0 1
regression=# table foo;
ERROR:  integer out of range

That may or may not be per spec, but I submit that it's extremely
unhelpful and unfriendly, as well as being an undesirable discrepancy
from the behavior of a stored generated column.

Of course, calculating the value at insertion time won't completely
prevent such problems: if the expression is less immutable than
advertised, we could still fail at readout time.  But that's pretty
clearly a case of user misfeasance.  Index expressions that aren't
really immutable cause worse problems than this, so I don't have a
problem with saying "you broke it, you get to keep both pieces".

Perhaps someone will argue that their expression is too expensive for
it to be okay to calculate at insertion.  But if it's that expensive,
why in the world did they make the column virtual rather than stored?

In short, I think we ought to compute these values during
INSERT/UPDATE, even though we're not going to write them to disk.
And then they'd be available to display in this error message.

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-06 03:54         ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-06 12:25           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:06             ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:47               ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  2026-02-26 20:29                 ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 22:14                   ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
@ 2026-03-03 17:01                     ` Matheus Alcantara <matheusssilv97@gmail.com>
  2026-03-16 15:36                       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  0 siblings, 1 reply; 16+ messages in thread

From: Matheus Alcantara @ 2026-03-03 17:01 UTC (permalink / raw)
  To: Tom Lane <tgl@sss.pgh.pa.us>; +Cc: pgsql-hackers@lists.postgresql.org; Peter Eisentraut <peter@eisentraut.org>; Yugo Nagata <nagata@sraoss.co.jp>

On 26/02/26 19:14, Tom Lane wrote:
>> Thanks for the feedback. After investigating the code a bit, I found
>> that IIUC virtual column values are actually never computed and stored
>> separately, they're computed by expanding the expression wherever the
>> column is referenced.
> 
> Correct: rather than storing them, we recalculate the expression
> whenever the column's value is demanded.  But what I'm suggesting is
> that we ought to calculate the value during INSERT/UPDATE as well,
> even though it will not get written to disk.  It'd be useful to do
> that for the purposes of this error message.  But I think we ought to
> do it even when there are no constraints, because that ensures that
> it's *possible* to calculate the value, and that there are not for
> instance overflow problems.  As things stand, it's possible to create
> a row that cannot be fetched:
> 
> regression=# create table foo (f1 int, f2 int generated always as (f1 * 1000000));
> CREATE TABLE
> regression=# insert into foo values(1);
> INSERT 0 1
> regression=# table foo;
>   f1 |   f2
> ----+---------
>    1 | 1000000
> (1 row)
> 
> regression=# insert into foo values(1000000);
> INSERT 0 1
> regression=# table foo;
> ERROR:  integer out of range
> 
> That may or may not be per spec, but I submit that it's extremely
> unhelpful and unfriendly, as well as being an undesirable discrepancy
> from the behavior of a stored generated column.
> 
> Of course, calculating the value at insertion time won't completely
> prevent such problems: if the expression is less immutable than
> advertised, we could still fail at readout time.  But that's pretty
> clearly a case of user misfeasance.  Index expressions that aren't
> really immutable cause worse problems than this, so I don't have a
> problem with saying "you broke it, you get to keep both pieces".
> 
> Perhaps someone will argue that their expression is too expensive for
> it to be okay to calculate at insertion.  But if it's that expensive,
> why in the world did they make the column virtual rather than stored?
> 

An addition to this, the expression is evaluated multiple times for 
each check constraint that a virtual column have.

> In short, I think we ought to compute these values during
> INSERT/UPDATE, even though we're not going to write them to disk.
> And then they'd be available to display in this error message.
> 

Please see the attached patch where I implement this. I followed a 
similar pattern used for stored generated columns with some small 
changes. Some special handling for virtual columns was removed but I 
still left some comments but I'm not sure if it's really needed.

With this change I think that doc/src/sgml/ddl.sgml should be updated 
to mention that a virtual generated column expression is also 
evaluated when writing the data, or perhaps we can just remove the 
following:

    as if it were a normal column.  A virtual generated column occupies no
-   storage and is computed when it is read.  Thus, a virtual generated column
+   storage.  Thus, a virtual generated column
    is similar to a view and a stored generated column is similar to a

--
Matheus Alcantara
EDB: https://www.enterprisedb.com
From b45bcdee84c55065d3f84dbdeedfa16b4e916b40 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v4] Compute virtual generated columns during INSERT/UPDATE

Previously, virtual generated column values were never computed and
stored in the slot. Instead, references to e.g virtual columns in check
constraints were expanded to their generation expressions during
ExecCheck().

This commit changes the approach to compute virtual generated column
values and store them in the slot before constraint checking, similar
to how stored generated columns are handled. This has two benefits:

   Check constraints can now read virtual column values directly from
   the slot, simplifying constraint evaluation. It also avoid re-execute
   the virtual expression for each check constraint evaluation.

   Error messages now display the actual computed value (e.g., "10")
   instead of the "virtual" string, making them more consistent with
   stored generated columns and easier to understand.

Discussion: https://www.postgresql.org/message-id/DG5DV8SED62G.2WFJB46D7WIT8%40gmail.com
---
 src/backend/executor/execExprInterp.c         |   7 +-
 src/backend/executor/execMain.c               |  21 ++-
 src/backend/executor/execReplication.c        |  12 ++
 src/backend/executor/nodeModifyTable.c        | 131 +++++++++++++++++-
 src/include/executor/nodeModifyTable.h        |   4 +
 src/include/nodes/execnodes.h                 |  10 ++
 .../regress/expected/generated_virtual.out    |  22 ++-
 src/test/regress/expected/partition_merge.out |   8 +-
 src/test/regress/sql/generated_virtual.sql    |   4 +-
 src/test/regress/sql/partition_merge.sql      |   6 +-
 10 files changed, 194 insertions(+), 31 deletions(-)

diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 61ff5ddc74c..fc1ca898842 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -2405,9 +2405,10 @@ CheckVarSlotCompatibility(TupleTableSlot *slot, int attnum, Oid vartype)
 
 		attr = TupleDescAttr(slot_tupdesc, attnum - 1);
 
-		/* Internal error: somebody forgot to expand it. */
-		if (attr->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-			elog(ERROR, "unexpected virtual generated column reference");
+		/*
+		 * Virtual generated columns are now computed and stored in the slot
+		 * by ExecComputeVirtualGenerated(), so reading them is valid.
+		 */
 
 		if (attr->attisdropped)
 			ereport(ERROR,
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index bfd3ebc601e..fcc59a784f5 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -51,6 +51,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -1296,6 +1298,8 @@ InitResultRelInfo(ResultRelInfo *resultRelInfo,
 	resultRelInfo->ri_GenVirtualNotNullConstraintExprs = NULL;
 	resultRelInfo->ri_GeneratedExprsI = NULL;
 	resultRelInfo->ri_GeneratedExprsU = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsI = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsU = NULL;
 	resultRelInfo->ri_projectReturning = NULL;
 	resultRelInfo->ri_onConflictArbiterIndexes = NIL;
 	resultRelInfo->ri_onConflict = NULL;
@@ -1815,7 +1819,13 @@ ExecRelCheck(ResultRelInfo *resultRelInfo,
 				continue;
 
 			checkconstr = stringToNode(check[i].ccbin);
-			checkconstr = (Expr *) expand_generated_columns_in_expr((Node *) checkconstr, rel, 1);
+
+			/*
+			 * No need to call expand_generated_columns_in_expr() here.
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated() before we get here, so
+			 * the expression evaluator can read them directly from the slot.
+			 */
 			resultRelInfo->ri_CheckConstraintExprs[i] =
 				ExecPrepareExpr(checkconstr, estate);
 		}
@@ -2476,9 +2486,12 @@ ExecBuildSlotValueDescription(Oid reloid,
 
 		if (table_perm || column_perm)
 		{
-			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
-			else if (slot->tts_isnull[i])
+			/*
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated(), so we can read them
+			 * directly like regular columns.
+			 */
+			if (slot->tts_isnull[i])
 				val = "null";
 			else
 			{
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index 2497ee7edc5..7e9152e4224 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -834,6 +834,12 @@ ExecSimpleRelationInsert(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
@@ -938,6 +944,12 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_UPDATE);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_UPDATE);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 793c76d4f82..5a93a74bcc9 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -425,8 +425,10 @@ ExecCheckTIDVisible(EState *estate,
  * Initialize generated columns handling for a tuple
  *
  * This fills the resultRelInfo's ri_GeneratedExprsI/ri_NumGeneratedNeededI or
- * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields, depending on cmdtype.
- * This is used only for stored generated columns.
+ * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields for stored generated
+ * columns, and ri_VirtualGeneratedExprsI/ri_NumVirtualGeneratedNeededI or
+ * ri_VirtualGeneratedExprsU/ri_NumVirtualGeneratedNeededU fields for virtual
+ * generated columns, depending on cmdtype.
  *
  * If cmdType == CMD_UPDATE, the ri_extraUpdatedCols field is filled too.
  * This is used by both stored and virtual generated columns.
@@ -446,6 +448,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	int			natts = tupdesc->natts;
 	ExprState **ri_GeneratedExprs;
 	int			ri_NumGeneratedNeeded;
+	ExprState **ri_VirtualGeneratedExprs;
+	int			ri_NumVirtualGeneratedNeeded;
 	Bitmapset  *updatedCols;
 	MemoryContext oldContext;
 
@@ -473,6 +477,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 
 	ri_GeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
 	ri_NumGeneratedNeeded = 0;
+	ri_VirtualGeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
+	ri_NumVirtualGeneratedNeeded = 0;
 
 	for (int i = 0; i < natts; i++)
 	{
@@ -508,6 +514,11 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 				ri_GeneratedExprs[i] = ExecPrepareExpr(expr, estate);
 				ri_NumGeneratedNeeded++;
 			}
+			else if (attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
+			{
+				ri_VirtualGeneratedExprs[i] = ExecPrepareExpr(expr, estate);
+				ri_NumVirtualGeneratedNeeded++;
+			}
 
 			/* If UPDATE, mark column in resultRelInfo->ri_extraUpdatedCols */
 			if (cmdtype == CMD_UPDATE)
@@ -524,14 +535,24 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 		ri_GeneratedExprs = NULL;
 	}
 
+	if (ri_NumVirtualGeneratedNeeded == 0)
+	{
+		/* didn't need it after all */
+		pfree(ri_VirtualGeneratedExprs);
+		ri_VirtualGeneratedExprs = NULL;
+	}
+
 	/* Save in appropriate set of fields */
 	if (cmdtype == CMD_UPDATE)
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsU == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsU == NULL);
 
 		resultRelInfo->ri_GeneratedExprsU = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededU = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsU = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededU = ri_NumVirtualGeneratedNeeded;
 
 		resultRelInfo->ri_extraUpdatedCols_valid = true;
 	}
@@ -539,9 +560,12 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsI == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsI == NULL);
 
 		resultRelInfo->ri_GeneratedExprsI = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededI = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsI = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededI = ri_NumVirtualGeneratedNeeded;
 	}
 
 	MemoryContextSwitchTo(oldContext);
@@ -637,6 +661,85 @@ ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 	MemoryContextSwitchTo(oldContext);
 }
 
+/*
+ * Compute virtual generated columns for a tuple
+ *
+ * This evaluates the generation expressions for virtual generated columns
+ * and stores the results directly in the slot. Unlike stored generated
+ * columns, these values are not persisted to disk but are computed for
+ * use in constraint checking and error messages.
+ */
+void
+ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+							EState *estate, TupleTableSlot *slot,
+							CmdType cmdtype)
+{
+	Relation	rel = resultRelInfo->ri_RelationDesc;
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	int			natts = tupdesc->natts;
+	ExprContext *econtext = GetPerTupleExprContext(estate);
+	ExprState **ri_VirtualGeneratedExprs;
+	MemoryContext oldContext;
+
+	/* We should not be called unless this is true */
+	Assert(tupdesc->constr && tupdesc->constr->has_generated_virtual);
+
+	/*
+	 * Initialize the expressions if we didn't already, and check whether we
+	 * can exit early because nothing needs to be computed.
+	 */
+	if (cmdtype == CMD_UPDATE)
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsU == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededU == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsU;
+	}
+	else
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsI == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededI == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsI;
+	}
+
+	oldContext = MemoryContextSwitchTo(GetPerTupleMemoryContext(estate));
+
+	/* Make sure all base column values are available */
+	slot_getallattrs(slot);
+
+	econtext->ecxt_scantuple = slot;
+
+	for (int i = 0; i < natts; i++)
+	{
+		CompactAttribute *attr = TupleDescCompactAttr(tupdesc, i);
+
+		if (ri_VirtualGeneratedExprs[i])
+		{
+			Datum		val;
+			bool		isnull;
+
+			Assert(TupleDescAttr(tupdesc, i)->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL);
+
+			val = ExecEvalExpr(ri_VirtualGeneratedExprs[i], econtext, &isnull);
+
+			/*
+			 * We must make a copy of val as we have no guarantees about where
+			 * memory for a pass-by-reference Datum is located.
+			 */
+			if (!isnull)
+				val = datumCopy(val, attr->attbyval, attr->attlen);
+
+			slot->tts_values[i] = val;
+			slot->tts_isnull[i] = isnull;
+		}
+	}
+
+	MemoryContextSwitchTo(oldContext);
+}
+
 /*
  * ExecInitInsertProjection
  *		Do one-time initialization of projection data for INSERT tuples.
@@ -945,6 +1048,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * If the FDW supports batching, and batching is requested, accumulate
 		 * rows and insert them in batches. Otherwise use the per-row inserts.
@@ -1070,6 +1181,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * Check any RLS WITH CHECK policies.
 		 *
@@ -2178,6 +2297,14 @@ ExecUpdatePrepareSlot(ResultRelInfo *resultRelInfo,
 		resultRelationDesc->rd_att->constr->has_generated_stored)
 		ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 								   CMD_UPDATE);
+
+	/*
+	 * Compute virtual generated columns
+	 */
+	if (resultRelationDesc->rd_att->constr &&
+		resultRelationDesc->rd_att->constr->has_generated_virtual)
+		ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+									CMD_UPDATE);
 }
 
 /*
diff --git a/src/include/executor/nodeModifyTable.h b/src/include/executor/nodeModifyTable.h
index f6070e1cdf3..7cbbe8c1a1f 100644
--- a/src/include/executor/nodeModifyTable.h
+++ b/src/include/executor/nodeModifyTable.h
@@ -23,6 +23,10 @@ extern void ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 									   EState *estate, TupleTableSlot *slot,
 									   CmdType cmdtype);
 
+extern void ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+										EState *estate, TupleTableSlot *slot,
+										CmdType cmdtype);
+
 extern ModifyTableState *ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags);
 extern void ExecEndModifyTable(ModifyTableState *node);
 extern void ExecReScanModifyTable(ModifyTableState *node);
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 63c067d5aae..502e4e14d64 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -573,6 +573,16 @@ typedef struct ResultRelInfo
 	int			ri_NumGeneratedNeededI;
 	int			ri_NumGeneratedNeededU;
 
+	/*
+	 * Arrays of virtual generated columns ExprStates for INSERT/UPDATE/MERGE.
+	 */
+	ExprState **ri_VirtualGeneratedExprsI;
+	ExprState **ri_VirtualGeneratedExprsU;
+
+	/* number of virtual generated columns we need to compute */
+	int			ri_NumVirtualGeneratedNeededI;
+	int			ri_NumVirtualGeneratedNeededU;
+
 	/* list of RETURNING expressions */
 	List	   *ri_returningList;
 
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 6dab60c937b..18cfd5ab46c 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -166,11 +166,9 @@ SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
  2 | 4
 (1 row)
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
 ERROR:  integer out of range
-DELETE FROM gtest1 WHERE a = 2000000000;
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
 INSERT INTO gtestx VALUES (11, 1), (22, 2), (33, 3);
@@ -638,7 +636,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, 60).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint
 ERROR:  check constraint "gtest20_b_check" of relation "gtest20" is violated by some row
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 3);  -- ok
@@ -684,18 +682,18 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 -- not-null constraints
 CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL);
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -705,17 +703,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- not-null constraint with partitioned table
@@ -730,10 +728,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, null).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, null).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 925fe4f570a..82914be45e4 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1060,9 +1060,9 @@ SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i =
 DROP TABLE t;
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
@@ -1073,7 +1073,7 @@ INSERT INTO t VALUES (16);
 -- ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, 2).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
diff --git a/src/test/regress/sql/generated_virtual.sql b/src/test/regress/sql/generated_virtual.sql
index e750866d2d8..c8e6c749992 100644
--- a/src/test/regress/sql/generated_virtual.sql
+++ b/src/test/regress/sql/generated_virtual.sql
@@ -71,10 +71,8 @@ SELECT * FROM gtest1 ORDER BY a;
 SELECT a, b, b * 2 AS b2 FROM gtest1 ORDER BY a;
 SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
-DELETE FROM gtest1 WHERE a = 2000000000;
 
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index a211fee2ad1..df4239718cb 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -762,9 +762,9 @@ DROP TABLE t;
 
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
-- 
2.52.0



Attachments:

  [text/plain] v4-0001-Compute-virtual-generated-columns-during-INSERT-U.patch (22.0K, ../../DGTB4FS1B1TC.1EL91GOCVD37U@gmail.com/2-v4-0001-Compute-virtual-generated-columns-during-INSERT-U.patch)
  download | inline diff:
From b45bcdee84c55065d3f84dbdeedfa16b4e916b40 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v4] Compute virtual generated columns during INSERT/UPDATE

Previously, virtual generated column values were never computed and
stored in the slot. Instead, references to e.g virtual columns in check
constraints were expanded to their generation expressions during
ExecCheck().

This commit changes the approach to compute virtual generated column
values and store them in the slot before constraint checking, similar
to how stored generated columns are handled. This has two benefits:

   Check constraints can now read virtual column values directly from
   the slot, simplifying constraint evaluation. It also avoid re-execute
   the virtual expression for each check constraint evaluation.

   Error messages now display the actual computed value (e.g., "10")
   instead of the "virtual" string, making them more consistent with
   stored generated columns and easier to understand.

Discussion: https://www.postgresql.org/message-id/DG5DV8SED62G.2WFJB46D7WIT8%40gmail.com
---
 src/backend/executor/execExprInterp.c         |   7 +-
 src/backend/executor/execMain.c               |  21 ++-
 src/backend/executor/execReplication.c        |  12 ++
 src/backend/executor/nodeModifyTable.c        | 131 +++++++++++++++++-
 src/include/executor/nodeModifyTable.h        |   4 +
 src/include/nodes/execnodes.h                 |  10 ++
 .../regress/expected/generated_virtual.out    |  22 ++-
 src/test/regress/expected/partition_merge.out |   8 +-
 src/test/regress/sql/generated_virtual.sql    |   4 +-
 src/test/regress/sql/partition_merge.sql      |   6 +-
 10 files changed, 194 insertions(+), 31 deletions(-)

diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 61ff5ddc74c..fc1ca898842 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -2405,9 +2405,10 @@ CheckVarSlotCompatibility(TupleTableSlot *slot, int attnum, Oid vartype)
 
 		attr = TupleDescAttr(slot_tupdesc, attnum - 1);
 
-		/* Internal error: somebody forgot to expand it. */
-		if (attr->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-			elog(ERROR, "unexpected virtual generated column reference");
+		/*
+		 * Virtual generated columns are now computed and stored in the slot
+		 * by ExecComputeVirtualGenerated(), so reading them is valid.
+		 */
 
 		if (attr->attisdropped)
 			ereport(ERROR,
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index bfd3ebc601e..fcc59a784f5 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -51,6 +51,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -1296,6 +1298,8 @@ InitResultRelInfo(ResultRelInfo *resultRelInfo,
 	resultRelInfo->ri_GenVirtualNotNullConstraintExprs = NULL;
 	resultRelInfo->ri_GeneratedExprsI = NULL;
 	resultRelInfo->ri_GeneratedExprsU = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsI = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsU = NULL;
 	resultRelInfo->ri_projectReturning = NULL;
 	resultRelInfo->ri_onConflictArbiterIndexes = NIL;
 	resultRelInfo->ri_onConflict = NULL;
@@ -1815,7 +1819,13 @@ ExecRelCheck(ResultRelInfo *resultRelInfo,
 				continue;
 
 			checkconstr = stringToNode(check[i].ccbin);
-			checkconstr = (Expr *) expand_generated_columns_in_expr((Node *) checkconstr, rel, 1);
+
+			/*
+			 * No need to call expand_generated_columns_in_expr() here.
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated() before we get here, so
+			 * the expression evaluator can read them directly from the slot.
+			 */
 			resultRelInfo->ri_CheckConstraintExprs[i] =
 				ExecPrepareExpr(checkconstr, estate);
 		}
@@ -2476,9 +2486,12 @@ ExecBuildSlotValueDescription(Oid reloid,
 
 		if (table_perm || column_perm)
 		{
-			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
-			else if (slot->tts_isnull[i])
+			/*
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated(), so we can read them
+			 * directly like regular columns.
+			 */
+			if (slot->tts_isnull[i])
 				val = "null";
 			else
 			{
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index 2497ee7edc5..7e9152e4224 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -834,6 +834,12 @@ ExecSimpleRelationInsert(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
@@ -938,6 +944,12 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_UPDATE);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_UPDATE);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 793c76d4f82..5a93a74bcc9 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -425,8 +425,10 @@ ExecCheckTIDVisible(EState *estate,
  * Initialize generated columns handling for a tuple
  *
  * This fills the resultRelInfo's ri_GeneratedExprsI/ri_NumGeneratedNeededI or
- * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields, depending on cmdtype.
- * This is used only for stored generated columns.
+ * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields for stored generated
+ * columns, and ri_VirtualGeneratedExprsI/ri_NumVirtualGeneratedNeededI or
+ * ri_VirtualGeneratedExprsU/ri_NumVirtualGeneratedNeededU fields for virtual
+ * generated columns, depending on cmdtype.
  *
  * If cmdType == CMD_UPDATE, the ri_extraUpdatedCols field is filled too.
  * This is used by both stored and virtual generated columns.
@@ -446,6 +448,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	int			natts = tupdesc->natts;
 	ExprState **ri_GeneratedExprs;
 	int			ri_NumGeneratedNeeded;
+	ExprState **ri_VirtualGeneratedExprs;
+	int			ri_NumVirtualGeneratedNeeded;
 	Bitmapset  *updatedCols;
 	MemoryContext oldContext;
 
@@ -473,6 +477,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 
 	ri_GeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
 	ri_NumGeneratedNeeded = 0;
+	ri_VirtualGeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
+	ri_NumVirtualGeneratedNeeded = 0;
 
 	for (int i = 0; i < natts; i++)
 	{
@@ -508,6 +514,11 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 				ri_GeneratedExprs[i] = ExecPrepareExpr(expr, estate);
 				ri_NumGeneratedNeeded++;
 			}
+			else if (attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
+			{
+				ri_VirtualGeneratedExprs[i] = ExecPrepareExpr(expr, estate);
+				ri_NumVirtualGeneratedNeeded++;
+			}
 
 			/* If UPDATE, mark column in resultRelInfo->ri_extraUpdatedCols */
 			if (cmdtype == CMD_UPDATE)
@@ -524,14 +535,24 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 		ri_GeneratedExprs = NULL;
 	}
 
+	if (ri_NumVirtualGeneratedNeeded == 0)
+	{
+		/* didn't need it after all */
+		pfree(ri_VirtualGeneratedExprs);
+		ri_VirtualGeneratedExprs = NULL;
+	}
+
 	/* Save in appropriate set of fields */
 	if (cmdtype == CMD_UPDATE)
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsU == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsU == NULL);
 
 		resultRelInfo->ri_GeneratedExprsU = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededU = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsU = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededU = ri_NumVirtualGeneratedNeeded;
 
 		resultRelInfo->ri_extraUpdatedCols_valid = true;
 	}
@@ -539,9 +560,12 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsI == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsI == NULL);
 
 		resultRelInfo->ri_GeneratedExprsI = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededI = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsI = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededI = ri_NumVirtualGeneratedNeeded;
 	}
 
 	MemoryContextSwitchTo(oldContext);
@@ -637,6 +661,85 @@ ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 	MemoryContextSwitchTo(oldContext);
 }
 
+/*
+ * Compute virtual generated columns for a tuple
+ *
+ * This evaluates the generation expressions for virtual generated columns
+ * and stores the results directly in the slot. Unlike stored generated
+ * columns, these values are not persisted to disk but are computed for
+ * use in constraint checking and error messages.
+ */
+void
+ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+							EState *estate, TupleTableSlot *slot,
+							CmdType cmdtype)
+{
+	Relation	rel = resultRelInfo->ri_RelationDesc;
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	int			natts = tupdesc->natts;
+	ExprContext *econtext = GetPerTupleExprContext(estate);
+	ExprState **ri_VirtualGeneratedExprs;
+	MemoryContext oldContext;
+
+	/* We should not be called unless this is true */
+	Assert(tupdesc->constr && tupdesc->constr->has_generated_virtual);
+
+	/*
+	 * Initialize the expressions if we didn't already, and check whether we
+	 * can exit early because nothing needs to be computed.
+	 */
+	if (cmdtype == CMD_UPDATE)
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsU == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededU == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsU;
+	}
+	else
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsI == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededI == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsI;
+	}
+
+	oldContext = MemoryContextSwitchTo(GetPerTupleMemoryContext(estate));
+
+	/* Make sure all base column values are available */
+	slot_getallattrs(slot);
+
+	econtext->ecxt_scantuple = slot;
+
+	for (int i = 0; i < natts; i++)
+	{
+		CompactAttribute *attr = TupleDescCompactAttr(tupdesc, i);
+
+		if (ri_VirtualGeneratedExprs[i])
+		{
+			Datum		val;
+			bool		isnull;
+
+			Assert(TupleDescAttr(tupdesc, i)->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL);
+
+			val = ExecEvalExpr(ri_VirtualGeneratedExprs[i], econtext, &isnull);
+
+			/*
+			 * We must make a copy of val as we have no guarantees about where
+			 * memory for a pass-by-reference Datum is located.
+			 */
+			if (!isnull)
+				val = datumCopy(val, attr->attbyval, attr->attlen);
+
+			slot->tts_values[i] = val;
+			slot->tts_isnull[i] = isnull;
+		}
+	}
+
+	MemoryContextSwitchTo(oldContext);
+}
+
 /*
  * ExecInitInsertProjection
  *		Do one-time initialization of projection data for INSERT tuples.
@@ -945,6 +1048,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * If the FDW supports batching, and batching is requested, accumulate
 		 * rows and insert them in batches. Otherwise use the per-row inserts.
@@ -1070,6 +1181,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * Check any RLS WITH CHECK policies.
 		 *
@@ -2178,6 +2297,14 @@ ExecUpdatePrepareSlot(ResultRelInfo *resultRelInfo,
 		resultRelationDesc->rd_att->constr->has_generated_stored)
 		ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 								   CMD_UPDATE);
+
+	/*
+	 * Compute virtual generated columns
+	 */
+	if (resultRelationDesc->rd_att->constr &&
+		resultRelationDesc->rd_att->constr->has_generated_virtual)
+		ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+									CMD_UPDATE);
 }
 
 /*
diff --git a/src/include/executor/nodeModifyTable.h b/src/include/executor/nodeModifyTable.h
index f6070e1cdf3..7cbbe8c1a1f 100644
--- a/src/include/executor/nodeModifyTable.h
+++ b/src/include/executor/nodeModifyTable.h
@@ -23,6 +23,10 @@ extern void ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 									   EState *estate, TupleTableSlot *slot,
 									   CmdType cmdtype);
 
+extern void ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+										EState *estate, TupleTableSlot *slot,
+										CmdType cmdtype);
+
 extern ModifyTableState *ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags);
 extern void ExecEndModifyTable(ModifyTableState *node);
 extern void ExecReScanModifyTable(ModifyTableState *node);
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 63c067d5aae..502e4e14d64 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -573,6 +573,16 @@ typedef struct ResultRelInfo
 	int			ri_NumGeneratedNeededI;
 	int			ri_NumGeneratedNeededU;
 
+	/*
+	 * Arrays of virtual generated columns ExprStates for INSERT/UPDATE/MERGE.
+	 */
+	ExprState **ri_VirtualGeneratedExprsI;
+	ExprState **ri_VirtualGeneratedExprsU;
+
+	/* number of virtual generated columns we need to compute */
+	int			ri_NumVirtualGeneratedNeededI;
+	int			ri_NumVirtualGeneratedNeededU;
+
 	/* list of RETURNING expressions */
 	List	   *ri_returningList;
 
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 6dab60c937b..18cfd5ab46c 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -166,11 +166,9 @@ SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
  2 | 4
 (1 row)
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
 ERROR:  integer out of range
-DELETE FROM gtest1 WHERE a = 2000000000;
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
 INSERT INTO gtestx VALUES (11, 1), (22, 2), (33, 3);
@@ -638,7 +636,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, 60).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint
 ERROR:  check constraint "gtest20_b_check" of relation "gtest20" is violated by some row
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 3);  -- ok
@@ -684,18 +682,18 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 -- not-null constraints
 CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL);
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -705,17 +703,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- not-null constraint with partitioned table
@@ -730,10 +728,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, null).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, null).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 925fe4f570a..82914be45e4 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1060,9 +1060,9 @@ SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i =
 DROP TABLE t;
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
@@ -1073,7 +1073,7 @@ INSERT INTO t VALUES (16);
 -- ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, 2).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
diff --git a/src/test/regress/sql/generated_virtual.sql b/src/test/regress/sql/generated_virtual.sql
index e750866d2d8..c8e6c749992 100644
--- a/src/test/regress/sql/generated_virtual.sql
+++ b/src/test/regress/sql/generated_virtual.sql
@@ -71,10 +71,8 @@ SELECT * FROM gtest1 ORDER BY a;
 SELECT a, b, b * 2 AS b2 FROM gtest1 ORDER BY a;
 SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
-DELETE FROM gtest1 WHERE a = 2000000000;
 
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index a211fee2ad1..df4239718cb 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -762,9 +762,9 @@ DROP TABLE t;
 
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
-- 
2.52.0



^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-06 03:54         ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-06 12:25           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:06             ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:47               ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  2026-02-26 20:29                 ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 22:14                   ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  2026-03-03 17:01                     ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
@ 2026-03-16 15:36                       ` Matheus Alcantara <matheusssilv97@gmail.com>
  2026-07-10 21:13                         ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  0 siblings, 1 reply; 16+ messages in thread

From: Matheus Alcantara @ 2026-03-16 15:36 UTC (permalink / raw)
  To: Tom Lane <tgl@sss.pgh.pa.us>; +Cc: pgsql-hackers@lists.postgresql.org, Peter Eisentraut <peter@eisentraut.org>; Yugo Nagata <nagata@sraoss.co.jp>

On 03/03/26 14:01, I wrote:
> Please see the attached patch where I implement this. I followed a
> similar pattern used for stored generated columns with some small
> changes. Some special handling for virtual columns was removed but I
> still left some comments but I'm not sure if it's really needed.
> 
> With this change I think that doc/src/sgml/ddl.sgml should be updated
> to mention that a virtual generated column expression is also
> evaluated when writing the data ...
> 

Attaching v5 with documentation changes included and rebased with master.

--
Matheus Alcantara
EDB: https://www.enterprisedb.com
From 798fb7abbbaa3ee08ba5e138f11f34e7eb3dfcfc Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v5] Compute virtual generated columns during INSERT/UPDATE

Previously, virtual generated column values were never computed and
stored in the slot. Instead, references to e.g virtual columns in check
constraints were expanded to their generation expressions during
ExecCheck().

This commit changes the approach to compute virtual generated column
values and store them in the slot before constraint checking, similar
to how stored generated columns are handled. This has two benefits:

   Check constraints can now read virtual column values directly from
   the slot, simplifying constraint evaluation. It also avoid re-execute
   the virtual expression for each check constraint evaluation.

   Error messages now display the actual computed value (e.g., "10")
   instead of the "virtual" string, making them more consistent with
   stored generated columns and easier to understand.

Discussion: https://www.postgresql.org/message-id/DG5DV8SED62G.2WFJB46D7WIT8%40gmail.com
---
 doc/src/sgml/ddl.sgml                         |   9 +-
 doc/src/sgml/ref/create_foreign_table.sgml    |  13 +-
 doc/src/sgml/ref/create_table.sgml            |   4 +-
 src/backend/executor/execExprInterp.c         |   7 +-
 src/backend/executor/execMain.c               |  21 ++-
 src/backend/executor/execReplication.c        |  12 ++
 src/backend/executor/nodeModifyTable.c        | 131 +++++++++++++++++-
 src/include/executor/nodeModifyTable.h        |   4 +
 src/include/nodes/execnodes.h                 |  10 ++
 .../regress/expected/generated_virtual.out    |  22 ++-
 src/test/regress/expected/partition_merge.out |   8 +-
 src/test/regress/sql/generated_virtual.sql    |   4 +-
 src/test/regress/sql/partition_merge.sql      |   6 +-
 13 files changed, 208 insertions(+), 43 deletions(-)

diff --git a/doc/src/sgml/ddl.sgml b/doc/src/sgml/ddl.sgml
index 8421ecace1b..786880d54a2 100644
--- a/doc/src/sgml/ddl.sgml
+++ b/doc/src/sgml/ddl.sgml
@@ -357,10 +357,11 @@ INSERT INTO people (id, name, address) VALUES (<emphasis>DEFAULT</emphasis>, 'C'
    columns.  Thus, it is for columns what a view is for tables.  There are two
    kinds of generated columns: stored and virtual.  A stored generated column
    is computed when it is written (inserted or updated) and occupies storage
-   as if it were a normal column.  A virtual generated column occupies no
-   storage and is computed when it is read.  Thus, a virtual generated column
-   is similar to a view and a stored generated column is similar to a
-   materialized view (except that it is always updated automatically).
+   as if it were a normal column.  A virtual generated column is also computed when
+   it is written, but occupies no storage and is recomputed on every read.
+   Thus, a virtual generated column is similar to a view and a stored generated
+   column is similar to a materialized view (except that it is always updated
+   automatically)
   </para>
 
   <para>
diff --git a/doc/src/sgml/ref/create_foreign_table.sgml b/doc/src/sgml/ref/create_foreign_table.sgml
index 083f16772b7..e85579be18f 100644
--- a/doc/src/sgml/ref/create_foreign_table.sgml
+++ b/doc/src/sgml/ref/create_foreign_table.sgml
@@ -403,12 +403,13 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
 
      <para>
       When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read.  (The foreign-data wrapper will see it as a
-      null value in new rows and may choose to store it as a null value or
-      ignore it altogether.)  When <literal>STORED</literal> is specified, the
-      column will be computed on write.  (The computed value will be presented
-      to the foreign-data wrapper for storage and must be returned on
-      reading.)  <literal>VIRTUAL</literal> is the default.
+      computed when it is written, but it will not occupy any storage.
+      (The foreign-data wrapper will see it as a null value in new rows
+      and may choose to store it as a null value or ignore it altogether.)
+      When <literal>STORED</literal> is specified, the column will be computed
+      on write.  (The computed value will be presented to the foreign-data
+      wrapper for storage and must be returned on reading.)
+      <literal>VIRTUAL</literal> is the default.
      </para>
 
      <para>
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index 982532fe725..8c11c856042 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -913,8 +913,8 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
      </para>
 
      <para>
-      When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read, and it will not occupy any storage.  When
+      When <literal>VIRTUAL</literal> is specified, the column will be computed
+      when it is written and read, but it will not occupy any storage.  When
       <literal>STORED</literal> is specified, the column will be computed on
       write and will be stored on disk.  <literal>VIRTUAL</literal> is the
       default.
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 61ff5ddc74c..fc1ca898842 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -2405,9 +2405,10 @@ CheckVarSlotCompatibility(TupleTableSlot *slot, int attnum, Oid vartype)
 
 		attr = TupleDescAttr(slot_tupdesc, attnum - 1);
 
-		/* Internal error: somebody forgot to expand it. */
-		if (attr->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-			elog(ERROR, "unexpected virtual generated column reference");
+		/*
+		 * Virtual generated columns are now computed and stored in the slot
+		 * by ExecComputeVirtualGenerated(), so reading them is valid.
+		 */
 
 		if (attr->attisdropped)
 			ereport(ERROR,
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index 4015aef1b1f..fe027378a37 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -51,6 +51,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -1309,6 +1311,8 @@ InitResultRelInfo(ResultRelInfo *resultRelInfo,
 	resultRelInfo->ri_GenVirtualNotNullConstraintExprs = NULL;
 	resultRelInfo->ri_GeneratedExprsI = NULL;
 	resultRelInfo->ri_GeneratedExprsU = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsI = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsU = NULL;
 	resultRelInfo->ri_projectReturning = NULL;
 	resultRelInfo->ri_onConflictArbiterIndexes = NIL;
 	resultRelInfo->ri_onConflict = NULL;
@@ -1828,7 +1832,13 @@ ExecRelCheck(ResultRelInfo *resultRelInfo,
 				continue;
 
 			checkconstr = stringToNode(check[i].ccbin);
-			checkconstr = (Expr *) expand_generated_columns_in_expr((Node *) checkconstr, rel, 1);
+
+			/*
+			 * No need to call expand_generated_columns_in_expr() here.
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated() before we get here, so
+			 * the expression evaluator can read them directly from the slot.
+			 */
 			resultRelInfo->ri_CheckConstraintExprs[i] =
 				ExecPrepareExpr(checkconstr, estate);
 		}
@@ -2489,9 +2499,12 @@ ExecBuildSlotValueDescription(Oid reloid,
 
 		if (table_perm || column_perm)
 		{
-			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
-			else if (slot->tts_isnull[i])
+			/*
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated(), so we can read them
+			 * directly like regular columns.
+			 */
+			if (slot->tts_isnull[i])
 				val = "null";
 			else
 			{
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index 2497ee7edc5..7e9152e4224 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -834,6 +834,12 @@ ExecSimpleRelationInsert(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
@@ -938,6 +944,12 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_UPDATE);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_UPDATE);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 327c27abff9..6959e477f83 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -425,8 +425,10 @@ ExecCheckTIDVisible(EState *estate,
  * Initialize generated columns handling for a tuple
  *
  * This fills the resultRelInfo's ri_GeneratedExprsI/ri_NumGeneratedNeededI or
- * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields, depending on cmdtype.
- * This is used only for stored generated columns.
+ * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields for stored generated
+ * columns, and ri_VirtualGeneratedExprsI/ri_NumVirtualGeneratedNeededI or
+ * ri_VirtualGeneratedExprsU/ri_NumVirtualGeneratedNeededU fields for virtual
+ * generated columns, depending on cmdtype.
  *
  * If cmdType == CMD_UPDATE, the ri_extraUpdatedCols field is filled too.
  * This is used by both stored and virtual generated columns.
@@ -446,6 +448,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	int			natts = tupdesc->natts;
 	ExprState **ri_GeneratedExprs;
 	int			ri_NumGeneratedNeeded;
+	ExprState **ri_VirtualGeneratedExprs;
+	int			ri_NumVirtualGeneratedNeeded;
 	Bitmapset  *updatedCols;
 	MemoryContext oldContext;
 
@@ -473,6 +477,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 
 	ri_GeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
 	ri_NumGeneratedNeeded = 0;
+	ri_VirtualGeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
+	ri_NumVirtualGeneratedNeeded = 0;
 
 	for (int i = 0; i < natts; i++)
 	{
@@ -508,6 +514,11 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 				ri_GeneratedExprs[i] = ExecPrepareExpr(expr, estate);
 				ri_NumGeneratedNeeded++;
 			}
+			else if (attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
+			{
+				ri_VirtualGeneratedExprs[i] = ExecPrepareExpr(expr, estate);
+				ri_NumVirtualGeneratedNeeded++;
+			}
 
 			/* If UPDATE, mark column in resultRelInfo->ri_extraUpdatedCols */
 			if (cmdtype == CMD_UPDATE)
@@ -524,14 +535,24 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 		ri_GeneratedExprs = NULL;
 	}
 
+	if (ri_NumVirtualGeneratedNeeded == 0)
+	{
+		/* didn't need it after all */
+		pfree(ri_VirtualGeneratedExprs);
+		ri_VirtualGeneratedExprs = NULL;
+	}
+
 	/* Save in appropriate set of fields */
 	if (cmdtype == CMD_UPDATE)
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsU == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsU == NULL);
 
 		resultRelInfo->ri_GeneratedExprsU = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededU = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsU = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededU = ri_NumVirtualGeneratedNeeded;
 
 		resultRelInfo->ri_extraUpdatedCols_valid = true;
 	}
@@ -539,9 +560,12 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsI == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsI == NULL);
 
 		resultRelInfo->ri_GeneratedExprsI = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededI = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsI = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededI = ri_NumVirtualGeneratedNeeded;
 	}
 
 	MemoryContextSwitchTo(oldContext);
@@ -637,6 +661,85 @@ ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 	MemoryContextSwitchTo(oldContext);
 }
 
+/*
+ * Compute virtual generated columns for a tuple
+ *
+ * This evaluates the generation expressions for virtual generated columns
+ * and stores the results directly in the slot. Unlike stored generated
+ * columns, these values are not persisted to disk but are computed for
+ * use in constraint checking and error messages.
+ */
+void
+ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+							EState *estate, TupleTableSlot *slot,
+							CmdType cmdtype)
+{
+	Relation	rel = resultRelInfo->ri_RelationDesc;
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	int			natts = tupdesc->natts;
+	ExprContext *econtext = GetPerTupleExprContext(estate);
+	ExprState **ri_VirtualGeneratedExprs;
+	MemoryContext oldContext;
+
+	/* We should not be called unless this is true */
+	Assert(tupdesc->constr && tupdesc->constr->has_generated_virtual);
+
+	/*
+	 * Initialize the expressions if we didn't already, and check whether we
+	 * can exit early because nothing needs to be computed.
+	 */
+	if (cmdtype == CMD_UPDATE)
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsU == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededU == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsU;
+	}
+	else
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsI == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededI == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsI;
+	}
+
+	oldContext = MemoryContextSwitchTo(GetPerTupleMemoryContext(estate));
+
+	/* Make sure all base column values are available */
+	slot_getallattrs(slot);
+
+	econtext->ecxt_scantuple = slot;
+
+	for (int i = 0; i < natts; i++)
+	{
+		CompactAttribute *attr = TupleDescCompactAttr(tupdesc, i);
+
+		if (ri_VirtualGeneratedExprs[i])
+		{
+			Datum		val;
+			bool		isnull;
+
+			Assert(TupleDescAttr(tupdesc, i)->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL);
+
+			val = ExecEvalExpr(ri_VirtualGeneratedExprs[i], econtext, &isnull);
+
+			/*
+			 * We must make a copy of val as we have no guarantees about where
+			 * memory for a pass-by-reference Datum is located.
+			 */
+			if (!isnull)
+				val = datumCopy(val, attr->attbyval, attr->attlen);
+
+			slot->tts_values[i] = val;
+			slot->tts_isnull[i] = isnull;
+		}
+	}
+
+	MemoryContextSwitchTo(oldContext);
+}
+
 /*
  * ExecInitInsertProjection
  *		Do one-time initialization of projection data for INSERT tuples.
@@ -945,6 +1048,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * If the FDW supports batching, and batching is requested, accumulate
 		 * rows and insert them in batches. Otherwise use the per-row inserts.
@@ -1070,6 +1181,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * Check any RLS WITH CHECK policies.
 		 *
@@ -2178,6 +2297,14 @@ ExecUpdatePrepareSlot(ResultRelInfo *resultRelInfo,
 		resultRelationDesc->rd_att->constr->has_generated_stored)
 		ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 								   CMD_UPDATE);
+
+	/*
+	 * Compute virtual generated columns
+	 */
+	if (resultRelationDesc->rd_att->constr &&
+		resultRelationDesc->rd_att->constr->has_generated_virtual)
+		ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+									CMD_UPDATE);
 }
 
 /*
diff --git a/src/include/executor/nodeModifyTable.h b/src/include/executor/nodeModifyTable.h
index f6070e1cdf3..7cbbe8c1a1f 100644
--- a/src/include/executor/nodeModifyTable.h
+++ b/src/include/executor/nodeModifyTable.h
@@ -23,6 +23,10 @@ extern void ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 									   EState *estate, TupleTableSlot *slot,
 									   CmdType cmdtype);
 
+extern void ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+										EState *estate, TupleTableSlot *slot,
+										CmdType cmdtype);
+
 extern ModifyTableState *ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags);
 extern void ExecEndModifyTable(ModifyTableState *node);
 extern void ExecReScanModifyTable(ModifyTableState *node);
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 63c067d5aae..502e4e14d64 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -573,6 +573,16 @@ typedef struct ResultRelInfo
 	int			ri_NumGeneratedNeededI;
 	int			ri_NumGeneratedNeededU;
 
+	/*
+	 * Arrays of virtual generated columns ExprStates for INSERT/UPDATE/MERGE.
+	 */
+	ExprState **ri_VirtualGeneratedExprsI;
+	ExprState **ri_VirtualGeneratedExprsU;
+
+	/* number of virtual generated columns we need to compute */
+	int			ri_NumVirtualGeneratedNeededI;
+	int			ri_NumVirtualGeneratedNeededU;
+
 	/* list of RETURNING expressions */
 	List	   *ri_returningList;
 
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 6dab60c937b..18cfd5ab46c 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -166,11 +166,9 @@ SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
  2 | 4
 (1 row)
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
 ERROR:  integer out of range
-DELETE FROM gtest1 WHERE a = 2000000000;
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
 INSERT INTO gtestx VALUES (11, 1), (22, 2), (33, 3);
@@ -638,7 +636,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, 60).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint
 ERROR:  check constraint "gtest20_b_check" of relation "gtest20" is violated by some row
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 3);  -- ok
@@ -684,18 +682,18 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 -- not-null constraints
 CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL);
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -705,17 +703,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- not-null constraint with partitioned table
@@ -730,10 +728,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, null).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, null).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 925fe4f570a..82914be45e4 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1060,9 +1060,9 @@ SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i =
 DROP TABLE t;
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
@@ -1073,7 +1073,7 @@ INSERT INTO t VALUES (16);
 -- ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, 2).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
diff --git a/src/test/regress/sql/generated_virtual.sql b/src/test/regress/sql/generated_virtual.sql
index e750866d2d8..c8e6c749992 100644
--- a/src/test/regress/sql/generated_virtual.sql
+++ b/src/test/regress/sql/generated_virtual.sql
@@ -71,10 +71,8 @@ SELECT * FROM gtest1 ORDER BY a;
 SELECT a, b, b * 2 AS b2 FROM gtest1 ORDER BY a;
 SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
-DELETE FROM gtest1 WHERE a = 2000000000;
 
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index a211fee2ad1..df4239718cb 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -762,9 +762,9 @@ DROP TABLE t;
 
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
-- 
2.52.0



Attachments:

  [text/plain] v5-0001-Compute-virtual-generated-columns-during-INSERT-U.patch (25.5K, ../../68dcb0b5-1c0b-41ed-b2eb-bbb12c3ea5f3@gmail.com/2-v5-0001-Compute-virtual-generated-columns-during-INSERT-U.patch)
  download | inline diff:
From 798fb7abbbaa3ee08ba5e138f11f34e7eb3dfcfc Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v5] Compute virtual generated columns during INSERT/UPDATE

Previously, virtual generated column values were never computed and
stored in the slot. Instead, references to e.g virtual columns in check
constraints were expanded to their generation expressions during
ExecCheck().

This commit changes the approach to compute virtual generated column
values and store them in the slot before constraint checking, similar
to how stored generated columns are handled. This has two benefits:

   Check constraints can now read virtual column values directly from
   the slot, simplifying constraint evaluation. It also avoid re-execute
   the virtual expression for each check constraint evaluation.

   Error messages now display the actual computed value (e.g., "10")
   instead of the "virtual" string, making them more consistent with
   stored generated columns and easier to understand.

Discussion: https://www.postgresql.org/message-id/DG5DV8SED62G.2WFJB46D7WIT8%40gmail.com
---
 doc/src/sgml/ddl.sgml                         |   9 +-
 doc/src/sgml/ref/create_foreign_table.sgml    |  13 +-
 doc/src/sgml/ref/create_table.sgml            |   4 +-
 src/backend/executor/execExprInterp.c         |   7 +-
 src/backend/executor/execMain.c               |  21 ++-
 src/backend/executor/execReplication.c        |  12 ++
 src/backend/executor/nodeModifyTable.c        | 131 +++++++++++++++++-
 src/include/executor/nodeModifyTable.h        |   4 +
 src/include/nodes/execnodes.h                 |  10 ++
 .../regress/expected/generated_virtual.out    |  22 ++-
 src/test/regress/expected/partition_merge.out |   8 +-
 src/test/regress/sql/generated_virtual.sql    |   4 +-
 src/test/regress/sql/partition_merge.sql      |   6 +-
 13 files changed, 208 insertions(+), 43 deletions(-)

diff --git a/doc/src/sgml/ddl.sgml b/doc/src/sgml/ddl.sgml
index 8421ecace1b..786880d54a2 100644
--- a/doc/src/sgml/ddl.sgml
+++ b/doc/src/sgml/ddl.sgml
@@ -357,10 +357,11 @@ INSERT INTO people (id, name, address) VALUES (<emphasis>DEFAULT</emphasis>, 'C'
    columns.  Thus, it is for columns what a view is for tables.  There are two
    kinds of generated columns: stored and virtual.  A stored generated column
    is computed when it is written (inserted or updated) and occupies storage
-   as if it were a normal column.  A virtual generated column occupies no
-   storage and is computed when it is read.  Thus, a virtual generated column
-   is similar to a view and a stored generated column is similar to a
-   materialized view (except that it is always updated automatically).
+   as if it were a normal column.  A virtual generated column is also computed when
+   it is written, but occupies no storage and is recomputed on every read.
+   Thus, a virtual generated column is similar to a view and a stored generated
+   column is similar to a materialized view (except that it is always updated
+   automatically)
   </para>
 
   <para>
diff --git a/doc/src/sgml/ref/create_foreign_table.sgml b/doc/src/sgml/ref/create_foreign_table.sgml
index 083f16772b7..e85579be18f 100644
--- a/doc/src/sgml/ref/create_foreign_table.sgml
+++ b/doc/src/sgml/ref/create_foreign_table.sgml
@@ -403,12 +403,13 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
 
      <para>
       When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read.  (The foreign-data wrapper will see it as a
-      null value in new rows and may choose to store it as a null value or
-      ignore it altogether.)  When <literal>STORED</literal> is specified, the
-      column will be computed on write.  (The computed value will be presented
-      to the foreign-data wrapper for storage and must be returned on
-      reading.)  <literal>VIRTUAL</literal> is the default.
+      computed when it is written, but it will not occupy any storage.
+      (The foreign-data wrapper will see it as a null value in new rows
+      and may choose to store it as a null value or ignore it altogether.)
+      When <literal>STORED</literal> is specified, the column will be computed
+      on write.  (The computed value will be presented to the foreign-data
+      wrapper for storage and must be returned on reading.)
+      <literal>VIRTUAL</literal> is the default.
      </para>
 
      <para>
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index 982532fe725..8c11c856042 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -913,8 +913,8 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
      </para>
 
      <para>
-      When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read, and it will not occupy any storage.  When
+      When <literal>VIRTUAL</literal> is specified, the column will be computed
+      when it is written and read, but it will not occupy any storage.  When
       <literal>STORED</literal> is specified, the column will be computed on
       write and will be stored on disk.  <literal>VIRTUAL</literal> is the
       default.
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 61ff5ddc74c..fc1ca898842 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -2405,9 +2405,10 @@ CheckVarSlotCompatibility(TupleTableSlot *slot, int attnum, Oid vartype)
 
 		attr = TupleDescAttr(slot_tupdesc, attnum - 1);
 
-		/* Internal error: somebody forgot to expand it. */
-		if (attr->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-			elog(ERROR, "unexpected virtual generated column reference");
+		/*
+		 * Virtual generated columns are now computed and stored in the slot
+		 * by ExecComputeVirtualGenerated(), so reading them is valid.
+		 */
 
 		if (attr->attisdropped)
 			ereport(ERROR,
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index 4015aef1b1f..fe027378a37 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -51,6 +51,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -1309,6 +1311,8 @@ InitResultRelInfo(ResultRelInfo *resultRelInfo,
 	resultRelInfo->ri_GenVirtualNotNullConstraintExprs = NULL;
 	resultRelInfo->ri_GeneratedExprsI = NULL;
 	resultRelInfo->ri_GeneratedExprsU = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsI = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsU = NULL;
 	resultRelInfo->ri_projectReturning = NULL;
 	resultRelInfo->ri_onConflictArbiterIndexes = NIL;
 	resultRelInfo->ri_onConflict = NULL;
@@ -1828,7 +1832,13 @@ ExecRelCheck(ResultRelInfo *resultRelInfo,
 				continue;
 
 			checkconstr = stringToNode(check[i].ccbin);
-			checkconstr = (Expr *) expand_generated_columns_in_expr((Node *) checkconstr, rel, 1);
+
+			/*
+			 * No need to call expand_generated_columns_in_expr() here.
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated() before we get here, so
+			 * the expression evaluator can read them directly from the slot.
+			 */
 			resultRelInfo->ri_CheckConstraintExprs[i] =
 				ExecPrepareExpr(checkconstr, estate);
 		}
@@ -2489,9 +2499,12 @@ ExecBuildSlotValueDescription(Oid reloid,
 
 		if (table_perm || column_perm)
 		{
-			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
-			else if (slot->tts_isnull[i])
+			/*
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated(), so we can read them
+			 * directly like regular columns.
+			 */
+			if (slot->tts_isnull[i])
 				val = "null";
 			else
 			{
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index 2497ee7edc5..7e9152e4224 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -834,6 +834,12 @@ ExecSimpleRelationInsert(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
@@ -938,6 +944,12 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_UPDATE);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_UPDATE);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 327c27abff9..6959e477f83 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -425,8 +425,10 @@ ExecCheckTIDVisible(EState *estate,
  * Initialize generated columns handling for a tuple
  *
  * This fills the resultRelInfo's ri_GeneratedExprsI/ri_NumGeneratedNeededI or
- * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields, depending on cmdtype.
- * This is used only for stored generated columns.
+ * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields for stored generated
+ * columns, and ri_VirtualGeneratedExprsI/ri_NumVirtualGeneratedNeededI or
+ * ri_VirtualGeneratedExprsU/ri_NumVirtualGeneratedNeededU fields for virtual
+ * generated columns, depending on cmdtype.
  *
  * If cmdType == CMD_UPDATE, the ri_extraUpdatedCols field is filled too.
  * This is used by both stored and virtual generated columns.
@@ -446,6 +448,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	int			natts = tupdesc->natts;
 	ExprState **ri_GeneratedExprs;
 	int			ri_NumGeneratedNeeded;
+	ExprState **ri_VirtualGeneratedExprs;
+	int			ri_NumVirtualGeneratedNeeded;
 	Bitmapset  *updatedCols;
 	MemoryContext oldContext;
 
@@ -473,6 +477,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 
 	ri_GeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
 	ri_NumGeneratedNeeded = 0;
+	ri_VirtualGeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
+	ri_NumVirtualGeneratedNeeded = 0;
 
 	for (int i = 0; i < natts; i++)
 	{
@@ -508,6 +514,11 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 				ri_GeneratedExprs[i] = ExecPrepareExpr(expr, estate);
 				ri_NumGeneratedNeeded++;
 			}
+			else if (attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
+			{
+				ri_VirtualGeneratedExprs[i] = ExecPrepareExpr(expr, estate);
+				ri_NumVirtualGeneratedNeeded++;
+			}
 
 			/* If UPDATE, mark column in resultRelInfo->ri_extraUpdatedCols */
 			if (cmdtype == CMD_UPDATE)
@@ -524,14 +535,24 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 		ri_GeneratedExprs = NULL;
 	}
 
+	if (ri_NumVirtualGeneratedNeeded == 0)
+	{
+		/* didn't need it after all */
+		pfree(ri_VirtualGeneratedExprs);
+		ri_VirtualGeneratedExprs = NULL;
+	}
+
 	/* Save in appropriate set of fields */
 	if (cmdtype == CMD_UPDATE)
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsU == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsU == NULL);
 
 		resultRelInfo->ri_GeneratedExprsU = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededU = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsU = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededU = ri_NumVirtualGeneratedNeeded;
 
 		resultRelInfo->ri_extraUpdatedCols_valid = true;
 	}
@@ -539,9 +560,12 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsI == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsI == NULL);
 
 		resultRelInfo->ri_GeneratedExprsI = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededI = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsI = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededI = ri_NumVirtualGeneratedNeeded;
 	}
 
 	MemoryContextSwitchTo(oldContext);
@@ -637,6 +661,85 @@ ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 	MemoryContextSwitchTo(oldContext);
 }
 
+/*
+ * Compute virtual generated columns for a tuple
+ *
+ * This evaluates the generation expressions for virtual generated columns
+ * and stores the results directly in the slot. Unlike stored generated
+ * columns, these values are not persisted to disk but are computed for
+ * use in constraint checking and error messages.
+ */
+void
+ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+							EState *estate, TupleTableSlot *slot,
+							CmdType cmdtype)
+{
+	Relation	rel = resultRelInfo->ri_RelationDesc;
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	int			natts = tupdesc->natts;
+	ExprContext *econtext = GetPerTupleExprContext(estate);
+	ExprState **ri_VirtualGeneratedExprs;
+	MemoryContext oldContext;
+
+	/* We should not be called unless this is true */
+	Assert(tupdesc->constr && tupdesc->constr->has_generated_virtual);
+
+	/*
+	 * Initialize the expressions if we didn't already, and check whether we
+	 * can exit early because nothing needs to be computed.
+	 */
+	if (cmdtype == CMD_UPDATE)
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsU == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededU == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsU;
+	}
+	else
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsI == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededI == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsI;
+	}
+
+	oldContext = MemoryContextSwitchTo(GetPerTupleMemoryContext(estate));
+
+	/* Make sure all base column values are available */
+	slot_getallattrs(slot);
+
+	econtext->ecxt_scantuple = slot;
+
+	for (int i = 0; i < natts; i++)
+	{
+		CompactAttribute *attr = TupleDescCompactAttr(tupdesc, i);
+
+		if (ri_VirtualGeneratedExprs[i])
+		{
+			Datum		val;
+			bool		isnull;
+
+			Assert(TupleDescAttr(tupdesc, i)->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL);
+
+			val = ExecEvalExpr(ri_VirtualGeneratedExprs[i], econtext, &isnull);
+
+			/*
+			 * We must make a copy of val as we have no guarantees about where
+			 * memory for a pass-by-reference Datum is located.
+			 */
+			if (!isnull)
+				val = datumCopy(val, attr->attbyval, attr->attlen);
+
+			slot->tts_values[i] = val;
+			slot->tts_isnull[i] = isnull;
+		}
+	}
+
+	MemoryContextSwitchTo(oldContext);
+}
+
 /*
  * ExecInitInsertProjection
  *		Do one-time initialization of projection data for INSERT tuples.
@@ -945,6 +1048,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * If the FDW supports batching, and batching is requested, accumulate
 		 * rows and insert them in batches. Otherwise use the per-row inserts.
@@ -1070,6 +1181,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * Check any RLS WITH CHECK policies.
 		 *
@@ -2178,6 +2297,14 @@ ExecUpdatePrepareSlot(ResultRelInfo *resultRelInfo,
 		resultRelationDesc->rd_att->constr->has_generated_stored)
 		ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 								   CMD_UPDATE);
+
+	/*
+	 * Compute virtual generated columns
+	 */
+	if (resultRelationDesc->rd_att->constr &&
+		resultRelationDesc->rd_att->constr->has_generated_virtual)
+		ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+									CMD_UPDATE);
 }
 
 /*
diff --git a/src/include/executor/nodeModifyTable.h b/src/include/executor/nodeModifyTable.h
index f6070e1cdf3..7cbbe8c1a1f 100644
--- a/src/include/executor/nodeModifyTable.h
+++ b/src/include/executor/nodeModifyTable.h
@@ -23,6 +23,10 @@ extern void ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 									   EState *estate, TupleTableSlot *slot,
 									   CmdType cmdtype);
 
+extern void ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+										EState *estate, TupleTableSlot *slot,
+										CmdType cmdtype);
+
 extern ModifyTableState *ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags);
 extern void ExecEndModifyTable(ModifyTableState *node);
 extern void ExecReScanModifyTable(ModifyTableState *node);
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 63c067d5aae..502e4e14d64 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -573,6 +573,16 @@ typedef struct ResultRelInfo
 	int			ri_NumGeneratedNeededI;
 	int			ri_NumGeneratedNeededU;
 
+	/*
+	 * Arrays of virtual generated columns ExprStates for INSERT/UPDATE/MERGE.
+	 */
+	ExprState **ri_VirtualGeneratedExprsI;
+	ExprState **ri_VirtualGeneratedExprsU;
+
+	/* number of virtual generated columns we need to compute */
+	int			ri_NumVirtualGeneratedNeededI;
+	int			ri_NumVirtualGeneratedNeededU;
+
 	/* list of RETURNING expressions */
 	List	   *ri_returningList;
 
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 6dab60c937b..18cfd5ab46c 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -166,11 +166,9 @@ SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
  2 | 4
 (1 row)
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
 ERROR:  integer out of range
-DELETE FROM gtest1 WHERE a = 2000000000;
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
 INSERT INTO gtestx VALUES (11, 1), (22, 2), (33, 3);
@@ -638,7 +636,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, 60).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint
 ERROR:  check constraint "gtest20_b_check" of relation "gtest20" is violated by some row
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 3);  -- ok
@@ -684,18 +682,18 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 -- not-null constraints
 CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL NOT NULL);
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -705,17 +703,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- not-null constraint with partitioned table
@@ -730,10 +728,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, null).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, null).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index 925fe4f570a..82914be45e4 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1060,9 +1060,9 @@ SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i =
 DROP TABLE t;
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
@@ -1073,7 +1073,7 @@ INSERT INTO t VALUES (16);
 -- ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, 2).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
diff --git a/src/test/regress/sql/generated_virtual.sql b/src/test/regress/sql/generated_virtual.sql
index e750866d2d8..c8e6c749992 100644
--- a/src/test/regress/sql/generated_virtual.sql
+++ b/src/test/regress/sql/generated_virtual.sql
@@ -71,10 +71,8 @@ SELECT * FROM gtest1 ORDER BY a;
 SELECT a, b, b * 2 AS b2 FROM gtest1 ORDER BY a;
 SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
-DELETE FROM gtest1 WHERE a = 2000000000;
 
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index a211fee2ad1..df4239718cb 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -762,9 +762,9 @@ DROP TABLE t;
 
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
-- 
2.52.0



^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-06 03:54         ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-06 12:25           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:06             ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:47               ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  2026-02-26 20:29                 ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 22:14                   ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  2026-03-03 17:01                     ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-03-16 15:36                       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
@ 2026-07-10 21:13                         ` Matheus Alcantara <matheusssilv97@gmail.com>
  2026-09-01 20:10                           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  0 siblings, 1 reply; 16+ messages in thread

From: Matheus Alcantara @ 2026-07-10 21:13 UTC (permalink / raw)
  To: Tom Lane <tgl@sss.pgh.pa.us>; +Cc: pgsql-hackers@lists.postgresql.org; Peter Eisentraut <peter@eisentraut.org>; Yugo Nagata <nagata@sraoss.co.jp>

On Mon Mar 16, 2026 at 12:36 PM -03, Matheus Alcantara wrote:
> Attaching v5 with documentation changes included and rebased with master.
>

Attaching v6 rebased due to changes on a4639d64e21

--
Matheus Alcantara
EDB: https://www.enterprisedb.com
From e340f8f10c03b4c9278ac6b136bb65ece11911d3 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v6] Compute virtual generated columns during INSERT/UPDATE

Previously, virtual generated column values were never computed and
stored in the slot. Instead, references to e.g virtual columns in check
constraints were expanded to their generation expressions during
ExecCheck().

This commit changes the approach to compute virtual generated column
values and store them in the slot before constraint checking, similar
to how stored generated columns are handled. This has two benefits:

   Check constraints can now read virtual column values directly from
   the slot, simplifying constraint evaluation. It also avoid re-execute
   the virtual expression for each check constraint evaluation.

   Error messages now display the actual computed value (e.g., "10")
   instead of the "virtual" string, making them more consistent with
   stored generated columns and easier to understand.

Discussion: https://www.postgresql.org/message-id/DG5DV8SED62G.2WFJB46D7WIT8%40gmail.com
---
 doc/src/sgml/ddl.sgml                         |   9 +-
 doc/src/sgml/ref/create_foreign_table.sgml    |  13 +-
 doc/src/sgml/ref/create_table.sgml            |   4 +-
 src/backend/executor/execExprInterp.c         |   7 +-
 src/backend/executor/execMain.c               |  21 ++-
 src/backend/executor/execReplication.c        |  12 ++
 src/backend/executor/nodeModifyTable.c        | 131 +++++++++++++++++-
 src/include/executor/nodeModifyTable.h        |   4 +
 src/include/nodes/execnodes.h                 |  10 ++
 .../regress/expected/generated_virtual.out    |  22 ++-
 src/test/regress/expected/partition_merge.out |   8 +-
 src/test/regress/sql/generated_virtual.sql    |   4 +-
 src/test/regress/sql/partition_merge.sql      |   6 +-
 13 files changed, 208 insertions(+), 43 deletions(-)

diff --git a/doc/src/sgml/ddl.sgml b/doc/src/sgml/ddl.sgml
index 2b08b54edf5..8943269db7e 100644
--- a/doc/src/sgml/ddl.sgml
+++ b/doc/src/sgml/ddl.sgml
@@ -357,10 +357,11 @@ INSERT INTO people (id, name, address) VALUES (<emphasis>DEFAULT</emphasis>, 'C'
    columns.  Thus, it is for columns what a view is for tables.  There are two
    kinds of generated columns: stored and virtual.  A stored generated column
    is computed when it is written (inserted or updated) and occupies storage
-   as if it were a normal column.  A virtual generated column occupies no
-   storage and is computed when it is read.  Thus, a virtual generated column
-   is similar to a view and a stored generated column is similar to a
-   materialized view (except that it is always updated automatically).
+   as if it were a normal column.  A virtual generated column is also computed when
+   it is written, but occupies no storage and is recomputed on every read.
+   Thus, a virtual generated column is similar to a view and a stored generated
+   column is similar to a materialized view (except that it is always updated
+   automatically)
   </para>
 
   <para>
diff --git a/doc/src/sgml/ref/create_foreign_table.sgml b/doc/src/sgml/ref/create_foreign_table.sgml
index 083f16772b7..e85579be18f 100644
--- a/doc/src/sgml/ref/create_foreign_table.sgml
+++ b/doc/src/sgml/ref/create_foreign_table.sgml
@@ -403,12 +403,13 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
 
      <para>
       When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read.  (The foreign-data wrapper will see it as a
-      null value in new rows and may choose to store it as a null value or
-      ignore it altogether.)  When <literal>STORED</literal> is specified, the
-      column will be computed on write.  (The computed value will be presented
-      to the foreign-data wrapper for storage and must be returned on
-      reading.)  <literal>VIRTUAL</literal> is the default.
+      computed when it is written, but it will not occupy any storage.
+      (The foreign-data wrapper will see it as a null value in new rows
+      and may choose to store it as a null value or ignore it altogether.)
+      When <literal>STORED</literal> is specified, the column will be computed
+      on write.  (The computed value will be presented to the foreign-data
+      wrapper for storage and must be returned on reading.)
+      <literal>VIRTUAL</literal> is the default.
      </para>
 
      <para>
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index e342585c7f0..b5d214e3611 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -933,8 +933,8 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
      </para>
 
      <para>
-      When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read, and it will not occupy any storage.  When
+      When <literal>VIRTUAL</literal> is specified, the column will be computed
+      when it is written and read, but it will not occupy any storage.  When
       <literal>STORED</literal> is specified, the column will be computed on
       write and will be stored on disk.  <literal>VIRTUAL</literal> is the
       default.
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 0634af964a9..c4060c7039d 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -2415,9 +2415,10 @@ CheckVarSlotCompatibility(TupleTableSlot *slot, int attnum, Oid vartype)
 
 		attr = TupleDescAttr(slot_tupdesc, attnum - 1);
 
-		/* Internal error: somebody forgot to expand it. */
-		if (attr->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-			elog(ERROR, "unexpected virtual generated column reference");
+		/*
+		 * Virtual generated columns are now computed and stored in the slot
+		 * by ExecComputeVirtualGenerated(), so reading them is valid.
+		 */
 
 		if (attr->attisdropped)
 			ereport(ERROR,
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index fde502efd38..bacb7d44e93 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -53,6 +53,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -1357,6 +1359,8 @@ InitResultRelInfo(ResultRelInfo *resultRelInfo,
 	resultRelInfo->ri_GenVirtualNotNullConstraintExprs = NULL;
 	resultRelInfo->ri_GeneratedExprsI = NULL;
 	resultRelInfo->ri_GeneratedExprsU = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsI = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsU = NULL;
 	resultRelInfo->ri_projectReturning = NULL;
 	resultRelInfo->ri_onConflictArbiterIndexes = NIL;
 	resultRelInfo->ri_onConflict = NULL;
@@ -1877,7 +1881,13 @@ ExecRelCheck(ResultRelInfo *resultRelInfo,
 				continue;
 
 			checkconstr = stringToNode(check[i].ccbin);
-			checkconstr = (Expr *) expand_generated_columns_in_expr((Node *) checkconstr, rel, 1);
+
+			/*
+			 * No need to call expand_generated_columns_in_expr() here.
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated() before we get here, so
+			 * the expression evaluator can read them directly from the slot.
+			 */
 			resultRelInfo->ri_CheckConstraintExprs[i] =
 				ExecPrepareExpr(checkconstr, estate);
 		}
@@ -2538,9 +2548,12 @@ ExecBuildSlotValueDescription(Oid reloid,
 
 		if (table_perm || column_perm)
 		{
-			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
-			else if (slot->tts_isnull[i])
+			/*
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated(), so we can read them
+			 * directly like regular columns.
+			 */
+			if (slot->tts_isnull[i])
 				val = "null";
 			else
 			{
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index b2ca5cbf117..640c0ff58cf 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -838,6 +838,12 @@ ExecSimpleRelationInsert(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
@@ -942,6 +948,12 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_UPDATE);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_UPDATE);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index b9781eb3b95..9e958902165 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -439,8 +439,10 @@ ExecCheckTIDVisible(EState *estate,
  * Initialize generated columns handling for a tuple
  *
  * This fills the resultRelInfo's ri_GeneratedExprsI/ri_NumGeneratedNeededI or
- * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields, depending on cmdtype.
- * This is used only for stored generated columns.
+ * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields for stored generated
+ * columns, and ri_VirtualGeneratedExprsI/ri_NumVirtualGeneratedNeededI or
+ * ri_VirtualGeneratedExprsU/ri_NumVirtualGeneratedNeededU fields for virtual
+ * generated columns, depending on cmdtype.
  *
  * If cmdType == CMD_UPDATE, the ri_extraUpdatedCols field is filled too.
  * This is used by both stored and virtual generated columns.
@@ -460,6 +462,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	int			natts = tupdesc->natts;
 	ExprState **ri_GeneratedExprs;
 	int			ri_NumGeneratedNeeded;
+	ExprState **ri_VirtualGeneratedExprs;
+	int			ri_NumVirtualGeneratedNeeded;
 	Bitmapset  *updatedCols;
 	MemoryContext oldContext;
 
@@ -487,6 +491,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 
 	ri_GeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
 	ri_NumGeneratedNeeded = 0;
+	ri_VirtualGeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
+	ri_NumVirtualGeneratedNeeded = 0;
 
 	for (int i = 0; i < natts; i++)
 	{
@@ -522,6 +528,11 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 				ri_GeneratedExprs[i] = ExecPrepareExpr(expr, estate);
 				ri_NumGeneratedNeeded++;
 			}
+			else if (attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
+			{
+				ri_VirtualGeneratedExprs[i] = ExecPrepareExpr(expr, estate);
+				ri_NumVirtualGeneratedNeeded++;
+			}
 
 			/* If UPDATE, mark column in resultRelInfo->ri_extraUpdatedCols */
 			if (cmdtype == CMD_UPDATE)
@@ -538,14 +549,24 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 		ri_GeneratedExprs = NULL;
 	}
 
+	if (ri_NumVirtualGeneratedNeeded == 0)
+	{
+		/* didn't need it after all */
+		pfree(ri_VirtualGeneratedExprs);
+		ri_VirtualGeneratedExprs = NULL;
+	}
+
 	/* Save in appropriate set of fields */
 	if (cmdtype == CMD_UPDATE)
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsU == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsU == NULL);
 
 		resultRelInfo->ri_GeneratedExprsU = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededU = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsU = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededU = ri_NumVirtualGeneratedNeeded;
 
 		resultRelInfo->ri_extraUpdatedCols_valid = true;
 	}
@@ -553,9 +574,12 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsI == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsI == NULL);
 
 		resultRelInfo->ri_GeneratedExprsI = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededI = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsI = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededI = ri_NumVirtualGeneratedNeeded;
 	}
 
 	MemoryContextSwitchTo(oldContext);
@@ -651,6 +675,85 @@ ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 	MemoryContextSwitchTo(oldContext);
 }
 
+/*
+ * Compute virtual generated columns for a tuple
+ *
+ * This evaluates the generation expressions for virtual generated columns
+ * and stores the results directly in the slot. Unlike stored generated
+ * columns, these values are not persisted to disk but are computed for
+ * use in constraint checking and error messages.
+ */
+void
+ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+							EState *estate, TupleTableSlot *slot,
+							CmdType cmdtype)
+{
+	Relation	rel = resultRelInfo->ri_RelationDesc;
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	int			natts = tupdesc->natts;
+	ExprContext *econtext = GetPerTupleExprContext(estate);
+	ExprState **ri_VirtualGeneratedExprs;
+	MemoryContext oldContext;
+
+	/* We should not be called unless this is true */
+	Assert(tupdesc->constr && tupdesc->constr->has_generated_virtual);
+
+	/*
+	 * Initialize the expressions if we didn't already, and check whether we
+	 * can exit early because nothing needs to be computed.
+	 */
+	if (cmdtype == CMD_UPDATE)
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsU == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededU == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsU;
+	}
+	else
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsI == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededI == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsI;
+	}
+
+	oldContext = MemoryContextSwitchTo(GetPerTupleMemoryContext(estate));
+
+	/* Make sure all base column values are available */
+	slot_getallattrs(slot);
+
+	econtext->ecxt_scantuple = slot;
+
+	for (int i = 0; i < natts; i++)
+	{
+		CompactAttribute *attr = TupleDescCompactAttr(tupdesc, i);
+
+		if (ri_VirtualGeneratedExprs[i])
+		{
+			Datum		val;
+			bool		isnull;
+
+			Assert(TupleDescAttr(tupdesc, i)->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL);
+
+			val = ExecEvalExpr(ri_VirtualGeneratedExprs[i], econtext, &isnull);
+
+			/*
+			 * We must make a copy of val as we have no guarantees about where
+			 * memory for a pass-by-reference Datum is located.
+			 */
+			if (!isnull)
+				val = datumCopy(val, attr->attbyval, attr->attlen);
+
+			slot->tts_values[i] = val;
+			slot->tts_isnull[i] = isnull;
+		}
+	}
+
+	MemoryContextSwitchTo(oldContext);
+}
+
 /*
  * ExecInitInsertProjection
  *		Do one-time initialization of projection data for INSERT tuples.
@@ -959,6 +1062,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * If the FDW supports batching, and batching is requested, accumulate
 		 * rows and insert them in batches. Otherwise use the per-row inserts.
@@ -1084,6 +1195,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * Check any RLS WITH CHECK policies.
 		 *
@@ -2429,6 +2548,14 @@ ExecUpdatePrepareSlot(ResultRelInfo *resultRelInfo,
 		resultRelationDesc->rd_att->constr->has_generated_stored)
 		ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 								   CMD_UPDATE);
+
+	/*
+	 * Compute virtual generated columns
+	 */
+	if (resultRelationDesc->rd_att->constr &&
+		resultRelationDesc->rd_att->constr->has_generated_virtual)
+		ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+									CMD_UPDATE);
 }
 
 /*
diff --git a/src/include/executor/nodeModifyTable.h b/src/include/executor/nodeModifyTable.h
index f6070e1cdf3..7cbbe8c1a1f 100644
--- a/src/include/executor/nodeModifyTable.h
+++ b/src/include/executor/nodeModifyTable.h
@@ -23,6 +23,10 @@ extern void ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 									   EState *estate, TupleTableSlot *slot,
 									   CmdType cmdtype);
 
+extern void ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+										EState *estate, TupleTableSlot *slot,
+										CmdType cmdtype);
+
 extern ModifyTableState *ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags);
 extern void ExecEndModifyTable(ModifyTableState *node);
 extern void ExecReScanModifyTable(ModifyTableState *node);
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index e64fd8c7ea3..8ca87eb4be5 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -604,6 +604,16 @@ typedef struct ResultRelInfo
 	int			ri_NumGeneratedNeededI;
 	int			ri_NumGeneratedNeededU;
 
+	/*
+	 * Arrays of virtual generated columns ExprStates for INSERT/UPDATE/MERGE.
+	 */
+	ExprState **ri_VirtualGeneratedExprsI;
+	ExprState **ri_VirtualGeneratedExprsU;
+
+	/* number of virtual generated columns we need to compute */
+	int			ri_NumVirtualGeneratedNeededI;
+	int			ri_NumVirtualGeneratedNeededU;
+
 	/* list of RETURNING expressions */
 	List	   *ri_returningList;
 
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 6ee029796f1..43f5cd8dc78 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -166,11 +166,9 @@ SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
  2 | 4
 (1 row)
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
 ERROR:  integer out of range
-DELETE FROM gtest1 WHERE a = 2000000000;
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
 INSERT INTO gtestx VALUES (11, 1), (22, 2), (33, 3);
@@ -647,7 +645,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, 60).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint
 ERROR:  check constraint "gtest20_b_check" of relation "gtest20" is violated by some row
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 3);  -- ok
@@ -693,7 +691,7 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest20c ALTER COLUMN b SET EXPRESSION AS (NULL::int);  -- violates constraint
 ERROR:  check constraint "whole_row_check" of relation "gtest20c" is violated by some row
 -- index with whole-row reference needs rebuild
@@ -708,12 +706,12 @@ CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -723,17 +721,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- virtual generated columns are not physically stored, even when not null
@@ -790,10 +788,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, null).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, null).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index ccda2b5843b..cea42615167 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1058,9 +1058,9 @@ SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i =
 DROP TABLE t;
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
@@ -1071,7 +1071,7 @@ INSERT INTO t VALUES (16);
 -- ERROR
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, 2).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
diff --git a/src/test/regress/sql/generated_virtual.sql b/src/test/regress/sql/generated_virtual.sql
index ed9d50fe784..859b36727cf 100644
--- a/src/test/regress/sql/generated_virtual.sql
+++ b/src/test/regress/sql/generated_virtual.sql
@@ -71,10 +71,8 @@ SELECT * FROM gtest1 ORDER BY a;
 SELECT a, b, b * 2 AS b2 FROM gtest1 ORDER BY a;
 SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
-DELETE FROM gtest1 WHERE a = 2000000000;
 
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 80dc365b0ce..5aa78b2f585 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -760,9 +760,9 @@ DROP TABLE t;
 
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
-- 
2.50.1 (Apple Git-155)



Attachments:

  [text/plain] v6-0001-Compute-virtual-generated-columns-during-INSERT-U.patch (25.7K, ../../DJV79NVENPC8.DVXIKOV4VBNV@gmail.com/2-v6-0001-Compute-virtual-generated-columns-during-INSERT-U.patch)
  download | inline diff:
From e340f8f10c03b4c9278ac6b136bb65ece11911d3 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Mon, 2 Feb 2026 19:06:44 -0300
Subject: [PATCH v6] Compute virtual generated columns during INSERT/UPDATE

Previously, virtual generated column values were never computed and
stored in the slot. Instead, references to e.g virtual columns in check
constraints were expanded to their generation expressions during
ExecCheck().

This commit changes the approach to compute virtual generated column
values and store them in the slot before constraint checking, similar
to how stored generated columns are handled. This has two benefits:

   Check constraints can now read virtual column values directly from
   the slot, simplifying constraint evaluation. It also avoid re-execute
   the virtual expression for each check constraint evaluation.

   Error messages now display the actual computed value (e.g., "10")
   instead of the "virtual" string, making them more consistent with
   stored generated columns and easier to understand.

Discussion: https://www.postgresql.org/message-id/DG5DV8SED62G.2WFJB46D7WIT8%40gmail.com
---
 doc/src/sgml/ddl.sgml                         |   9 +-
 doc/src/sgml/ref/create_foreign_table.sgml    |  13 +-
 doc/src/sgml/ref/create_table.sgml            |   4 +-
 src/backend/executor/execExprInterp.c         |   7 +-
 src/backend/executor/execMain.c               |  21 ++-
 src/backend/executor/execReplication.c        |  12 ++
 src/backend/executor/nodeModifyTable.c        | 131 +++++++++++++++++-
 src/include/executor/nodeModifyTable.h        |   4 +
 src/include/nodes/execnodes.h                 |  10 ++
 .../regress/expected/generated_virtual.out    |  22 ++-
 src/test/regress/expected/partition_merge.out |   8 +-
 src/test/regress/sql/generated_virtual.sql    |   4 +-
 src/test/regress/sql/partition_merge.sql      |   6 +-
 13 files changed, 208 insertions(+), 43 deletions(-)

diff --git a/doc/src/sgml/ddl.sgml b/doc/src/sgml/ddl.sgml
index 2b08b54edf5..8943269db7e 100644
--- a/doc/src/sgml/ddl.sgml
+++ b/doc/src/sgml/ddl.sgml
@@ -357,10 +357,11 @@ INSERT INTO people (id, name, address) VALUES (<emphasis>DEFAULT</emphasis>, 'C'
    columns.  Thus, it is for columns what a view is for tables.  There are two
    kinds of generated columns: stored and virtual.  A stored generated column
    is computed when it is written (inserted or updated) and occupies storage
-   as if it were a normal column.  A virtual generated column occupies no
-   storage and is computed when it is read.  Thus, a virtual generated column
-   is similar to a view and a stored generated column is similar to a
-   materialized view (except that it is always updated automatically).
+   as if it were a normal column.  A virtual generated column is also computed when
+   it is written, but occupies no storage and is recomputed on every read.
+   Thus, a virtual generated column is similar to a view and a stored generated
+   column is similar to a materialized view (except that it is always updated
+   automatically)
   </para>
 
   <para>
diff --git a/doc/src/sgml/ref/create_foreign_table.sgml b/doc/src/sgml/ref/create_foreign_table.sgml
index 083f16772b7..e85579be18f 100644
--- a/doc/src/sgml/ref/create_foreign_table.sgml
+++ b/doc/src/sgml/ref/create_foreign_table.sgml
@@ -403,12 +403,13 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
 
      <para>
       When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read.  (The foreign-data wrapper will see it as a
-      null value in new rows and may choose to store it as a null value or
-      ignore it altogether.)  When <literal>STORED</literal> is specified, the
-      column will be computed on write.  (The computed value will be presented
-      to the foreign-data wrapper for storage and must be returned on
-      reading.)  <literal>VIRTUAL</literal> is the default.
+      computed when it is written, but it will not occupy any storage.
+      (The foreign-data wrapper will see it as a null value in new rows
+      and may choose to store it as a null value or ignore it altogether.)
+      When <literal>STORED</literal> is specified, the column will be computed
+      on write.  (The computed value will be presented to the foreign-data
+      wrapper for storage and must be returned on reading.)
+      <literal>VIRTUAL</literal> is the default.
      </para>
 
      <para>
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index e342585c7f0..b5d214e3611 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -933,8 +933,8 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
      </para>
 
      <para>
-      When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read, and it will not occupy any storage.  When
+      When <literal>VIRTUAL</literal> is specified, the column will be computed
+      when it is written and read, but it will not occupy any storage.  When
       <literal>STORED</literal> is specified, the column will be computed on
       write and will be stored on disk.  <literal>VIRTUAL</literal> is the
       default.
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 0634af964a9..c4060c7039d 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -2415,9 +2415,10 @@ CheckVarSlotCompatibility(TupleTableSlot *slot, int attnum, Oid vartype)
 
 		attr = TupleDescAttr(slot_tupdesc, attnum - 1);
 
-		/* Internal error: somebody forgot to expand it. */
-		if (attr->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-			elog(ERROR, "unexpected virtual generated column reference");
+		/*
+		 * Virtual generated columns are now computed and stored in the slot
+		 * by ExecComputeVirtualGenerated(), so reading them is valid.
+		 */
 
 		if (attr->attisdropped)
 			ereport(ERROR,
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index fde502efd38..bacb7d44e93 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -53,6 +53,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -1357,6 +1359,8 @@ InitResultRelInfo(ResultRelInfo *resultRelInfo,
 	resultRelInfo->ri_GenVirtualNotNullConstraintExprs = NULL;
 	resultRelInfo->ri_GeneratedExprsI = NULL;
 	resultRelInfo->ri_GeneratedExprsU = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsI = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsU = NULL;
 	resultRelInfo->ri_projectReturning = NULL;
 	resultRelInfo->ri_onConflictArbiterIndexes = NIL;
 	resultRelInfo->ri_onConflict = NULL;
@@ -1877,7 +1881,13 @@ ExecRelCheck(ResultRelInfo *resultRelInfo,
 				continue;
 
 			checkconstr = stringToNode(check[i].ccbin);
-			checkconstr = (Expr *) expand_generated_columns_in_expr((Node *) checkconstr, rel, 1);
+
+			/*
+			 * No need to call expand_generated_columns_in_expr() here.
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated() before we get here, so
+			 * the expression evaluator can read them directly from the slot.
+			 */
 			resultRelInfo->ri_CheckConstraintExprs[i] =
 				ExecPrepareExpr(checkconstr, estate);
 		}
@@ -2538,9 +2548,12 @@ ExecBuildSlotValueDescription(Oid reloid,
 
 		if (table_perm || column_perm)
 		{
-			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
-			else if (slot->tts_isnull[i])
+			/*
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated(), so we can read them
+			 * directly like regular columns.
+			 */
+			if (slot->tts_isnull[i])
 				val = "null";
 			else
 			{
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index b2ca5cbf117..640c0ff58cf 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -838,6 +838,12 @@ ExecSimpleRelationInsert(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
@@ -942,6 +948,12 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_UPDATE);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_UPDATE);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index b9781eb3b95..9e958902165 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -439,8 +439,10 @@ ExecCheckTIDVisible(EState *estate,
  * Initialize generated columns handling for a tuple
  *
  * This fills the resultRelInfo's ri_GeneratedExprsI/ri_NumGeneratedNeededI or
- * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields, depending on cmdtype.
- * This is used only for stored generated columns.
+ * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields for stored generated
+ * columns, and ri_VirtualGeneratedExprsI/ri_NumVirtualGeneratedNeededI or
+ * ri_VirtualGeneratedExprsU/ri_NumVirtualGeneratedNeededU fields for virtual
+ * generated columns, depending on cmdtype.
  *
  * If cmdType == CMD_UPDATE, the ri_extraUpdatedCols field is filled too.
  * This is used by both stored and virtual generated columns.
@@ -460,6 +462,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	int			natts = tupdesc->natts;
 	ExprState **ri_GeneratedExprs;
 	int			ri_NumGeneratedNeeded;
+	ExprState **ri_VirtualGeneratedExprs;
+	int			ri_NumVirtualGeneratedNeeded;
 	Bitmapset  *updatedCols;
 	MemoryContext oldContext;
 
@@ -487,6 +491,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 
 	ri_GeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
 	ri_NumGeneratedNeeded = 0;
+	ri_VirtualGeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
+	ri_NumVirtualGeneratedNeeded = 0;
 
 	for (int i = 0; i < natts; i++)
 	{
@@ -522,6 +528,11 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 				ri_GeneratedExprs[i] = ExecPrepareExpr(expr, estate);
 				ri_NumGeneratedNeeded++;
 			}
+			else if (attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
+			{
+				ri_VirtualGeneratedExprs[i] = ExecPrepareExpr(expr, estate);
+				ri_NumVirtualGeneratedNeeded++;
+			}
 
 			/* If UPDATE, mark column in resultRelInfo->ri_extraUpdatedCols */
 			if (cmdtype == CMD_UPDATE)
@@ -538,14 +549,24 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 		ri_GeneratedExprs = NULL;
 	}
 
+	if (ri_NumVirtualGeneratedNeeded == 0)
+	{
+		/* didn't need it after all */
+		pfree(ri_VirtualGeneratedExprs);
+		ri_VirtualGeneratedExprs = NULL;
+	}
+
 	/* Save in appropriate set of fields */
 	if (cmdtype == CMD_UPDATE)
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsU == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsU == NULL);
 
 		resultRelInfo->ri_GeneratedExprsU = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededU = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsU = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededU = ri_NumVirtualGeneratedNeeded;
 
 		resultRelInfo->ri_extraUpdatedCols_valid = true;
 	}
@@ -553,9 +574,12 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsI == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsI == NULL);
 
 		resultRelInfo->ri_GeneratedExprsI = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededI = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsI = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededI = ri_NumVirtualGeneratedNeeded;
 	}
 
 	MemoryContextSwitchTo(oldContext);
@@ -651,6 +675,85 @@ ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 	MemoryContextSwitchTo(oldContext);
 }
 
+/*
+ * Compute virtual generated columns for a tuple
+ *
+ * This evaluates the generation expressions for virtual generated columns
+ * and stores the results directly in the slot. Unlike stored generated
+ * columns, these values are not persisted to disk but are computed for
+ * use in constraint checking and error messages.
+ */
+void
+ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+							EState *estate, TupleTableSlot *slot,
+							CmdType cmdtype)
+{
+	Relation	rel = resultRelInfo->ri_RelationDesc;
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	int			natts = tupdesc->natts;
+	ExprContext *econtext = GetPerTupleExprContext(estate);
+	ExprState **ri_VirtualGeneratedExprs;
+	MemoryContext oldContext;
+
+	/* We should not be called unless this is true */
+	Assert(tupdesc->constr && tupdesc->constr->has_generated_virtual);
+
+	/*
+	 * Initialize the expressions if we didn't already, and check whether we
+	 * can exit early because nothing needs to be computed.
+	 */
+	if (cmdtype == CMD_UPDATE)
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsU == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededU == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsU;
+	}
+	else
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsI == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededI == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsI;
+	}
+
+	oldContext = MemoryContextSwitchTo(GetPerTupleMemoryContext(estate));
+
+	/* Make sure all base column values are available */
+	slot_getallattrs(slot);
+
+	econtext->ecxt_scantuple = slot;
+
+	for (int i = 0; i < natts; i++)
+	{
+		CompactAttribute *attr = TupleDescCompactAttr(tupdesc, i);
+
+		if (ri_VirtualGeneratedExprs[i])
+		{
+			Datum		val;
+			bool		isnull;
+
+			Assert(TupleDescAttr(tupdesc, i)->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL);
+
+			val = ExecEvalExpr(ri_VirtualGeneratedExprs[i], econtext, &isnull);
+
+			/*
+			 * We must make a copy of val as we have no guarantees about where
+			 * memory for a pass-by-reference Datum is located.
+			 */
+			if (!isnull)
+				val = datumCopy(val, attr->attbyval, attr->attlen);
+
+			slot->tts_values[i] = val;
+			slot->tts_isnull[i] = isnull;
+		}
+	}
+
+	MemoryContextSwitchTo(oldContext);
+}
+
 /*
  * ExecInitInsertProjection
  *		Do one-time initialization of projection data for INSERT tuples.
@@ -959,6 +1062,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * If the FDW supports batching, and batching is requested, accumulate
 		 * rows and insert them in batches. Otherwise use the per-row inserts.
@@ -1084,6 +1195,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * Check any RLS WITH CHECK policies.
 		 *
@@ -2429,6 +2548,14 @@ ExecUpdatePrepareSlot(ResultRelInfo *resultRelInfo,
 		resultRelationDesc->rd_att->constr->has_generated_stored)
 		ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 								   CMD_UPDATE);
+
+	/*
+	 * Compute virtual generated columns
+	 */
+	if (resultRelationDesc->rd_att->constr &&
+		resultRelationDesc->rd_att->constr->has_generated_virtual)
+		ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+									CMD_UPDATE);
 }
 
 /*
diff --git a/src/include/executor/nodeModifyTable.h b/src/include/executor/nodeModifyTable.h
index f6070e1cdf3..7cbbe8c1a1f 100644
--- a/src/include/executor/nodeModifyTable.h
+++ b/src/include/executor/nodeModifyTable.h
@@ -23,6 +23,10 @@ extern void ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 									   EState *estate, TupleTableSlot *slot,
 									   CmdType cmdtype);
 
+extern void ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+										EState *estate, TupleTableSlot *slot,
+										CmdType cmdtype);
+
 extern ModifyTableState *ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags);
 extern void ExecEndModifyTable(ModifyTableState *node);
 extern void ExecReScanModifyTable(ModifyTableState *node);
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index e64fd8c7ea3..8ca87eb4be5 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -604,6 +604,16 @@ typedef struct ResultRelInfo
 	int			ri_NumGeneratedNeededI;
 	int			ri_NumGeneratedNeededU;
 
+	/*
+	 * Arrays of virtual generated columns ExprStates for INSERT/UPDATE/MERGE.
+	 */
+	ExprState **ri_VirtualGeneratedExprsI;
+	ExprState **ri_VirtualGeneratedExprsU;
+
+	/* number of virtual generated columns we need to compute */
+	int			ri_NumVirtualGeneratedNeededI;
+	int			ri_NumVirtualGeneratedNeededU;
+
 	/* list of RETURNING expressions */
 	List	   *ri_returningList;
 
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 6ee029796f1..43f5cd8dc78 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -166,11 +166,9 @@ SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
  2 | 4
 (1 row)
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
 ERROR:  integer out of range
-DELETE FROM gtest1 WHERE a = 2000000000;
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
 INSERT INTO gtestx VALUES (11, 1), (22, 2), (33, 3);
@@ -647,7 +645,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, 60).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint
 ERROR:  check constraint "gtest20_b_check" of relation "gtest20" is violated by some row
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 3);  -- ok
@@ -693,7 +691,7 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest20c ALTER COLUMN b SET EXPRESSION AS (NULL::int);  -- violates constraint
 ERROR:  check constraint "whole_row_check" of relation "gtest20c" is violated by some row
 -- index with whole-row reference needs rebuild
@@ -708,12 +706,12 @@ CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -723,17 +721,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- virtual generated columns are not physically stored, even when not null
@@ -790,10 +788,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, null).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, null).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/expected/partition_merge.out b/src/test/regress/expected/partition_merge.out
index ccda2b5843b..cea42615167 100644
--- a/src/test/regress/expected/partition_merge.out
+++ b/src/test/regress/expected/partition_merge.out
@@ -1058,9 +1058,9 @@ SELECT count(*) FROM t WHERE i = 0 AND tab_id IN (SELECT tab_id FROM t WHERE i =
 DROP TABLE t;
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
@@ -1071,7 +1071,7 @@ INSERT INTO t VALUES (16);
 -- ERROR
 INSERT INTO t VALUES (0);
 ERROR:  new row for relation "tp_12" violates check constraint "t_i_check"
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, 2).
 -- Should be 3 rows: (5), (15), (16):
 SELECT i FROM t ORDER BY i;
  i  
diff --git a/src/test/regress/sql/generated_virtual.sql b/src/test/regress/sql/generated_virtual.sql
index ed9d50fe784..859b36727cf 100644
--- a/src/test/regress/sql/generated_virtual.sql
+++ b/src/test/regress/sql/generated_virtual.sql
@@ -71,10 +71,8 @@ SELECT * FROM gtest1 ORDER BY a;
 SELECT a, b, b * 2 AS b2 FROM gtest1 ORDER BY a;
 SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
-DELETE FROM gtest1 WHERE a = 2000000000;
 
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
diff --git a/src/test/regress/sql/partition_merge.sql b/src/test/regress/sql/partition_merge.sql
index 80dc365b0ce..5aa78b2f585 100644
--- a/src/test/regress/sql/partition_merge.sql
+++ b/src/test/regress/sql/partition_merge.sql
@@ -760,9 +760,9 @@ DROP TABLE t;
 
 -- Test for generated columns (different order of columns in partitioned table
 -- and partitions).
-CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + tableoid::int)) PARTITION BY RANGE (i);
-CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
-CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + tableoid::int), i int);
+CREATE TABLE t (i int, g int GENERATED ALWAYS AS (i + 2)) PARTITION BY RANGE (i);
+CREATE TABLE tp_1 (g int GENERATED ALWAYS AS (i + 2), i int);
+CREATE TABLE tp_2 (g int GENERATED ALWAYS AS (i + 2), i int);
 ALTER TABLE t ATTACH PARTITION tp_1 FOR VALUES FROM (-1) TO (10);
 ALTER TABLE t ATTACH PARTITION tp_2 FOR VALUES FROM (10) TO (20);
 ALTER TABLE t ADD CHECK (g > 0);
-- 
2.50.1 (Apple Git-155)



^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-06 03:54         ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-06 12:25           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:06             ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:47               ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  2026-02-26 20:29                 ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 22:14                   ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  2026-03-03 17:01                     ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-03-16 15:36                       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-07-10 21:13                         ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
@ 2026-09-01 20:10                           ` Matheus Alcantara <matheusssilv97@gmail.com>
  2026-09-17 07:19                             ` Re: Show expression of virtual columns in error messages solai v <solai.cdac@gmail.com>
  0 siblings, 1 reply; 16+ messages in thread

From: Matheus Alcantara @ 2026-09-01 20:10 UTC (permalink / raw)
  To: Tom Lane <tgl@sss.pgh.pa.us>; +Cc: pgsql-hackers@lists.postgresql.org, Peter Eisentraut <peter@eisentraut.org>; Yugo Nagata <nagata@sraoss.co.jp>

On 10/07/26 18:13, Matheus Alcantara wrote:
> On Mon Mar 16, 2026 at 12:36 PM -03, Matheus Alcantara wrote:
>> Attaching v5 with documentation changes included and rebased with master.
>>
> 
> Attaching v6 rebased due to changes on a4639d64e21
> 

Attaching v7 rebased with master


--
Matheus Alcantara
EDB: https://www.enterprisedb.com
From 5ca633f056bdc390db8ba5892490cb51777e2823 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Fri, 28 Aug 2026 08:14:30 -0300
Subject: [PATCH v7] Compute virtual generated columns during INSERT/UPDATE

Previously, virtual generated column values were never computed and
stored in the slot. Instead, references to e.g virtual columns in check
constraints were expanded to their generation expressions during
ExecCheck().

This commit changes the approach to compute virtual generated column
values and store them in the slot before constraint checking, similar
to how stored generated columns are handled. This has two benefits:

Check constraints can now read virtual column values directly from
the slot, simplifying constraint evaluation. It also avoid re-execute
the virtual expression for each check constraint evaluation.

Error messages now display the actual computed value (e.g., "10")
instead of the "virtual" string, making them more consistent with
stored generated columns and easier to understand.

Discussion: https://www.postgresql.org/message-id/DG5DV8SED62G.2WFJB46D7WIT8%40gmail.com
---
 doc/src/sgml/ddl.sgml                         |   9 +-
 doc/src/sgml/ref/create_foreign_table.sgml    |  13 +-
 doc/src/sgml/ref/create_table.sgml            |   4 +-
 src/backend/executor/execExprInterp.c         |   7 +-
 src/backend/executor/execMain.c               |  21 ++-
 src/backend/executor/execReplication.c        |  12 ++
 src/backend/executor/nodeModifyTable.c        | 131 +++++++++++++++++-
 src/include/executor/nodeModifyTable.h        |   4 +
 src/include/nodes/execnodes.h                 |  10 ++
 .../regress/expected/generated_virtual.out    |  22 ++-
 src/test/regress/sql/generated_virtual.sql    |   4 +-
 11 files changed, 201 insertions(+), 36 deletions(-)

diff --git a/doc/src/sgml/ddl.sgml b/doc/src/sgml/ddl.sgml
index cb8bf7965cd..32112157952 100644
--- a/doc/src/sgml/ddl.sgml
+++ b/doc/src/sgml/ddl.sgml
@@ -357,10 +357,11 @@ INSERT INTO people (id, name, address) VALUES (<emphasis>DEFAULT</emphasis>, 'C'
    columns.  Thus, it is for columns what a view is for tables.  There are two
    kinds of generated columns: stored and virtual.  A stored generated column
    is computed when it is written (inserted or updated) and occupies storage
-   as if it were a normal column.  A virtual generated column occupies no
-   storage and is computed when it is read.  Thus, a virtual generated column
-   is similar to a view and a stored generated column is similar to a
-   materialized view (except that it is always updated automatically).
+   as if it were a normal column.  A virtual generated column is also computed when
+   it is written, but occupies no storage and is recomputed on every read.
+   Thus, a virtual generated column is similar to a view and a stored generated
+   column is similar to a materialized view (except that it is always updated
+   automatically)
   </para>
 
   <para>
diff --git a/doc/src/sgml/ref/create_foreign_table.sgml b/doc/src/sgml/ref/create_foreign_table.sgml
index 083f16772b7..e85579be18f 100644
--- a/doc/src/sgml/ref/create_foreign_table.sgml
+++ b/doc/src/sgml/ref/create_foreign_table.sgml
@@ -403,12 +403,13 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
 
      <para>
       When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read.  (The foreign-data wrapper will see it as a
-      null value in new rows and may choose to store it as a null value or
-      ignore it altogether.)  When <literal>STORED</literal> is specified, the
-      column will be computed on write.  (The computed value will be presented
-      to the foreign-data wrapper for storage and must be returned on
-      reading.)  <literal>VIRTUAL</literal> is the default.
+      computed when it is written, but it will not occupy any storage.
+      (The foreign-data wrapper will see it as a null value in new rows
+      and may choose to store it as a null value or ignore it altogether.)
+      When <literal>STORED</literal> is specified, the column will be computed
+      on write.  (The computed value will be presented to the foreign-data
+      wrapper for storage and must be returned on reading.)
+      <literal>VIRTUAL</literal> is the default.
      </para>
 
      <para>
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index e342585c7f0..b5d214e3611 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -933,8 +933,8 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
      </para>
 
      <para>
-      When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read, and it will not occupy any storage.  When
+      When <literal>VIRTUAL</literal> is specified, the column will be computed
+      when it is written and read, but it will not occupy any storage.  When
       <literal>STORED</literal> is specified, the column will be computed on
       write and will be stored on disk.  <literal>VIRTUAL</literal> is the
       default.
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 397219f7a3a..3e49d5bf267 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -2415,9 +2415,10 @@ CheckVarSlotCompatibility(TupleTableSlot *slot, int attnum, Oid vartype)
 
 		attr = TupleDescAttr(slot_tupdesc, attnum - 1);
 
-		/* Internal error: somebody forgot to expand it. */
-		if (attr->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-			elog(ERROR, "unexpected virtual generated column reference");
+		/*
+		 * Virtual generated columns are now computed and stored in the slot
+		 * by ExecComputeVirtualGenerated(), so reading them is valid.
+		 */
 
 		if (attr->attisdropped)
 			ereport(ERROR,
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index 6e47856cf25..5cbaf11e93e 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -53,6 +53,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -1357,6 +1359,8 @@ InitResultRelInfo(ResultRelInfo *resultRelInfo,
 	resultRelInfo->ri_GenVirtualNotNullConstraintExprs = NULL;
 	resultRelInfo->ri_GeneratedExprsI = NULL;
 	resultRelInfo->ri_GeneratedExprsU = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsI = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsU = NULL;
 	resultRelInfo->ri_projectReturning = NULL;
 	resultRelInfo->ri_onConflictArbiterIndexes = NIL;
 	resultRelInfo->ri_onConflict = NULL;
@@ -1893,7 +1897,13 @@ ExecRelCheck(ResultRelInfo *resultRelInfo,
 				continue;
 
 			checkconstr = stringToNode(check[i].ccbin);
-			checkconstr = (Expr *) expand_generated_columns_in_expr((Node *) checkconstr, rel, 1);
+
+			/*
+			 * No need to call expand_generated_columns_in_expr() here.
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated() before we get here, so
+			 * the expression evaluator can read them directly from the slot.
+			 */
 			resultRelInfo->ri_CheckConstraintExprs[i] =
 				ExecPrepareExpr(checkconstr, estate);
 		}
@@ -2554,9 +2564,12 @@ ExecBuildSlotValueDescription(Oid reloid,
 
 		if (table_perm || column_perm)
 		{
-			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
-			else if (slot->tts_isnull[i])
+			/*
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated(), so we can read them
+			 * directly like regular columns.
+			 */
+			if (slot->tts_isnull[i])
 				val = "null";
 			else
 			{
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index b2ca5cbf117..640c0ff58cf 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -838,6 +838,12 @@ ExecSimpleRelationInsert(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
@@ -942,6 +948,12 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_UPDATE);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_UPDATE);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 3056b850f73..9683de4dc04 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -439,8 +439,10 @@ ExecCheckTIDVisible(EState *estate,
  * Initialize generated columns handling for a tuple
  *
  * This fills the resultRelInfo's ri_GeneratedExprsI/ri_NumGeneratedNeededI or
- * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields, depending on cmdtype.
- * This is used only for stored generated columns.
+ * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields for stored generated
+ * columns, and ri_VirtualGeneratedExprsI/ri_NumVirtualGeneratedNeededI or
+ * ri_VirtualGeneratedExprsU/ri_NumVirtualGeneratedNeededU fields for virtual
+ * generated columns, depending on cmdtype.
  *
  * If cmdType == CMD_UPDATE, the ri_extraUpdatedCols field is filled too.
  * This is used by both stored and virtual generated columns.
@@ -460,6 +462,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	int			natts = tupdesc->natts;
 	ExprState **ri_GeneratedExprs;
 	int			ri_NumGeneratedNeeded;
+	ExprState **ri_VirtualGeneratedExprs;
+	int			ri_NumVirtualGeneratedNeeded;
 	Bitmapset  *updatedCols;
 	MemoryContext oldContext;
 
@@ -487,6 +491,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 
 	ri_GeneratedExprs = palloc0_array(ExprState *, natts);
 	ri_NumGeneratedNeeded = 0;
+	ri_VirtualGeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
+	ri_NumVirtualGeneratedNeeded = 0;
 
 	for (int i = 0; i < natts; i++)
 	{
@@ -522,6 +528,11 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 				ri_GeneratedExprs[i] = ExecPrepareExpr(expr, estate);
 				ri_NumGeneratedNeeded++;
 			}
+			else if (attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
+			{
+				ri_VirtualGeneratedExprs[i] = ExecPrepareExpr(expr, estate);
+				ri_NumVirtualGeneratedNeeded++;
+			}
 
 			/* If UPDATE, mark column in resultRelInfo->ri_extraUpdatedCols */
 			if (cmdtype == CMD_UPDATE)
@@ -538,14 +549,24 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 		ri_GeneratedExprs = NULL;
 	}
 
+	if (ri_NumVirtualGeneratedNeeded == 0)
+	{
+		/* didn't need it after all */
+		pfree(ri_VirtualGeneratedExprs);
+		ri_VirtualGeneratedExprs = NULL;
+	}
+
 	/* Save in appropriate set of fields */
 	if (cmdtype == CMD_UPDATE)
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsU == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsU == NULL);
 
 		resultRelInfo->ri_GeneratedExprsU = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededU = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsU = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededU = ri_NumVirtualGeneratedNeeded;
 
 		resultRelInfo->ri_extraUpdatedCols_valid = true;
 	}
@@ -553,9 +574,12 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsI == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsI == NULL);
 
 		resultRelInfo->ri_GeneratedExprsI = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededI = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsI = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededI = ri_NumVirtualGeneratedNeeded;
 	}
 
 	MemoryContextSwitchTo(oldContext);
@@ -651,6 +675,85 @@ ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 	MemoryContextSwitchTo(oldContext);
 }
 
+/*
+ * Compute virtual generated columns for a tuple
+ *
+ * This evaluates the generation expressions for virtual generated columns
+ * and stores the results directly in the slot. Unlike stored generated
+ * columns, these values are not persisted to disk but are computed for
+ * use in constraint checking and error messages.
+ */
+void
+ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+							EState *estate, TupleTableSlot *slot,
+							CmdType cmdtype)
+{
+	Relation	rel = resultRelInfo->ri_RelationDesc;
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	int			natts = tupdesc->natts;
+	ExprContext *econtext = GetPerTupleExprContext(estate);
+	ExprState **ri_VirtualGeneratedExprs;
+	MemoryContext oldContext;
+
+	/* We should not be called unless this is true */
+	Assert(tupdesc->constr && tupdesc->constr->has_generated_virtual);
+
+	/*
+	 * Initialize the expressions if we didn't already, and check whether we
+	 * can exit early because nothing needs to be computed.
+	 */
+	if (cmdtype == CMD_UPDATE)
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsU == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededU == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsU;
+	}
+	else
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsI == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededI == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsI;
+	}
+
+	oldContext = MemoryContextSwitchTo(GetPerTupleMemoryContext(estate));
+
+	/* Make sure all base column values are available */
+	slot_getallattrs(slot);
+
+	econtext->ecxt_scantuple = slot;
+
+	for (int i = 0; i < natts; i++)
+	{
+		CompactAttribute *attr = TupleDescCompactAttr(tupdesc, i);
+
+		if (ri_VirtualGeneratedExprs[i])
+		{
+			Datum		val;
+			bool		isnull;
+
+			Assert(TupleDescAttr(tupdesc, i)->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL);
+
+			val = ExecEvalExpr(ri_VirtualGeneratedExprs[i], econtext, &isnull);
+
+			/*
+			 * We must make a copy of val as we have no guarantees about where
+			 * memory for a pass-by-reference Datum is located.
+			 */
+			if (!isnull)
+				val = datumCopy(val, attr->attbyval, attr->attlen);
+
+			slot->tts_values[i] = val;
+			slot->tts_isnull[i] = isnull;
+		}
+	}
+
+	MemoryContextSwitchTo(oldContext);
+}
+
 /*
  * ExecInitInsertProjection
  *		Do one-time initialization of projection data for INSERT tuples.
@@ -959,6 +1062,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * If the FDW supports batching, and batching is requested, accumulate
 		 * rows and insert them in batches. Otherwise use the per-row inserts.
@@ -1084,6 +1195,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * Check any RLS WITH CHECK policies.
 		 *
@@ -2439,6 +2558,14 @@ ExecUpdatePrepareSlot(ResultRelInfo *resultRelInfo,
 		resultRelationDesc->rd_att->constr->has_generated_stored)
 		ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 								   CMD_UPDATE);
+
+	/*
+	 * Compute virtual generated columns
+	 */
+	if (resultRelationDesc->rd_att->constr &&
+		resultRelationDesc->rd_att->constr->has_generated_virtual)
+		ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+									CMD_UPDATE);
 }
 
 /*
diff --git a/src/include/executor/nodeModifyTable.h b/src/include/executor/nodeModifyTable.h
index f6070e1cdf3..7cbbe8c1a1f 100644
--- a/src/include/executor/nodeModifyTable.h
+++ b/src/include/executor/nodeModifyTable.h
@@ -23,6 +23,10 @@ extern void ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 									   EState *estate, TupleTableSlot *slot,
 									   CmdType cmdtype);
 
+extern void ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+										EState *estate, TupleTableSlot *slot,
+										CmdType cmdtype);
+
 extern ModifyTableState *ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags);
 extern void ExecEndModifyTable(ModifyTableState *node);
 extern void ExecReScanModifyTable(ModifyTableState *node);
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index f0cb21444b2..e3ec3192924 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -603,6 +603,16 @@ typedef struct ResultRelInfo
 	int			ri_NumGeneratedNeededI;
 	int			ri_NumGeneratedNeededU;
 
+	/*
+	 * Arrays of virtual generated columns ExprStates for INSERT/UPDATE/MERGE.
+	 */
+	ExprState **ri_VirtualGeneratedExprsI;
+	ExprState **ri_VirtualGeneratedExprsU;
+
+	/* number of virtual generated columns we need to compute */
+	int			ri_NumVirtualGeneratedNeededI;
+	int			ri_NumVirtualGeneratedNeededU;
+
 	/* list of RETURNING expressions */
 	List	   *ri_returningList;
 
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 6ee029796f1..43f5cd8dc78 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -166,11 +166,9 @@ SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
  2 | 4
 (1 row)
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
 ERROR:  integer out of range
-DELETE FROM gtest1 WHERE a = 2000000000;
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
 INSERT INTO gtestx VALUES (11, 1), (22, 2), (33, 3);
@@ -647,7 +645,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, 60).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint
 ERROR:  check constraint "gtest20_b_check" of relation "gtest20" is violated by some row
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 3);  -- ok
@@ -693,7 +691,7 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest20c ALTER COLUMN b SET EXPRESSION AS (NULL::int);  -- violates constraint
 ERROR:  check constraint "whole_row_check" of relation "gtest20c" is violated by some row
 -- index with whole-row reference needs rebuild
@@ -708,12 +706,12 @@ CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -723,17 +721,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- virtual generated columns are not physically stored, even when not null
@@ -790,10 +788,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, null).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, null).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/sql/generated_virtual.sql b/src/test/regress/sql/generated_virtual.sql
index e4ea63bb3a1..3cd013d150b 100644
--- a/src/test/regress/sql/generated_virtual.sql
+++ b/src/test/regress/sql/generated_virtual.sql
@@ -71,10 +71,8 @@ SELECT * FROM gtest1 ORDER BY a;
 SELECT a, b, b * 2 AS b2 FROM gtest1 ORDER BY a;
 SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
-DELETE FROM gtest1 WHERE a = 2000000000;
 
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
-- 
2.50.1 (Apple Git-155)



Attachments:

  [text/plain] v7-0001-Compute-virtual-generated-columns-during-INSERT-U.patch (23.2K, ../../b63bb75e-b2fb-4691-8f2e-8c5562f38031@gmail.com/2-v7-0001-Compute-virtual-generated-columns-during-INSERT-U.patch)
  download | inline diff:
From 5ca633f056bdc390db8ba5892490cb51777e2823 Mon Sep 17 00:00:00 2001
From: Matheus Alcantara <mths.dev@pm.me>
Date: Fri, 28 Aug 2026 08:14:30 -0300
Subject: [PATCH v7] Compute virtual generated columns during INSERT/UPDATE

Previously, virtual generated column values were never computed and
stored in the slot. Instead, references to e.g virtual columns in check
constraints were expanded to their generation expressions during
ExecCheck().

This commit changes the approach to compute virtual generated column
values and store them in the slot before constraint checking, similar
to how stored generated columns are handled. This has two benefits:

Check constraints can now read virtual column values directly from
the slot, simplifying constraint evaluation. It also avoid re-execute
the virtual expression for each check constraint evaluation.

Error messages now display the actual computed value (e.g., "10")
instead of the "virtual" string, making them more consistent with
stored generated columns and easier to understand.

Discussion: https://www.postgresql.org/message-id/DG5DV8SED62G.2WFJB46D7WIT8%40gmail.com
---
 doc/src/sgml/ddl.sgml                         |   9 +-
 doc/src/sgml/ref/create_foreign_table.sgml    |  13 +-
 doc/src/sgml/ref/create_table.sgml            |   4 +-
 src/backend/executor/execExprInterp.c         |   7 +-
 src/backend/executor/execMain.c               |  21 ++-
 src/backend/executor/execReplication.c        |  12 ++
 src/backend/executor/nodeModifyTable.c        | 131 +++++++++++++++++-
 src/include/executor/nodeModifyTable.h        |   4 +
 src/include/nodes/execnodes.h                 |  10 ++
 .../regress/expected/generated_virtual.out    |  22 ++-
 src/test/regress/sql/generated_virtual.sql    |   4 +-
 11 files changed, 201 insertions(+), 36 deletions(-)

diff --git a/doc/src/sgml/ddl.sgml b/doc/src/sgml/ddl.sgml
index cb8bf7965cd..32112157952 100644
--- a/doc/src/sgml/ddl.sgml
+++ b/doc/src/sgml/ddl.sgml
@@ -357,10 +357,11 @@ INSERT INTO people (id, name, address) VALUES (<emphasis>DEFAULT</emphasis>, 'C'
    columns.  Thus, it is for columns what a view is for tables.  There are two
    kinds of generated columns: stored and virtual.  A stored generated column
    is computed when it is written (inserted or updated) and occupies storage
-   as if it were a normal column.  A virtual generated column occupies no
-   storage and is computed when it is read.  Thus, a virtual generated column
-   is similar to a view and a stored generated column is similar to a
-   materialized view (except that it is always updated automatically).
+   as if it were a normal column.  A virtual generated column is also computed when
+   it is written, but occupies no storage and is recomputed on every read.
+   Thus, a virtual generated column is similar to a view and a stored generated
+   column is similar to a materialized view (except that it is always updated
+   automatically)
   </para>
 
   <para>
diff --git a/doc/src/sgml/ref/create_foreign_table.sgml b/doc/src/sgml/ref/create_foreign_table.sgml
index 083f16772b7..e85579be18f 100644
--- a/doc/src/sgml/ref/create_foreign_table.sgml
+++ b/doc/src/sgml/ref/create_foreign_table.sgml
@@ -403,12 +403,13 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
 
      <para>
       When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read.  (The foreign-data wrapper will see it as a
-      null value in new rows and may choose to store it as a null value or
-      ignore it altogether.)  When <literal>STORED</literal> is specified, the
-      column will be computed on write.  (The computed value will be presented
-      to the foreign-data wrapper for storage and must be returned on
-      reading.)  <literal>VIRTUAL</literal> is the default.
+      computed when it is written, but it will not occupy any storage.
+      (The foreign-data wrapper will see it as a null value in new rows
+      and may choose to store it as a null value or ignore it altogether.)
+      When <literal>STORED</literal> is specified, the column will be computed
+      on write.  (The computed value will be presented to the foreign-data
+      wrapper for storage and must be returned on reading.)
+      <literal>VIRTUAL</literal> is the default.
      </para>
 
      <para>
diff --git a/doc/src/sgml/ref/create_table.sgml b/doc/src/sgml/ref/create_table.sgml
index e342585c7f0..b5d214e3611 100644
--- a/doc/src/sgml/ref/create_table.sgml
+++ b/doc/src/sgml/ref/create_table.sgml
@@ -933,8 +933,8 @@ WITH ( MODULUS <replaceable class="parameter">numeric_literal</replaceable>, REM
      </para>
 
      <para>
-      When <literal>VIRTUAL</literal> is specified, the column will be
-      computed when it is read, and it will not occupy any storage.  When
+      When <literal>VIRTUAL</literal> is specified, the column will be computed
+      when it is written and read, but it will not occupy any storage.  When
       <literal>STORED</literal> is specified, the column will be computed on
       write and will be stored on disk.  <literal>VIRTUAL</literal> is the
       default.
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 397219f7a3a..3e49d5bf267 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -2415,9 +2415,10 @@ CheckVarSlotCompatibility(TupleTableSlot *slot, int attnum, Oid vartype)
 
 		attr = TupleDescAttr(slot_tupdesc, attnum - 1);
 
-		/* Internal error: somebody forgot to expand it. */
-		if (attr->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-			elog(ERROR, "unexpected virtual generated column reference");
+		/*
+		 * Virtual generated columns are now computed and stored in the slot
+		 * by ExecComputeVirtualGenerated(), so reading them is valid.
+		 */
 
 		if (attr->attisdropped)
 			ereport(ERROR,
diff --git a/src/backend/executor/execMain.c b/src/backend/executor/execMain.c
index 6e47856cf25..5cbaf11e93e 100644
--- a/src/backend/executor/execMain.c
+++ b/src/backend/executor/execMain.c
@@ -53,6 +53,8 @@
 #include "foreign/fdwapi.h"
 #include "mb/pg_wchar.h"
 #include "miscadmin.h"
+#include "nodes/makefuncs.h"
+#include "nodes/nodeFuncs.h"
 #include "nodes/queryjumble.h"
 #include "parser/parse_relation.h"
 #include "pgstat.h"
@@ -1357,6 +1359,8 @@ InitResultRelInfo(ResultRelInfo *resultRelInfo,
 	resultRelInfo->ri_GenVirtualNotNullConstraintExprs = NULL;
 	resultRelInfo->ri_GeneratedExprsI = NULL;
 	resultRelInfo->ri_GeneratedExprsU = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsI = NULL;
+	resultRelInfo->ri_VirtualGeneratedExprsU = NULL;
 	resultRelInfo->ri_projectReturning = NULL;
 	resultRelInfo->ri_onConflictArbiterIndexes = NIL;
 	resultRelInfo->ri_onConflict = NULL;
@@ -1893,7 +1897,13 @@ ExecRelCheck(ResultRelInfo *resultRelInfo,
 				continue;
 
 			checkconstr = stringToNode(check[i].ccbin);
-			checkconstr = (Expr *) expand_generated_columns_in_expr((Node *) checkconstr, rel, 1);
+
+			/*
+			 * No need to call expand_generated_columns_in_expr() here.
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated() before we get here, so
+			 * the expression evaluator can read them directly from the slot.
+			 */
 			resultRelInfo->ri_CheckConstraintExprs[i] =
 				ExecPrepareExpr(checkconstr, estate);
 		}
@@ -2554,9 +2564,12 @@ ExecBuildSlotValueDescription(Oid reloid,
 
 		if (table_perm || column_perm)
 		{
-			if (att->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
-				val = "virtual";
-			else if (slot->tts_isnull[i])
+			/*
+			 * Virtual generated column values are computed and stored in the
+			 * slot by ExecComputeVirtualGenerated(), so we can read them
+			 * directly like regular columns.
+			 */
+			if (slot->tts_isnull[i])
 				val = "null";
 			else
 			{
diff --git a/src/backend/executor/execReplication.c b/src/backend/executor/execReplication.c
index b2ca5cbf117..640c0ff58cf 100644
--- a/src/backend/executor/execReplication.c
+++ b/src/backend/executor/execReplication.c
@@ -838,6 +838,12 @@ ExecSimpleRelationInsert(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
@@ -942,6 +948,12 @@ ExecSimpleRelationUpdate(ResultRelInfo *resultRelInfo,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_UPDATE);
 
+		/* Compute virtual generated columns */
+		if (rel->rd_att->constr &&
+			rel->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_UPDATE);
+
 		/* Check the constraints of the tuple */
 		if (rel->rd_att->constr)
 			ExecConstraints(resultRelInfo, slot, estate);
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 3056b850f73..9683de4dc04 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -439,8 +439,10 @@ ExecCheckTIDVisible(EState *estate,
  * Initialize generated columns handling for a tuple
  *
  * This fills the resultRelInfo's ri_GeneratedExprsI/ri_NumGeneratedNeededI or
- * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields, depending on cmdtype.
- * This is used only for stored generated columns.
+ * ri_GeneratedExprsU/ri_NumGeneratedNeededU fields for stored generated
+ * columns, and ri_VirtualGeneratedExprsI/ri_NumVirtualGeneratedNeededI or
+ * ri_VirtualGeneratedExprsU/ri_NumVirtualGeneratedNeededU fields for virtual
+ * generated columns, depending on cmdtype.
  *
  * If cmdType == CMD_UPDATE, the ri_extraUpdatedCols field is filled too.
  * This is used by both stored and virtual generated columns.
@@ -460,6 +462,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	int			natts = tupdesc->natts;
 	ExprState **ri_GeneratedExprs;
 	int			ri_NumGeneratedNeeded;
+	ExprState **ri_VirtualGeneratedExprs;
+	int			ri_NumVirtualGeneratedNeeded;
 	Bitmapset  *updatedCols;
 	MemoryContext oldContext;
 
@@ -487,6 +491,8 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 
 	ri_GeneratedExprs = palloc0_array(ExprState *, natts);
 	ri_NumGeneratedNeeded = 0;
+	ri_VirtualGeneratedExprs = (ExprState **) palloc0(natts * sizeof(ExprState *));
+	ri_NumVirtualGeneratedNeeded = 0;
 
 	for (int i = 0; i < natts; i++)
 	{
@@ -522,6 +528,11 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 				ri_GeneratedExprs[i] = ExecPrepareExpr(expr, estate);
 				ri_NumGeneratedNeeded++;
 			}
+			else if (attgenerated == ATTRIBUTE_GENERATED_VIRTUAL)
+			{
+				ri_VirtualGeneratedExprs[i] = ExecPrepareExpr(expr, estate);
+				ri_NumVirtualGeneratedNeeded++;
+			}
 
 			/* If UPDATE, mark column in resultRelInfo->ri_extraUpdatedCols */
 			if (cmdtype == CMD_UPDATE)
@@ -538,14 +549,24 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 		ri_GeneratedExprs = NULL;
 	}
 
+	if (ri_NumVirtualGeneratedNeeded == 0)
+	{
+		/* didn't need it after all */
+		pfree(ri_VirtualGeneratedExprs);
+		ri_VirtualGeneratedExprs = NULL;
+	}
+
 	/* Save in appropriate set of fields */
 	if (cmdtype == CMD_UPDATE)
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsU == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsU == NULL);
 
 		resultRelInfo->ri_GeneratedExprsU = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededU = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsU = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededU = ri_NumVirtualGeneratedNeeded;
 
 		resultRelInfo->ri_extraUpdatedCols_valid = true;
 	}
@@ -553,9 +574,12 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	{
 		/* Don't call twice */
 		Assert(resultRelInfo->ri_GeneratedExprsI == NULL);
+		Assert(resultRelInfo->ri_VirtualGeneratedExprsI == NULL);
 
 		resultRelInfo->ri_GeneratedExprsI = ri_GeneratedExprs;
 		resultRelInfo->ri_NumGeneratedNeededI = ri_NumGeneratedNeeded;
+		resultRelInfo->ri_VirtualGeneratedExprsI = ri_VirtualGeneratedExprs;
+		resultRelInfo->ri_NumVirtualGeneratedNeededI = ri_NumVirtualGeneratedNeeded;
 	}
 
 	MemoryContextSwitchTo(oldContext);
@@ -651,6 +675,85 @@ ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 	MemoryContextSwitchTo(oldContext);
 }
 
+/*
+ * Compute virtual generated columns for a tuple
+ *
+ * This evaluates the generation expressions for virtual generated columns
+ * and stores the results directly in the slot. Unlike stored generated
+ * columns, these values are not persisted to disk but are computed for
+ * use in constraint checking and error messages.
+ */
+void
+ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+							EState *estate, TupleTableSlot *slot,
+							CmdType cmdtype)
+{
+	Relation	rel = resultRelInfo->ri_RelationDesc;
+	TupleDesc	tupdesc = RelationGetDescr(rel);
+	int			natts = tupdesc->natts;
+	ExprContext *econtext = GetPerTupleExprContext(estate);
+	ExprState **ri_VirtualGeneratedExprs;
+	MemoryContext oldContext;
+
+	/* We should not be called unless this is true */
+	Assert(tupdesc->constr && tupdesc->constr->has_generated_virtual);
+
+	/*
+	 * Initialize the expressions if we didn't already, and check whether we
+	 * can exit early because nothing needs to be computed.
+	 */
+	if (cmdtype == CMD_UPDATE)
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsU == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededU == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsU;
+	}
+	else
+	{
+		if (resultRelInfo->ri_VirtualGeneratedExprsI == NULL)
+			ExecInitGenerated(resultRelInfo, estate, cmdtype);
+		if (resultRelInfo->ri_NumVirtualGeneratedNeededI == 0)
+			return;
+		ri_VirtualGeneratedExprs = resultRelInfo->ri_VirtualGeneratedExprsI;
+	}
+
+	oldContext = MemoryContextSwitchTo(GetPerTupleMemoryContext(estate));
+
+	/* Make sure all base column values are available */
+	slot_getallattrs(slot);
+
+	econtext->ecxt_scantuple = slot;
+
+	for (int i = 0; i < natts; i++)
+	{
+		CompactAttribute *attr = TupleDescCompactAttr(tupdesc, i);
+
+		if (ri_VirtualGeneratedExprs[i])
+		{
+			Datum		val;
+			bool		isnull;
+
+			Assert(TupleDescAttr(tupdesc, i)->attgenerated == ATTRIBUTE_GENERATED_VIRTUAL);
+
+			val = ExecEvalExpr(ri_VirtualGeneratedExprs[i], econtext, &isnull);
+
+			/*
+			 * We must make a copy of val as we have no guarantees about where
+			 * memory for a pass-by-reference Datum is located.
+			 */
+			if (!isnull)
+				val = datumCopy(val, attr->attbyval, attr->attlen);
+
+			slot->tts_values[i] = val;
+			slot->tts_isnull[i] = isnull;
+		}
+	}
+
+	MemoryContextSwitchTo(oldContext);
+}
+
 /*
  * ExecInitInsertProjection
  *		Do one-time initialization of projection data for INSERT tuples.
@@ -959,6 +1062,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * If the FDW supports batching, and batching is requested, accumulate
 		 * rows and insert them in batches. Otherwise use the per-row inserts.
@@ -1084,6 +1195,14 @@ ExecInsert(ModifyTableContext *context,
 			ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 									   CMD_INSERT);
 
+		/*
+		 * Compute virtual generated columns
+		 */
+		if (resultRelationDesc->rd_att->constr &&
+			resultRelationDesc->rd_att->constr->has_generated_virtual)
+			ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+										CMD_INSERT);
+
 		/*
 		 * Check any RLS WITH CHECK policies.
 		 *
@@ -2439,6 +2558,14 @@ ExecUpdatePrepareSlot(ResultRelInfo *resultRelInfo,
 		resultRelationDesc->rd_att->constr->has_generated_stored)
 		ExecComputeStoredGenerated(resultRelInfo, estate, slot,
 								   CMD_UPDATE);
+
+	/*
+	 * Compute virtual generated columns
+	 */
+	if (resultRelationDesc->rd_att->constr &&
+		resultRelationDesc->rd_att->constr->has_generated_virtual)
+		ExecComputeVirtualGenerated(resultRelInfo, estate, slot,
+									CMD_UPDATE);
 }
 
 /*
diff --git a/src/include/executor/nodeModifyTable.h b/src/include/executor/nodeModifyTable.h
index f6070e1cdf3..7cbbe8c1a1f 100644
--- a/src/include/executor/nodeModifyTable.h
+++ b/src/include/executor/nodeModifyTable.h
@@ -23,6 +23,10 @@ extern void ExecComputeStoredGenerated(ResultRelInfo *resultRelInfo,
 									   EState *estate, TupleTableSlot *slot,
 									   CmdType cmdtype);
 
+extern void ExecComputeVirtualGenerated(ResultRelInfo *resultRelInfo,
+										EState *estate, TupleTableSlot *slot,
+										CmdType cmdtype);
+
 extern ModifyTableState *ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags);
 extern void ExecEndModifyTable(ModifyTableState *node);
 extern void ExecReScanModifyTable(ModifyTableState *node);
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index f0cb21444b2..e3ec3192924 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -603,6 +603,16 @@ typedef struct ResultRelInfo
 	int			ri_NumGeneratedNeededI;
 	int			ri_NumGeneratedNeededU;
 
+	/*
+	 * Arrays of virtual generated columns ExprStates for INSERT/UPDATE/MERGE.
+	 */
+	ExprState **ri_VirtualGeneratedExprsI;
+	ExprState **ri_VirtualGeneratedExprsU;
+
+	/* number of virtual generated columns we need to compute */
+	int			ri_NumVirtualGeneratedNeededI;
+	int			ri_NumVirtualGeneratedNeededU;
+
 	/* list of RETURNING expressions */
 	List	   *ri_returningList;
 
diff --git a/src/test/regress/expected/generated_virtual.out b/src/test/regress/expected/generated_virtual.out
index 6ee029796f1..43f5cd8dc78 100644
--- a/src/test/regress/expected/generated_virtual.out
+++ b/src/test/regress/expected/generated_virtual.out
@@ -166,11 +166,9 @@ SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
  2 | 4
 (1 row)
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
 ERROR:  integer out of range
-DELETE FROM gtest1 WHERE a = 2000000000;
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
 INSERT INTO gtestx VALUES (11, 1), (22, 2), (33, 3);
@@ -647,7 +645,7 @@ CREATE TABLE gtest20 (a int PRIMARY KEY, b int GENERATED ALWAYS AS (a * 2) VIRTU
 INSERT INTO gtest20 (a) VALUES (10);  -- ok
 INSERT INTO gtest20 (a) VALUES (30);  -- violates constraint
 ERROR:  new row for relation "gtest20" violates check constraint "gtest20_b_check"
-DETAIL:  Failing row contains (30, virtual).
+DETAIL:  Failing row contains (30, 60).
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 100);  -- violates constraint
 ERROR:  check constraint "gtest20_b_check" of relation "gtest20" is violated by some row
 ALTER TABLE gtest20 ALTER COLUMN b SET EXPRESSION AS (a * 3);  -- ok
@@ -693,7 +691,7 @@ ALTER TABLE gtest20c ADD CONSTRAINT whole_row_check CHECK (gtest20c IS NOT NULL)
 INSERT INTO gtest20c VALUES (1);  -- ok
 INSERT INTO gtest20c VALUES (NULL);  -- fails
 ERROR:  new row for relation "gtest20c" violates check constraint "whole_row_check"
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest20c ALTER COLUMN b SET EXPRESSION AS (NULL::int);  -- violates constraint
 ERROR:  check constraint "whole_row_check" of relation "gtest20c" is violated by some row
 -- index with whole-row reference needs rebuild
@@ -708,12 +706,12 @@ CREATE TABLE gtest21a (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0
 INSERT INTO gtest21a (a) VALUES (1);  -- ok
 INSERT INTO gtest21a (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21a" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 -- also check with table constraint syntax
 CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL, CONSTRAINT cc NOT NULL b);
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21ax (a) VALUES (1);  --ok
 -- SET EXPRESSION supports not null constraint
 ALTER TABLE gtest21ax ALTER COLUMN b SET EXPRESSION AS (nullif(a, 1)); --error
@@ -723,17 +721,17 @@ CREATE TABLE gtest21ax (a int PRIMARY KEY, b int GENERATED ALWAYS AS (nullif(a,
 ALTER TABLE gtest21ax ADD CONSTRAINT cc NOT NULL b;
 INSERT INTO gtest21ax (a) VALUES (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21ax" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 DROP TABLE gtest21ax;
 CREATE TABLE gtest21b (a int, b int GENERATED ALWAYS AS (nullif(a, 0)) VIRTUAL);
 ALTER TABLE gtest21b ALTER COLUMN b SET NOT NULL;
 INSERT INTO gtest21b (a) VALUES (1);  -- ok
 INSERT INTO gtest21b (a) VALUES (2), (0);  -- violates constraint
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (0, virtual).
+DETAIL:  Failing row contains (0, null).
 INSERT INTO gtest21b (a) VALUES (NULL);  -- error
 ERROR:  null value in column "b" of relation "gtest21b" violates not-null constraint
-DETAIL:  Failing row contains (null, virtual).
+DETAIL:  Failing row contains (null, null).
 ALTER TABLE gtest21b ALTER COLUMN b DROP NOT NULL;
 INSERT INTO gtest21b (a) VALUES (0);  -- ok now
 -- virtual generated columns are not physically stored, even when not null
@@ -790,10 +788,10 @@ CREATE TABLE gtestnn_childdef PARTITION OF gtestnn_parent default;
 INSERT INTO gtestnn_parent VALUES (2, 2, default), (3, 5, default), (14, 12, default);  -- ok
 INSERT INTO gtestnn_parent VALUES (1, 2, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (1, 2, virtual).
+DETAIL:  Failing row contains (1, 2, null).
 INSERT INTO gtestnn_parent VALUES (2, 10, default);  -- error
 ERROR:  null value in column "f3" of relation "gtestnn_child" violates not-null constraint
-DETAIL:  Failing row contains (2, 10, virtual).
+DETAIL:  Failing row contains (2, 10, null).
 ALTER TABLE gtestnn_parent ALTER COLUMN f3 SET EXPRESSION AS (nullif(f1, 2) + nullif(f2, 11));  -- error
 ERROR:  column "f3" of relation "gtestnn_child" contains null values
 INSERT INTO gtestnn_parent VALUES (10, 11, default);  -- ok
diff --git a/src/test/regress/sql/generated_virtual.sql b/src/test/regress/sql/generated_virtual.sql
index e4ea63bb3a1..3cd013d150b 100644
--- a/src/test/regress/sql/generated_virtual.sql
+++ b/src/test/regress/sql/generated_virtual.sql
@@ -71,10 +71,8 @@ SELECT * FROM gtest1 ORDER BY a;
 SELECT a, b, b * 2 AS b2 FROM gtest1 ORDER BY a;
 SELECT a, b FROM gtest1 WHERE b = 4 ORDER BY a;
 
--- test that overflow error happens on read
+-- test that overflow error happens on write
 INSERT INTO gtest1 VALUES (2000000000);
-SELECT * FROM gtest1;
-DELETE FROM gtest1 WHERE a = 2000000000;
 
 -- test with joins
 CREATE TABLE gtestx (x int, y int);
-- 
2.50.1 (Apple Git-155)



^ permalink  raw  reply  [nested|flat] 16+ messages in thread

* Re: Show expression of virtual columns in error messages
  2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-04 14:57 ` Re: Show expression of virtual columns in error messages Peter Eisentraut <peter@eisentraut.org>
  2026-02-04 17:54   ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-05 02:45     ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-05 18:51       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-06 03:54         ` Re: Show expression of virtual columns in error messages Yugo Nagata <nagata@sraoss.co.jp>
  2026-02-06 12:25           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:06             ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 18:47               ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  2026-02-26 20:29                 ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-02-26 22:14                   ` Re: Show expression of virtual columns in error messages Tom Lane <tgl@sss.pgh.pa.us>
  2026-03-03 17:01                     ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-03-16 15:36                       ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-07-10 21:13                         ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
  2026-09-01 20:10                           ` Re: Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
@ 2026-09-17 07:19                             ` solai v <solai.cdac@gmail.com>
  0 siblings, 0 replies; 16+ messages in thread

From: solai v @ 2026-09-17 07:19 UTC (permalink / raw)
  To: Matheus Alcantara <matheusssilv97@gmail.com>; +Cc: Tom Lane <tgl@sss.pgh.pa.us>; pgsql-hackers@lists.postgresql.org, Peter Eisentraut <peter@eisentraut.org>; Yugo Nagata <nagata@sraoss.co.jp>

Hi all,

On Tue, Sep 15, 2026 at 5:55 PM Matheus Alcantara
<matheusssilv97@gmail.com> wrote:
>
> On 10/07/26 18:13, Matheus Alcantara wrote:
> > On Mon Mar 16, 2026 at 12:36 PM -03, Matheus Alcantara wrote:
> >> Attaching v5 with documentation changes included and rebased with master.
> >>
> >
> > Attaching v6 rebased due to changes on a4639d64e21
> >
>
> Attaching v7 rebased with master
>
>
> --

I reviewed and tested the v7 patch and tested the main cases related
to virtual generated columns and found the behavior to be as expected.
I verified that:

1. CHECK constraint errors now show the actual value of the virtual
generated column instead of virtual.
2. NULL/NOT NULL cases correctly show null in the failing row.
3. Overflow from a virtual generated column is now detected during
INSERT itself instead of when the value is read.
4. UPDATE and UPDATE with CHECK constraint work as expected.
5. INSERT ... ON CONFLICT DO UPDATE correctly computes the virtual
column and reports its value in the error.
6. Both MERGE ... WHEN MATCHED THEN UPDATE and MERGE ... WHEN NOT
MATCHED THEN INSERT works correctly.
7. Pass-by-reference values were also tested using a virtual text
generated column.
8. The generated_virtual regression test passed successfully without any issues.

I also went through the relevant executor changes. The virtual
generated columns are computed before constraint checking in the
INSERT/UPDATE paths, and the computed values are available in the
tuple slot for both constraint checking and error reporting. The
corresponding simple replication INSERT/UPDATE paths have also been
updated. One observation I noticed while testing the existing
partition_merge regression test is that the error detail now shows the
computed value instead of virtual. Since the virtual expression uses
tableoid::int, the value is OID-dependent and changes between runs. I
have therefore not modified partition_merge.out with a hard-coded
value. The patch itself does not include a change to that expected
file.
Based on the above testing, the patch behaves as intended in the cases I tested.


Regards
Solai






^ permalink  raw  reply  [nested|flat] 16+ messages in thread


end of thread, other threads:[~2026-09-17 07:19 UTC | newest]

Thread overview: 16+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2026-02-03 14:06 Show expression of virtual columns in error messages Matheus Alcantara <matheusssilv97@gmail.com>
2026-02-04 14:57 ` Peter Eisentraut <peter@eisentraut.org>
2026-02-04 17:54   ` Matheus Alcantara <matheusssilv97@gmail.com>
2026-02-05 02:45     ` Yugo Nagata <nagata@sraoss.co.jp>
2026-02-05 18:51       ` Matheus Alcantara <matheusssilv97@gmail.com>
2026-02-06 03:54         ` Yugo Nagata <nagata@sraoss.co.jp>
2026-02-06 12:25           ` Matheus Alcantara <matheusssilv97@gmail.com>
2026-02-26 18:06             ` Matheus Alcantara <matheusssilv97@gmail.com>
2026-02-26 18:47               ` Tom Lane <tgl@sss.pgh.pa.us>
2026-02-26 20:29                 ` Matheus Alcantara <matheusssilv97@gmail.com>
2026-02-26 22:14                   ` Tom Lane <tgl@sss.pgh.pa.us>
2026-03-03 17:01                     ` Matheus Alcantara <matheusssilv97@gmail.com>
2026-03-16 15:36                       ` Matheus Alcantara <matheusssilv97@gmail.com>
2026-07-10 21:13                         ` Matheus Alcantara <matheusssilv97@gmail.com>
2026-09-01 20:10                           ` Matheus Alcantara <matheusssilv97@gmail.com>
2026-09-17 07:19                             ` solai v <solai.cdac@gmail.com>

This inbox is served by DDX for PostgreSQL; see mirroring instructions
for how to clone and mirror all data and code used for this inbox