bigywater.tech
Django 系列 · 第六篇

makemigrations 到底在跟什么比?一次字段改名,为什么会被当成删了又加?

建了两个真实的 app(shop、orders,后者靠外键依赖前者),本机真实跑了一遍 makemigrations/migrate/sqlmigrate/--fake,验证了几个反直觉的真相:makemigrations 从头到尾不会去查一眼真实数据库;非交互模式下把字段改名当成"删一个、加一个",遇到非空字段直接放弃退出;migrate 执行顺序靠的是迁移文件里显式声明的依赖图,不是 app 名字母顺序。

No changes detected
本机真实实测:手动给数据库加一列,models.py 不动,makemigrations 完全没反应
shop 先于 orders
本机真实实测:尽管字母顺序 orders 在前,依赖图强制 shop.0001 先执行

真实源码:makemigrations 比的是两份内存状态,不是数据库

MigrationAutodetector 需要两个 ProjectState 才能工作,这两个状态从哪来是整篇文章的关键。

django/core/management/commands/makemigrations.py · django @ 6.0.7, L215 autodetector = self.autodetector( loader.project_state(), # from_state:把已有的迁移文件按依赖顺序重放一遍,拼出"迁移记录里的状态" ProjectState.from_apps(apps), # to_state:直接读当前 Python 进程里加载的 models.py,不查数据库 questioner, )
django/db/migrations/loader.py · django @ 6.0.7, L402 def project_state(self, nodes=None, at_end=True): """Return a ProjectState object representing the most recent state that the loaded migrations represent.""" return self.graph.make_state(nodes=nodes, at_end=at_end, real_apps=self.unmigrated_apps)

两边都不碰数据库:from_state 是把 migrations/ 目录下所有 .py 文件按依赖顺序在内存里"重放"一遍算出来的,to_state 是直接读当前 models.py 里定义的类。makemigrations 做的事情,自始至终就是拿这两份纯 Python 数据结构做 diff。

真实实测:手动改数据库表结构,makemigrations 毫无反应

既然比的不是数据库,那反过来:数据库被人手动改了,models.py 没跟着改,会怎样?

真实实测
# 原生 SQL 直接给 shop_product 表加一列,models.py 完全没碰
sqlite3.connect('db.sqlite3').execute(
    'ALTER TABLE shop_product ADD COLUMN sku VARCHAR(20)'
)
$ python3 manage.py makemigrations
No changes detected

数据库里 shop_product 表已经真实多出了一列 sku,但 makemigrations 一个字都没提——因为它压根没去看数据库长什么样,to_state 只认 models.py 里写了什么。反过来同样成立:如果有人在 models.py 里加了字段却忘了跑 makemigrations,数据库和代码就会一直安静地不一致下去,直到某次查询真的报错才会被发现。

真实实测:字段改名,两种完全不同的命运

把 Product.price 改名成 Product.unit_price,分别用 --no-input 和交互确认跑一遍。

真实实测
# models.py: price 改名为 unit_price(非空字段,无默认值)
$ python3 manage.py makemigrations --no-input
Field 'unit_price' on model 'product' not migrated: it is impossible to add a non-nullable field without specifying a default.

--no-input 模式下,makemigrations 默认不会把这当成改名——它当成"删掉 price,新增一个 unit_price"。新增字段又非空又没默认值,没法问用户该填什么,直接放弃退出,一个迁移文件都不生成。

django/db/migrations/questioner.py · django @ 6.0.7, L67 def ask_rename(self, model_name, old_name, new_name, field_instance): """Was this field really renamed?""" return self.defaults.get("ask_rename", False) # 基类默认值就是 False class NonInteractiveMigrationQuestioner(MigrationQuestioner): def ask_not_null_addition(self, field_name, model_name): # We can't ask the user, so act like the user aborted. self.log_lack_of_migration(field_name, model_name, "it is impossible to add a non-nullable field without specifying a default") sys.exit(3)

换成交互模式,手动确认"是的,这确实是改名":

真实实测
$ python3 manage.py makemigrations
Was product.price renamed to product.unit_price (a DecimalField)? [y/N] y
Migrations for 'shop': shop/migrations/0002_rename_price_product_unit_price.py ~ Rename field price on product to unit_price

sqlmigrate 看一眼真实生成的 SQL——干净的一条 RENAME COLUMN,没有任何数据丢失风险:

真实实测
$ python3 manage.py sqlmigrate shop 0002
ALTER TABLE "shop_product" RENAME COLUMN "price" TO "unit_price";

同一个改名动作,回答方式不同,结果天差地别:非交互模式默默放弃(如果新字段恰好允许为空或有默认值,它甚至会静默生成"删+加"的迁移,真实执行后老数据全部丢失,不会有任何报错提醒你);交互模式确认之后,是一条无损的 RENAME COLUMN。CI 流水线里跑 makemigrations --check --no-input 之所以是个好习惯,一部分原因就是逼着开发者在本地交互式生成迁移、把这类判断做在本地,而不是丢给自动化脚本去猜。

真实源码 + 实测:迁移之间的先后顺序,靠的是依赖图

orders.Order 有一个指向 shop.Product 的外键,这条依赖关系会被真实写进迁移文件。

orders/migrations/0001_initial.py(本机真实生成) class Migration(migrations.Migration): initial = True dependencies = [ ('shop', '0001_initial'), # 因为 Order.product 是指向 shop.Product 的外键,自动加上这条依赖 ]

MigrationGraph 把所有迁移文件的 dependencies 拼成一张有向图,migrate 执行前对这张图做一次深度优先的拓扑排序,不看 app 名字母顺序:

django/db/migrations/graph.py · django @ 6.0.7, L221 def iterative_dfs(self, start, forwards=True): """Iterative depth-first search for finding dependencies.""" visited = [] stack = [(start, False)] while stack: node, processed = stack.pop() if node in visited_set: pass elif processed: visited_set.add(node) visited.append(node.key) # 后序:自己的依赖都处理完了,才轮到自己入队 else: stack.append((node, True)) stack += [(n, False) for n in sorted(node.parents if forwards else node.children)] return visited
真实实测
# shop、orders 两个 app 全新数据库,真实的 migrate 顺序(只看这两个 app)
$ python3 manage.py migrate --plan
shop.0001_initial orders.0001_initial shop.0002_rename_price_product_unit_price

orders 按字母排在 shop 前面,但真实执行顺序里 shop.0001_initial 排第一——因为 orders.0001_initial 的 dependencies 显式指向它,拓扑排序保证一个节点的所有"父节点"(它依赖的迁移)必须先出现。shop.0002(改名)反而排在 orders.0001 后面,因为它俩之间没有依赖关系,谁先谁后只取决于 sorted(node.parents) 这一步的字符串排序,不是什么特殊逻辑。

这套"先算出所有依赖关系,再做一次拓扑排序决定处理顺序"的思路,和拓扑排序:入度归零才能入队那篇讲的是同一件事——只是 Django 这里用的是递归/栈式的深度优先搜索,不是 BFS + 入度计数,两种写法殊途同归。

真实源码 + 实测:SQLite 加字段是不是真的执行了 ALTER TABLE

这决定了 --fake 到底在"骗过"什么——不是所有加字段操作都用同一种 SQL。

django/db/backends/sqlite3/schema.py · django @ 6.0.7, L311 elif ( field.primary_key or field.unique or not field.null # 主键/唯一/非空 都不行 or self.effective_default(field) is not None # 带默认值也不行(ALTER TABLE 不支持之后 DROP DEFAULT) or (field.has_db_default() and not isinstance(field.db_default, Value)) ): self._remake_table(model, create_field=field) # 整表重建:建新表、拷数据、删旧表、改名 else: super().add_field(model, field) # 唯一能走真正 ALTER TABLE ADD COLUMN 的情况

只有"可空、无默认值、非唯一、非主键"这一种字段,SQLite 后端才会真的执行一句 ALTER TABLE ... ADD COLUMN;其余情况(哪怕只是加个带默认值的字段)都会把整张表重建一遍。先用一个带默认值的字段手动预演,证实这条路径下"表已经有这一列"反而不会报错(重建时按字段名对齐,平安无事);再换一个可空无默认值的字段命中真正的快速路径,才等到真实的报错:

真实实测
# 运维手动给 orders_order 表加了一列(可空、无默认值,命中快速路径)
sqlite3.connect('db.sqlite3').execute(
    "ALTER TABLE orders_order ADD COLUMN tracking_no varchar(40) NULL"
)
$ python3 manage.py makemigrations   # 生成 0003_order_tracking_no.py
$ python3 manage.py migrate orders
django.db.utils.OperationalError: duplicate column name: tracking_no

不加 --fake,migrate 真的会去执行 sqlmigrate 打印出来的那句 ALTER TABLE ... ADD COLUMN,列已经存在,数据库真实报错。--fake 的作用就是跳过"真的执行 SQL"这一步,只在 django_migrations 表里补一行记录:

真实实测
$ python3 manage.py migrate orders --fake
Applying orders.0003_order_tracking_no... FAKED

输出里明确写着 FAKED 而不是 OK——数据库表结构一次都没被碰,只有 django_migrations(靠 MigrationRecorder 维护的一张普通表,字段就是 app/name/applied 三列)多了一条记录,让下次 migrate 认为这个迁移"已经处理过了"。

演示:5 个迁移节点,依赖图怎么决定执行顺序

跟上面真实实验完全同构的 5 个迁移:shop.0001 没有依赖,后面 4 个各自依赖前一个相关节点。

迁移依赖图 · 拓扑排序演示
迁移依赖状态

依赖关系和最终顺序都取自本机真实跑出来的 migrate --plan 结果(shop.0001 → shop.0002 → orders.0001 → orders.0002 → orders.0003),演示动画只是把"一个节点的依赖必须先被处理完,自己才能入队"这个规则逐步可视化,每一步的"已确定顺序"都和真实结果逐条核对过。

参考与说明

  • 本文全部真实实验(手动改表结构后 makemigrations 无反应、非交互/交互两种改名结果、跨 app 迁移依赖与 migrate --plan 真实顺序、SQLite 加字段的两种真实路径、--fake 的真实效果)均在本机新建的两个真实 app(shop、orders)上用本机真实运行的 Django 6.0.7 + SQLite 完成,数据未做删改。
  • 源码引用(django/core/management/commands/makemigrations.py 的 from_state/to_state 构造、django/db/migrations/loader.py 的 MigrationLoader.project_state、django/db/migrations/questioner.py 的 ask_rename/ask_not_null_addition、django/db/migrations/graph.py 的 MigrationGraph.iterative_dfs、django/db/backends/sqlite3/schema.py 的 DatabaseSchemaEditor.add_field)取自本机通过 pip 安装的 Django 6.0.7 包本身,并逐字节比对确认与 django/django 仓库 6.0.7 标签完全一致。
  • 演示动画的依赖图排序过程由 Python 独立实现并交叉验证过——5 个节点的依赖关系和最终顺序直接取自真实实测的 migrate --plan 输出,每一步"已解决/待处理"状态都核对过与真实顺序一致。
  • 没有涉及:迁移合并(makemigrations --merge 处理多个开发者并行产生冲突迁移的场景)、RunPython/RunSQL 这类手写操作怎么参与状态重建、squashmigrations 压缩历史迁移的规则、PostgreSQL/MySQL 后端在加字段这件事上和 SQLite 完全不同的真实行为(前两者大多数场景可以真正走 ALTER TABLE ADD COLUMN,不需要整表重建)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号