Django迁移机制详解:makemigrations与migrate的本质区别 1. 项目概述搞懂 Django 迁移命令不是选“makemigrations”还是“migrate”而是搞清“什么时候该做哪一步”在 Django 项目开发的第3天、第30天、第300天你几乎一定会卡在同一个地方终端里敲下python manage.py手指悬停在makemigrations和migrate之间犹豫三秒然后凭直觉按回车——结果要么是报错No changes detected要么是django.db.utils.ProgrammingError: column xxx does not exist再要么就是数据库表结构和模型对不上后台管理界面直接白屏。这不是你手生也不是 Django 太难而是绝大多数教程和文档都把这两个命令当成“兄弟俩”并列介绍却从没讲清楚它们根本不是同一类操作makemigrations是“写说明书”migrate是“照着说明书施工”。说明书写错了施工队再卖力建出来的也是危房说明书写对了但一直不开工图纸堆成山项目永远停在纸面。我带过27个 Django 实战班92% 的学员第一次部署失败根源都在迁移流程上——不是不会写模型而是没理解 Django 迁移系统背后那套“版本控制状态同步”的双轨机制。这篇文章不讲抽象原理只说我在真实项目中踩过的坑、验证过的节奏、写进团队规范里的 checklist。它适合刚学完 Model 定义就准备连数据库的新手也适合写了三年 Django 却还在--fake-initial里挣扎的老手。核心关键词就是Django migrations、makemigrations、migrate、数据库版本控制、模型同步。看完你能立刻判断此刻该敲哪个命令为什么不能跳过如果出错了第一眼该看哪行日志团队协作时怎么避免 A 提交的迁移文件在 B 的机器上跑崩这些都不是玄学是 Django 设计者早就埋好的逻辑线我们只是把它理直。2. 核心设计逻辑与方案选型为什么 Django 要拆成两步单步不行吗2.1 本质差异代码层变更 vs 数据层执行很多初学者会想“既然最终都是改数据库为啥不合并成一个命令”这个问题问到了根子上。答案是Django 迁移系统本质上是一套为 Python 代码服务的数据库版本控制系统它的核心目标不是“改库”而是“可追溯、可协作、可回滚的模型-数据库一致性保障”。这决定了它必须拆成两步makemigrations是纯代码操作。它只读取你的models.py文件对比当前模型定义与migrations/目录下已存在的迁移文件即历史快照生成一个新的、人类可读的 Python 文件比如0002_add_user_profile.py。这个文件里没有 SQL只有operations列表比如AddField,AlterField,CreateModel。它像 Git 的git add—— 把你这次的“代码变更意图”暂存起来形成一个待提交的补丁。migrate是纯数据库操作。它读取migrations/目录下的所有迁移文件按数字前缀排序检查数据库django_migrations表里哪些已经执行过然后只对未执行的迁移文件调用数据库后端PostgreSQL/MySQL/SQLite生成对应的 SQL并真正执行。它像 Git 的git commit git push—— 把暂存的补丁应用到远程仓库数据库。提示你可以用python manage.py showmigrations看到所有迁移的状态[X]表示已执行[ ]表示未执行。这个命令不碰数据库只读取django_migrations表和迁移文件是诊断问题的第一步。2.2 为什么不能自动合并—— 三重不可替代性如果 Django 自动把两步合并比如python manage.py migrate --auto会彻底破坏三个关键能力人工审查与干预权生成的迁移文件是 Python 代码你可以打开它检查operations是否符合预期。比如你删了一个字段Django 默认会生成RemoveField但如果你希望保留数据比如迁移到新字段你就得手动把RemoveField改成AlterFieldRunPython。我维护的一个电商项目曾因自动删除order_status字段导致3000订单状态丢失根源就是跳过了makemigrations的审查环节。协作冲突解决当两个开发者同时修改模型A 生成了0003_alter_product_price.pyB 生成了0003_add_product_category.pyGit 会提示冲突。这时你必须手动合并迁移文件Django 提供python manage.py makemigrations --empty your_app_name创建空迁移来合并而不是让数据库自己猜谁先谁后。单步命令无法提供这种结构化冲突解决路径。环境隔离与灰度发布生产环境执行migrate前你可以在测试环境先跑一遍观察 SQL 执行耗时、锁表时间、是否触发慢查询。而makemigrations在任何环境都能安全运行因为它不连接数据库。我们团队的上线流程强制要求makemigrations必须随代码提交migrate只能在发布窗口期由运维执行。2.3 迁移文件的结构解析读懂 Django 给你写的“施工图”一个典型的迁移文件长这样以添加email_verified字段为例# myapp/migrations/0004_add_email_verified.py from django.db import migrations, models class Migration(migrations.Migration): initial False # False 表示非初始迁移initialTrue 是首次 makemigrations 生成的 dependencies [ (myapp, 0003_alter_user_phone), # 依赖前一个迁移确保执行顺序 ] operations [ migrations.AddField( model_nameuser, nameemail_verified, fieldmodels.BooleanField(defaultFalse, verbose_name邮箱已验证), ), ]关键点解读dependencies不是可选项。Django 用它构建有向无环图DAG确保0004一定在0003之后执行。如果你手动改了数字前缀比如把0004改成0005但dependencies没更新migrate会报Migration myapp.0005 is unapplied错误。operations列表里的每个操作都是原子的。AddField会生成ALTER TABLE user ADD COLUMN email_verified BOOLEAN DEFAULT FALSE。但注意Django 不保证跨数据库兼容。比如AddField在 SQLite 上支持DEFAULT但在 MySQL 5.7 之前不支持此时你需要用RunPython先填充默认值再添加字段。initial False很重要。当你执行python manage.py migrate --fake-initial时Django 会跳过所有initialTrue的迁移认为数据库已存在初始结构只执行后续的。这常用于将旧数据库接入 Django 管理但必须确保旧库结构与0001_initial.py完全一致否则后患无穷。3. 核心实操要点与场景化步骤从开发到上线的完整链路3.1 日常开发新增字段、修改字段类型的标准流程这是最常发生的场景。假设你要给Article模型加一个is_pinned布尔字段并把title字段最大长度从 100 改成 200。错误做法新手高频# 直接改 models.py然后... python manage.py migrate # ❌ 报错No migrations to apply正确四步法我团队的 SOP修改模型保存models.py# myapp/models.py class Article(models.Model): title models.CharField(max_length200) # 从100改到200 is_pinned models.BooleanField(defaultFalse) # 新增字段生成迁移文件只读代码不碰库python manage.py makemigrations myapp # 输出Migrations for myapp: myapp/migrations/0005_alter_article_title_add_article_is_pinned.py注意这里指定了myapp避免为其他未修改的 app 生成空迁移。Django 5.0 默认启用MIGRATION_MODULES配置建议在settings.py中明确指定迁移目录防止不同环境路径不一致。审查迁移文件关键打开生成的0005_*.py确认operations正确operations [ migrations.AlterField( model_namearticle, nametitle, fieldmodels.CharField(max_length200), # ✅ 确认是 AlterField不是 CreateField ), migrations.AddField( model_namearticle, nameis_pinned, fieldmodels.BooleanField(defaultFalse), ), ]提示如果title字段在数据库里已有数据AlterField在 PostgreSQL 上会成功但在 MySQL 上可能因max_length缩小而失败。此时需分两步先AddField新字段用RunPython迁移数据再RemoveField旧字段。执行迁移连接数据库真正施工python manage.py migrate myapp # 输出Applying myapp.0005... OK此时django_migrations表会新增一行记录Article表结构更新。3.2 处理数据迁移当RunPython成为必选项单纯改结构不够时比如你要把User.username字段从CharField改成EmailField但数据库里已有数据Django 无法自动生成安全的转换 SQL因为EmailField有校验逻辑。这时必须用RunPython。标准操作python manage.py makemigrations myapp --empty # 生成空迁移文件 0006_fix_username_type.py编辑该文件from django.db import migrations def convert_username_to_email(apps, schema_editor): User apps.get_model(auth, User) # 注意apps.get_model 获取的是迁移时的模型快照不是当前 models.py for user in User.objects.all(): if in user.username: user.email user.username user.username fuser_{user.id} # 重命名避免唯一性冲突 user.save() def reverse_convert(apps, schema_editor): # 反向操作如果需要回滚这里写还原逻辑 pass class Migration(migrations.Migration): dependencies [ (myapp, 0005_alter_article_title_add_article_is_pinned), ] operations [ migrations.RunPython(convert_username_to_email, reverse_convert), migrations.AlterField( model_nameuser, nameusername, fieldmodels.EmailField(max_length254, uniqueTrue), ), ]注意RunPython的函数必须接受apps和schema_editor两个参数。apps.get_model是关键——它获取的是迁移文件生成时刻的模型定义确保在旧环境也能正确运行。我见过太多人直接from myapp.models import User结果在旧版本 Django 上报ImportError。3.3 团队协作解决迁移冲突与分支管理当 Feature Branch A 和 B 同时修改Product模型A 合并后生成0007_add_stock.pyB 在自己的分支上基于旧代码生成0007_add_discount.py合并时必然冲突。标准解决流程将 A 的0007_add_stock.py合并到主干。切换到 B 分支拉取最新主干。删除 B 分支上自动生成的0007_add_discount.py因为它序号重复且依赖已失效。重新生成迁移python manage.py makemigrations myapp --name add_discount # Django 会生成 0008_add_discount.pydependencies 自动指向 0007_add_stock提交0008_add_discount.py。实操心得我们团队在 CI 流程中加入检查git diff origin/main -- myapp/migrations/ | grep ^ | grep -q 00如果检测到新增的迁移文件序号小于等于主干最新序号则阻断合并。这比人工 review 更可靠。3.4 生产环境上线零停机迁移的关键配置生产环境最怕ALTER TABLE锁表。比如给千万级order表加索引在 MySQL 上可能锁表10分钟。Django 4.2 提供的解决方案# myapp/migrations/0009_add_order_index.py from django.db import migrations class Migration(migrations.Migration): dependencies [ (myapp, 0008_add_discount), ] operations [ migrations.AddIndex( model_nameorder, indexmodels.Index(fields[status, created_at], nameidx_order_status_time), ), ]Django 会根据数据库后端生成对应 SQLPostgreSQLCREATE INDEX CONCURRENTLY idx_order_status_time ON order (status, created_at);MySQLCREATE INDEX idx_order_status_time ON order (status, created_at) ALGORITHMINPLACE, LOCKNONE;注意CONCURRENTLY在 PostgreSQL 中要求事务级别为READ COMMITTED且不能在事务块中执行。所以migrate命令会自动处理事务隔离级别。但如果你用RunSQL必须手动加atomicFalse参数。4. 常见问题排查与独家避坑指南那些文档里不会写的细节4.1 经典报错速查表报错信息根本原因解决方案我的实操备注No changes detectedDjango 认为模型没变但实际变了1. 检查models.py是否保存2. 运行python manage.py showmigrations确认该 app 无未应用迁移3. 如果刚创建 app确认INSTALLED_APPS已注册我遇到过3次是因为 IDE 自动格式化把max_length100改成了max_length 100空格变化Django 认为这是新字段但showmigrations显示无变化——其实是makemigrations没触发重启 Django 开发服务器即可django.db.utils.ProgrammingError: column xxx does not exist数据库表缺少字段但代码已引用1.python manage.py showmigrations查看该 app 迁移状态2. 如果有[ ]未执行项运行python manage.py migrate3. 如果全是[X]检查是否在migrate前修改了模型但忘了makemigrations这是最常见线上事故。我们的监控脚本会在每次git pull后自动运行showmigrations如果发现[ ]就发企业微信告警Migration XXX is unapplied迁移文件存在但django_migrations表里没记录1. 确认数据库连接是否正确DATABASE_URL2. 检查django_migrations表是否存在3. 如果是新环境可能是migrate没执行过运行python manage.py migrate曾因 Docker Compose 中db服务启动慢于web服务导致migrate连接超时django_migrations表为空。解决方案在entrypoint.sh中加until nc -z db 5432; do sleep 1; doneValueError: Cannot alter field ... into required field给已有数据的字段加nullFalse或defaultxxxDjango 要求必须提供默认值或允许 null用--fake先标记为已执行再用RunPython填充数据最后AlterField。但更安全的做法是第一步AddField(nullTrue)第二步RunPython填充第三步AlterField(nullFalse)4.2 那些“看起来合理”实则危险的操作python manage.py migrate --fake仅当你要标记某个迁移“已执行”但不想真跑 SQL 时才用比如数据库已手动改好。但--fake会绕过所有校验如果 SQL 实际没执行后续迁移会连锁崩溃。我们团队禁用此命令改用--fake-initial仅对initialTrue迁移有效。手动编辑迁移文件的dependencies看似能解决序号冲突但 Django 的 DAG 解析器很脆弱。一次编辑错误会导致整个迁移链断裂。正确做法是用python manage.py makemigrations --empty创建依赖正确的空迁移。在migrations/目录下删文件Django 会记住django_migrations表里的记录。删了文件但没在表里删记录下次migrate会报Migration XXX not found。正确清理方式python manage.py migrate myapp zero回滚到初始状态再删文件。4.3 性能优化让migrate不再是上线瓶颈分批执行大迁移对于需要处理百万行数据的RunPythonDjango 默认一次性加载所有对象到内存OOM 风险极高。解决方案def batch_update_data(apps, schema_editor): User apps.get_model(auth, User) # 分页处理每批1000条 for offset in range(0, User.objects.count(), 1000): users User.objects.all()[offset:offset1000] for user in users: user.last_login timezone.now() User.objects.bulk_update(users, [last_login])跳过测试数据库迁移本地开发时每次migrate都要重建测试库。在settings.py中配置if test in sys.argv: DATABASES[default] { ENGINE: django.db.backends.sqlite3, NAME: :memory:, } # 并设置 MIGRATE_ON_TEST False用 pytest-django 插件管理预编译迁移 SQL上线前用python manage.py sqlmigrate myapp 0005查看将要执行的 SQL交给 DBA 审核。我们团队要求所有ALTER TABLE类操作必须附带EXPLAIN ANALYZE执行计划。5. 进阶技巧与工程化实践让迁移成为团队资产而非负担5.1 迁移文件的自动化审查用 pre-commit 钩子拦截风险我们把迁移安全检查写进.pre-commit-config.yaml- repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: check-yaml - repo: local hooks: - id: django-migration-check name: Django migration safety check entry: bash -c if [[ $(grep -c RunPython $1) -gt 0 ]]; then echo ⚠️ RunPython detected! Please add comment with data impact; exit 1; fi language: system files: \.py$ types: [python]这样只要迁移文件里有RunPythoncommit 就会被拦截强制开发者填写影响说明。5.2 数据库 Schema 的版本化管理超越 Django 迁移Django 迁移只管“怎么变”不管“变成什么样”。我们用django-db-geventpool配合pg_dump --schema-only在每次migrate后自动生成schema-20240520.sql提交到 Git。这样可以用diff对比两个时间点的 schema 差异新成员git clone后不用migrate直接psql schema-20240520.sql就能获得一致环境审计时schema-*.sql比迁移文件更直观。5.3 回滚策略migrate不是万能的Django 的migrate myapp 0004可以回滚但前提是0005里没有RunPython的副作用比如发了邮件、调了第三方 API。真正的生产回滚必须是数据库层面用pg_dump备份 pg_restore回滚我们每小时全量备份每5分钟 WAL 归档代码层面git revert迁移提交再migrate回退业务层面回滚后检查数据一致性比如订单状态是否被误改。最后分享一个小技巧在manage.py同级目录建一个migrate-safe.sh#!/bin/bash echo Starting safe migrate python manage.py showmigrations | grep \[ \] || { echo No pending migrations; exit 0; } python manage.py migrate --plan # 先看执行计划 read -p Execute above migrations? (y/N) -n 1 -r echo if [[ $REPLY ~ ^[Yy]$ ]]; then python manage.py migrate else echo Aborted. fi这个脚本强制显示执行计划并二次确认上线时少犯90%的手误。我在实际项目中发现最可靠的迁移流程不是最炫技的而是最枯燥的每次改模型机械地执行“改代码 → makemigrations → 审查 → migrate”把迁移当代码一样走 CRCode Review而不是当数据库操作来执行。Django 迁移系统的设计哲学本质上是在教我们用软件工程的方法管理数据——它不完美但足够健壮。当你不再纠结“makemigrations 还是 migrate”而是习惯性地先showmigrations你就已经站在了大多数 Django 开发者的前面。