Migration 客製化¶
開始之前¶
任務目標
在這個任務中,你將學習:
- 了解 Migration 檔案的結構
- 為既有 Model 新增必填欄位的正確步驟
- 使用 RunPython 執行資料遷移
- 撰寫正向與反向的 Migration 操作
- 建立空白 Migration 初始化資料
- 處理 Migration 的相依性
Migration 檔案結構¶
在前面的章節中,我們使用 makemigrations 自動產生 Migration 檔案。現在讓我們深入了解 Migration 檔案的結構。
Migration 類別的組成¶
每個 Migration 檔案都是一個 Python 類別,繼承自 django.db.migrations.Migration。基本結構如下:
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True # (1)!
dependencies = [] # (2)!
operations = [ # (3)!
migrations.CreateModel(
name='Article',
fields=[
('id', models.BigAutoField(primary_key=True)),
('title', models.CharField(max_length=200)),
('content', models.TextField()),
],
),
]
initial = True表示這是第一個 Migrationdependencies列出這個 Migration 依賴的其他 Migrationoperations列出要執行的資料庫操作
Migration 的執行順序¶
Django 透過 dependencies 來決定 Migration 的執行順序:
graph LR
A[0001_initial] --> B[0002_add_author]
B --> C[0003_add_tags]
C --> D[0004_add_created_by]
每個 Migration 都會記錄它依賴的前一個 Migration:
class Migration(migrations.Migration):
dependencies = [
('blog', '0001_initial'), # 依賴 blog app 的 0001_initial
]
Migration 的狀態追蹤
Django 在資料庫中有一個 django_migrations 資料表,記錄哪些 Migration 已經執行過。
執行 uv run manage.py showmigrations 可以查看所有 Migration 的狀態:
[X] 表示已執行,[ ] 表示尚未執行。
使用 RunPython¶
RunPython 是 Migration 中執行 Python 程式碼的方式,讓我們深入了解它的用法。
RunPython 的基本語法¶
函式參數說明¶
正向和反向函式都接收兩個參數:
apps:使用apps.get_model(app_label, model_name)取得 Modelschema_editor:用於執行低階的資料庫 schema 操作(進階用途)
使用歷史 Model¶
在 Migration 中取得 Model 的正確方式:
def forward_func(apps, schema_editor):
# ✅ 正確:使用 apps.get_model()
Article = apps.get_model("blog", "Article")
User = apps.get_model("auth", "User")
# ❌ 錯誤:不要直接 import
# from blog.models import Article
# from django.contrib.auth.models import User
為什麼不能直接 import?
Migration 是資料庫的「時光機」,每個 Migration 代表某個時間點的狀態。
如果直接 import Model:
你會取得「現在」的 Model 定義。但在執行舊的 Migration 時,Model 的欄位可能不同!
使用 apps.get_model():
你會取得該 Migration 執行時的 Model 定義,確保相容性。
不可反向的操作¶
有些操作無法反向,例如刪除資料:
from django.db import migrations
def delete_spam_articles(apps, schema_editor):
"""刪除標題包含垃圾內容的文章"""
Article = apps.get_model("blog", "Article")
Article.objects.filter(title__contains="垃圾廣告").delete()
class Migration(migrations.Migration):
dependencies = [
("blog", "0007_alter_article_created_by"),
]
operations = [
migrations.RunPython(
delete_spam_articles,
reverse_code=migrations.RunPython.noop, # (1)!
),
]
- 使用
migrations.RunPython.noop表示無法反向
使用 noop 的注意事項
如果使用 noop,在回退 Migration 時會跳過這個操作:
這個指令會回退到 0007,但不會恢復被刪除的文章,因為這是不可能的。
只有在確定無法或不需要反向操作時才使用 noop。
空白 Migration 的其他用途¶
除了資料遷移,空白 Migration 還有很多其他用途。
建立初始資料¶
建立系統必要的初始資料:
from django.db import migrations
def create_default_tags(apps, schema_editor):
Tag = apps.get_model("blog", "Tag")
default_tags = [
"Django",
"Python",
"Web Development",
"Backend",
"Database",
]
for tag_name in default_tags:
Tag.objects.get_or_create(name=tag_name)
def delete_default_tags(apps, schema_editor):
Tag = apps.get_model("blog", "Tag")
Tag.objects.filter(
name__in=[
"Django",
"Python",
"Web Development",
"Backend",
"Database",
]
).delete()
class Migration(migrations.Migration):
dependencies = [
("blog", "0008_create_custom_permissions"),
]
operations = [
migrations.RunPython(
create_default_tags,
delete_default_tags,
),
]
執行複雜的資料轉換¶
假設我們要將文章的 author 欄位(Author Model)改為從 created_by(User Model)自動產生:
from django.db import migrations
def sync_author_from_user(apps, schema_editor):
User = apps.get_model("auth", "User")
Author = apps.get_model("blog", "Author")
Article = apps.get_model("blog", "Article")
for article in Article.objects.select_related("created_by"):
user = article.created_by
# 為該使用者建立 Author
author, created = Author.objects.get_or_create(
email=user.email,
defaults={
"name": user.get_full_name() or user.username,
"bio": f"這是 {user.username} 的個人檔案。",
},
)
# 設定文章的 author
article.author = author
article.save()
class Migration(migrations.Migration):
dependencies = [
("blog", "0009_create_default_tags"),
]
operations = [
migrations.RunPython(
sync_author_from_user,
reverse_code=migrations.RunPython.noop,
),
]
Migration 相依性管理¶
當 Migration 需要依賴其他 App 的 Migration 時,需要正確設定相依性。
設定 dependencies¶
每個 Migration 的 dependencies 列表定義了它依賴的 Migration:
class Migration(migrations.Migration):
dependencies = [
("blog", "0009_create_default_tags"), # 同 App 的依賴
("auth", "0012_alter_user_first_name_max_length"), # 跨 App 的依賴
]
跨 App 的 Migration 相依¶
當你的 Model 使用其他 App 的 Model 時(如 ForeignKey 到 User),Django 會自動加入相依性。
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), # (1)!
("blog", "0005_article_created_by"),
]
swappable_dependency會根據AUTH_USER_MODEL設定自動解析到正確的 User Model,確保在執行這個 Migration 之前,User Model 已經存在
手動設定跨 App 相依性¶
有時需要手動設定相依性,例如在空白 Migration 中:
from django.db import migrations
def my_data_migration(apps, schema_editor):
User = apps.get_model("auth", "User")
Article = apps.get_model("blog", "Article")
# ... 使用 User 和 Article
class Migration(migrations.Migration):
dependencies = [
("blog", "0010_sync_author_from_user"),
("auth", "0012_alter_user_first_name_max_length"), # (1)!
]
operations = [
migrations.RunPython(my_data_migration),
]
- 明確指定依賴
authApp 的特定 Migration
如何找到最新的 Migration?
使用 showmigrations 指令:
輸出:
auth
[X] 0001_initial
[X] 0002_alter_permission_name_max_length
...
[X] 0012_alter_user_first_name_max_length
最後一個就是最新的 Migration。
或者直接查看 auth/migrations/ 目錄。
Migration 最佳實踐¶
團隊協作注意事項¶
在團隊開發中,Migration 是容易發生衝突的地方:
gitGraph
commit id: "0003_add_tags"
branch feature-A
branch feature-B
checkout feature-A
commit id: "0004_add_category (A)"
checkout feature-B
commit id: "0004_add_status (B)"
checkout main
merge feature-A
merge feature-B id: "衝突!兩個 0004"
解決方式:
執行指令:
Django 會自動建立一個合併 Migration。
Migration 編號衝突
如果兩個分支都建立了 0004_xxx.py,Git 不會衝突(檔名不同),但 Django 會不知道該執行哪一個。
使用 makemigrations --merge 可以解決這個問題。
合併 Migration¶
當 Migration 檔案太多時,可以使用 squashmigrations 壓縮:
這會將 0001 到 0009 的 Migration 合併成一個新的 Migration。
何時需要 squash?
通常在以下情況:
- Migration 數量超過 50 個
- Migration 執行時間過長
- 想要清理歷史記錄
實作範例:為文章加上建立人欄位¶
情境說明¶
在實際的部落格系統中,我們需要記錄每篇文章是由哪個使用者建立的。讓我們為 Article Model 加上 created_by 欄位,連結到 Django 的 User Model。
但是,資料庫中已經有一些文章資料了。這些文章的建立人是誰?我們不知道!
這時候如果直接加上必填的 created_by 欄位,Django 會問我們:
It is impossible to add a non-nullable field 'created_by' to article without specifying a default. This is because the database needs something to populate existing rows.
Please select a fix:
1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
2) Quit and manually define a default value in models.py.
這個問題的正確解決方式是分三個步驟:
graph TD
A[步驟一:新增可為空的欄位] --> B[步驟二:為既有資料設定預設值]
B --> C[步驟三:將欄位改為必填]
步驟一:新增可為空的欄位¶
首先,我們允許 created_by 欄位為空(null=True),這樣既有的文章資料就不會出錯。
修改 Model¶
在 blog/models.py 中修改 Article Model:
from django.conf import settings
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100)
email = models.EmailField(unique=True)
bio = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
def __str__(self):
return self.name
class Article(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
created_by = models.ForeignKey( # (1)!
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True, # (2)!
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_published = models.BooleanField(default=False)
author = models.ForeignKey(
Author,
on_delete=models.CASCADE,
related_name="articles",
null=True,
blank=True,
)
tags = models.ManyToManyField(
Tag,
related_name="articles",
blank=True,
)
def __str__(self):
return self.title
- 新增
created_by欄位,使用settings.AUTH_USER_MODEL連結到 User Model - 設定
null=True和blank=True,允許欄位為空
author vs created_by
兩個欄位的差異:
author:文章作者(AuthorModel),可能是真實的作者資訊created_by:建立人(UserModel),記錄是哪個使用者在系統中建立這篇文章
一篇文章的作者和建立人可能不同。例如編輯代為發表作家的文章。
執行 makemigrations¶
執行以下指令產生 Migration:
Django 會產生類似這樣的 Migration 檔案:
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("blog", "0003_author_tag_article_author_article_tags"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="article",
name="created_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
]
執行
來套用這個 Migration。
步驟二:建立未知使用者並設定既有資料¶
現在 created_by 欄位已經存在了,但既有文章的 created_by 都是 NULL。我們需要為這些文章設定一個預設的建立人。
建立空白 Migration¶
使用 --empty 參數建立一個空白的 Migration,並用 -n 指定名稱:
Django 會產生一個空白的 Migration 檔案:
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("blog", "0004_article_created_by"),
]
operations = []
為什麼用 --empty?
空白 Migration 讓我們可以執行自訂的 Python 程式碼或 SQL,而不只是 Model 欄位的變更。
常見用途:
- 資料遷移和轉換
- 建立初始資料(如預設分類、設定)
- 執行複雜的資料庫操作
撰寫 RunPython 操作¶
編輯這個空白 Migration,加入資料遷移的邏輯:
from django.db import migrations
def set_default_created_by(apps, schema_editor): # (1)!
User = apps.get_model("auth", "User") # (2)!
Article = apps.get_model("blog", "Article")
# 建立或取得「未知使用者」
unknown_user, _ = User.objects.get_or_create( # (3)!
username="unknown",
defaults={
"email": "unknown@example.com",
"first_name": "未知",
"last_name": "使用者",
},
)
# 將所有 created_by 為 NULL 的文章設定為 unknown_user
Article.objects.filter(created_by__isnull=True).update(created_by=unknown_user) # (4)!
def reverse_set_default_created_by(apps, schema_editor): # (5)!
User = apps.get_model("auth", "User")
Article = apps.get_model("blog", "Article")
# 清空所有文章的 created_by
Article.objects.filter(created_by__username="unknown").update(created_by=None)
# 刪除未知使用者
User.objects.filter(username="unknown").delete()
class Migration(migrations.Migration):
dependencies = [
("blog", "0004_article_created_by"),
]
operations = [
migrations.RunPython( # (6)!
set_default_created_by,
reverse_set_default_created_by,
),
]
- 正向操作函式,接收
apps和schema_editor兩個參數 - 使用
apps.get_model()取得 Model,而不是直接 import get_or_create()會在資料不存在時建立,存在時直接取得- 使用
filter().update()批次更新所有符合條件的文章 - 反向操作函式,用於
migrate回退時執行 RunPython接收兩個參數:正向函式和反向函式
執行 Migration¶
執行 uv run manage.py migrate 來套用這個 Migration:
你會看到類似的輸出:
現在所有既有文章的 created_by 都設定為「未知使用者」了!
測試反向操作
可以使用以下指令測試反向 Migration:
這會回退到 0005_article_created_by,執行 reverse_set_default_created_by 函式。
步驟三:將欄位改為必填¶
既有資料都有 created_by 了,現在可以將這個欄位改為必填。
修改 Model¶
移除 null=True 和 blank=True:
from django.conf import settings
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100)
email = models.EmailField(unique=True)
bio = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
def __str__(self):
return self.name
class Article(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
created_by = models.ForeignKey( # (1)!
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_published = models.BooleanField(default=False)
author = models.ForeignKey(
Author,
on_delete=models.CASCADE,
related_name="articles",
null=True,
blank=True,
)
tags = models.ManyToManyField(
Tag,
related_name="articles",
blank=True,
)
def __str__(self):
return self.title
- 移除
null=True和blank=True,現在created_by是必填欄位
執行 makemigrations¶
再次執行 makemigrations:
Django 會出現這樣的提示訊息:
It is impossible to change a nullable field 'created_by' on article to non-nullable without providing a default. This is because the database needs something to populate existing rows.
Please select a fix:
1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
2) Ignore for now. Existing rows that contain NULL values will have to be handled manually, for example with a RunPython or RunSQL operation.
3) Quit and manually define a default value in models.py.
這時候請選 2 因為我們使用 RunPython 處理資料了。
Django 會產生新的 Migration:
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("blog", "0005_set_default_created_by"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="article",
name="created_by",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
]
執行指令,套用這個 Migration
完成!現在 created_by 是必填欄位,且所有既有文章都有正確的值。
說明
這邊為了方便操作將步驟拆成三個獨立的 migration 檔案,但在實際專案中可以透過手動複製 operations 的步驟將三個合併成一個。
在表單中處理建立人¶
檢查表單欄位¶
現在 created_by 是必填欄位,我們需要在建立文章時自動設定建立人,而不是讓使用者選擇。開始下方步驟前請確認 created_by 不在 form 的欄位中
在 View 中設定 created_by¶
在 blog/views.py 的 article_create View 中,使用 commit=False 來設定 created_by:
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render
from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article
def article_list(request):
filter_ = ArticleFilter(
request.GET or None,
queryset=Article.objects.select_related("author").prefetch_related("tags"),
)
return render(request, "blog/article_list.html", {"filter": filter_})
def article_detail(request, article_id):
article = get_object_or_404(
Article.objects.select_related("author").prefetch_related("tags"),
id=article_id,
)
return render(request, "blog/article_detail.html", {"article": article})
@permission_required("blog.add_article", raise_exception=True) # (1)!
def article_create(request):
form = ArticleForm(request.POST or None)
if form.is_valid():
article = form.save(commit=False) # (2)!
article.created_by = request.user # (3)!
article.save() # (4)!
messages.success(request, f"文章「{article.title}」已成功建立。")
return redirect("blog:article_detail", article_id=article.id)
return render(request, "blog/article_create.html", {"form": form})
@permission_required("blog.change_article", raise_exception=True)
def article_edit(request, article_id):
article = get_object_or_404(Article, id=article_id)
form = ArticleForm(request.POST or None, instance=article)
if form.is_valid():
article = form.save()
messages.success(request, f"文章「{article.title}」已成功更新。")
return redirect("blog:article_detail", article_id=article.id)
return render(request, "blog/article_edit.html", {"form": form, "article": article})
@permission_required("blog.delete_article", raise_exception=True)
def article_delete(request, article_id):
article = get_object_or_404(Article, id=article_id)
if request.method == "POST":
article.delete()
messages.success(request, f"文章「{article.title}」已成功刪除。")
return redirect("blog:article_list")
return render(request, "blog/article_delete.html", {"article": article})
- 使用
@login_required確保只有登入的使用者才能建立文章 commit=False會建立 Model 實例但不存入資料庫- 設定
created_by為當前登入的使用者 - 呼叫
save()真正存入資料庫
commit=False 的用途
form.save(commit=False) 是 ModelForm 的常用技巧:
article = form.save(commit=False) # 建立物件但不存入資料庫
article.created_by = request.user # 設定額外的欄位
article.some_field = some_value # 可以設定多個欄位
article.save() # 存入資料庫
這讓我們可以在存入資料庫前,設定表單沒有包含的欄位。
提醒
另外如果表單中有多對多欄位記得呼叫 .save_m2m(),可以參考前面的表單章節。
生產環境的 Migration 策略¶
在正式環境執行 Migration 時要特別小心:
- 備份資料庫:執行 Migration 前考慮要先備份
- 先在測試環境測試:確保 Migration 可以正常執行
- 考慮向下相容性:
- 刪除欄位時,先部署不使用該欄位的程式碼
- 再執行 Migration 刪除欄位
- 監控執行時間:大型資料表的 Migration 可能需要很長時間
- 使用維護模式:避免在 Migration 期間有使用者操作
不要修改已部署的 Migration
絕對不要修改已經在生產環境執行過的 Migration!
如果發現 Migration 有問題:
- ✅ 建立新的 Migration 來修正
- ❌ 不要修改舊的 Migration
因為 Django 是透過檔名和內容來追蹤 Migration 狀態,修改會導致狀態不一致。
大型資料表的 Migration¶
對於大型資料表,Migration 可能需要很長時間:
# 慢:會鎖定整個資料表
migrations.AddField(
model_name='article',
name='view_count',
field=models.IntegerField(default=0),
)
優化策略:
- 分批處理:使用
RunPython分批更新 - 使用資料庫特性:如 PostgreSQL 的
CONCURRENTLY - 考慮停機時間:在低流量時段執行
def add_view_count_in_batches(apps, schema_editor):
"""分批加入 view_count 欄位的預設值"""
Article = apps.get_model("blog", "Article")
batch_size = 1000
articles = Article.objects.all()
total = articles.count()
for i in range(0, total, batch_size):
batch = articles[i : i + batch_size]
for article in batch:
article.view_count = 0
article.save()
任務結束¶
任務完成
恭喜你完成了這個任務!現在你已經學會:
- 了解 Migration 檔案的結構
- 為既有 Model 新增必填欄位的正確步驟
- 使用 RunPython 執行資料遷移
- 撰寫正向與反向的 Migration 操作
- 建立空白 Migration 初始化資料
- 處理 Migration 的相依性