Subject: [PATCH v1 1/2] Support changing a column into a stored generated column

From: Alberto Piai <[email-masked]>
Date: Tue, 17 Mar 2026 00:25:20 +0100

This adds basic support for an ALTER TABLE ... ALTER COLUMN command to
turn a regular column into a stored generated column.

The syntax is chosen to be similar to

... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY

with the difference that in this case, since we're dealing with a
generated column, only ALWAYS is supported. Additionally, STORED must
always be specified.

Since this is a first prototype, no thought has been given to
partitioned nor foreign tables, so these are not supported either.

This operation always rewrites the contents of the column using the new
generated expression.
---
 src/backend/commands/tablecmds.c              | 137 +++++++++++++++++-
 src/backend/parser/gram.y                     |  31 ++++
 src/include/nodes/parsenodes.h                |   1 +
 .../test_ddl_deparse/test_ddl_deparse.c       |   2 +
 src/test/regress/expected/alter_table.out     | 122 ++++++++++++++++
 src/test/regress/sql/alter_table.sql          |  69 +++++++++
 6 files changed, 361 insertions(+), 1 deletion(-)

diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 67e42e5df29..e7386e81b07 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -760,6 +760,10 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation
 static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab,
 								 Relation rel, PartitionCmd *cmd,
 								 AlterTableUtilityContext *context);
+static ObjectAddress ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+													Relation rel,
+													const char *colName,
+													Constraint *def);
 
 /* ----------------------------------------------------------------
  *		DefineRelation
@@ -4746,6 +4750,7 @@ AlterTableGetLockLevel(List *cmds)
 			case AT_SetExpression:
 			case AT_DropExpression:
 			case AT_SetCompression:
+			case AT_AddGeneratedAsExprStored:
 				cmd_lockmode = AccessExclusiveLock;
 				break;
 
@@ -5321,6 +5326,16 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
 			/* No command-specific prep needed */
 			pass = AT_PASS_MISC;
 			break;
+		case AT_AddGeneratedAsExprStored:
+			/* No support yet for: partitioned tables, foreign tables */
+			ATSimplePermissions(cmd->subtype, rel, ATT_TABLE);
+
+			/*
+			 * This has similar mechanics to AT_SetExpression, let's use the
+			 * same pass.
+			 */
+			pass = AT_PASS_SET_EXPRESSION;
+			break;
 		default:				/* oops */
 			elog(ERROR, "unrecognized alter table type: %d",
 				 (int) cmd->subtype);
@@ -5733,6 +5748,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 			ATExecSplitPartition(wqueue, tab, rel, (PartitionCmd *) cmd->def,
 								 context);
 			break;
+		case AT_AddGeneratedAsExprStored:
+			cmd = ATParseTransformCmd(wqueue, tab, rel, cmd, false, lockmode,
+									  cur_pass, context);
+			Assert(cmd != NULL);
+			address = ATExecAddGeneratedAsExprStored(tab, rel, cmd->name, (Constraint *) cmd->def);
+			break;
 		default:				/* oops */
 			elog(ERROR, "unrecognized alter table type: %d",
 				 (int) cmd->subtype);
@@ -6785,6 +6806,8 @@ alter_table_type_to_string(AlterTableType cmdtype)
 			return "ALTER COLUMN ... DROP IDENTITY";
 		case AT_ReAddStatistics:
 			return NULL;		/* not real grammar */
+		case AT_AddGeneratedAsExprStored:
+			return "ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED";
 	}
 
 	return NULL;
@@ -8823,6 +8846,118 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
 	return address;
 }
 
+/*
+ * ALTER TABLE ALTER COLUMN ADD GENERATED ALWAYS AS expr STORED
+ */
+static ObjectAddress
+ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab,
+							   Relation rel,
+							   const char *colName,
+							   Constraint *def)
+{
+	HeapTuple	tuple;
+	Form_pg_attribute attTup;
+	AttrNumber	attnum;
+	ObjectAddress address;
+	Expr	   *defval;
+	NewColumnValue *newval;
+	RawColumnDefault *rawEnt;
+	Relation	pg_attribute;
+
+	Assert(def->raw_expr != NULL);
+	Assert(def->cooked_expr == NULL);
+	Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS);
+	Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED);
+
+	tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName);
+	if (!HeapTupleIsValid(tuple))
+		ereport(ERROR,
+				(errcode(ERRCODE_UNDEFINED_COLUMN),
+				 errmsg("column \"%s\" of relation \"%s\" does not exist",
+						colName, RelationGetRelationName(rel))));
+
+	attTup = (Form_pg_attribute) GETSTRUCT(tuple);
+
+	attnum = attTup->attnum;
+	if (attnum <= 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("cannot alter system column \"%s\"",
+						colName)));
+
+	if (attTup->attgenerated)
+		ereport(ERROR,
+				(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+				 errmsg("column \"%s\" of relation \"%s\" is already a generated column",
+						colName, RelationGetRelationName(rel))));
+
+	/* Mark as generated stored in pg_attribute */
+	pg_attribute = table_open(AttributeRelationId, RowExclusiveLock);
+	attTup->attgenerated = ATTRIBUTE_GENERATED_STORED;
+	CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple);
+	table_close(pg_attribute, RowExclusiveLock);
+
+	/* Make above changes visible */
+	CommandCounterIncrement();
+
+	ReleaseSysCache(tuple);
+
+	/*
+	 * Find everything that depends on the column (constraints, indexes, etc),
+	 * and record enough information to let us recreate the objects.
+	 */
+	RememberAllDependentForRebuilding(tab, AT_AddGeneratedAsExprStored,
+									  rel, attnum, colName);
+
+	/*
+	 * Remove previous default value, if any, and store the new generator
+	 * expression.
+	 */
+	RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT,
+					  false, false);
+
+	rawEnt = palloc_object(RawColumnDefault);
+	rawEnt->attnum = attnum;
+	rawEnt->raw_default = def->raw_expr;
+	rawEnt->generated = def->generated_kind;
+	AddRelationNewConstraints(rel, list_make1(rawEnt), NIL,
+							  false, true, false, NULL);
+
+	/* Make above changes visible */
+	CommandCounterIncrement();
+
+	/*
+	 * Clear all the missing values if we're rewriting the table, since this
+	 * renders them pointless.
+	 */
+	RelationClearMissing(rel);
+
+	/* Make above changes visible */
+	CommandCounterIncrement();
+
+	/* Drop any pg_statistic entry for the column */
+	RemoveStatistics(RelationGetRelid(rel), attnum);
+
+	/* Build a concrete expression for the new default (generated) value */
+	defval = (Expr *) build_column_default(rel, attnum);
+	defval = expression_planner(defval);
+
+	/* Schedule a rewrite */
+	newval = palloc0_object(NewColumnValue);
+	newval->attnum = attnum;
+	newval->expr = defval;
+	newval->is_generated = true;
+	tab->newvals = lappend(tab->newvals, newval);
+	tab->rewrite |= AT_REWRITE_DEFAULT_VAL;
+
+	InvokeObjectPostAlterHook(RelationRelationId,
+							  RelationGetRelid(rel), attnum);
+
+	ObjectAddressSubSet(address, RelationRelationId,
+						RelationGetRelid(rel), attnum);
+	return address;
+}
+
 /*
  * ALTER TABLE ALTER COLUMN DROP EXPRESSION
  */
@@ -15290,7 +15425,7 @@ RememberAllDependentForRebuilding(AlteredTableInfo *tab, AlterTableType subtype,
 	SysScanDesc scan;
 	HeapTuple	depTup;
 
-	Assert(subtype == AT_AlterColumnType || subtype == AT_SetExpression);
+	Assert(subtype == AT_AlterColumnType || subtype == AT_SetExpression || subtype == AT_AddGeneratedAsExprStored);
 
 	depRel = table_open(DependRelationId, RowExclusiveLock);
 
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index c2584249603..74440e801d4 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -2717,6 +2717,37 @@ alter_table_cmd:
 					n->name = $3;
 					n->def = (Node *) c;
 
+					$$ = (Node *) n;
+				}
+			/* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED ALWAYS AS ( <expression> ) STORED */
+			| ALTER opt_column ColId ADD_P GENERATED generated_when AS '(' a_expr ')' STORED
+				{
+					AlterTableCmd *n = makeNode(AlterTableCmd);
+					Constraint *c = makeNode(Constraint);
+
+					c->contype = CONSTR_GENERATED;
+					c->generated_when = $6;
+					c->raw_expr = $9;
+					c->cooked_expr = NULL;
+					c->generated_kind = ATTRIBUTE_GENERATED_STORED;
+					c->location = @5;
+
+					/*
+					 * Like in the case of ColConstraintElem, we cannot handle
+					 * this in the grammar because IDENTITY allows both ALWAYS
+					 * and BY DEFAULT, while generated columns only allow
+					 * ALWAYS. This would lead to shift/reduce conflicts.
+					 */
+					if (c->generated_when != ATTRIBUTE_IDENTITY_ALWAYS)
+						ereport(ERROR,
+								(errcode(ERRCODE_SYNTAX_ERROR),
+								 errmsg("for a generated column, GENERATED ALWAYS must be specified"),
+								 parser_errposition(@6)));
+
+					n->subtype = AT_AddGeneratedAsExprStored;
+					n->name = $3;
+					n->def = (Node *) c;
+
 					$$ = (Node *) n;
 				}
 			/* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index ffadd667167..6b61513e6d0 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -2568,6 +2568,7 @@ typedef enum AlterTableType
 	AT_SetIdentity,				/* SET identity column options */
 	AT_DropIdentity,			/* DROP IDENTITY */
 	AT_ReAddStatistics,			/* internal to commands/tablecmds.c */
+	AT_AddGeneratedAsExprStored,	/* ADD GENERATED ALWAYS AS (...) STORED */
 } AlterTableType;
 
 typedef struct AlterTableCmd	/* one subcommand of an ALTER TABLE */
diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
index 64a1dfa9f79..7c1699d538c 100644
--- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
+++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c
@@ -315,6 +315,8 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS)
 			case AT_ReAddStatistics:
 				strtype = "(re) ADD STATS";
 				break;
+			case AT_AddGeneratedAsExprStored:
+				strtype = "ADD GENERATED ALWAYS AS (...) STORED";
 		}
 
 		if (subcmd->recurse)
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index ccd79dfecc0..75f64628aef 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -4863,3 +4863,125 @@ drop publication pub1;
 drop schema alter1 cascade;
 drop schema alter2 cascade;
 NOTICE:  drop cascades to table alter2.t1
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+  select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+  add generated always as (a * 2) stored;
+\d+ testgen.t1
+                                                 Table "testgen.t1"
+ Column |  Type   | Collation | Nullable |              Default               | Storage | Stats target | Description 
+--------+---------+-----------+----------+------------------------------------+---------+--------------+-------------
+ a      | integer |           |          |                                    | plain   |              | 
+ b      | integer |           | not null | generated always as (a * 2) stored | plain   |              | 
+Not-null constraints:
+    "t1_b_not_null" NOT NULL "b"
+
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.t1 order by a;
+ a  | b  | expected | correct 
+----+----+----------+---------
+  1 |  2 |        2 | t
+  2 |  4 |        4 | t
+  3 |  6 |        6 | t
+  4 |  8 |        8 | t
+  5 | 10 |       10 | t
+  6 | 12 |       12 | t
+  7 | 14 |       14 | t
+  8 | 16 |       16 | t
+  9 | 18 |       18 | t
+ 10 | 20 |       20 | t
+(10 rows)
+
+insert into testgen.t1 (a, b) values (10, 20);
+ERROR:  cannot insert a non-DEFAULT value into column "b"
+DETAIL:  Column "b" is a generated column.
+insert into testgen.t1 (a, b) values (10, 21);
+ERROR:  cannot insert a non-DEFAULT value into column "b"
+DETAIL:  Column "b" is a generated column.
+drop table testgen.t1;
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+ERROR:  check constraint "chk_gen_clause" of relation "t2" is violated by some row
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+ did_not_rewrite 
+-----------------
+ t
+(1 row)
+
+\d+ testgen.t2
+                                    Table "testgen.t2"
+ Column |  Type   | Collation | Nullable | Default | Storage | Stats target | Description 
+--------+---------+-----------+----------+---------+---------+--------------+-------------
+ a      | integer |           |          |         | plain   |              | 
+ b      | integer |           | not null |         | plain   |              | 
+Check constraints:
+    "chk_gen_clause" CHECK (b = (a * 2))
+Not-null constraints:
+    "t2_b_not_null" NOT NULL "b"
+
+drop table testgen.t2;
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+alter table testgen.t3 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
+select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
+ did_rewrite_idx 
+-----------------
+ t
+(1 row)
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+  add generated always as (bar * 2) stored;
+ERROR:  relation "doesnotexist" does not exist
+create table testgen.t1 (a int);
+alter table testgen.t1 alter column doesnotexist
+  add generated always as (bar * 2) stored;
+ERROR:  column "doesnotexist" of relation "t1" does not exist
+alter table testgen.t1 add column b int;
+alter table testgen.t1 alter column b
+    add generated always as (doesnotexist * 2) stored;
+ERROR:  column "doesnotexist" does not exist
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+    add generated by default as (a * 2) stored;
+ERROR:  for a generated column, GENERATED ALWAYS must be specified
+LINE 2:     add generated by default as (a * 2) stored;
+                          ^
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+ERROR:  syntax error at or near ";"
+LINE 1: ...e testgen.t1 alter column b add generated always as (a * 2);
+                                                                      ^
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+ERROR:  syntax error at or near "virtual"
+LINE 1: ...n.t1 alter column b add generated always as (a * 2) virtual;
+                                                               ^
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+ERROR:  column "b" of relation "t2" is already a generated column
+-- not supported: partitioned tables
+create table testgen.tpart (a int, b int) partition by hash (a);
+alter table testgen.tpart alter column b add generated always as (a * 2) stored;
+ERROR:  ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart"
+DETAIL:  This operation is not supported for partitioned tables.
+drop schema testgen cascade;
+NOTICE:  drop cascades to 4 other objects
+DETAIL:  drop cascades to table testgen.t3
+drop cascades to table testgen.t1
+drop cascades to table testgen.t2
+drop cascades to table testgen.tpart
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index f5f13bbd3e7..d776595a6ed 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -3159,3 +3159,72 @@ alter table alter1.t1 set schema alter2;
 drop publication pub1;
 drop schema alter1 cascade;
 drop schema alter2 cascade;
+
+-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED
+-- turning a regular column into a stored generated column
+create schema testgen;
+
+create table testgen.t1 (a int, b int not null);
+insert into testgen.t1 (a, b)
+  select x, x from generate_series(1, 10) x;
+alter table testgen.t1 alter column b
+  add generated always as (a * 2) stored;
+\d+ testgen.t1
+select a, b, a * 2 as expected, b = (a * 2) as correct
+  from testgen.t1 order by a;
+insert into testgen.t1 (a, b) values (10, 20);
+insert into testgen.t1 (a, b) values (10, 21);
+drop table testgen.t1;
+
+-- turning a regular column into a stored generated column
+-- fails when another constraint conflicts with the new expression
+create table testgen.t2 (a int, b int not null);
+insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x;
+alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2);
+select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset
+alter table testgen.t2 alter column b add generated always as (a * 3) stored;
+select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset
+select :t2_filenode_before = :t2_filenode_after as did_not_rewrite;
+\d+ testgen.t2
+drop table testgen.t2;
+
+-- rewrite an indexed column
+create table testgen.t3 (a int, b int);
+create index idx_b on testgen.t3 (b);
+insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset
+alter table testgen.t3 alter column b add generated always as (a * 2) stored;
+select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset
+select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx;
+
+-- tests for invalid invocations
+alter table doesnotexist alter column foo
+  add generated always as (bar * 2) stored;
+
+create table testgen.t1 (a int);
+
+alter table testgen.t1 alter column doesnotexist
+  add generated always as (bar * 2) stored;
+
+alter table testgen.t1 add column b int;
+
+alter table testgen.t1 alter column b
+    add generated always as (doesnotexist * 2) stored;
+
+-- invalid: only supports ALWAYS
+alter table testgen.t1 alter column b
+    add generated by default as (a * 2) stored;
+
+-- invalid: only supports STORED
+alter table testgen.t1 alter column b add generated always as (a * 2);
+alter table testgen.t1 alter column b add generated always as (a * 2) virtual;
+
+-- invalid: b is already a generated column
+create table testgen.t2 (a int, b int generated always as (a * 2) stored);
+alter table testgen.t2 alter column b add generated always as (a * 2) stored;
+
+-- not supported: partitioned tables
+create table testgen.tpart (a int, b int) partition by hash (a);
+alter table testgen.tpart alter column b add generated always as (a * 2) stored;
+
+drop schema testgen cascade;
-- 
2.51.2

--24dzbv6kpqxe4yje
Content-Type: text/x-patch; charset=utf-8
Content-Disposition: attachment;
	filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch"