Naming constraints with a metadata naming convention

Set naming_convention on your DeclarativeBase metadata before the first migration — ix, uq, ck, fk and pk templates — so every constraint gets the same name in every environment, and Alembic can drop and alter them by name. This guide belongs to managing enums, constraints and indexes in migrations.

Quick Answer

Without a convention, SQLAlchemy emits unnamed constraints and the database invents names. The model then has no way to refer to them, and Alembic cannot drop them.

Who chooses the name decides whether you can drop it Left: unique=True with no convention; SQLAlchemy sends an unnamed constraint, PostgreSQL invents customers_email_key, and a later autogenerated drop emits drop_constraint(None) which fails because the constraint has no name in the model. Right: with a naming convention, SQLAlchemy names it uq_customers_email in the DDL, the database stores that name, and a later migration drops it by the same name in every environment. no naming convention mapped_column(unique=True) DDL: UNIQUE (email) PostgreSQL names it customers_email_key op.drop_constraint(None, ...) Constraint must have a name naming_convention on MetaData mapped_column(unique=True) DDL: CONSTRAINT uq_customers_email every environment has the same name op.drop_constraint('uq_customers_email') autogenerate can round-trip it Default names differ between databases — MySQL, SQLite and PostgreSQL each invent their own — so a migration written against one environment can fail against another.

Before — database-invented names, and a migration that cannot drop one:

from sqlalchemy import CheckConstraint, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"))
    total_cents: Mapped[int]
    __table_args__ = (CheckConstraint("total_cents >= 0"),)

# PostgreSQL names them orders_pkey, orders_customer_id_fkey, orders_total_cents_check.
# A later autogenerated migration:
#     op.drop_constraint(None, "orders", type_="foreignkey")
# ValueError: Constraint must have a name

After — a naming convention on the metadata:

from sqlalchemy import CheckConstraint, ForeignKey, MetaData
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

NAMING_CONVENTION = {
    "ix": "ix_%(column_0_label)s",
    "uq": "uq_%(table_name)s_%(column_0_name)s",
    "ck": "ck_%(table_name)s_%(constraint_name)s",
    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
    "pk": "pk_%(table_name)s",
}


class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=NAMING_CONVENTION)


class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)                  # pk_orders
    customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"))
    # fk_orders_customer_id_customers
    total_cents: Mapped[int]
    __table_args__ = (
        CheckConstraint("total_cents >= 0", name="positive_total"),   # ck_orders_positive_total
    )

The check constraint still needs a short name=, because the ck template uses %(constraint_name)s — there is no column to name a check after. Omit it and SQLAlchemy raises InvalidRequestError: Naming convention including %(constraint_name)s token requires that constraint is explicitly named.

Execution Context & Async Workflow Integration

A naming convention is applied when SQLAlchemy builds a constraint object attached to a table in that MetaData. It fills in the template from the table and columns, and the resulting name is used in every piece of DDL the metadata produces: create_all(), Alembic's autogenerate output, and the comparison autogenerate performs against the database.

One template per kind of constraint Five tiles. Index: ix_ plus the column label, giving ix_orders_customer_id. Unique: uq_ plus table and column, uq_customers_email. Check: ck_ plus table and the constraint name you supply, ck_orders_positive_total. Foreign key: fk_ plus table, column and referred table, fk_orders_customer_id_customers. Primary key: pk_ plus table, pk_orders. ix ix_%(column_0_label)s ix_orders_customer_id uq uq_%(table_name)s_%(column_0_name)s uq_customers_email ck ck_%(table_name)s_%(constraint_name)s ck_orders_positive_total fk fk_…_%(referred_table_name)s fk_orders_customer_id_customers pk pk_%(table_name)s pk_orders The ck template needs a name you supply, because a check constraint has no column to name it after.

That last point is what makes conventions matter for migrations. Autogenerate matches constraints by name. When the model has a named constraint and the database has the same name, it knows they are the same object and compares their definitions. When the model's constraint is unnamed, autogenerate can still emit op.create_foreign_key(None, ...) — PostgreSQL will invent a name — but it cannot emit a working drop, because a drop needs a name, and so it writes op.drop_constraint(None, ...), which fails when the migration runs.

Invented names are also not portable. PostgreSQL produces orders_customer_id_fkey, MySQL produces orders_ibfk_1, and SQLite keeps most constraints anonymous. A migration hand-written against a developer's PostgreSQL works in production only if production invented the same name, which is true until the day a table was created differently.

Alembic wraps names produced from the convention in a marker, op.f("fk_orders_customer_id_customers"), in generated scripts. op.f() says "this name is already final"; without it, a name passed to an operation is treated as the %(constraint_name)s input and run through the convention again, producing ck_orders_ck_orders_positive_total. Leave op.f() in place when editing generated revisions.

The convention costs nothing at runtime and is invisible to the async engine — it affects only DDL, and Alembic's async env.py runs autogenerate inside run_sync() against the same target_metadata. The only requirement is that env.py imports the Base.metadata that carries the convention, not a bare MetaData() created elsewhere, which is a quiet way to lose it — as described in setting up Alembic env.py for asyncpg.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
ValueError: Constraint must have a nameAutogenerate emitted op.drop_constraint(None, ...) for an unnamed model constraint.Add a naming convention; name the existing constraint in a migration.
Naming convention including %(constraint_name)s token requires that constraint is explicitly named.A CheckConstraint (or other constraint) with a ck-style template and no name=.Pass a short name="positive_total".
ck_orders_ck_orders_positive_total in the databaseAn already-converted name was passed back through the convention.Wrap final names in op.f().
constraint "orders_customer_id_fkey" of relation "orders" does not existA hand-written migration used PostgreSQL's invented name, and this environment invented a different one.Adopt a convention and rename constraints to it everywhere.
Autogenerate proposes dropping and recreating every constraintA convention was added to the model, but the database still has the old names.Rename the database constraints to the conventional names first.
SQLite batch migrations cannot alter a constraintSQLite constraints are anonymous unless named; batch mode rebuilds tables and needs names to find them.The same convention; batch mode reads it from target_metadata.
Adopting a convention on an existing schema Five steps. Add naming_convention to the MetaData. Run a comparison script that lists every constraint whose database name differs from the conventional name. Generate a migration of ALTER TABLE RENAME CONSTRAINT and ALTER INDEX RENAME statements from that list. Apply it; renames are catalog-only and fast. Run autogenerate and confirm it produces an empty revision. add naming_convention to Base.metadata no DDL yet compare names database vs convention reflect with the inspector rename migration RENAME CONSTRAINT / RENAME TO catalog-only, brief lock autogenerate must produce an empty revision Renames are metadata changes: no table rewrite, just a short ACCESS EXCLUSIVE lock per table.

The drop-and-recreate row is the one that catches teams adopting a convention late. After adding naming_convention, the model's foreign key is called fk_orders_customer_id_customers while the database's is still orders_customer_id_fkey. Autogenerate sees two different constraints: one only in the database, to drop, and one only in the model, to create. Applied as generated, that drops and re-adds every foreign key — which on large tables means re-validating every row under lock. The next section renames instead.

Advanced: Adopting a Convention on an Existing Database

Renaming constraints is cheap — ALTER TABLE ... RENAME CONSTRAINT and ALTER INDEX ... RENAME TO only change catalog entries — so the adoption migration is fast even on large tables. The work is producing the list of renames, and the inspector does it by comparing reflected names with the names the convention would produce.

Long names are truncated at 63 bytes Bar chart against PostgreSQL 63 byte identifier limit. A typical foreign key name of 40 bytes fits. A name built from long table and column names of 78 bytes would exceed the limit, and SQLAlchemy shortens it deterministically, keeping a prefix and appending a short hash so the name is still unique and stable. fk_orders_customer_id_customers 31 bytes — fits PostgreSQL limit (NAMEDATALEN − 1) 63 bytes fk_fulfilment_shipment_line_items_… 78 bytes — shortened with a hash suffix Without deterministic shortening PostgreSQL truncates silently, and two long names can collide.
import asyncio

from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import create_async_engine

from shop.models import Base


def _planned_renames(sync_conn) -> list[str]:
    inspector = inspect(sync_conn)
    statements: list[str] = []
    for table in Base.metadata.sorted_tables:
        live_fks = {tuple(fk["constrained_columns"]): fk["name"]
                    for fk in inspector.get_foreign_keys(table.name)}
        for fk in table.foreign_key_constraints:
            cols = tuple(c.name for c in fk.columns)
            live = live_fks.get(cols)
            if live and live != fk.name:
                statements.append(
                    f'ALTER TABLE {table.name} RENAME CONSTRAINT "{live}" TO "{fk.name}"'
                )
        live_uqs = {tuple(uq["column_names"]): uq["name"]
                    for uq in inspector.get_unique_constraints(table.name)}
        for uq in table.constraints:
            if uq.__class__.__name__ == "UniqueConstraint":
                cols = tuple(c.name for c in uq.columns)
                live = live_uqs.get(cols)
                if live and live != uq.name:
                    statements.append(
                        f'ALTER TABLE {table.name} RENAME CONSTRAINT "{live}" TO "{uq.name}"'
                    )
        pk = inspector.get_pk_constraint(table.name)
        if pk["name"] and pk["name"] != table.primary_key.name:
            statements.append(
                f'ALTER TABLE {table.name} RENAME CONSTRAINT "{pk["name"]}" '
                f'TO "{table.primary_key.name}"'
            )
        live_ix = {tuple(ix["column_names"]): ix["name"]
                   for ix in inspector.get_indexes(table.name)}
        for index in table.indexes:
            cols = tuple(c.name for c in index.columns)
            live = live_ix.get(cols)
            if live and live != index.name:
                statements.append(f'ALTER INDEX "{live}" RENAME TO "{index.name}"')
    return statements


async def main() -> None:
    engine = create_async_engine("postgresql+asyncpg://shop:secret@db/shop")
    async with engine.connect() as conn:
        for statement in await conn.run_sync(_planned_renames):
            print(f'    op.execute(\'{statement}\')')
    await engine.dispose()


asyncio.run(main())

Paste the printed lines into a new revision's upgrade(), reverse them for downgrade(), and apply it. Check constraints are left out of the script deliberately: the inspector reports their SQL text in the database's normalised form, which rarely matches the model's text, so matching them automatically is unreliable. List them with inspector.get_check_constraints() and rename by hand.

The finishing test is the one that matters: run alembic revision --autogenerate against the renamed database. An empty upgrade() means the model and the database agree on every name, and from here on every generated migration will too. Checking that in CI, as described in detecting column type and server default changes in autogenerate, keeps it that way.

Multi-Column Constraints and Custom Tokens

The templates in the quick answer name a constraint after its first column, which is fine until a table has two composite constraints that start with the same column. UniqueConstraint("tenant_id", "sku") and UniqueConstraint("tenant_id", "slug") both become uq_products_tenant_id, and the second CREATE fails with relation "uq_products_tenant_id" already exists.

First column only, or every column Two tiles. With column_0_name, two unique constraints on tenant_id and sku, and on tenant_id and slug, both produce uq_products_tenant_id, and creating the second fails because the name exists. With column_0_N_name, they produce uq_products_tenant_id_sku and uq_products_tenant_id_slug, which are distinct and readable. %(column_0_name)s (tenant_id, sku) → uq_products_tenant_id (tenant_id, slug) → uq_products_tenant_id relation already exists %(column_0_N_name)s (tenant_id, sku) → …_tenant_id_sku (tenant_id, slug) → …_tenant_id_slug distinct and readable IntegrityError messages quote the constraint name, so a readable name is also better alerting.

SQLAlchemy provides multi-column variants of each column token. %(column_0_N_name)s joins every column name with underscores, and %(column_0N_name)s joins them with no separator:

from sqlalchemy import MetaData, UniqueConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

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 Base(DeclarativeBase):
    metadata = MetaData(naming_convention=NAMING_CONVENTION)


class Product(Base):
    __tablename__ = "products"
    id: Mapped[int] = mapped_column(primary_key=True)
    tenant_id: Mapped[int]
    sku: Mapped[str]
    slug: Mapped[str]
    __table_args__ = (
        UniqueConstraint("tenant_id", "sku"),    # uq_products_tenant_id_sku
        UniqueConstraint("tenant_id", "slug"),   # uq_products_tenant_id_slug
    )

Choose the multi-column form from the start if you can. Switching templates later renames every existing constraint in the model, and the adoption script from the previous section has to be run again.

When no built-in token fits, a convention can reference a token you define as a function. The function receives the constraint and its table and returns a string, which is useful for shortening predictably long names yourself rather than relying on truncation:

import hashlib

from sqlalchemy import MetaData


def column_hash(constraint, table) -> str:
    joined = "_".join(column.name for column in constraint.columns)
    return hashlib.sha1(joined.encode()).hexdigest()[:8]


metadata = MetaData(
    naming_convention={
        "column_hash": column_hash,
        "ix": "ix_%(table_name)s_%(column_hash)s",
    }
)

A hashed name is stable and short but tells a reader nothing, so reserve it for index names on tables with many wide composite indexes, and keep readable names for constraints that appear in error messages. IntegrityError output quotes the constraint name, and uq_products_tenant_id_sku in an alert explains itself in a way ix_products_3f9a1c2d never will.

Frequently Asked Questions

Where should the naming convention be defined?

On the MetaData of your declarative base, before any tables are defined against it: class Base(DeclarativeBase): metadata = MetaData(naming_convention=...). Alembic must use that same metadata as target_metadata.

What does %(column_0_label)s mean?

The label of the first column, which is the table name and column name joined by an underscore — orders_customer_id. %(column_0_name)s is the column name alone. Variants with N, such as %(column_0N_name)s, join every column in a multi-column constraint.

What happens when a generated name is longer than 63 characters?

SQLAlchemy shortens convention-generated names that exceed the dialect limit deterministically, truncating and appending a short hash, so the name stays stable and unique. PostgreSQL itself would truncate silently, which can make two long names collide.

Do I need a convention if I only use PostgreSQL?

Yes. PostgreSQL invents names consistently for a given table definition, but not across definitions that changed over time, and you cannot refer to an invented name from the model. The convention makes the model the source of truth for names.