Subject: Show expression of virtual columns in error messages

From: "Matheus Alcantara" <[email-masked]>
Date: Tue, 03 Feb 2026 11:06:47 -0300

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 <[email-masked]>
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

---

From: Peter Eisentraut <[email-masked]>
Date: Wed, 4 Feb 2026 15:57:49 +0100

>    DETAIL:  Failing row contains (5, 10, a * 2).

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