Alembic rename table and FK columns - constraint names diverge from SQLAlchemy naming convention on PostgreSQL
Renaming a table and its primary-key/FK columns in an Alembic migration on PostgreSQL 18.2 (Alembic 1.20.0, SQLAlchemy 2.0.54, pytest-alembic 0.12.1, psycopg2 2.9.13) with op.rename_table + op.alter_column(new_column_name=...). SQLAlchemy MetaData uses a naming_convention (pk_%(table_name)s, fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s, ix_..., uq_...). After the migration, the app and pytest-alembic's test_model_definitions_match_ddl both pass, but pg_constraint still lists names like pk_oldtable, fk_child_old_id_oldtable, ix_child_old_id, and constraints I never created: oldtable_old_id_not_null, oldtable_created_at_not_null, child_old_id_not_null. A fresh metadata.create_all produces different names, so the migrated schema and a freshly built one silently diverge; the next autogenerated drop/alter by name would target a name that doesn't exist. I assumed RENAME TABLE/RENAME COLUMN would carry names along, and that the drift test compares constraint names. Separately, a descriptive revision id like '20260925_rename_oldtables_to_newtables' failed at the very end with psycopg2.errors.StringDataRightTruncation: value too long for type character varying(32) on UPDATE alembic_version SET version_num=....
Postgres never renames constraints or indexes when you rename a table or column; only their definitions follow. PostgreSQL 18 makes this more visible because NOT NULL is now stored as a real pg_constraint row with an auto-generated name <table>_<column>_not_null (PG 18 release notes: https://www.postgresql.org/docs/18/release-18.html), so every renamed NOT NULL column leaves a stale name too. Alembic autogenerate (and so pytest-alembic's test_model_definitions_match_ddl) matches FKs by columns and ignores NOT NULL constraint names, so nothing fails.
Fix: rename by pattern inside the same migration, after the table/column renames. Constraint renames carry their backing index (pk/uq); plain indexes need ALTER INDEX. RENAME CONSTRAINT works on PG18 not-null constraints.
_RENAME_OBJECTS = """
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT c.conrelid::regclass::text AS tbl, c.conname AS name
FROM pg_constraint c JOIN pg_namespace n ON n.oid = c.connamespace
WHERE n.nspname = current_schema()
AND (c.conname LIKE '%{old_table}%' OR c.conname LIKE '%{old_col}%')
LOOP
EXECUTE format('ALTER TABLE %I RENAME CONSTRAINT %I TO %I', r.tbl, r.name,
replace(replace(r.name, '{old_table}', '{new_table}'), '{old_col}', '{new_col}'));
END LOOP;
FOR r IN
SELECT indexname AS name FROM pg_indexes
WHERE schemaname = current_schema()
AND (indexname LIKE '%{old_table}%' OR indexname LIKE '%{old_col}%')
LOOP
EXECUTE format('ALTER INDEX %I RENAME TO %I', r.name,
replace(replace(r.name, '{old_table}', '{new_table}'), '{old_col}', '{new_col}'));
END LOOP;
END $$;
"""
op.execute(_RENAME_OBJECTS.format(old_table="oldtable", new_table="newtable",
old_col="old_id", new_col="new_id"))Downgrade is the same call with the arguments swapped. If you recreate a CHECK constraint, use raw ALTER TABLE ... ADD CONSTRAINT ck_t_kind CHECK (...): op.create_check_constraint('ck_t_kind', ...) runs the name through a ck_%(table_name)s_%(constraint_name)s convention and doubles the prefix.
Verify what the drift test can't: create a scratch DB from the model DDL (metadata.create_all, or CreateTable output piped to psql), pg_dump -s it and the migrated DB (excluding alembic_version), and diff sorted lines. With names renamed they are identical.
The truncation error is Alembic's default alembic_version.version_num VARCHAR(32). PostgreSQL's transactional DDL rolls the whole migration back, so just shorten the revision id (e.g. 20260925_rename_t).