跳轉到

Migration 客製化

開始之前

任務目標

在這個任務中,你將學習:

  • 了解 Migration 檔案的結構
  • 為既有 Model 新增必填欄位的正確步驟
  • 使用 RunPython 執行資料遷移
  • 撰寫正向與反向的 Migration 操作
  • 建立空白 Migration 初始化資料
  • 處理 Migration 的相依性

Migration 檔案結構

在前面的章節中,我們使用 makemigrations 自動產生 Migration 檔案。現在讓我們深入了解 Migration 檔案的結構。

Migration 類別的組成

每個 Migration 檔案都是一個 Python 類別,繼承自 django.db.migrations.Migration。基本結構如下:

blog/migrations/0001_initial.py
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()),
            ],
        ),
    ]
  1. initial = True 表示這是第一個 Migration
  2. dependencies 列出這個 Migration 依賴的其他 Migration
  3. operations 列出要執行的資料庫操作

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:

blog/migrations/0002_add_author.py
class Migration(migrations.Migration):
    dependencies = [
        ('blog', '0001_initial'),  # 依賴 blog app 的 0001_initial
    ]

Migration 的狀態追蹤

Django 在資料庫中有一個 django_migrations 資料表,記錄哪些 Migration 已經執行過。

執行 uv run manage.py showmigrations 可以查看所有 Migration 的狀態:

blog
 [X] 0001_initial
 [X] 0002_add_author
 [ ] 0003_add_tags

[X] 表示已執行,[ ] 表示尚未執行。

使用 RunPython

RunPython 是 Migration 中執行 Python 程式碼的方式,讓我們深入了解它的用法。

RunPython 的基本語法

migrations.RunPython(
    code,           # 正向操作函式
    reverse_code,   # 反向操作函式(可選)
    hints,          # 提示字典(可選)
)

函式參數說明

正向和反向函式都接收兩個參數:

def forward_func(apps, schema_editor):
    pass
  • apps:使用 apps.get_model(app_label, model_name) 取得 Model
  • schema_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:

from blog.models import Article  # 這是「現在」的 Article

你會取得「現在」的 Model 定義。但在執行舊的 Migration 時,Model 的欄位可能不同!

使用 apps.get_model()

Article = apps.get_model("blog", "Article")  # 這是「當時」的 Article

你會取得該 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)!
        ),
    ]
  1. 使用 migrations.RunPython.noop 表示無法反向

使用 noop 的注意事項

如果使用 noop,在回退 Migration 時會跳過這個操作:

uv run manage.py migrate blog 0007

這個指令會回退到 0007,但不會恢復被刪除的文章,因為這是不可能的。

只有在確定無法或不需要反向操作時才使用 noop

空白 Migration 的其他用途

除了資料遷移,空白 Migration 還有很多其他用途。

建立初始資料

建立系統必要的初始資料:

blog/migrations/0009_create_default_tags.py
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_byUser Model)自動產生:

blog/migrations/0010_sync_author_from_user.py
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 時(如 ForeignKeyUser),Django 會自動加入相依性。

class Migration(migrations.Migration):
    dependencies = [
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),  # (1)!
        ("blog", "0005_article_created_by"),
    ]
  1. swappable_dependency 會根據 AUTH_USER_MODEL 設定自動解析到正確的 User Model,確保在執行這個 Migration 之前,User Model 已經存在

手動設定跨 App 相依性

有時需要手動設定相依性,例如在空白 Migration 中:

blog/migrations/0011_custom_migration.py
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),
    ]
  1. 明確指定依賴 auth App 的特定 Migration

如何找到最新的 Migration?

使用 showmigrations 指令:

uv run manage.py showmigrations auth

輸出:

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"

解決方式:

執行指令:

uv run manage.py makemigrations --merge

Django 會自動建立一個合併 Migration。

Migration 編號衝突

如果兩個分支都建立了 0004_xxx.py,Git 不會衝突(檔名不同),但 Django 會不知道該執行哪一個。

使用 makemigrations --merge 可以解決這個問題。

合併 Migration

當 Migration 檔案太多時,可以使用 squashmigrations 壓縮:

uv run manage.py squashmigrations blog 0001 0009

這會將 00010009 的 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:

blog/models.py
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
  1. 新增 created_by 欄位,使用 settings.AUTH_USER_MODEL 連結到 User Model
  2. 設定 null=Trueblank=True,允許欄位為空

author vs created_by

兩個欄位的差異:

  • author:文章作者(Author Model),可能是真實的作者資訊
  • created_by:建立人(User Model),記錄是哪個使用者在系統中建立這篇文章

一篇文章的作者和建立人可能不同。例如編輯代為發表作家的文章。

執行 makemigrations

執行以下指令產生 Migration:

uv run manage.py makemigrations

Django 會產生類似這樣的 Migration 檔案:

blog/migrations/0004_article_created_by.py
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,
            ),
        ),
    ]

執行

uv run manage.py migrate

來套用這個 Migration。

步驟二:建立未知使用者並設定既有資料

現在 created_by 欄位已經存在了,但既有文章的 created_by 都是 NULL。我們需要為這些文章設定一個預設的建立人。

建立空白 Migration

使用 --empty 參數建立一個空白的 Migration,並用 -n 指定名稱:

uv run manage.py makemigrations blog --empty -n set_default_created_by

Django 會產生一個空白的 Migration 檔案:

blog/migrations/0005_set_default_created_by.py
from django.db import migrations


class Migration(migrations.Migration):
    dependencies = [
        ("blog", "0004_article_created_by"),
    ]

    operations = []

為什麼用 --empty?

空白 Migration 讓我們可以執行自訂的 Python 程式碼或 SQL,而不只是 Model 欄位的變更。

常見用途:

  • 資料遷移和轉換
  • 建立初始資料(如預設分類、設定)
  • 執行複雜的資料庫操作

撰寫 RunPython 操作

編輯這個空白 Migration,加入資料遷移的邏輯:

blog/migrations/0005_set_default_created_by.py
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,
        ),
    ]
  1. 正向操作函式,接收 appsschema_editor 兩個參數
  2. 使用 apps.get_model() 取得 Model,而不是直接 import
  3. get_or_create() 會在資料不存在時建立,存在時直接取得
  4. 使用 filter().update() 批次更新所有符合條件的文章
  5. 反向操作函式,用於 migrate 回退時執行
  6. RunPython 接收兩個參數:正向函式和反向函式

執行 Migration

執行 uv run manage.py migrate 來套用這個 Migration:

uv run manage.py migrate

你會看到類似的輸出:

Running migrations:
  Applying blog.0005_set_default_created_by... OK

現在所有既有文章的 created_by 都設定為「未知使用者」了!

測試反向操作

可以使用以下指令測試反向 Migration:

uv run manage.py migrate blog 0005

這會回退到 0005_article_created_by,執行 reverse_set_default_created_by 函式。

步驟三:將欄位改為必填

既有資料都有 created_by 了,現在可以將這個欄位改為必填。

修改 Model

移除 null=Trueblank=True

blog/models.py
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
  1. 移除 null=Trueblank=True,現在 created_by 是必填欄位

執行 makemigrations

再次執行 makemigrations

uv run manage.py 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:

blog/migrations/0006_alter_article_created_by.py
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

uv run manage.py migrate

完成!現在 created_by 是必填欄位,且所有既有文章都有正確的值。

說明

這邊為了方便操作將步驟拆成三個獨立的 migration 檔案,但在實際專案中可以透過手動複製 operations 的步驟將三個合併成一個。

在表單中處理建立人

檢查表單欄位

現在 created_by 是必填欄位,我們需要在建立文章時自動設定建立人,而不是讓使用者選擇。開始下方步驟前請確認 created_by 不在 form 的欄位中

在 View 中設定 created_by

blog/views.pyarticle_create View 中,使用 commit=False 來設定 created_by

blog/views.py
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})
  1. 使用 @login_required 確保只有登入的使用者才能建立文章
  2. commit=False 會建立 Model 實例但不存入資料庫
  3. 設定 created_by 為當前登入的使用者
  4. 呼叫 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 時要特別小心:

  1. 備份資料庫:執行 Migration 前考慮要先備份
  2. 先在測試環境測試:確保 Migration 可以正常執行
  3. 考慮向下相容性
    • 刪除欄位時,先部署不使用該欄位的程式碼
    • 再執行 Migration 刪除欄位
  4. 監控執行時間:大型資料表的 Migration 可能需要很長時間
  5. 使用維護模式:避免在 Migration 期間有使用者操作

不要修改已部署的 Migration

絕對不要修改已經在生產環境執行過的 Migration!

如果發現 Migration 有問題:

  1. ✅ 建立新的 Migration 來修正
  2. ❌ 不要修改舊的 Migration

因為 Django 是透過檔名和內容來追蹤 Migration 狀態,修改會導致狀態不一致。

大型資料表的 Migration

對於大型資料表,Migration 可能需要很長時間:

# 慢:會鎖定整個資料表
migrations.AddField(
    model_name='article',
    name='view_count',
    field=models.IntegerField(default=0),
)

優化策略:

  1. 分批處理:使用 RunPython 分批更新
  2. 使用資料庫特性:如 PostgreSQL 的 CONCURRENTLY
  3. 考慮停機時間:在低流量時段執行
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 的相依性