導入:なぜ「手動DDL」は本番で必ず破綻するのか
スキーマ変更を psql から手で ALTER TABLE する——開発初期はそれで回ります。しかし本番運用に入った瞬間、この方法は次の問いに答えられなくなります。
- 「ステージングと本番のスキーマは本当に一致しているか? 誰も保証できない」
- 「3 週間前に流した
ALTERを、ロールバックしたい。どう戻す?」 - 「新しく入ったメンバーのローカル DB を、本番と同じ状態にするには何をどの順で流す?」
スキーマはコードと同じく、バージョン管理され、再現可能で、前進も後退もできるべきものです。これを Python で実現する事実上の標準が、SQLAlchemy 作者本人が開発する Alembic です。
しかし Alembic には、初学者が必ずハマる落とし穴があります。それが autogenerate(自動生成)への過信です。alembic revision --autogenerate は強力ですが、公式ドキュメントが明確に「検出できない変更」を列挙しているにもかかわらず、生成されたスクリプトを無検証でマージする運用が事故を生みます。テーブル名のリネームが「全データを失うドロップ&クリエイト」として生成される——これは autogenerate の仕様であって、バグではありません。
この記事は、入門の繰り返しではありません。公式ドキュメント(alembic.sqlalchemy.org)に忠実でありながら、それより一段わかりやすく、実務で必ず直面する以下の壁を突破することを目的とします。
- 「
env.pyのtarget_metadataを何に繋げば autogenerate が動くのか分からない」 - 「autogenerate が何を検出し、何を検出しないのかを正確に知りたい」
- 「制約名が DB ごとにバラバラで、
DROP CONSTRAINTが書けない」 - 「列の追加に NOT NULL を付けたらデプロイ中にアプリが落ちた。ゼロダウンタイムでスキーマを進化させたい」
ここで扱う SQLAlchemy モデル(Base.metadata)の設計そのものは、姉妹記事の SQLAlchemy 2.0 実践ガイド で詳説しています。本記事は、そのモデルを安全にデータベースへ反映するフェーズに焦点を当てます。
筆者は、経済産業大臣賞を受賞した B2B SaaS のバックエンドを Python / SQLAlchemy 2.0 / PostgreSQL 16 で設計・運用し、本番 DB のスキーマ進化を Alembic で回してきました。本記事は、その実装で得た知見を公式ドキュメントの裏付けと共に整理したものです。
1. セットアップ:alembic init と env.py の target_metadata 配線
Alembic のすべては、プロジェクトルートで 1 コマンド叩くところから始まります。
alembic init alembic
公式ドキュメントによれば、これは「generic(汎用)テンプレートを使った環境」を生成します。作られる構成は次のとおりです。
| パス | 役割 |
|---|---|
alembic.ini | 設定ファイル。script_location(環境のパス)が唯一の必須設定 |
alembic/env.py | マイグレーション実行ごとに走る Python スクリプト。ここが配線の心臓部 |
alembic/script.py.mako | 新しいマイグレーションファイルを生成するテンプレート |
alembic/versions/ | 生成されたマイグレーションスクリプトが溜まるディレクトリ |
alembic/README | 説明ファイル |
H3: target_metadata を Base.metadata に繋ぐ
autogenerate を機能させる唯一にして最重要の配線が、env.py 内の target_metadata です。ここに自分たちの SQLAlchemy モデルの MetaData を渡すことで、Alembic は「あるべきスキーマ」を知ります。
# alembic/env.py(上部)
from myapp.models import Base # 自分たちの DeclarativeBase
target_metadata = Base.metadata
そして env.py の run_migrations_online() で、この target_metadata を context.configure() に渡します。公式の env.py はおおむね次の形をしています。
def run_migrations_online():
engine = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
)
with engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
なぜこれが優れているのか?
autogenerate は、target_metadata(コードが定義する「あるべき姿」)と、接続先 DB の実際のスキーマ(reflection で読み取った「現状」)を差分比較して、その差を埋めるマイグレーションを生成します。つまり Base.metadata を渡し忘れると autogenerate は何も検出できず、逆に正しく繋げば、モデルを変更してコマンドを 1 つ叩くだけで差分スクリプトの「下書き」が手に入ります。
⚠️ 接続情報の扱い:
alembic.iniのsqlalchemy.urlに本番の接続文字列を平文で書くのは禁物です。env.py内でos.environから読み込み、config.set_main_option("sqlalchemy.url", ...)で注入するか、後述のオフラインモードで接続情報自体を環境から切り離してください。認証情報はコードにもリポジトリにも残さない——これが鉄則です。
2. autogenerate を「信頼しつつ検証する」——検出できる/できないの早見表
ここが本記事で最も誤解の多いテーマです。autogenerate は便利ですが、全能ではありません。公式ドキュメントは検出能力を明示的に 3 段階に分類しています。生成コマンドはこうです。
alembic revision --autogenerate -m "add user table"
公式は重要な警告を添えています。「生成されたマイグレーションは候補であり、実行前に人間によるレビューと調整を必須とする(candidates requiring manual review)」。これを軽視した運用が、本番事故の源泉になります。
H3: 公式に忠実な「検出能力」早見表
下表は公式の "What does Autogenerate Detect (and what does it not detect?)" を、実務の判断軸で整理したものです。
| 分類 | 対象 | 実務上の意味 |
|---|---|---|
| ✅ 常に検出 | テーブルの追加・削除 | 新規エンティティはそのまま生成される |
| ✅ 常に検出 | 列の追加・削除 | 列の増減は信頼してよい |
| ✅ 常に検出 | 列の nullable 状態の変化 | NULL 許容 ↔ NOT NULL は検出される |
| ✅ 常に検出 | インデックス・明示的に命名された一意制約の基本的な変化 | 命名規約が効く理由(§3) |
| ✅ 常に検出 | 外部キー制約の基本的な変化 | 参照関係の増減 |
| ⚠️ オプションで検出 | 列の型変更 | compare_type=True を有効化して初めて検出 |
| ⚠️ オプションで検出 | サーバーデフォルトの変化 | compare_server_default=True で初めて検出 |
| ❌ 検出しない | テーブル名の変更(リネーム) | 別テーブルの追加+削除として生成される |
| ❌ 検出しない | 列名の変更(リネーム) | 同様に列の追加+削除ペアになる |
| ❌ 検出しない | 匿名(無名)の制約 | 名前がないと差分を追跡できない(§3) |
| ❌ 検出しない | ENUM 等の特殊型(直接サポートしない DB 上で) | 手書きが必要 |
| ❌ 検出しない | 一部の独立した制約(PRIMARY KEY・EXCLUDE・CHECK) | 手書きで補う |
| ❌ 検出しない | シーケンスの追加・削除 | 未実装(手書き) |
H3: 最も危険な落とし穴——リネームが「ドロップ&クリエイト」になる
公式の言葉を正確に引きます。テーブル名やカラム名の変更について——
「これらは 2 つの異なるテーブルの追加/削除として出力される。名前変更へと手で書き直すべきである。」
つまり、モデルで name 列を full_name にリネームしただけで autogenerate を信じると、生成されるのは次のような破壊的スクリプトです。
def upgrade() -> None:
# autogenerate が生成する「危険な」差分(そのまま流すとデータ消失)
op.add_column("users", sa.Column("full_name", sa.String(), nullable=True))
op.drop_column("users", "name") # ← name のデータは全消失する
正しくは、op.alter_column の new_column_name を使い、データを保持したままリネームへ手で書き直します。
def upgrade() -> None:
op.alter_column("users", "name", new_column_name="full_name")
def downgrade() -> None:
op.alter_column("users", "full_name", new_column_name="name")
だからこそ、生成スクリプトのレビューは「規律」として運用に組み込むべきです。 筆者のチームでは、autogenerate の出力は必ず PR の差分として人間がレビューし、drop_column / drop_table を含む差分には特別な注意フラグを立てていました。autogenerate は「下書きを書いてくれるペアプログラマ」であって、「無検証で信頼する自動操縦」ではありません。
💡 型変更を検出させたいなら明示的に有効化する:
env.pyのcontext.configure(...)にcompare_type=Trueとcompare_server_default=Trueを渡すと、列型とサーバーデフォルトの変化も差分に含まれます。ただし型比較は DB の方言差で誤検出(false positive)も出るため、有効化したうえで生成結果を必ず目視する前提で使ってください。
3. 命名規約:制約名を決定的にして autogenerate 差分を安定させる
§2 の表で「明示的に命名された一意制約」「匿名の制約は検出しない」と繰り返したのには理由があります。Alembic が制約の差分を追跡できるのは、制約に安定した名前が付いているときだけだからです。
ところが、名前を指定せずに UniqueConstraint(...) や ForeignKey(...) を書くと、制約名は DB のバックエンド任せになります。公式が挙げる例では、Oracle は SYS_C0029334 のような毎回変わりうるコードを生成します。これでは autogenerate が「同じ制約」と認識できず、差分が不安定になり、DROP CONSTRAINT も書けません。
H3: MetaData(naming_convention=...) で名前を一元化する
解決策は、MetaData に**命名規約(naming convention)**を設定し、すべての制約名を決定的に自動生成させることです。公式が推奨する規約辞書はこれです。
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase
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)
各トークンの意味は次のとおりです。
| トークン | 制約の種類 | 生成例 |
|---|---|---|
ix | インデックス | ix_users_email |
uq | 一意制約 | uq_users_email |
ck | チェック制約 | ck_users_status |
fk | 外部キー | fk_orders_user_id_users |
pk | 主キー | pk_users |
なぜこれが決定的に重要なのか?
この規約を Base.metadata に設定すると、明示的に名前を書いていない制約にも一貫した名前が自動で付与されます。結果、
- autogenerate の差分が安定する:制約名が毎回同じなので、Alembic が「変化なし」を正しく判定でき、ノイズ差分が消える。
DROP CONSTRAINT/ALTER CONSTRAINTが書ける:名前が予測可能なので、後のマイグレーションで制約を確実に操作できる。- DB 移植性が上がる:PostgreSQL でも他の DB でも同じ名前になる。
これは典型的な「ETC(Easy To Change)」かつ「DRY」の実践です。命名ルールが一箇所に集約され、全制約に一貫適用される。この設定は、autogenerate を本番運用に乗せるなら最初に必ず入れるべき土台です。後から導入すると既存制約のリネーム差分が大量発生するため、プロジェクトの初日に入れてください。
4. マイグレーション運用:upgrade / downgrade と CI 適用、batch 操作
生成されたマイグレーションスクリプトは、alembic/versions/ に次の骨格で置かれます。
"""add user table
Revision ID: 1975ea83b712
Revises: 9a8b7c6d5e4f
"""
revision = "1975ea83b712" # このスクリプト自身の ID
down_revision = "9a8b7c6d5e4f" # 1 つ前のスクリプトの ID(リビジョンの鎖)
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(255), nullable=False),
)
op.create_index("ix_users_email", "users", ["email"], unique=True)
def downgrade() -> None:
op.drop_index("ix_users_email", "users")
op.drop_table("users")
revision と down_revision が有向の鎖を作り、Alembic はこの鎖を辿って「どの順でどこまで適用するか」を決めます。downgrade() は upgrade() の逆操作を、適用と逆順で書くのが原則です。
H3: 日常運用で使う主要コマンド
| コマンド | 動作 |
|---|---|
alembic upgrade head | 未適用のマイグレーションを最新(head)まですべて適用 |
alembic upgrade +2 | 次の 2 つだけ適用 |
alembic downgrade -1 | 1 つ戻す |
alembic downgrade base | 初期状態まですべて巻き戻す |
alembic current | 現在 DB が適用済みのリビジョンを表示 |
alembic history | マイグレーション履歴を表示(--verbose で詳細) |
alembic heads | 現在の head 一覧(複数あれば §6 の分岐状態) |
リビジョン ID は曖昧でなければ部分指定が効きます(ae1 が ae1027a6acf を指す)。
H3: CI で「マイグレーションの正しさ」を検証する
スキーマ変更を本番に出す前に、CI で次を機械的に検証することが、回復性(Reliability)の要になります。
# 空の使い捨て DB に対して、head まで適用 → 1 つ戻す → 再度適用 が通るか
alembic upgrade head
alembic downgrade -1
alembic upgrade head
なぜこれが効くのか?
downgrade() を書き忘れた、あるいは逆操作が間違っているマイグレーションは、この往復で必ず落ちます。「前進はできるが後退できない」マイグレーションは、本番障害時のロールバック手段を失うことを意味します。CI でこの往復を強制すれば、ロールバック不能なマイグレーションがマージされる前に検出できます。さらに、autogenerate を --autogenerate で再実行して「差分が空(=モデルと DB が一致)」であることを確認すれば、書き漏れも検知できます。
H3: batch 操作——SQLite の ALTER 制約を回避する
ローカル開発やテストで SQLite を使う場合、深刻な制約に直面します。公式いわく SQLite は「スキーマ移行が依存する ALTER 文をほとんどサポートしない」。ALTER TABLE ... DROP COLUMN のような操作が、そのままでは動きません。
Alembic の解決策が batch 操作です。内部で「move and copy」ワークフロー——既存テーブルを反映し、変更を加えた新テーブルを作り、INSERT ... SELECT でデータをコピーし、旧テーブルを落として新テーブルを元の名前にリネームする——を自動で行います。
def upgrade() -> None:
with op.batch_alter_table("users") as batch_op:
batch_op.add_column(sa.Column("phone", sa.String(20)))
batch_op.drop_column("legacy_field")
さらに、autogenerate にこの batch 構文で出力させたい場合、env.py の context.configure(...) に render_as_batch=True を渡します。
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
)
💡 PostgreSQL では batch は実質ノーオペ:公式によれば「batch コンテキストはデフォルトでは SQLite が使われているときだけ move and copy を行う」。つまり
render_as_batch=Trueを有効にしても、本番の PostgreSQL では通常のALTERが発行されます。「テストは SQLite、本番は PostgreSQL」という構成でも、同じマイグレーションコードが両方で動く——これが batch を入れておく価値です。
5. ゼロダウンタイム・スキーマ変更:expand/contract パターン(最重要)
ここからが本番運用の核心です。スキーマ変更そのものは Alembic が安全に流してくれます。問題は、アプリケーションが動き続けている最中にスキーマを変えるときに起きます。
素朴に「NOT NULL の新しい列をモデルに足して alembic upgrade head」を実行すると、何が起きるか。デプロイの過渡期には、新スキーマを前提とする新コードと、旧スキーマを前提とする旧コードが一時的に混在します。新列に NOT NULL を即座に課すと、その列を知らない旧コードの INSERT が制約違反で全滅し、サービスが落ちます。
これを避ける定石が、expand / contract パターン(並行変更, Parallel Change)です。スキーマ変更を「拡張(expand)」と「収縮(contract)」の複数フェーズに分け、各フェーズで旧コードと新コードの両方が動ける状態を常に保ちます。
H3: 「列の追加とリネーム」を 4 デプロイで安全に行う
例として、users.name を、より厳密な users.full_name(最終的に NOT NULL)へ移行するケースを考えます。前述のとおり、これを直接 alter_column(new_column_name=...) でやると、過渡期に旧コードが存在しない列を参照して壊れます。直接リネームしてはいけない理由がこれです。
| フェーズ | マイグレーション | アプリのコード | 安全性 |
|---|---|---|---|
| ① Expand | full_name を nullable で追加 | 旧コードは name を読み書き(無変更) | 新列は空でも誰も困らない |
| ② Dual-write | (なし) | 新コードをデプロイ:書き込みは name と full_name の両方へ | 旧コードが残っていても両者整合 |
| ③ Backfill | op.execute で既存行の full_name を埋める | 全行が埋まったら読み取りを full_name に切替 | 過去データも揃う |
| ④ Contract | full_name を NOT NULL 化 → 旧列 name を削除 | 新コードのみ稼働 | 旧コードはもういない |
各フェーズが独立したデプロイになります。マイグレーションのコードはこうです。
# フェーズ①:拡張(nullable で追加するのでロックは一瞬。誰も壊れない)
def upgrade() -> None:
op.add_column("users", sa.Column("full_name", sa.String(255), nullable=True))
# フェーズ③:バックフィル(スキーマ変更ではなくデータ移行)
def upgrade() -> None:
op.execute("UPDATE users SET full_name = name WHERE full_name IS NULL")
# フェーズ④:収縮(全行が埋まり、旧コードが消えてから初めて NOT NULL 化)
def upgrade() -> None:
op.alter_column("users", "full_name", nullable=False)
op.drop_column("users", "name")
なぜこの多段デプロイが必要なのか? 鍵は「いかなる瞬間も、稼働中のコードがスキーマと矛盾しない」状態を保つことです。expand フェーズで列を nullable で足すから旧コードは無傷、dual-write で両系列のデータが揃うから読み取り切替が安全、backfill で過去分も埋まるから NOT NULL 化が成立する。各ステップが「前のステップが完了している」前提を満たして初めて次へ進む——これがゼロダウンタイムの本質です。
⚠️ 大規模テーブルでの長時間ロックに注意:PostgreSQL では、
ALTER TABLE ... SET NOT NULLや非並行なCREATE INDEXは、テーブル全体に強いロックを取り、その間INSERT/UPDATEをブロックします。数千万行のテーブルでこれをやると、ロック待ちでアプリがタイムアウトします。対策は、(1) インデックスはop.create_index(..., postgresql_concurrently=True)で並行作成する(このマイグレーションはトランザクション外で実行する必要がある点に注意)、(2) NOT NULL 化は、先にCHECK (col IS NOT NULL) NOT VALID制約を貼ってVALIDATEしてからSET NOT NULLする、といった PostgreSQL 固有の技法を使うことです。スキーマ変更のロック特性は、対象 DB の挙動を必ず事前に確認してください。
H3: オフラインモード(--sql)で DBA レビューを通す
本番 DB の変更を、アプリから直接 upgrade してはいけない組織は少なくありません。DBA が SQL をレビューし、メンテナンスウィンドウで手動適用する運用が求められます。
Alembic のオフラインモードは、DB に接続せず、適用されるはずの SQL を標準出力に吐くだけにできます。
# 接続せず、適用予定の SQL を生成して DBA に渡す
alembic upgrade head --sql > migration.sql
# 特定リビジョン間の差分だけを SQL 化することも可能
alembic upgrade 9a8b7c:1975ea --sql > step.sql
なぜこれが優れているのか?
生成された migration.sql は、実行前に人間(DBA)がレビューできる成果物になります。DROP が含まれていないか、ロックの強い ALTER がないか、実行計画への影響はどうか——これらを実行前に机上で検証できます。アプリケーションに本番 DB の DDL 権限を与えず、最小権限の原則(Principle of Least Privilege)を保ったまま、スキーマ変更だけを安全な経路に切り出せます。
6. 分岐とマージ:複数 head を解消する
チーム開発では、2 人の開発者が同じ親リビジョンから別々にマイグレーションを作ることがあります。たとえば A さんが 1975ea を親に ae1027 を、B さんが同じ 1975ea を親に 27c6a3 を作ると、マージ後に2 つの headが生まれます。
この状態で alembic upgrade head を打つと、行き先が曖昧なためエラーになります。まず head を確認します。
alembic heads # 現在の head 一覧
alembic branches --verbose # 分岐点を表示
解消には alembic merge を使い、両 head を 1 つに束ねるマージリビジョンを作ります。
alembic merge -m "merge ae1027 and 27c6a3" ae1027 27c6a3
# あるいは全 head を一括で
alembic merge -m "merge heads" heads
生成されるマージリビジョンは、down_revision が両親を指すタプルになります。
down_revision = ("ae1027a6acf", "27c6a30d7c24")
これで鎖が再び 1 本の head に収束し、alembic upgrade head が通るようになります。Git のブランチマージとまったく同じ発想——分岐は悪ではなく、明示的にマージして履歴を一本化すればよい、と理解すれば怖くありません。
結論:スキーマを「コードと同じく」進化させる
Alembic は、スキーマ変更を再現可能・可逆・レビュー可能にする、SQLAlchemy エコシステムの標準ツールです。本記事の要点を再掲します。
env.pyのtarget_metadata = Base.metadataが autogenerate のすべての起点。- autogenerate は「下書き」。検出できない変更(リネーム・匿名制約・シーケンス等)を正確に把握し、生成スクリプトは必ずレビューする。
MetaData(naming_convention=...)で制約名を決定的にし、差分を安定させる(初日に入れる)。- CI で
upgrade → downgrade → upgradeの往復を検証し、ロールバック不能なマイグレーションを弾く。 - **batch 操作(
render_as_batch=True)**で、SQLite テストと PostgreSQL 本番を 1 つのコードで両立する。 - expand/contract パターンで、稼働中アプリと矛盾しない多段デプロイを設計し、ゼロダウンタイムを実現する。
- **オフラインモード(
--sql)**で DBA レビューを通し、最小権限を保つ。
「動くスキーマ変更」と「無停止で安全に進化できるスキーマ」の差は、これら一つひとつの設計判断の積み重ねにあります。Alembic がマイグレーションするモデルの設計は SQLAlchemy 2.0 実践ガイド で、それを載せる API 層の運用は FastAPI 本番運用ガイド で、それぞれ解説しています。
さらなる探求として、公式ドキュメントの以下を本記事の運用観点で再読することをお勧めします。
- Alembic Tutorial(基本コマンドとスクリプト構造)
- Auto Generating Migrations(検出能力の正確な範囲)
- The Importance of Naming Constraints(命名規約)
- Running "Batch" Migrations for SQLite and Other Databases(batch 操作)
- Working with Branches(分岐・マージ)
- Operation Reference(
op.*の全リファレンス)
本番DBのスキーマ移行・ゼロダウンタイム化のご相談
筆者は、ここで解説した Alembic によるスキーマ進化を、経済産業大臣賞を受賞した B2B SaaS の本番環境(SQLAlchemy 2.0 / PostgreSQL 16)で実装・運用してきました。また、サーバーレス決済プラットフォームでは、ゼロダウンタイム移行と冪等性設計により本番二重課金 0 件を達成しています。expand/contract による無停止スキーマ変更、命名規約の整備、CI での往復検証、DBA レビュー経路の設計といった、事業の信頼性に直結するデータベース基盤を、生成 AI を活用して高速かつ高品質に構築します。本番 DB のスキーマ移行・ゼロダウンタイム化・既存マイグレーション運用の改善について、お気軽にご相談ください。