sqlalchemy / sqlalchemy/alembic
Autogenerate produces no migration when a Computed column expression changes (PostgreSQL)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 4.4k
- Forks
- 375
- PR merge metrics
- No merged PRs in 30d
Description
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
Computedexpression 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): 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):
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 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
- #624 — Original issue that added Computed column rendering support. @zzzeek noted that "the next level is support of changes in computed" was not implemented, and @CaselIT confirmed "I'll omit the support for changes for now".
- #1121 —
_normalize_computed_defaultdoesn't strip newlines, causing false-positive warnings (open, labeledbug). - #1151 — False-positive "cannot be modified" warnings due to PostgreSQL reformatting the expression on reflection (closed as not planned).
- #1607 — Same warning on MariaDB due to
TRUEvs1normalization (open, labeledautogenerate - detection). - #1391 — Computed statements containing newlines cause warnings.
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 viacomparators.dispatch_for? - Is there a cleaner public API for wiring the downgrade reverse than setting
DropColumnOp._reversedirectly? - 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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in alembic/autogenerate/compare/server_defaults.py at _compare_computed_default and trace how AlterColumnOp.has_changes() controls migration emission. Reproduce the PostgreSQL case from the issue, then inspect the proposed PostgreSQL dialect and operation paths. Done means expression changes generate reversible drop-and-add operations with the old and new computed definitions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- postgresql, python
- Domain
- databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100