Managing Enums, Constraints and Indexes in Migrations
Name every constraint through a MetaData naming convention, hand-write migrations for enum label changes, and build indexes and constraints on live tables with CONCURRENTLY or NOT VALID inside a lock budget — these are the schema objects where Alembic's autogenerate is incomplete or unsafe. This topic belongs to Alembic async migrations and schema evolution.
Concept & Execution Model
Alembic's autogenerate is a comparison between two descriptions of a schema: the MetaData your models build, and what the database's catalog reports through SQLAlchemy's inspector. For tables and columns that comparison is close to complete. For the other objects a production PostgreSQL schema accumulates — enum types, check constraints, named indexes, generated columns — it ranges from reliable, through partial, to absent. This topic is about those objects, and about the migrations autogenerate either cannot write or writes in a form that is unsafe on a live table.
Three habits cover most of it. Name every constraint through a convention, so model and database agree on names and autogenerate can compare them. Treat enum label changes as hand-written migrations, because autogenerate never sees them. And treat any index or constraint on an existing large table as an operation with a lock profile, not just a line of DDL.
import enum
from sqlalchemy import CheckConstraint, Enum, ForeignKey, Index, MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
metadata = MetaData(
naming_convention={
"ix": "ix_%(table_name)s_%(column_0_N_name)s",
"uq": "uq_%(table_name)s_%(column_0_N_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
)
class OrderStatus(enum.Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"))
status: Mapped[OrderStatus] = mapped_column(
Enum(OrderStatus, name="order_status",
values_callable=lambda members: [m.value for m in members])
)
total_cents: Mapped[int]
__table_args__ = (
CheckConstraint("total_cents >= 0", name="positive_total"),
Index(None, "customer_id"), # named by the convention: ix_orders_customer_id
)
Each piece of that model has its own guide. Naming constraints with a metadata naming convention covers the templates and adopting them on a database that already has invented names. Adding a value to a Postgres enum in Alembic covers ALTER TYPE, names versus values, and removing labels. Creating indexes concurrently in Alembic migrations covers building the index above on a table that is already taking writes.
This topic sits within Alembic async migrations and schema evolution, next to the broader zero-downtime schema migration strategies that the lock-avoidance techniques here feed into.
Query Construction & Async Execution Patterns
Migration code is synchronous, even in a project whose application runs entirely on asyncpg. The async boundary is crossed exactly once, in env.py, where an AsyncConnection hands a synchronous facade to Alembic through run_sync(). Every op.* call, every op.get_bind() and every autocommit block inside a revision runs on that facade.
The classic synchronous runner and the async runner differ only at that boundary:
# Sync env.py (excerpt)
from alembic import context
from sqlalchemy import create_engine
from shop.models import Base
def run_migrations_online() -> None:
engine = create_engine(context.config.get_main_option("sqlalchemy.url"))
with engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=Base.metadata,
transaction_per_migration=True,
)
with context.begin_transaction():
context.run_migrations()
# Async env.py (excerpt)
import asyncio
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from shop.models import Base
def do_run_migrations(connection) -> None:
context.configure(
connection=connection,
target_metadata=Base.metadata,
transaction_per_migration=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
engine = create_async_engine(context.config.get_main_option("sqlalchemy.url"))
async with engine.connect() as connection:
await connection.run_sync(do_run_migrations)
await engine.dispose()
asyncio.run(run_migrations_online())
transaction_per_migration=True appears in both, and it matters more in this topic than anywhere else. Enum additions and concurrent index builds use autocommit_block(), which commits the transaction that is open when it is entered. With one transaction per migration, that commits only the current revision's earlier work. With the default single transaction for the whole run, it commits every migration applied so far in that run.
Queries inside migrations — checking whether an index is valid, counting rows that violate a new constraint, reading enum_range() — use op.get_bind() and ordinary text() or Core constructs, executed synchronously:
import sqlalchemy as sa
from alembic import op
def upgrade() -> None:
bind = op.get_bind()
violations = bind.execute(
sa.text("SELECT count(*) FROM orders WHERE total_cents < 0")
).scalar_one()
if violations:
raise RuntimeError(f"{violations} orders violate positive_total; backfill first")
op.create_check_constraint("positive_total", "orders", "total_cents >= 0")
Failing a migration early with a precise message is much better than letting ADD CONSTRAINT fail after a full table scan with a message that names only the constraint.
State Management & Session Boundaries
Migrations and the running application share a database, and schema objects are where their timelines collide. A migration changes the schema at one moment; the application fleet changes over the minutes of a rolling deploy. For a while, old code runs against the new schema, and occasionally new code runs against the old one.
Each kind of object has its own compatibility rule.
Enum labels must be added before any code writes them, and readers must tolerate labels they do not know. An old release loading a row with a new label raises LookupError: 'refunded' is not among the defined enum values inside the ORM's result processing, which fails the whole query, not just that row. Ship the migration and a tolerant reader first, and the writer in a later release.
Constraints must be added after all code that could violate them is gone. A check constraint added while old code still writes negative totals turns those writes into IntegrityErrors in production. The safe order is: deploy code that respects the rule, backfill, add the constraint NOT VALID, validate.
Indexes have no compatibility risk for correctness, only for performance. Dropping an index old code still relies on is the danger, so drop indexes one release after the last query that used them is gone.
Inside the application, sessions do not notice schema changes — but they do cache. SQLAlchemy's compiled-statement cache and asyncpg's prepared-statement cache are both per connection, and a migration that changes a column's type can invalidate prepared statements on connections that were open before it ran. asyncpg reports this as InvalidCachedStatementError: cached statement plan is invalid due to a database schema or configuration change. SQLAlchemy's asyncpg dialect responds by invalidating its prepared-statement caches, so the statement fails once and a retry succeeds. Deploys that run migrations against a live fleet should expect a brief burst of these, and pool_pre_ping does not prevent them, because the connections are healthy — only their cached plans are stale.
Migration sessions themselves should be short and single-purpose. A data backfill run inside a schema migration holds that migration's locks for as long as the backfill takes, so large backfills belong in a separate, batched job between the migrations that bracket them, as writing data migrations safely describes.
Advanced Schema Routing and Type Extensions
Two situations push these objects beyond a single schema: multi-tenant layouts that repeat the same tables in many PostgreSQL schemas, and custom types that Alembic has to render in migration files.
In schema-per-tenant layouts, enum types are schema-scoped. CREATE TYPE order_status in tenant_17 is a different type from the one in tenant_18, and adding a label means running ALTER TYPE tenant_17.order_status ADD VALUE ... in every schema. A migration that loops over tenants has to run each ALTER TYPE in its own autocommit statement, and it should be idempotent with IF NOT EXISTS, because a loop over hundreds of schemas is the migration most likely to be interrupted halfway. Index names, by contrast, only need to be unique per schema, so a naming convention produces the same ix_orders_customer_id everywhere, which is what you want.
import sqlalchemy as sa
from alembic import op
def upgrade() -> None:
bind = op.get_bind()
schemas = bind.execute(
sa.text("SELECT schema_name FROM information_schema.schemata "
"WHERE schema_name LIKE 'tenant\\_%'")
).scalars().all()
with op.get_context().autocommit_block():
for schema in schemas:
op.execute(
f'ALTER TYPE "{schema}".order_status ADD VALUE IF NOT EXISTS \'refunded\''
)
The routing side of schema-per-tenant — schema_translate_map and per-request switching — is covered in switching schemas per request.
Custom types raise a different problem: autogenerate renders a column's type into the migration by its Python repr, so a TypeDecorator appears as shop.types.EncryptedString(length=255), and the migration file needs import shop.types. If the type later moves or is renamed, old migrations stop importing. The render_item hook in env.py can render custom types as their underlying implementation, which keeps migration files independent of application code:
from shop.types import EncryptedString
def render_item(type_, obj, autogen_context):
if type_ == "type" and isinstance(obj, EncryptedString):
autogen_context.imports.add("import sqlalchemy as sa")
return f"sa.String(length={obj.impl.length})"
return False # default rendering for everything else
# in do_run_migrations(): context.configure(..., render_item=render_item)
The same hook is where teams render postgresql.ENUM(..., create_type=False) for enum types shared between tables, so the second table's migration does not try to create the type again.
Hybrid Architectures & Migration Strategies
Most schemas that reach this topic started without a naming convention, with enum columns defined before anyone decided between names and values, and with indexes created by whatever autogenerate emitted. Moving them to the conventions above is a sequence of small, safe releases rather than one large migration.
Names first. Add the naming convention to the model, then write a migration that renames existing constraints and indexes to the conventional names. Renames only touch the catalog, so this is fast on any table size. The end state is verified by autogenerate producing an empty revision. Nothing else should change in this release.
Enum storage second. If an enum column stores member names and you want values, the change is a set of ALTER TYPE ... RENAME VALUE statements plus the values_callable change in the model, and both must land together — the migration and the release that reads the new labels cannot be separated by a rolling deploy without a compatibility shim. Many teams decide the names are fine and document the choice instead, which is a perfectly good outcome.
Build practices last. Add the CI check that rejects non-concurrent indexes on existing tables and validated constraints added without NOT VALID, so the practices stick.
Core and ORM mix naturally here, because migrations are Core. op.execute() with text() handles ALTER TYPE and VALIDATE CONSTRAINT; op.create_index() and op.create_check_constraint() handle what Alembic models directly; and the application's ORM models remain the source of truth that autogenerate compares against.
A 1.4-era project often also carries create_constraint=True behaviour it did not ask for: before 1.4, non-native Enum and Boolean types created CHECK constraints by default, with names the database invented. Those constraints survive the upgrade to 2.0 even though 2.0 no longer creates them, and autogenerate does not report them, because unnamed check constraints are not compared. List them with SELECT conname FROM pg_constraint WHERE contype = 'c', and either name them into the convention or drop them deliberately.
Lock Budgets for Schema Changes
Every recommendation in this topic comes back to one question: how long will this statement block writes to a table that is taking them? It is worth making that question explicit, with a budget, rather than answering it case by case.
A lock budget is a limit on how long any migration statement may hold, or wait for, a lock that blocks writes on a production table. A common choice is a few seconds. Two PostgreSQL settings enforce it from inside the migration, and they are best set once, on the engine env.py creates for migrations, so every revision inherits them — and the application's own engine, which should not have a five-second lock timeout on ordinary work, is unaffected:
from sqlalchemy.ext.asyncio import create_async_engine
from alembic import context
migration_engine = create_async_engine(
context.config.get_main_option("sqlalchemy.url"),
connect_args={
"server_settings": {
# Fail fast instead of queueing: a waiting ALTER blocks every query behind it.
"lock_timeout": "5s",
# Bound statements that should be quick; long operations opt out explicitly.
"statement_timeout": "60s",
}
},
)
lock_timeout is the more important of the two, and the reason is counter-intuitive. An ALTER TABLE waiting for its lock is not harmless: PostgreSQL's lock queue is first-come-first-served, so every ordinary query that arrives after the waiting ALTER queues behind it, even queries that would not conflict with whatever the ALTER is waiting for. A migration stuck behind one long-running report can take an API down in seconds. With lock_timeout, the migration fails quickly and can be retried when the report finishes.
Operations that legitimately take longer — a concurrent index build, a batched backfill — opt out explicitly with SET statement_timeout = 0 inside their revision. That keeps the default strict and makes the exceptions visible in review.
Against the budget, the choices in this topic sort themselves. Concurrent index builds and NOT VALID plus VALIDATE hold write-blocking locks only for moments. Plain index builds, validated foreign keys and column type changes hold them for a full table scan, and fit the budget only on small tables. Enum label additions and constraint renames are catalog-only and always fit.
Indexes that cover part of a table, or an expression rather than a column, have to be declared on the model before autogenerate can produce them. Creating partial and expression indexes from SQLAlchemy models covers postgresql_where, functional indexes, the postgresql_using variants for GIN and GiST, and making the query actually match the index it was built for.
Production Pitfalls & Anti-Patterns
ValueError: Constraint must have a name— autogenerate emittedop.drop_constraint(None, ...). Add a naming convention and rename existing constraints to it.invalid input value for enum order_status: "refunded"— the Python enum gained a member with no migration. Hand-writeALTER TYPE ... ADD VALUE.unsafe use of new value "refunded"— the label was added and used in one transaction. Useautocommit_block().CREATE INDEX CONCURRENTLY cannot run inside a transaction block— missing autocommit block. Wrap the operation.type "order_status" already exists— a secondcreate_tableor a re-run created the type again. Usepostgresql.ENUM(..., create_type=False), and drop types indowngrade().canceling statement due to lock timeout— the budget did its job. Find the blocker inpg_stat_activityand retry.
The pitfall that produces no error at all is an invalid index left by a failed concurrent build: maintained on every write, used by no query. Check pg_index.indisvalid after every deploy that builds one.
Frequently Asked Questions
Which schema changes does autogenerate miss?
Enum label changes, check constraint bodies, computed column expressions, and anything about how DDL should run, such as CONCURRENTLY or NOT VALID. Server default changes are only compared when compare_server_default=True.
Should every index in a migration be concurrent?
Every index on an existing table that takes writes, yes. Indexes on tables created in the same revision should be plain, because there is nothing to block and a plain build is faster and transactional.
Is it safe to rename constraints in production?
Yes. ALTER TABLE ... RENAME CONSTRAINT and ALTER INDEX ... RENAME TO change only the catalog. They take a brief exclusive lock, so run them with a lock_timeout like any other DDL.
Do these migrations work with an async env.py?
Yes, unchanged. Revisions run inside run_sync() on a synchronous connection facade, so op.*, op.get_bind() and autocommit_block() all behave as they do with a synchronous driver.
Related
- Creating partial and expression indexes from SQLAlchemy models — Partial, functional and GIN indexes declared where autogenerate sees them.
- Adding a value to a Postgres enum in Alembic — ALTER TYPE, names versus values, and removing labels.
- Creating indexes concurrently in Alembic migrations — Autocommit blocks, invalid indexes and lock waits.
- Naming constraints with a metadata naming convention — Templates, adoption on existing schemas, and the 63-byte limit.
- Zero-Downtime Schema Migration Strategies — Expand and contract for columns and tables.
- Autogenerating and Reviewing Migration Scripts — What to check in a generated revision before merging.