iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0

上一篇完成了資料庫版的 CRUD,API 已經可以正常新增、查詢、修改、刪除了。

今天要處理一個實際開發一定會遇到的問題:

寫到一半,發現資料表少了一個欄位,怎麼辦?

先把問題做出來

假設我們現在想幫作業加上「截止日期」。

在 Model 裡多加一個欄位:

class Task(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False)
    completed = db.Column(db.Boolean, nullable=False, default=False)
    due_date = db.Column(db.String(20))   # 新增這一行

存檔後重新啟動:

python app.py

然後打開 GET /tasks,Execute。

畫面上不會是資料,而是錯誤。

終端機也會看到類似這樣的訊息:

sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) no such column: task.due_date

加了欄位之後,GET /tasks 出現 500,終端機顯示 no such column
https://ithelp.ithome.com.tw/upload/images/20261007/201838653pAoQ02y0S.png
意思是:

程式裡的 Task 已經有 due_date 了,但資料庫裡的 task 表沒有這個欄位。

兩邊對不起來。

為什麼 db.create_all() 沒有幫我們加?

Day 20 有提過:

db.create_all() 只會建立「還不存在」的資料表。

因為 task 這張表已經存在了,所以它就直接跳過,不會去檢查我們是不是多加了欄位。

它不是壞掉,是它本來就只負責「建立」,不負責「修改」。

那我把資料庫刪掉重建不就好了?

在現在這種練習階段,確實可以:

把 instance/tasks.db 刪掉
重新執行程式,create_all() 會依照新的 Model 建一張新的表

但這個做法有一個很大的問題:

資料全部都會不見。

如果是已經上線的服務,裡面有使用者真實的資料,當然不可能「加個欄位就把資料庫砍掉重建」。

所以我們需要另一種方式:

在不刪資料的情況下,修改資料表的結構。

這就是 Migration。

Migration 是什麼?

Migration(資料庫遷移)可以理解成:

把資料庫結構的每一次改變,記錄成一份一份可以執行的檔案。

有點像資料庫的版本控制。

例如:

第 1 版:建立 task 表(id、name、completed)
第 2 版:task 表新增 due_date 欄位

每一版都是一個檔案,裡面會寫兩件事:

upgrade():要怎麼從上一版變成這一版
downgrade():如果要退回上一版,要怎麼做

這樣做的好處是:

資料不會不見,只是改結構
每一次改了什麼都有紀錄,可以往前也可以往回
團隊其他人或正式環境,只要執行同一份 migration,資料庫結構就會一致

(不然就會變成「我電腦上可以跑,你那邊說找不到欄位」。)

Flask-Migrate 是什麼?

一樣先分清楚名字:

Alembic
SQLAlchemy 官方的資料庫 migration 工具,真正在做事的是它。

Flask-Migrate
把 Alembic 包進 Flask,讓我們可以用 flask db … 這種指令操作。

跟前面的 Flask-SQLAlchemy 一樣,是「讓它在 Flask 裡好用」的角色。

安裝

pip install Flask-Migrate==4.1.0

https://ithelp.ithome.com.tw/upload/images/20261007/20183865OY6Now3K1S.png

修改程式

在 app.py 加入 Flask-Migrate:

from flask_migrate import Migrate

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tasks.db'

db = SQLAlchemy(app)
migrate = Migrate(app, db, render_as_batch=True)

Migrate(app, db) 就是把 Flask app 和 db 交給 Flask-Migrate。

那個 render_as_batch=True 要特別說一下。

SQLite 的 ALTER TABLE 功能比較陽春,像是「修改欄位型別」、「刪除欄位」這些操作,它本來是做不到的。

加上 render_as_batch=True 之後,Alembic 會改用「建一張新表 → 把資料搬過去 → 換掉舊表」的方式來完成,這樣在 SQLite 上也能正常修改。

用 SQLite 的話建議一開始就加上去,不然之後改欄位的時候會卡住。

另外,既然要交給 Migration 管理了,就可以把這兩行拿掉:

with app.app_context():
    db.create_all()

因為之後資料表的建立和修改,都會由 migration 負責。
兩邊同時管理反而會亂掉。

開始使用(第一次)

因為我們前面是用 create_all() 建表的,資料庫裡已經有一張 task 表,
但 migration 這邊完全不知道這件事,直接執行會撞在一起。

練習階段最單純的做法,就是重新開始:

先把 instance/tasks.db 刪掉(裡面只是測試資料)

然後在終端機依序執行:

步驟 1:建立 migration 環境

flask db init

執行完之後,專案裡會多出一個 migrations 資料夾。
https://ithelp.ithome.com.tw/upload/images/20261007/20183865pDJ89c2POb.png

專案裡多出 migrations 資料夾

步驟 2:產生 migration 檔

flask db migrate -m "create task table"

它會去比對「Model 的樣子」和「資料庫目前的樣子」,然後產生一份 migration 檔,放在:

migrations/versions/xxxxxxxx_create_task_table.py

https://ithelp.ithome.com.tw/upload/images/20261007/20183865bCCpLf6y8D.png
終端機顯示偵測到 task 表,並產生 migration 檔

打開那個檔案,會看到類似:

def upgrade():
    op.create_table(
        'task',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('name', sa.String(length=100), nullable=False),
        sa.Column('completed', sa.Boolean(), nullable=False),
        sa.Column('due_date', sa.String(length=20), nullable=True),
        sa.PrimaryKeyConstraint('id')
    )

def downgrade():
    op.drop_table('task')

這就是「這一版要做什麼」和「要退回去的話怎麼做」。

步驟 3:真的執行

flask db migrate 只是產生檔案,資料庫其實還沒有變。

要執行這一版:

flask db upgrade

執行 flask db upgrade 的結果
https://ithelp.ithome.com.tw/upload/images/20261007/201838658FL3EbWDJt.png
這時候 task 表才真的被建立出來。

(instance/tasks.db 這個檔案在上一步 flask db migrate 的時候就已經產生了,Alembic 要連資料庫做比對,所以會先把檔案建出來—— 只是裡面是空的,還沒有 task 表。)

可以用 check_db.py 確認:

python check_db.py

會看到 task 表,而且欄位多了 due_date。

另外還會看到一張沒印象的表:

alembic_version

那是 Alembic 用來記錄「目前資料庫在第幾版」的表,不用去動它。
https://ithelp.ithome.com.tw/upload/images/20261007/20183865uKEQpYe1rM.png
check_db.py 顯示 task 表有 due_date,以及多了 alembic_version 表

再改一次,體驗完整流程

剛剛是第一次建立,比較看不出 migration 的好處。

現在我們模擬「已經有資料之後才要改結構」。

先用 API 新增幾筆資料:https://ithelp.ithome.com.tw/upload/images/20261007/201838654M24aTBUSU.png
新增兩筆資料

接著在 Model 再加一個欄位,例如優先度:

class Task(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False)
    completed = db.Column(db.Boolean, nullable=False, default=False)
    due_date = db.Column(db.String(20))
    priority = db.Column(db.Integer, nullable=False, default=0)   # 新增

然後先產生 migration:

flask db migrate -m "add priority to task"

終端機會顯示:

INFO  [alembic.autogenerate.compare] Detected added column 'task.priority'

這次產生的 migration 檔跟第一次不一樣:

def upgrade():
    with op.batch_alter_table('task', schema=None) as batch_op:
        batch_op.add_column(sa.Column('priority', sa.Integer(), nullable=False))

可以看到它是 add_column(新增欄位),而不是 create_table。

而且因為我們有設定 render_as_batch=True,所以是用 batch_alter_table 的方式做
https://ithelp.ithome.com.tw/upload/images/20261007/20183865gDBrb1AYza.png

這裡我實際踩到一個坑

接著執行:

flask db upgrade

結果沒有成功,而是跳出一整串錯誤,最後一行是:

sqlite3.OperationalError: Cannot add a NOT NULL column with default value NULL

[SQL: ALTER TABLE task ADD COLUMN priority INTEGER NOT NULL]

flask db upgrade 失敗,顯示 Cannot add a NOT NULL column
為什麼會這樣?

因為 task 表裡面已經有兩筆資料了。

現在要加一個「不可以是空的」欄位,資料庫就會問:那舊的那兩筆資料,priority 要填什麼?

我們在 Model 裡寫的 default=0 幫不上忙,因為那是「SQLAlchemy 在新增資料時」才會用的預設值,
它是程式這一層的東西,資料庫本身並不知道。

資料庫要的是 server_default,也就是「寫在資料表定義裡」的預設值。

而 Alembic 自動產生的時候,並不會幫我們補這一段。

解法:自己打開 migration 檔改一行

把剛剛產生的那個檔案打開,在 add_column 裡加上 server_default:

def upgrade():
    with op.batch_alter_table('task', schema=None) as batch_op:
        batch_op.add_column(sa.Column('priority', sa.Integer(), nullable=False, server_default='0'))

意思是:舊資料的 priority 就先當作 0。

改完之後再執行一次:

flask db upgrade

這次就成功了:

INFO  [alembic.runtime.migration] Running upgrade 61bfc09212dd -> 2cc6f7318be2, add priority to task

這也剛好印證了下面「產生的 migration 要自己看一下」那一點。
flask db migrate 是自動偵測,它知道「多了一個欄位」,但它不知道「舊資料該填什麼」,那是我們要自己決定的。

最後還有一個容易漏掉的地方

資料表已經有 due_date 和 priority 了,但如果現在打開 GET /tasks,會發現回傳的還是只有 id、name、completed。

因為 Day 16 加的 @api.marshal_with(task_response) 會照著 task_response 把回傳的資料過濾一次,沒有寫在裡面的欄位就不會被傳出去。

所以 api.model 也要跟著補上:

task_response = api.model('TaskResponse', {
    'id': fields.Integer(readonly=True, description='作業編號'),
    'name': fields.String(description='作業名稱'),
    'completed': fields.Boolean(description='是否完成'),
    'due_date': fields.String(description='截止日期'),
    'priority': fields.Integer(description='優先度'),
})

這也呼應 Day 20 說的:資料表的 Model 和 API 的 model 是兩件不同的東西,改了資料庫不代表 API 的輸出就會跟著變。

執行成功之後,再打開 GET /tasks:

資料還在,而且每一筆都多了 due_date 和 priority 欄位。
https://ithelp.ithome.com.tw/upload/images/20261007/20183865j3A8AHtdzv.png
加完欄位後,原本的資料還在,也多了新欄位

這就是 migration 和「砍掉重建」最大的差別。

常用指令整理

flask db init → 建立 migrations 資料夾(只要做一次)
flask db migrate → 比對 Model 和資料庫,產生 migration 檔
flask db upgrade → 執行 migration,真的去改資料庫
flask db downgrade → 退回上一版
flask db current → 看目前在第幾版
flask db history → 看所有版本紀錄

容易搞混的是 migrate 和 upgrade:

migrate 是「寫好要做什麼」
upgrade 才是「真的去做」

幾個踩過的坑

  1. 產生的 migration 要自己看一下

flask db migrate 是「自動偵測」,不是每次都 100% 正確。
例如改欄位名稱,它常常會判斷成「刪掉舊的、新增一個新的」,這樣資料就會不見。

所以產生之後養成打開來看一眼的習慣,這也是為什麼 migration 檔要進版控。

  1. 新增 NOT NULL 欄位要小心

就是今天踩到的那個。

如果表裡面已經有資料,卻新增一個 nullable=False 的欄位,
資料庫會不知道舊資料這個欄位要填什麼,upgrade 就會失敗。

解法有兩個:

在 migration 檔裡補上 server_default(今天用的方法)
或是讓這個欄位可以是空的(不要寫 nullable=False)

要特別注意 Model 裡的 default=0 不等於 server_default。
default 是 SQLAlchemy 新增資料時用的,資料庫本身不知道有這回事。

  1. 沒有 flask db upgrade,資料庫不會變

只跑 migrate 就以為改好了,是很常見的狀況。

  1. 找不到 app 的時候

如果執行 flask db 指令出現找不到 app,可以先指定:

PowerShell:

$env:FLASK_APP = "app.py"

今天認識了:

db.create_all() 不會修改既有的資料表
Migration 是把資料庫結構的改變記錄成一份一份的檔案
Flask-Migrate 讓我們用 flask db 指令操作
init / migrate / upgrade 的差別
SQLite 記得加 render_as_batch=True

到這裡,Flask + Flask-RESTX + Flask-SQLAlchemy + Flask-Migrate 都湊齊了,
這次 30 天要學的四個主要套件也都用過一輪了。

功能是做完了,但現在的 API 其實還很脆弱:

使用者亂傳資料會怎麼樣?查不到資料的時候回什麼?程式出錯的時候會不會把錯誤訊息整包丟給使用者?

接下來幾天就來處理這些事情,下一篇先從錯誤處理開始。


上一篇
Day 22|完成資料庫 CRUD
系列文
從零開始的後端開發:用 Flask 實作 REST API,搞懂 API 與資料庫之間如何協作 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言