D1 Migrations:用版本控制管理資料表結構 | Cloudflare 完整教學

2026/08/20
D1 Migrations:用版本控制管理資料表結構 | Cloudflare 完整教學

上一篇《D1 入門:Serverless SQLite》我們學會了在 Workers 裡用 SQL 建表、查資料、做交易。但真實專案的資料表結構不會一次定案:今天加個 posts 表、明天幫 users 加個欄位、後天補幾個索引。如果每次都手動 wrangler d1 execute 一堆 SQL、還要用人腦記「本地套過沒、遠端套過沒」,遲早會亂。這就是 D1 Migrations(資料庫遷移)要解決的問題——它讓你像 Git 管理程式碼一樣,把 schema 的每一次變更變成有編號、可追蹤、可重播、跨環境一致的版本。這篇我們把 wrangler d1 migrations create / list / apply 一路走完,含遷移檔命名順序、--local vs --remote、種子資料、CI 自動遷移,以及和 Drizzle ORM 搭配的思路。

前言

**D1 Migrations(資料庫遷移)**是管理資料表結構(schema)版本的標準機制。每一次你要改動資料庫結構——新增一張表、幫既有的表加欄位、建立或刪除索引、調整外鍵關聯——都寫成一個獨立、有遞增編號的 SQL 檔案。Wrangler 會自動追蹤「哪些遷移已經套用過」,確保你在任何環境(本地、staging、production)都能把 schema 精準演進到同一個狀態。

打個比方:如果說你的程式碼是靠 Git 一個一個 commit 累積演進的——每個 commit 記錄一次改動、有先後順序、任何人 clone 下來重播就能得到一模一樣的程式碼——那 Migrations 就是「資料表結構的 Git」。你的 schema 不再是某個人腦裡「我記得好像改過」的模糊狀態,而是一疊按編號堆疊的變更檔案:0001 建初始表、0002 加文章表、0003 加使用者角色……。任何人拿到專案,只要「重播」這疊遷移,就能得到正確的資料表結構。手動 execute 就像不用版控、直接在檔案上覆蓋修改——短期能動,長期必亂。

這篇聚焦 D1 Migrations,承接上一篇的 D1 基礎(建庫、建表、查詢),把「schema 怎麼安全地演進」這件事講透。讀完你會掌握:

  • Migrations 是什麼、為何需要——版本控管 schema 的心智模型、d1_migrations 追蹤表如何運作、跟手動 execute 的本質差異
  • 三個核心指令——wrangler d1 migrations create 建檔、apply 套用、list 查狀態,以及遷移檔的命名與順序規則
  • 本地 vs 遠端——--local--remote 各自維護一份追蹤表,標準的「先本地後遠端」流程
  • 種子資料與 CI——如何灌初始種子資料(seed data)、在 CI/CD 中自動套用遷移、部署順序為何是「先遷移再部署」
  • 與 Drizzle ORM 搭配——用 drizzle-kit 從 TypeScript schema 自動產生遷移檔的思路

核心概念

什麼是 Migration?一疊有編號的 SQL 變更檔

一個 migration(遷移)就是一個 SQL 檔案,描述資料庫從「舊版 schema」到「新版 schema」的一次變更。Wrangler 把這些檔案集中放在一個目錄(預設 migrations/),並用遞增數字前綴確保它們的套用順序:

migrations/
├── 0001_initial_schema.sql      ← 第一次:建立 users、sessions 表
├── 0002_add_posts_table.sql     ← 第二次:新增 posts 表
└── 0003_add_user_roles.sql      ← 第三次:幫 users 加欄位、建 roles 表

關鍵在於編號決定順序。D1 一定會由小到大依序套用這些檔案,而且每個檔案只套用一次。這帶來三個重要性質:

  • 可追蹤(traceable):每次 schema 變更都是一個檔案,存進 Git,誰改了什麼、何時改的一目了然。
  • 可重播(replayable):任何人拿到專案,重播這疊檔案就能重建出一模一樣的 schema。
  • 跨環境一致(consistent):本地、staging、production 都跑同一疊遷移,schema 保證同步。

d1_migrations 追蹤表:D1 怎麼知道哪些套過了

你可能會問:D1 怎麼知道 00010002 已經套用、0003 還沒?答案是——它會在每個資料庫裡自動建立一張追蹤表 d1_migrations,記錄已套用的遷移檔名與套用時間:

-- D1 自動建立與維護的追蹤表(你不需要手動建,了解結構即可)
CREATE TABLE d1_migrations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT UNIQUE,                              -- 遷移檔名,例如 0001_initial_schema.sql
  applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- 套用時間
);

每次你執行 wrangler d1 migrations apply,D1 會:

  1. 讀取 migrations/ 目錄裡所有 .sql 檔,依編號排序。
  2. d1_migrations 表,找出還沒被記錄的遷移檔。
  3. 只執行那些未套用的,一個一個依序跑,跑完就在 d1_migrations 插入一筆紀錄。

因為追蹤表存在資料庫本身,所以本地資料庫和遠端資料庫各自維護一份——這也是為什麼你必須對 --local--remote 分別套用遷移(稍後詳談)。

遷移檔的命名與順序規則

檔名格式是 NNNN_描述.sql(四位數編號 + 底線 + 描述)。你不用手動想編號——wrangler d1 migrations create 會自動找出目前最大編號、加一,幫你產生正確前綴。你只需要提供一個有意義的描述(用底線連接),讓人看檔名就知道這次改了什麼:

好的描述不好的描述
add_posts_tableupdate
add_index_on_emailfix
add_role_column_to_usersnew_migration

最重要的鐵律:遷移檔不可變(immutable)

這是 Migrations 唯一、也最容易犯的錯:已經套用過的遷移檔,永遠不要回頭編輯它。因為 D1 是用「檔名」判斷某個遷移套過沒——一旦 0002_add_posts.sql 在遠端被標記為已套用,你再改它的內容,apply 會直接跳過(因為檔名已在 d1_migrations 裡),修改不會生效,還會造成本地與遠端 schema 分岔。

正確做法永遠是往前修:寫錯了、要調整,就新建一個更高編號的遷移檔去修正,而不是改舊的。這跟 Git 一樣——你不會去竄改已 push 的歷史 commit,而是新增一個 commit 修正它。

關鍵術語速覽

術語一句話定義
migration一個描述 schema 變更的 .sql 檔,有遞增編號決定順序
migrations/存放所有遷移檔的目錄(可在 wrangler.jsonc 設定 migrations_dir)
d1_migrationsD1 自動維護的追蹤表,記錄哪些遷移已套用
migrations create建立一個新的空白遷移檔(自動加編號前綴)
migrations apply套用所有「尚未套用」的遷移到指定資料庫
migrations list列出已套用/待套用的遷移狀態
seed data種子資料——資料庫初始化時預先塞入的基礎資料(如角色、分類)

實作範例

我們用一組可執行的 CLI 與 SQL,把整個遷移流程從無到有走一遍。假設你已經照上一篇建好資料庫 my-app-db 並綁定好 wrangler.jsonc

1. 設定 migrations 目錄

先在 wrangler.jsoncd1_databases 裡指定遷移目錄(不設定的話預設就是 migrations,但明確寫出來比較清楚):

{
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2026-01-01",
  "d1_databases": [
    {
      "binding": "DB",                                        // 程式中用 env.DB 存取
      "database_name": "my-app-db",                           // 建立後不可更改
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      "migrations_dir": "migrations",                         // 遷移檔所在目錄(預設值)
      "migrations_table": "d1_migrations"                     // 追蹤表名稱(預設值,通常不用改)
    }
  ]
}

2. 建立第一個遷移檔:migrations create

wrangler d1 migrations create 建立遷移檔。注意第一個參數是資料庫名稱、第二個是描述:

# 語法:wrangler d1 migrations create <資料庫名稱> <描述>
wrangler d1 migrations create my-app-db initial_schema

# 輸出範例:
# ✅ Successfully created Migration '0001_initial_schema.sql'!
#
# 此檔案位於:migrations/0001_initial_schema.sql
# 請在裡面寫入你的 SQL,然後用 apply 套用。

Wrangler 自動幫檔案加了 0001_ 前綴。這個指令只建立空檔案、不執行任何 SQL,你需要打開它、寫入這次的 schema 變更。

3. 寫入遷移內容:初始 schema

打開剛建的 migrations/0001_initial_schema.sql,寫入建立基礎表格與索引的 SQL:

-- migrations/0001_initial_schema.sql
-- 建立基本使用者表與 session 表,含常用索引

CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT UNIQUE NOT NULL,
  name TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT 'user',
  active INTEGER NOT NULL DEFAULT 1,                 -- SQLite 沒有原生 boolean,用 0/1
  created_at TEXT NOT NULL DEFAULT (datetime('now')) -- 日期用 TEXT(ISO 8601)
);

-- 常查詢的欄位建索引
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);

CREATE TABLE IF NOT EXISTS sessions (
  id TEXT PRIMARY KEY,                                -- UUID
  user_id INTEGER NOT NULL,
  expires_at TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);

4. 套用到本地:migrations apply --local

遷移寫好後,先套用到本地模擬資料庫測試。這一步不會碰到任何線上資料:

# 套用所有尚未套用的遷移到本地模擬庫
wrangler d1 migrations apply my-app-db --local

# 輸出範例:
# Migrations to be applied:
# ┌─────────────────────────────┐
# │ Name                        │
# ├─────────────────────────────┤
# │ 0001_initial_schema.sql     │
# └─────────────────────────────┘
# ✅ 0001_initial_schema.sql 已成功套用

套用後,可以啟動本地開發伺服器驗證資料表確實建好了:

# wrangler dev 預設連本地模擬庫,安全
wrangler dev

# 或直接下命令確認表存在
wrangler d1 execute my-app-db --local --command="SELECT name FROM sqlite_master WHERE type='table'"
# 應該看到 users、sessions,以及 D1 自動建的 d1_migrations

5. 查看狀態:migrations list

migrations list 讓你隨時看清楚「哪些套過了、哪些還沒」。本地和遠端要分開查(因為各自一份追蹤表):

# 查本地狀態
wrangler d1 migrations list my-app-db --local

# 查遠端狀態
wrangler d1 migrations list my-app-db --remote

# 遠端輸出範例(還沒套用任何遷移到遠端時):
# ┌─────────────────────────────┬──────────────────┐
# │ Name                        │ Applied At       │
# ├─────────────────────────────┼──────────────────┤
# │ 0001_initial_schema.sql     │ ⏳ Not yet applied │
# └─────────────────────────────┴──────────────────┘

6. 套用到遠端:migrations apply --remote

本地驗證無誤後,才套用到雲端正式資料庫。這一步才會真正改到線上 schema:

# 套用到 Cloudflare 上的正式資料庫
wrangler d1 migrations apply my-app-db --remote

# 套用後再 list 一次確認
wrangler d1 migrations list my-app-db --remote
# 這次 0001 應該顯示已套用的時間戳記,而非 "Not yet applied"

記住這個標準節奏:寫遷移 → --local 套用測試 → 確認無誤 → --remote 套用上線。

7. 演進 schema:新增第二個遷移

需求變了——要加文章功能。不要去改 0001,而是新建 0002:

wrangler d1 migrations create my-app-db add_posts_table
# 建立 migrations/0002_add_posts_table.sql

寫入新表與對既有表的變更(這裡示範 ALTER TABLE 加欄位):

-- migrations/0002_add_posts_table.sql
-- 新增文章表,並幫 users 補上 last_login 欄位

CREATE TABLE IF NOT EXISTS posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  author_id INTEGER NOT NULL,
  title TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  content TEXT,
  published INTEGER NOT NULL DEFAULT 0,
  published_at TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
);

CREATE INDEX IF NOT EXISTS idx_posts_author_id ON posts(author_id);
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published, published_at DESC);

-- 對既有 users 表新增欄位(schema 演進)
ALTER TABLE users ADD COLUMN last_login TEXT;

再跑一次 apply,D1 會只套用 0002(因為 0001 已在 d1_migrations 裡,自動跳過):

wrangler d1 migrations apply my-app-db --local
wrangler d1 migrations apply my-app-db --remote

8. 灌種子資料(seed data)

**種子資料(seed data)**指資料庫初始化時要預先塞入的基礎資料,例如角色、分類、預設設定。有兩種做法:

做法一:把種子資料寫進遷移檔(適合「屬於 schema 一部分」的固定資料,如角色定義):

-- migrations/0003_seed_roles.sql
-- 建立角色表並塞入預設角色(這類固定資料適合放進遷移)

CREATE TABLE IF NOT EXISTS roles (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT UNIQUE NOT NULL,
  permissions TEXT NOT NULL DEFAULT '[]'   -- 用 JSON 字串存權限陣列
);

INSERT INTO roles (name, permissions) VALUES
  ('admin',  '["read","write","delete","manage_users"]'),
  ('editor', '["read","write"]'),
  ('viewer', '["read"]');

做法二:用獨立的 seed 檔搭配 execute(適合「只在開發環境用」的測試假資料,不該進正式庫也不該進 d1_migrations 追蹤):

-- seed-dev.sql:僅供本地開發的測試假資料(不放進 migrations/)
INSERT INTO users (email, name, role) VALUES
  ('alice@example.com', 'Alice', 'admin'),
  ('bob@example.com',   'Bob',   'editor');
# 用 execute 灌測試資料,只灌本地,不進追蹤表
wrangler d1 execute my-app-db --local --file=./seed-dev.sql

判斷準則:資料是「應用邏輯依賴的固定基礎資料」(角色、國家列表)→ 放進遷移檔,連 production 都要;資料是「開發時方便測試的假資料」→ 用獨立 seed 檔配 --local execute,別進遷移。

9. 在 CI/CD 中自動套用遷移

手動記得跑 --remote 很容易忘,正式團隊都把它放進 CI/CD,由 pipeline 在部署前自動執行。關鍵是順序:一定要先套用遷移、再部署 Worker——這樣新程式碼跑起來時,它依賴的資料表結構已經就緒:

# .github/workflows/deploy.yml(節錄)
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci

      # 步驟一:先套用資料庫遷移(schema 先就緒)
      - name: Apply D1 migrations
        run: npx wrangler d1 migrations apply my-app-db --remote
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

      # 步驟二:再部署 Worker(此時新程式碼依賴的表已存在)
      - name: Deploy Worker
        run: npx wrangler deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

為什麼順序不能反?如果先部署新版 Worker、再套遷移,那在遷移完成前的空窗期,新程式碼會去查一張還不存在的表或欄位,直接報 no such table / no such column。「先遷移、後部署」才能避免這段空窗。

10. 與 Drizzle ORM 搭配的遷移思路

如果你用 Drizzle ORM(目前與 D1 整合最完整的 ORM),遷移思路會反過來:你不手寫 SQL 遷移檔,而是改 TypeScript 定義的 schema,再讓 drizzle-kit 自動幫你「diff」出遷移 SQL

// src/schema.ts:用 TypeScript 定義 schema(這是唯一真相來源)
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";

export const users = sqliteTable("users", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  email: text("email").notNull().unique(),
  name: text("name").notNull(),
  active: integer("active", { mode: "boolean" }).notNull().default(true),
  createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
}, (t) => ({
  emailIdx: index("idx_users_email").on(t.email),
}));

流程是:改 schema.tsdrizzle-kit generate 產生遷移檔到 migrations/ → 一樣用 wrangler d1 migrations apply 套用:

# 1. drizzle-kit 比對 schema.ts 與現有遷移,自動產生新的遷移 SQL
npx drizzle-kit generate
# 產生 migrations/0001_xxx.sql(Drizzle 命名的遷移檔)

# 2. 用熟悉的 wrangler 指令套用(先本地後遠端)
wrangler d1 migrations apply my-app-db --local
wrangler d1 migrations apply my-app-db --remote

要讓 drizzle-kit generate 的產物被 wrangler d1 migrations apply 正確吃到,drizzle.config.tsout 要指向同一個 migrations/ 目錄。核心觀念不變:遷移檔仍是那疊有編號、不可變、可重播的變更,只是「產生方式」從手寫 SQL 變成 Drizzle 自動 diff 而已。

常見錯誤與最佳實踐

坑一:手動 execute 改 schema,不留任何遷移紀錄。

臨時用 wrangler d1 execute --command="ALTER TABLE ..." 改 production schema,當下能動,但這次變更沒有進 migrations/、沒進 Git、沒進 d1_migrations 追蹤表。結果就是:同事的環境、你的本地、production 三者 schema 悄悄分岔,某天重建資料庫時,這個手動改的欄位憑空消失。正確做法:任何 schema 變更都走遷移檔,execute 只用來查詢或灌開發用假資料。

坑二:只套了 --local,忘了 --remote

本地開發一切正常,一部署上線就 no such table / no such column——因為遠端資料庫根本沒跑過那次遷移。本地和遠端各有一份 d1_migrations,必須分別套用:

# ❌ 常見疏漏:只套本地就以為完成了
wrangler d1 migrations apply my-app-db --local

# ✅ 正確:本地驗證後,遠端也要套(或交給 CI 自動做)
wrangler d1 migrations apply my-app-db --local
wrangler d1 migrations apply my-app-db --remote

最穩的做法是把 --remote 套用放進 CI/CD,由 pipeline 在部署前自動執行,人就不會漏。

坑三:回頭編輯已套用的遷移檔。

改錯了想修,直接去編輯 0002_add_posts.sql 的內容——這是最致命的錯。D1 用檔名判斷遷移套過沒,已套用的檔案再改內容也不會被重新執行(直接跳過),還會讓本地(重建時套到新內容)與遠端(還是舊內容)永久分岔。正確做法是往前修:新建更高編號的遷移檔來修正:

# ❌ 錯誤:直接改已套用的 0002_add_posts.sql

# ✅ 正確:新建 0004 往前修(補欄位 / 改索引)
wrangler d1 migrations create my-app-db fix_posts_add_excerpt
-- migrations/0004_fix_posts_add_excerpt.sql
-- 用新遷移修正:補上當初漏掉的 excerpt 欄位
ALTER TABLE posts ADD COLUMN excerpt TEXT;

坑四:破壞性變更沒想清楚,資料回不來。

DROP TABLEDROP COLUMNDELETE FROM 這類破壞性變更一旦套到 production,資料就沒了。SQLite 對 ALTER TABLE DROP COLUMN 的支援也有限制,重構有外鍵關聯的表時,可能要先暫時停用外鍵檢查:

-- migrations/0005_restructure.sql
-- 重組有外鍵關聯的表時,先延遲外鍵檢查(交易結束自動重置)
PRAGMA defer_foreign_keys = true;

DROP TABLE IF EXISTS post_tags;
CREATE TABLE post_tags (
  post_id INTEGER NOT NULL,
  tag_id  INTEGER NOT NULL,
  PRIMARY KEY (post_id, tag_id),
  FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
  FOREIGN KEY (tag_id)  REFERENCES tags(id)  ON DELETE CASCADE
);

破壞性變更前,務必先確認 D1 的 Time Travel(時間點還原)能救回資料(免費方案保留 7 天、付費 30 天),必要時先手動 wrangler d1 export 備份。

最佳實踐小結:把握 Migrations 的心法——任何 schema 變更一律走遷移檔(絕不手動 execute 改結構);遷移檔不可變,要修就新建更高編號往前修;--local 驗證、再 --remote 上線,兩者各自一份追蹤表;--remote 套用放進 CI,且順序是「先遷移、後部署」;固定基礎資料放進遷移、開發假資料用獨立 seed 檔;破壞性變更前先想清楚、留好備份。守住這幾條,你的 schema 就能像程式碼一樣,安全、可追蹤地演進。

小結

上一篇《D1 入門:Serverless SQLite》,我們學會在 Workers 裡用熟悉的 SQL 建表、查資料、做原子交易;這一篇,我們補上讓資料表結構安全演進的關鍵機制——D1 Migrations:

  • Migrations 是什麼——一疊有編號、不可變、可重播的 SQL 變更檔,是「資料表結構的 Git」;D1 用自動維護的 d1_migrations 追蹤表記錄哪些套過了,只套用尚未套用的。
  • 三個核心指令——migrations create 建檔(自動加編號)、apply 套用(依序、只套未套用的)、list 查狀態。
  • 本地 vs 遠端——--local--remote 各自一份追蹤表,標準流程是先本地驗證、再遠端上線;最穩是把 --remote 放進 CI,順序「先遷移、後部署」。
  • 種子資料與 ORM——固定基礎資料放進遷移、開發假資料用獨立 seed 檔;搭配 Drizzle ORM 時改 TypeScript schema、用 drizzle-kit generate 自動產生遷移,再用 wrangler 套用。

到這裡,你的 D1 資料庫已經能安全地建立、查詢、並隨需求演進 schema 了。但當你的應用開始擴張到全球、讀取流量變大,你會想:能不能讓不同地區的使用者就近讀到資料、而不用每次都繞回主要資料庫?這正是 D1 的 **Sessions API 與讀取複本(Read Replication)**要解決的。下一篇《D1 Sessions API 與讀取複本》,我們就來看 D1 如何在全球六個地區部署唯讀複本、用書籤(Bookmark)機制在低延遲與一致性之間取得平衡。

想先查閱官方對 D1 Migrations 的完整說明,可以隨時參考 Cloudflare D1 Migrations 官方文件。schema 版本控管這一課已就緒,我們下一篇《D1 Sessions API 與讀取複本》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →