Describe the bug
When a Computed column expression is changed in the SQLAlchemy model and alembic revision --autogenerate is run, Alembic emits no migration at all. The built-in _compare_computed_default comparator detects the expression difference, emits a UserWarning: Computed default on <table>.<column> cannot be modified, and returns STOP — but it never sets alter_column_op.modify_server_default. As a result, has_changes() returns False and no AlterColumnOp is emitted. The rewriter (if any) never gets a chance to run.
This was acknowledged as a deliberate limitation in #624, where @zzzeek stated:
the next level is support of changes in "computed", which has to do with reading the table columns in the DB and reading them in the model and comparing, and I'm assuming this is what you're referring towards. we don't implement this feature for CHECK constraints either right now
and @CaselIT confirmed:
I'll omit the support for changes for now
The result is that users have no autogenerate path for computed column expression changes on PostgreSQL. The warning fires, but nothing is emitted — the user must hand-write the migration.
Expected behavior
When a Computed column expression changes, autogenerate should emit a migration that drops and re-adds the column. PostgreSQL does not support ALTER COLUMN on a generated/computed column — the column must be dropped and re-added. This is the correct DDL:
ALTER TABLE products DROP COLUMN total;
ALTER TABLE products ADD COLUMN total INTEGER GENERATED ALWAYS AS (price * quantity + tax) STORED NOT NULL;
The downgrade should reverse this: drop the new column and re-add the old one with the previous computed expression.
To Reproduce
import sqlalchemy as sa
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
price: Mapped[int] = mapped_column(sa.Integer, nullable=False)
quantity: Mapped[int] = mapped_column(sa.Integer, nullable=False)
# First migration: create the table with this computed column
total: Mapped[int] = mapped_column(
sa.Integer(),
sa.Computed("price * quantity", persisted=True),
nullable=False,
)
- Create the first migration and apply it.
- Change the
Computed expression from "price * quantity" to "price * quantity + tax" (or any other change to the expression).
- Run
alembic revision --autogenerate.
Error
.../alembic/autogenerate/compare/server_defaults.py:114: UserWarning: Computed default on products.total cannot be modified
util.warn("Computed default on %s.%s cannot be modified" % (tname, cname))
INFO [alembic.env] No changes in schema detected.
No migration file is generated. The computed expression change is silently ignored.
Root cause
The flow through the comparator dispatch (with compare_server_default=True):
-
_user_compare_server_default (FIRST priority): sets alter_column_op.existing_server_default to the old Computed (the DB state), then returns CONTINUE (because compare_server_default=True is not callable).
-
_compare_computed_default (default/MEDIUM priority): detects the expression difference via _normalize_computed_default, calls _warn_computed_not_supported, and returns STOP — but never sets alter_column_op.modify_server_default.
-
has_changes() returns False because modify_server_default is still False (the sentinel). No AlterColumnOp is emitted.
The key code in _compare_computed_default (alembic/autogenerate/compare/server_defaults.py):
if rendered_metadata_default != rendered_conn_default:
_warn_computed_not_supported(tname, cname)
return PriorityDispatchResult.STOP # STOP, but modify_server_default never set
Workaround we are currently using
We worked around this in our env.py with a custom comparator + rewriter. The comparator runs at FIRST priority (before _compare_computed_default) and actually sets modify_server_default when the expression differs. The rewriter then converts the resulting AlterColumnOp into a DropColumnOp + AddColumnOp pair, with DropColumnOp._reverse wired so the downgrade re-adds the old computed column.
from alembic.autogenerate import rewriter
from alembic.autogenerate.compare import comparators
from alembic.operations import ops
from alembic.util import DispatchPriority, PriorityDispatchResult
from sqlalchemy import Column
from sqlalchemy.sql.schema import Computed
from typing import Any, cast
writer = rewriter.Rewriter()
def _column_from_op(op: ops.AlterColumnOp, *, use_existing: bool) -> Column[Any]:
"""Reconstruct a Column from an AlterColumnOp.
use_existing=True -> existing_* attributes (current DB state, for downgrade)
use_existing=False -> modify_* with fallback to existing_* (target state, for upgrade)
"""
if use_existing:
col_type = op.existing_type
col_default = op.existing_server_default
col_nullable = op.existing_nullable
else:
col_type = op.modify_type if op.modify_type is not None else op.existing_type
col_default = (
op.modify_server_default
if op.modify_server_default is not False
else op.existing_server_default
)
col_nullable = (
op.modify_nullable if op.modify_nullable is not None else op.existing_nullable
)
return Column(
op.column_name,
col_type,
server_default=cast("Column[Any] | None", col_default),
nullable=col_nullable if col_nullable is not None else True,
)
@comparators.dispatch_for("column", priority=DispatchPriority.FIRST, qualifier="postgresql")
def _compare_computed_expression(
autogen_context: Any,
alter_column_op: ops.AlterColumnOp,
schema: str | None,
tname: str,
cname: str,
conn_col: Any,
metadata_col: Any,
) -> PriorityDispatchResult:
"""Detect computed-expression changes and set modify_server_default.
Runs at FIRST priority, before Alembic's _compare_computed_default (which
only warns and returns STOP without setting modify_server_default). When the
computed expression differs, we set modify_server_default to the new Computed
so that has_changes() returns True and an AlterColumnOp is emitted — which the
rewriter then converts to drop + add.
"""
metadata_default = metadata_col.server_default
conn_default = conn_col.server_default
if not isinstance(metadata_default, Computed):
return PriorityDispatchResult.CONTINUE
alter_column_op.existing_server_default = conn_default
if isinstance(conn_default, Computed):
rendered_metadata = str(
metadata_default.sqltext.compile(
dialect=autogen_context.dialect,
compile_kwargs={"literal_binds": True},
),
)
rendered_conn = str(
conn_default.sqltext.compile(
dialect=autogen_context.dialect,
compile_kwargs={"literal_binds": True},
),
)
if rendered_metadata == rendered_conn:
return PriorityDispatchResult.CONTINUE
elif conn_default is None:
pass # Column was not computed before but is now — treat as a change.
else:
return PriorityDispatchResult.CONTINUE
alter_column_op.modify_server_default = metadata_default
return PriorityDispatchResult.STOP
@writer.rewrites(ops.AlterColumnOp)
def drop_and_recreate_column(context, revision, op: ops.AlterColumnOp):
"""Convert AlterColumnOp on a computed column into drop + add.
The upgrade drops the old column and adds the new one (with the new computed
expression). The downgrade reverses this: drops the new column and re-adds the
old one, via DropColumnOp._reverse.
"""
if not any(
isinstance(sd, Computed)
for sd in (op.existing_server_default, op.modify_server_default)
):
return op
new_column = _column_from_op(op, use_existing=False)
old_column = _column_from_op(op, use_existing=True)
drop_op = ops.DropColumnOp(op.table_name, op.column_name, schema=op.schema)
drop_op._reverse = ops.AddColumnOp(op.table_name, old_column, schema=op.schema)
add_op = ops.AddColumnOp(op.table_name, new_column, schema=op.schema)
return [drop_op, add_op]
This produces the expected migration:
Upgrade:
op.drop_column('products', 'total')
op.add_column('products', sa.Column('total', sa.Integer(),
server_default=sa.Computed('price * quantity + tax', persisted=True),
nullable=False))
Downgrade:
op.drop_column('products', 'total')
op.add_column('products', sa.Column('total', sa.Integer(),
server_default=sa.Computed('price * quantity', persisted=True),
nullable=False))
Known limitations of the workaround
-
Indexes are not automatically recreated. If the computed column has index=True, dropping the column drops the index. Alembic's autogenerate will detect the missing index as a separate diff and emit a CreateIndexOp — this works, but the index recreation is a separate op, not part of the drop+add pair.
-
DropColumnOp._reverse is a private attribute. Wiring the downgrade via _reverse is the mechanism Alembic uses internally (see DropColumnOp.reverse()), but accessing it directly is not part of the public API.
-
Dialect-specific. The comparator is registered with qualifier="postgresql". SQL Server and MySQL/MariaDB also cannot ALTER COLUMN on computed columns, but the drop/recreate semantics may differ (e.g. SQL Server requires dropping indexes that reference the column first — see ariga/atlas#3595).
Related issues
Versions
- OS: NixOS
- Python: 3.14
- Alembic: 1.17.0
- SQLAlchemy: 2.0.44
- Database: PostgreSQL
- DBAPI: asyncpg
Additional context
I'm open to contributing a PR if the maintainers are interested. I appreciate guidance on the preferred architecture:
- Should the comparator + rewriter be integrated into the PostgreSQL dialect impl (
alembic/ddl/postgresql.py), or kept as a globally-registered comparator via comparators.dispatch_for?
- Is there a cleaner public API for wiring the downgrade reverse than setting
DropColumnOp._reverse directly?
- Should index drop/recreate be handled as part of the same op, or left as a separate autogenerate diff (current behavior)?
AI assistance disclosure
I've been using Alembic for over five years, but the analysis of Alembic's internal comparator dispatch, the root cause investigation, and the workaround code were assisted by open-source AI tooling (OpenCode with the GLM-5.2 model). I reviewed and tested everything before posting, but I want to be transparent about the process.
Have a nice day
Describe the bug
When a
Computedcolumn expression is changed in the SQLAlchemy model andalembic revision --autogenerateis run, Alembic emits no migration at all. The built-in_compare_computed_defaultcomparator detects the expression difference, emits aUserWarning: Computed default on <table>.<column> cannot be modified, and returnsSTOP— but it never setsalter_column_op.modify_server_default. As a result,has_changes()returnsFalseand noAlterColumnOpis emitted. The rewriter (if any) never gets a chance to run.This was acknowledged as a deliberate limitation in #624, where @zzzeek stated:
and @CaselIT confirmed:
The result is that users have no autogenerate path for computed column expression changes on PostgreSQL. The warning fires, but nothing is emitted — the user must hand-write the migration.
Expected behavior
When a
Computedcolumn expression changes, autogenerate should emit a migration that drops and re-adds the column. PostgreSQL does not supportALTER COLUMNon a generated/computed column — the column must be dropped and re-added. This is the correct DDL:The downgrade should reverse this: drop the new column and re-add the old one with the previous computed expression.
To Reproduce
Computedexpression from"price * quantity"to"price * quantity + tax"(or any other change to the expression).alembic revision --autogenerate.Error
No migration file is generated. The computed expression change is silently ignored.
Root cause
The flow through the comparator dispatch (with
compare_server_default=True):_user_compare_server_default(FIRST priority): setsalter_column_op.existing_server_defaultto the oldComputed(the DB state), then returnsCONTINUE(becausecompare_server_default=Trueis not callable)._compare_computed_default(default/MEDIUM priority): detects the expression difference via_normalize_computed_default, calls_warn_computed_not_supported, and returnsSTOP— but never setsalter_column_op.modify_server_default.has_changes()returnsFalsebecausemodify_server_defaultis stillFalse(the sentinel). NoAlterColumnOpis emitted.The key code in
_compare_computed_default(alembic/autogenerate/compare/server_defaults.py):Workaround we are currently using
We worked around this in our
env.pywith a custom comparator + rewriter. The comparator runs atFIRSTpriority (before_compare_computed_default) and actually setsmodify_server_defaultwhen the expression differs. The rewriter then converts the resultingAlterColumnOpinto aDropColumnOp+AddColumnOppair, withDropColumnOp._reversewired so the downgrade re-adds the old computed column.This produces the expected migration:
Upgrade:
Downgrade:
Known limitations of the workaround
Indexes are not automatically recreated. If the computed column has
index=True, dropping the column drops the index. Alembic's autogenerate will detect the missing index as a separate diff and emit aCreateIndexOp— this works, but the index recreation is a separate op, not part of the drop+add pair.DropColumnOp._reverseis a private attribute. Wiring the downgrade via_reverseis the mechanism Alembic uses internally (seeDropColumnOp.reverse()), but accessing it directly is not part of the public API.Dialect-specific. The comparator is registered with
qualifier="postgresql". SQL Server and MySQL/MariaDB also cannotALTER COLUMNon computed columns, but the drop/recreate semantics may differ (e.g. SQL Server requires dropping indexes that reference the column first — see ariga/atlas#3595).Related issues
_normalize_computed_defaultdoesn't strip newlines, causing false-positive warnings (open, labeledbug).TRUEvs1normalization (open, labeledautogenerate - detection).Versions
Additional context
I'm open to contributing a PR if the maintainers are interested. I appreciate guidance on the preferred architecture:
alembic/ddl/postgresql.py), or kept as a globally-registered comparator viacomparators.dispatch_for?DropColumnOp._reversedirectly?AI assistance disclosure
I've been using Alembic for over five years, but the analysis of Alembic's internal comparator dispatch, the root cause investigation, and the workaround code were assisted by open-source AI tooling (OpenCode with the GLM-5.2 model). I reviewed and tested everything before posting, but I want to be transparent about the process.
Have a nice day