はじめに
FlaskとPostgreSQLで開発している売上管理アプリのマイグレーション履歴を確認したところ、重大な問題が見つかりました。
既存のローカルDBでは正常に起動できるものの、まっさらなPostgreSQLデータベースでは、現在のマイグレーション履歴だけでテーブルを作成できない状態になっていました。
原因は、最初のマイグレーションがテーブル作成ではなく、既存テーブルへのカラム追加から始まっていたことです。
空のPostgreSQL
↓
productsテーブルへis_activeカラムを追加
↓
productsテーブルが存在しないため失敗
この記事では、以下の流れを記録します。
- 問題を発見した経緯
- マイグレーション履歴の調査
- 初期マイグレーションの追加
- 空DBからの構築検証
- 既存DBへ影響しないことの検証
- 実装時に注意した点
Codexを使った調査と検証
今回の調査・修正では、VS Code上のCodexを使用しました。
ただし、Codexへ全面的に任せたのではなく、作業を次の段階に分け、各段階で実行範囲を限定しました。
静的調査
↓
修正案の設計
↓
変更対象を限定した実装
↓
静的検証
↓
空DBでの動的検証
↓
既存DBの複製環境での検証
↓
最終確認
↓
commit・push
各工程では、以下のような条件を明示しました。
- 調査段階ではファイルを変更しない
- 実装対象をマイグレーション2ファイルだけに限定する
- 通常のローカルDBへ直接マイグレーションを実行しない
- 検証用のComposeプロジェクト名を分ける
- 既存DBは
pg_dumpから作成した複製DBで検証する - 問題が見つかっても勝手に修正しない
- commit・pushは最終確認後にだけ許可する
Codexは、マイグレーション履歴の確認、差分の表示、検証用コマンドの実行、結果の整理に使用しました。
一方で、修正方針の採用、DBを扱う際の安全条件、削除対象、commit・pushの判断は、各工程の結果を確認したうえで人間側が決定しました。
今回の経験から、AIコーディング支援は単にコードを生成させるだけではなく、
変更範囲、禁止事項、検証条件、停止条件を明示して使う
ことが重要だと感じました。
開発環境
主な構成は以下です。
- Python
- Flask
- Flask-SQLAlchemy
- Flask-Migrate
- Alembic
- PostgreSQL
- Docker Compose
- pytest
Dockerコンテナの起動時には、次のようにマイグレーションを適用してからGunicornを起動する構成にしていました。
flask db upgrade && gunicorn ...
この構成では、マイグレーションが失敗するとGunicornまで到達せず、Webアプリも起動しません。
問題の発見
migrations/versions/を確認すると、存在するマイグレーションファイルは1件だけでした。
043c481b4069_add_is_active_to_products.py
このファイルは履歴上の最初のrevisionでした。
revision = '043c481b4069'
down_revision = None
しかし、upgrade()が行っていたのは、既存のproductsテーブルへis_activeカラムを追加する処理だけでした。
def upgrade():
with op.batch_alter_table('products', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'is_active',
sa.Boolean(),
server_default=sa.true(),
nullable=False
)
)
つまり、次のテーブルを作成するマイグレーションがありませんでした。
productsdaily_sales
プロジェクト全体を調べても、以下の処理は存在しませんでした。
op.create_table('products', ...)
op.create_table('daily_sales', ...)
db.create_all()
なぜ既存DBでは動いていたのか
既存DBでは、Flask-Migrateを導入する以前に別の方法でテーブルが作成されていたと考えられます。
履歴としては、次のような状態です。
Alembic管理外でproductsとdaily_salesを作成
↓
Flask-Migrateを導入
↓
productsへis_activeを追加
そのため、すでにテーブルが存在する既存DBでは問題なく動きます。
一方、空DBでは最初から次の処理が実行されます。
ALTER TABLE products
ADD COLUMN is_active BOOLEAN DEFAULT true NOT NULL;
しかし、空DBにはproductsが存在しません。
その結果、概ね次の流れで失敗します。
productsテーブルが存在しない
↓
最初のマイグレーションが失敗
↓
flask db upgradeが非ゼロ終了
↓
Gunicornが起動しない
↓
Webアプリが起動しない
現在のモデル
アプリには、次の2モデルが存在します。
Product
class Product(db.Model):
id = db.Column(db.Integer, primary_key=True)
year = db.Column(db.Integer, nullable=False)
month = db.Column(db.Integer, nullable=False)
name = db.Column(db.String(100), nullable=False)
price = db.Column(db.Integer, nullable=False)
is_active = db.Column(
db.Boolean,
nullable=False,
server_default=db.true()
)
DailySales
class DailySales(db.Model):
id = db.Column(db.Integer, primary_key=True)
product_id = db.Column(
db.Integer,
db.ForeignKey('products.id'),
nullable=False
)
date = db.Column(db.Date, nullable=False)
quantity = db.Column(
db.Integer,
nullable=False,
default=0
)
修正方針
検討した主な方法は以下です。
1. 初期マイグレーションを履歴の先頭へ追加する
初期テーブル作成
↓
is_active追加
既存のカラム追加履歴を残しながら、自然なマイグレーション履歴へ修正できます。
2. 既存の最初のマイグレーションを書き換える
既存revision内で、現在のテーブルをすべて作成する方法です。
ただし、適用済みマイグレーションの処理内容そのものを大きく変更するため、履歴の信頼性が下がります。
3. テーブルの有無を判定する条件付きマイグレーションにする
productsがなければ作成し、存在すればカラム追加する方法です。
複数のDB状態に対応できますが、状態依存の処理が増え、検証が複雑になります。
4. db.create_all()とstampを使う
モデルから直接テーブルを作成し、Alembicのrevisionだけを記録する方法です。
短時間では実現できますが、モデルとマイグレーション履歴がずれる可能性があるため、今回は採用しませんでした。
採用した方法
今回は、初期マイグレーションを履歴の先頭へ追加する方法を採用しました。
変更後の履歴は次のとおりです。
base
↓
b7e2c4a91f30
create products and daily_sales
↓
043c481b4069
add is_active to products
↓
head
初期マイグレーションの実装
次のファイルを追加しました。
migrations/versions/b7e2c4a91f30_create_initial_tables.py
内容は以下です。
"""create initial products and daily_sales tables
Revision ID: b7e2c4a91f30
Revises:
Create Date: 2026-08-06
"""
from alembic import op
import sqlalchemy as sa
revision = 'b7e2c4a91f30'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'products',
sa.Column(
'id',
sa.Integer(),
nullable=False
),
sa.Column(
'year',
sa.Integer(),
nullable=False
),
sa.Column(
'month',
sa.Integer(),
nullable=False
),
sa.Column(
'name',
sa.String(length=100),
nullable=False
),
sa.Column(
'price',
sa.Integer(),
nullable=False
),
sa.PrimaryKeyConstraint('id')
)
op.create_table(
'daily_sales',
sa.Column(
'id',
sa.Integer(),
nullable=False
),
sa.Column(
'product_id',
sa.Integer(),
nullable=False
),
sa.Column(
'date',
sa.Date(),
nullable=False
),
sa.Column(
'quantity',
sa.Integer(),
nullable=False
),
sa.ForeignKeyConstraint(
['product_id'],
['products.id']
),
sa.PrimaryKeyConstraint('id')
)
def downgrade():
op.drop_table('daily_sales')
op.drop_table('products')
テーブルの作成順序
daily_sales.product_idはproducts.idを参照します。
そのため、upgradeでは必ずproductsを先に作成します。
1. products
2. daily_sales
downgradeでは逆順に削除します。
1. daily_sales
2. products
参照元のdaily_salesを先に削除しないと、外部キー制約によってproductsを削除できない可能性があります。
is_activeを初期マイグレーションに含めなかった理由
初期マイグレーションでは、あえてis_activeを作成していません。
基礎revision
└── productsをis_activeなしで作成
↓
既存revision
└── is_activeを追加
これにより、既存の「あとからis_activeを追加した」という履歴を維持できます。
初期マイグレーション単体ではなく、headまで適用した最終状態が現在のモデルと一致する構成です。
既存revisionの変更
既存の043c481b4069では、revisionの接続情報だけを変更しました。
変更前:
revision = '043c481b4069'
down_revision = None
変更後:
revision = '043c481b4069'
down_revision = 'b7e2c4a91f30'
ドキュメント文字列のRevisesも更新しました。
-Revises:
+Revises: b7e2c4a91f30
既存のupgrade()とdowngrade()の処理本体には触れていません。
quantityへserver_defaultを追加しなかった理由
DailySales.quantityには、モデル側でdefault=0があります。
quantity = db.Column(
db.Integer,
nullable=False,
default=0
)
このdefault=0は、SQLAlchemyがINSERT時に補うPython側のデフォルトです。
DB側のデフォルトを表すserver_defaultではありません。
そのため、初期マイグレーションには次の設定を追加しませんでした。
server_default='0'
追加してしまうと、現在のモデルが表していないDB側の仕様を新たに作ることになります。
静的検証
DBを起動する前に、マイグレーションファイルの構文を確認しました。
PYTHONPYCACHEPREFIX=/tmp/sales_data_app_pycompile \
python -m py_compile \
migrations/versions/b7e2c4a91f30_create_initial_tables.py \
migrations/versions/043c481b4069_add_is_active_to_products.py
結果は正常でした。
終了コード: 0
構文エラー: なし
revisionも次の1本道になっていることを確認しました。
base
↓
b7e2c4a91f30
↓
043c481b4069
↓
head
空DBからの初期構築検証
通常のローカルDBへ影響を与えないよう、Composeのプロジェクト名を変更して、完全に分離した検証環境を作成しました。
docker compose \
-p sales_data_app_migration_test \
up --build -d
通常環境と検証環境では、コンテナ・ネットワーク・ボリュームが別名になります。
通常環境:
sales_data_app_postgres_data
検証環境:
sales_data_app_migration_test_postgres_data
検証用DBは完全な空DBです。
起動ログでは、次の順序でマイグレーションが実行されました。
Running upgrade -> b7e2c4a91f30,
create initial products and daily_sales tables
Running upgrade b7e2c4a91f30 -> 043c481b4069,
add is_active to products
その後、Gunicornも正常に起動しました。
Starting gunicorn 26.0.0
Listening at: http://0.0.0.0:5000
Booting worker
HTTPアクセスも成功しました。
200 text/html; charset=utf-8
空DBに作成された構造
作成されたテーブルは次の3つです。
alembic_version
products
daily_sales
products
| 列 | 型 | NULL | DBデフォルト |
|---|---|---|---|
id |
integer | 不可 | IDシーケンス |
year |
integer | 不可 | なし |
month |
integer | 不可 | なし |
name |
varchar(100) | 不可 | なし |
price |
integer | 不可 | なし |
is_active |
boolean | 不可 | true |
daily_sales
| 列 | 型 | NULL | DBデフォルト |
|---|---|---|---|
id |
integer | 不可 | IDシーケンス |
product_id |
integer | 不可 | なし |
date |
date | 不可 | なし |
quantity |
integer | 不可 | なし |
外部キーも想定どおり作成されました。
daily_sales.product_id
↓
products.id
削除規則は次のとおりです。
ON DELETE: NO ACTION
ON UPDATE: NO ACTION
モデルに存在しないON DELETE CASCADEは追加されていません。
また、以下も追加されていないことを確認しました。
- 独立したUNIQUE制約
- CHECK制約
- モデルにないインデックス
-
quantityのDB側デフォルト
最終revisionは次のとおりです。
043c481b4069
既存DB相当の検証
次に確認したのは、すでに043c481b4069まで適用済みの既存DBで、追加した初期revisionが再実行されないことです。
既存DBそのものへ直接マイグレーションを実行するのではなく、次の手順を取りました。
通常DBを読み取り専用で起動
↓
pg_dumpを取得
↓
通常DBを停止
↓
別の検証用PostgreSQLへ復元
↓
複製DBだけにflask db upgradeを実行
通常DBのWebコンテナは起動していません。
ダンプ取得時には、読み取り専用設定を使用しました。
PGOPTIONS=-c default_transaction_read_only=on
複製DBでのupgrade
複製DBの状態は次のとおりでした。
alembic_version = 043c481b4069
products = 16件
daily_sales = 16件
この状態で、複製DBだけにマイグレーションを実行しました。
docker compose \
-p sales_data_app_existing_migration_test \
run --rm --no-deps --build \
web flask db upgrade
結果は正常終了しました。
終了コード: 0
ログには次の表示がありませんでした。
Running upgrade -> b7e2c4a91f30
create initial products and daily_sales tables
CREATE TABLEやテーブル重複エラーも発生していません。
つまり、Alembicは現在のDBをすでにheadへ到達している状態として扱い、あとから祖先に追加された基礎revisionを再実行しませんでした。
upgrade前後の比較
upgrade前後で、以下を比較しました。
alembic_version- テーブル一覧
- 列名
- データ型
- NULL制約
- DBデフォルト
- 主キー
- 外部キー
- UNIQUE制約
- CHECK制約
- インデックス
- シーケンス状態
-
productsの全行 -
daily_salesの全行
全行を主キー順のCSVに出力し、cmpとSHA-256ハッシュで比較しました。
結果はすべて完全一致でした。
| 比較対象 | upgrade前 | upgrade後 |
|---|---|---|
| revision | 043c481b4069 |
043c481b4069 |
| products | 16件 | 16件 |
| daily_sales | 16件 | 16件 |
| スキーマ | 一致 | 一致 |
| 制約 | 一致 | 一致 |
| インデックス | 一致 | 一致 |
| シーケンス | 一致 | 一致 |
| 全データ | 一致 | 一致 |
これにより、次の2点を確認できました。
空DB:
基礎revisionが実行される
既存DB:
基礎revisionは再実行されない
pytest
最後に、既存テストを実行しました。
PYTHONDONTWRITEBYTECODE=1 \
pytest -p no:cacheprovider
結果:
3 passed in 0.06s
Git差分
変更したのは、マイグレーション関連の2ファイルだけです。
migrations/versions/
├── b7e2c4a91f30_create_initial_tables.py
└── 043c481b4069_add_is_active_to_products.py
コミット内容:
9a4422e fix: add initial database migration
今回の学び
1. 既存DBで動くことと、空DBから再構築できることは別
既存DBに必要なテーブルが残っていれば、マイグレーション履歴が不完全でもアプリは動くことがあります。
しかし、新しい開発環境、テスト環境、別PC、再デプロイ先では、空DBから構築できることが重要です。
既存環境で起動できる
≠
マイグレーション履歴が正しい
2. マイグレーションは現在のスキーマだけでなく、到達経路も重要
モデルと現在のDB構造が一致していても、それだけでは十分ではありません。
空DB
↓
各revisionを順番に適用
↓
現在のモデルと一致
この到達経路が成立している必要があります。
3. 適用済みrevisionの変更は慎重に検証する
今回は既存revisionのdown_revisionを変更しました。
設計上は成立していても、既存DBでどのように扱われるかは、実際の複製DBで確認する必要があります。
今回は、既存DBを直接変更せず、pg_dumpから作った複製DBで検証しました。
4. 空DBと既存DBの両方を試す
マイグレーション修正では、少なくとも次の2経路を確認する必要があります。
1. 空DB → upgrade head
2. 適用済みDB → upgrade head
片方だけでは、新規環境か既存環境のどちらかを壊す可能性があります。
5. 検証環境の分離は名前だけでも大きな効果がある
Composeのプロジェクト名を変更することで、通常環境とは別のコンテナ・ネットワーク・ボリュームを作成できます。
docker compose -p migration_test ...
ただし、実行前には必ず実際のボリューム名や接続先を確認することが重要です。
6. ⚠ AIには変更内容だけでなく、停止条件も伝える ⚠
Codexへ指示するときは、実装内容だけでなく以下も明示しました。
- 今回は調査だけ
- まだDBを起動しない
- 問題を見つけても勝手に直さない
- 通常DBには書き込まない
- 削除前に対象を提示して承認を待つ
- commit・pushは最終確認後に行う
AIに作業を依頼する場合、「何をするか」 だけでなく 「どこで止まるか」 を指定することで、意図しない変更を防ぎやすくなります。
まとめ
今回の問題は、最初のマイグレーションがテーブル作成ではなく、既存テーブルへのカラム追加から始まっていたことでした。
修正前:
空DB
↓
productsへis_active追加
↓
productsが存在せず失敗
修正後:
空DB
↓
products作成
↓
daily_sales作成
↓
productsへis_active追加
↓
アプリ起動
さらに、既存DB相当の複製環境でも、基礎revisionが再実行されず、スキーマやデータが変化しないことを確認しました。
マイグレーションは、現在のDBでアプリが動くかだけではなく、
空のデータベースから、履歴だけを使って現在の状態へ到達できるか
まで確認する必要があると学びました。