Alembicのautogenerateが拾わない変更と対処法

Alembicのautogenerateが拾わない変更と対処法 | mohablog

Alembic 1.18.4 の autogenerate は ALTER TABLE 系をかなり拾ってくれます。ただし公式ドキュメントの What does Autogenerate Detect (and what does it _not_ detect?) セクションには、検出できないものがはっきり並んでいます。テーブル名・カラム名の rename、CHECK 制約、PostgreSQL の ENUM 型、Sequence。autogenerate 任せにすると、これらは差分として出ないまま本番のマイグレーションに進みます。

目次

autogenerate が見落とす変更の一覧

Alembic 1.18.4 のドキュメントには What does Autogenerate Detect (and what does it _not_ detect?) という長い見出しがあり、検出する/しないが項目でそのまま並んでいます。

変更内容autogenerate補い方
テーブルの追加・削除検出そのまま使える
カラムの追加・削除検出そのまま使える
nullable の変更検出そのまま使える
名前付き unique / FK 制約の変更検出そのまま使える
テーブル名の rename検出されない (add/drop で出る)diff を破棄して op.rename_table() を手書き
カラム名の rename検出されない (add/drop で出る)diff を破棄して op.alter_column(new_column_name=...) を手書き
CHECK 制約 / EXCLUDE / 匿名制約検出されないop.create_check_constraint() を手書き
PostgreSQL の ENUM 値追加・rename検出されないop.execute("ALTER TYPE ...") を手書き
Sequence の追加・削除検出されないop.execute("CREATE SEQUENCE ...") を手書き

ドキュメントの文末には autogenerate is not intended to be perfect とあり、生成後の手書きレビューが前提です。alembic revision --autogenerate -m "..." の結果を読まずにそのままコミットすると、rename 系はそのまま データを失う add/drop として実行されます。drop 側で旧カラムのデータが消失するので、autogenerate 後のレビューを省略するワークフローを組まないことが前提条件です。

ENUM 値の追加・rename を手書きで書く

PostgreSQL の ENUM は autogenerate が空ファイルを返す側の代表例で、Google サジェストにも alembic enum already existsalembic autogenerate empty migrationalembic enum values to rename がそのまま上位に並びます。値を増やしたのにマイグレーションに何も入っていない、という状態の検索流入がそのまま反映されています。

autogenerate が空のマイグレーションを返す理由

SQLAlchemy 側で sqlalchemy.Enum の値を増やしても、autogenerate は ENUM 型の差分を出しません。公式の検出対象に Special SQLAlchemy types like Enum on non-supporting backends が明示的に 非対応 と書かれています。結果として alembic revision --autogenerate は upgrade / downgrade が pass だけの空ファイルを吐きます。

# models.py
from sqlalchemy import Enum

class OrderStatus(str, enum.Enum):
    pending = "pending"
    paid = "paid"
    shipped = "shipped"
    cancelled = "cancelled"  # ← 新しく追加した値
$ alembic revision --autogenerate -m "add cancelled to order_status"
Generating /app/alembic/versions/8a1f3d_add_cancelled_to_order_status.py ... done

$ cat alembic/versions/8a1f3d_add_cancelled_to_order_status.py
def upgrade() -> None:
    pass

def downgrade() -> None:
    pass

「ENUM 値を増やしたのに upgrade が空」という状態は autogenerate のバグではなく、Alembic がそもそも検出しない仕様です。手書きで埋めます。

ALTER TYPE ADD VALUE はトランザクション内で実行できない

素直に op.execute("ALTER TYPE order_status ADD VALUE 'cancelled'") と書くと、PostgreSQL のバージョンによっては次のエラーが出ます。

psycopg.errors.ActiveSqlTransaction:
ALTER TYPE ... ADD cannot run inside a transaction block

Alembic はデフォルトで各マイグレーションをトランザクションで包みます。PostgreSQL 12 以降は ALTER TYPE ... ADD VALUE をトランザクション内で実行できる場合もありますが、IF NOT EXISTS や複数値の追加など条件が絡むと依然としてトランザクション外を要求してきます。安全側はマイグレーション単位でトランザクションを切る書き方です。

"""add cancelled to order_status

Revision ID: 8a1f3d
Revises: 7b2e0c
Create Date: 2026-05-07
"""
from alembic import op

revision = "8a1f3d"
down_revision = "7b2e0c"
branch_labels = None
depends_on = None

# このマイグレーションだけトランザクションで包まない
def upgrade() -> None:
    with op.get_context().autocommit_block():
        op.execute("ALTER TYPE order_status ADD VALUE IF NOT EXISTS 'cancelled'")

def downgrade() -> None:
    # ENUM 値の削除は標準 SQL で書けない (後述)
    raise NotImplementedError("PostgreSQL does not support DROP VALUE")

op.get_context().autocommit_block() はそのブロック内だけ自動コミットモードに切り替えるユーティリティです。これを使うと alembic enum already exists 系の二重実行も IF NOT EXISTS で防げます。

ENUM 値の rename と削除は別 ENUM 経由

PostgreSQL の ALTER TYPE には値の削除構文がありません。値の rename は PostgreSQL 10 以降 ALTER TYPE name RENAME VALUE 'old' TO 'new' が使えますが、こちらもトランザクション外推奨です。値を削除したい場合は新しい ENUM 型を作って差し替える定石になります。

def upgrade() -> None:
    op.execute("CREATE TYPE order_status_new AS ENUM('pending','paid','shipped')")
    op.execute("""
        ALTER TABLE orders
          ALTER COLUMN status TYPE order_status_new
          USING status::text::order_status_new
    """)
    op.execute("DROP TYPE order_status")
    op.execute("ALTER TYPE order_status_new RENAME TO order_status")

この4ステップで「ENUM 値の削除」が等価に表現できます。ただし ALTER COLUMN ... TYPE はテーブル全行を書き直すので、行数の多いテーブルでは SET lock_timeout = '5s' を前段に挟んで、長時間ロックを取る前にタイムアウトさせるほうが安全です。検証時に1000万行のテーブルでうっかり流して 30 分ブロックさせたことがあるので、本番投入前のリハーサルは省きません。

CHECK 制約・匿名制約・カラム rename の補い方

CHECK 制約・匿名制約・カラム rename も autogenerate は出力しません。手書きで補う方法はそれぞれ短く済みます。

CHECK 制約は op.create_check_constraint() で書く

SQLAlchemy 側のモデルに CheckConstraint を足しても、autogenerate は CHECK 制約を出力しません。手書きで op.create_check_constraint() を呼びます。

def upgrade() -> None:
    op.create_check_constraint(
        "ck_orders_total_positive",
        "orders",
        "total_amount >= 0",
    )

def downgrade() -> None:
    op.drop_constraint("ck_orders_total_positive", "orders", type_="check")

名前 (ck_orders_total_positive) を必ず付けるのがコツです。匿名で作ると後の drop で名前を引き当てられず、本番で constraint "ck_xxx" does not exist が出る要因になります。

匿名制約は naming_convention で名前を付けてから

SQLAlchemy 1.4 以降は MetaData(naming_convention=...) で UNIQUE / FK / CHECK 制約に自動で名前を付けられます。これを設定しておくと autogenerate が拾える対象が増えます。

from sqlalchemy import MetaData

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",
}
metadata = MetaData(naming_convention=NAMING_CONVENTION)

既存の DB に後から導入する場合は、既存制約に既に付いている匿名名と衝突するので、alembic revision --autogenerate の差分を1度全部レビューしてから流します。

カラム rename は autogenerate の差分を捨てる

カラム名を変えると autogenerate は「古いカラムを drop」「新しいカラムを add」のペアを書きます。これを実行するとデータが消えます。生成された差分は破棄して、手書きの op.alter_column() に書き換えるのが正解です。

def upgrade() -> None:
    op.alter_column(
        "users",
        "email",                 # 旧カラム名
        new_column_name="email_address",
        existing_type=sa.String(length=255),
        existing_nullable=False,
    )

existing_typeexisting_nullable を省くと、ALTER 文によっては型情報が落ちて NOT NULL が外れることがあります。ここは省略せず明示しておくほうが事故を防ぎやすいです。SQLAlchemy 2.0の新API—select()と型ヒント対応で変わった書き方 で書いた型ヒント対応の延長として、マイグレーション側でも existing_type を書くと型情報を一貫させやすくなります。

branch / merge で並行開発を捌く

並行ブランチで別々のマイグレーションを作ると、Alembic は Multiple head revisions are present と警告して止まります。公式ドキュメントの Working with BranchesMerging Branches セクションに正規の解消手順が書かれています。

場面コマンド動き
分岐を確認alembic branches --verbosehead が複数あるとここで2つ出る
マージ用 revision を作るalembic merge -m "merge ae1 and 27c" ae1027 27c6a2つの head を統合する空マイグレーションを生成
すべての head を一括alembic merge heads引数 heads で全 head をまとめて統合
upgrade で head 指定alembic upgrade headsマージしないまま並行で進めるときに使う

Git の merge と違って Alembic の merge は 空のマイグレーションファイルを1本足すだけです。両ブランチの DDL は既にそれぞれの revision で実行済みなので、merge revision には upgrade(): pass しか入りません。マージ後は alembic heads が1つだけ返れば成功です。

まとめ

  • Alembic 1.18.4 の autogenerate は テーブル/カラム rename・CHECK 制約・PostgreSQL ENUM・Sequence を検出しない。公式の What does Autogenerate Detect (and what does it _not_ detect?) セクションが原典。
  • ENUM 値の追加は op.execute("ALTER TYPE ... ADD VALUE IF NOT EXISTS")autocommit_block() で包む。値の削除は新 ENUM 経由で差し替える。
  • CHECK 制約・匿名制約は naming_convention で名前を付けてから扱う。名前を付けないと downgrade で drop できない。
  • カラム rename は autogenerate の add/drop ペアを捨てて op.alter_column(new_column_name=...) を手書きする。existing_typeexisting_nullable は省かない。
  • 並行開発で head が分岐したら alembic merge heads で空マイグレーションを足して統合。マージファイルに DDL は入らない。

autogenerate は alembic revision --autogenerate 1発で全部終わらせるためのものではなく、下書きを生成するためのアシストです。生成後にレビューして、検出されない領域を手書きで足すところまでがマイグレーション設計の本体になります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次