最近在带几位刚从教程转向实战的新手,观察到一个很一致的现象:写视图、写模板,大家基本都能照猫画虎,可一到models.py就集体卡住,卡点高度集中在Django常用字段的选择上。文档里每个字段都认识,组合到自己的项目里就不会选了——IntegerField和BigIntegerField到底什么时候用?CharField和TextField的边界在哪里?外键要不要手动写_id?日期字段怎么处理时区?这篇就把Django常用字段从头到尾捋一遍。重点不是抄文档,而是讲清楚每个字段在数据库里的真实落点、参数之间的实际效果,以及我在真实项目里踩过、带人时反复解释过的那些细节。适合刚学完基础、准备动手做第一个项目的读者,也适合想给团队系统梳理一遍字段知识的同学。
1. 字段类型与数据库映射:先搞清楚你在操作什么
很多教程整理字段时,喜欢按“数字、字符串、日期”分类,每个字段给一句话。这个方法学得快,但真正开写时还是会犹豫。我个人的建议是先建立一个认知:Django里的每个Field类,最后几乎都会映射成数据库里的某一种类型。你选择字段类型,其实就是在选择数据库列的类型和约束。理解了这一层,后面很多纠结都会自动消失。
1.1 数值字段:空间与精度是两回事
先从最常用的数值字段说起。如果你不手动指定主键,Django会自动给你加一个id = models.AutoField(primary_key=True),打开MySQL你会看到一列int类型的自增列。数据量预期会很大的时候,建议显式使用BigAutoField,否则int自增到上限之后会非常被动,想要扩容麻烦得多。
IntegerField、SmallIntegerField、BigIntegerField的区别主要在取值范围和存储空间。年龄、评分、排序值这类小数字,SmallIntegerField在MySQL里对应smallint,范围约三万左右,完全够用。订单量、PV量这种可能持续增长的计数,通常用IntegerField起步。而订单号、日志ID这类可能超过21亿的数值,直接BigIntegerField。不要随手什么都用BigIntegerField——虽然现在磁盘和内存不那么值钱,但数据库类型选择本身就是表设计的一部分,能明确约束的业务值域就别放宽。
FloatField和DecimalField的差别是真正的重点。FloatField在MySQL里对应double,属于浮点存储,做运算时会出现经典问题:0.1加0.2,结果是0.30000000000000004。金额、税率、库存成本这类需要精确计算的字段,一律用DecimalField。它需要必填的max_digits和decimal_places两个参数,比如价格字段:
price = models.DecimalField(max_digits=10, decimal_places=2)max_digits表示总位数,decimal_places表示小数部分占几位,这个例子在MySQL里对应decimal(10,2),能存的最大值是99999999.99。新手最容易在这里漏掉参数,迁移阶段会直接报错。我在实际项目里,财务相关字段从没见过用FloatField的。
整理一张数据库映射对照表,写模型时可以快速参考:
| 字段类型 | MySQL中的列 | 典型用途 |
|---|---|---|
| SmallIntegerField | smallint | 年龄、评分、排序值 |
| IntegerField | int | 通用计数、数量 |
| BigIntegerField | bigint | 订单号、大数量统计 |
| AutoField | int auto_increment | 默认主键 |
| BigAutoField | bigint auto_increment | 高并发业务主键 |
| FloatField | double | 科学计算等允许误差的数据 |
| DecimalField | decimal(max_digits, decimal_places) | 金额、价格、税率 |
注意:DecimalField不指定max_digits和decimal_places,Django会在迁移或校验阶段直接报错,这两个参数没有默认值。
1.2 字符串与文本字段:长度和价值
CharField是Django里使用频率最高的字段之一,它必须指定max_length,因为在MySQL里对应varchar(length)。设置长度不只是为了数据库,Django的校验器也会拿它作为表单输入长度上限,用户在前台填超了会被拦截。
TextField则对应MySQL的longtext(PostgreSQL里对应text),可以存很长的内容,不限制长度。但短文本不建议用TextField,它没法在普通业务中直接建常规索引,后台管理界面的输入控件默认也是个很大的textarea,编辑体验反而差。通常的边界是:标题、姓名、邮箱、手机号这类长度可控的,用CharField;正文、富文本、日志详情这类内容,用TextField。
EmailField、URLField看起来是独立字段类型,本质仍继承CharField,只是在Django的form层加了格式校验。SlugField也算这一类,它在表单层会做标准化处理。新手可以用,但心里要清楚它们是“带规则的CharField”,底层还是varchar。
这里有个容易混淆的点:不是字段类型决定了表单控件,而是字段类型参与决定。CharField在后台默认是单行输入框,TextField默认是多行文本框,但如果你愿意,也可以给CharField指定textarea的widget。所以选类型时优先想的是数据库存储和业务语义,而不是界面长什么样。
1.3 日期时间与布尔:容易被忽略的两个细节
DateField、DateTimeField、TimeField在MySQL里分别对应date、datetime(6)、time(6)。Django的DateTimeField默认支持微秒精度,虽然绝大多数业务用不到,但要知道这是框架的默认行为,建表后看到datetime(6)不要惊讶。
两个常用参数名字特别像:auto_now_add和auto_now。auto_now_add表示创建时写入一次当前时间,之后不再变化;auto_now表示每次调用Model.save()时都会刷新为当前时间。这里有个常见误区:很多人以为auto_now会在任意一次数据库更新中自动记录,实际上如果走Article.objects.filter(id=1).update(title='...')这种批量更新路径,auto_now字段不会自动维护,因为这条路径完全绕过了模型的save()方法。我在项目里做批量更新时,如果某个表需要精确的修改时间,通常会自己显式传updated_at值。
关于时区:settings里USE_TZ = True是新项目默认配置。开启后DateTimeField写入数据库时统一存成UTC时间,读取时再按当前时区输出。早期Django版本在表单渲染上还有use_utc参数的坑,后续版本行为已经比较稳定,但你在做历史数据展示时仍要注意时区转换,不要手动把时间字符串拼进datetime对象。
布尔字段里,除了BooleanField,老项目里还经常见到NullBooleanField。区别在于BooleanField在数据库里对应bool或tinyint(1),正常情况下只有True和False两种状态;NullBooleanField额外允许NULL,代表“未知”或“未设置”。较新版本的Django已经建议用BooleanField(null=True)直接表达同样的语义,NullBooleanField正逐步淡出。
2. 常用字段参数里的细节:null、blank、default、choices
字段类型选完之后,紧接着就是参数。参数不会改变字段“是哪种类型”,但决定了数据是否允许为空、是否允许重复、缺失时的默认行为,以及表单层如何校验。实际业务中,因为参数使用不当而返工的情况,远多于字段类型选错。
2.1 null与blank:数据库层和表单层一定要分开看
这是新手最容易混淆的一组,也是评审时必问的一个点。null是数据库层面的概念,决定数据库列是否允许NULL;blank是表单层面的概念,决定表单提交时字段是否允许为空。打个比方:null管的是“库里有没有值”,blank管的是“用户提交表单的时候能不能不填”。
在CharField这类字符串字段上,如果同时开了null=True和blank=True,你会很快遇到一个尴尬状况:数据库里同时存在NULL和空字符串''两种“空”。查询统计时,where phone = ''和where phone is null会得到两拨不同的结果,还得写额外的兼容逻辑。
我个人的实践经验是:字符串字段尽量不要开null=True,用blank=True加上default=""就够了;数值字段、日期字段、外键字段如果要表达“目前没有值”,反而建议用null=True,因为空字符串在这些类型上没有对应含义。举个例子,用户表的手机号字段如果允许暂不填写,推荐写法:
phone = models.CharField(max_length=20, blank=True, default="")而订单表的发货时间,在未发货时应该是空,就不应该用空字符串表达:
shipped_at = models.DateTimeField(null=True, blank=True)2.2 default、unique、db_index、db_column:决定默认值和约束
default可以是一个固定值,也可以是一个可调用对象。经典坑在于新手经常写成default=timezone.now(),这是把函数执行结果固化成模块加载那一刻的时间;正确写法是default=timezone.now,不带括号,这样每次创建新记录时才执行。生成随机token时同理,写default=get_random_token,而不是default=get_random_token()。这个细节直接影响线上数据质量。
unique=True会创建唯一索引;这里有个附带知识:unique=True再加db_index=True是多余的,因为唯一索引本身就是索引。db_index=True则创建普通索引,适合在查询条件里频繁出现的字段。但要记住不是每个字段都需要索引,索引会拖慢写入,尤其是高并发写入场景,索引数量和写入性能是相悖的。我给团队定的简单规则是:只有出现在filter、order_by、join字段里的字段,才值得考虑加索引。
db_column用于设置数据库列名。默认情况下Django会把字段名直接作为列名,多数时候不用管。当你对接的是历史数据库,或者数据库命名规范和Python代码风格不一致时,可以用它把两边桥接起来。还有个不常用的参数editable=False,会让字段不出现在表单里,比如服务端自动生成的标识字段。
2.3 choices:用枚举组织业务状态,而不是散落魔法值
业务模型里经常会有状态字段,比如文章有草稿、已发布、已下架。最直观的写法是自己记一套字符串,然后到处比较——“如果status == 'published'就……”。状态一多,代码里到处都是魔法值,改一个状态名就得全局搜索替换。
Django的choices参数可以把选项集中管理。常见写法有两种,第一种是模型里定义常量列表:
class Article(models.Model): STATUS_DRAFT = "draft" STATUS_PUBLISHED = "published" STATUS_CHOICES = ( (STATUS_DRAFT, "草稿"), (STATUS_PUBLISHED, "已发布"), ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, default=STATUS_DRAFT, )第二种是Django 3.0以来更推荐的TextChoices:
from django.db import models class ArticleStatus(models.TextChoices): DRAFT = "draft", "草稿" PUBLISHED = "published", "已发布" class Article(models.Model): status = models.CharField( max_length=20, choices=ArticleStatus.choices, default=ArticleStatus.DRAFT, )TextChoices的好处是定义、取值、标签都在一个地方,用起来还是普通枚举类的风格,团队协作时容易读。还有一点要特别说明:choices只是Django表单层的校验规则,它不会在数据库层对字段值做约束。你用ORM直接执行Article.objects.create(status="deleted"),数据库照样能写入。如果业务真的需要数据库层面强校验,要么在save方法里写逻辑,要么用CheckConstraint,不能指望choices给你设一道数据库防火墙。
3. 关系字段不只是加个外键:on_delete、反向查询与自关联
关系字段的行为比普通字段复杂得多,这部分坑我也总能在代码评审里看到。外键、一对一、多对多,看似只是字段类型不同,背后的约束和操作方式差异很大。
3.1 外键on_delete的六种选择,本质是对业务风险的决策
ForeignKey的第一个必填参数是on_delete。很多新手会随手写成models.CASCADE,实际上它的含义是:当被关联的对象被删除时,当前对象怎么办。这个决策直接影响数据安全。比如删除一个用户,用户发表的文章和评论是跟着一起消失,还是保留但不再关联,还是直接拒绝删除用户,都需要想清楚。
常用选项的语义可以这样理解:
| 选项 | 行为 | 典型场景 |
|---|---|---|
| CASCADE | 被关联对象删除时,当前对象一并删除 | 用户删除时,其草稿文章也删掉 |
| PROTECT | 被关联对象存在时禁止删除,抛出ProtectedError | 用户有资产数据时不允许删除用户 |
| RESTRICT | 与PROTECT类似,批量删除时检查顺序不同 | 对检查时机有精细化要求的场景 |
| SET_NULL | 外键置为NULL,前提是字段已设置null=True | 文章删除后,评论仍保留,作者置空 |
| SET_DEFAULT | 外键置为default值,前提是字段已设置default | 删除分类后,商品归入默认分类 |
| DO_NOTHING | Django不干预,是否报错交给数据库外键约束 | 极少用,需自己保证数据一致性 |
实际业务里我比较常用CASCADE和SET_NULL的组合。比如用户删除,他个人的草稿文章级联删除很合理;但文章删除后,历史评论如果还想保留,评论表的作者外键可以设为SET_NULL,把作者置成“已注销用户”。如果业务不允许删除有资产数据的用户,就在被引用的一侧用PROTECT,删除用户时直接让上层操作报错,逼着产品重新设计流程。
给新手最核心的建议:凡是打算用SET_NULL的,外键字段一定记得null=True和blank=True,不然删除关联对象时Django会因为没有值可赋而抛IntegrityError。这个错误我见过很多次。
3.2 related_name与related_query_name:反向查询的管理员名
定义外键时,related_name到底有什么用,很多人一开始理解不了。不写它,反向查询时默认用“模型名小写_set”,比如某个用户发布的所有文章,引用写法是user.post_set.all()。这种写法很容易和正向外键混淆,随着模型增多,每次都要在脑子里绕一圈。设置related_name="posts"之后,直接user.posts.all(),语义清楚得多。
如果同一个模型里有两个字段都指向同一个目标模型,比如评论表既有created_by,又有last_edited_by,不指定related_name会直接报错,因为Django无法自动生成两个完全一样的反向查询名。这时必须手动指定不同的名称区分。自关联场景也同理,典型例子是评论的楼层回复:
class Comment(models.Model): content = models.TextField() parent = models.ForeignKey( "self", on_delete=models.CASCADE, related_name="children", null=True, blank=True, )这样拿到一个评论后,comment.children.all()就能取到所有直接子评论,构造树形回复结构会顺手很多。related_query_name影响的是filter查询的字段名,实际使用频率不如related_name高,知道它存在即可。
3.3 OneToOneField与ManyToManyField:一对一扩展和多对多中间表
OneToOneField用于一对一关系,最典型的场景是扩展用户资料。用户的核心认证字段放主表,头像、简介、生日这类扩展资料放另一张表,用OneToOne关联。好处是主表干净,扩展字段再多也不影响用户表查询性能;坏处只是每次加资料字段要动资料表,这属于正常成本。
ManyToManyField最简单的用法是让框架自动生成中间表。但一旦中间表需要额外字段,比如用户加入群组的时间、在群里的角色,就必须改用through参数自定义中间模型:
class Membership(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) group = models.ForeignKey(Group, on_delete=models.CASCADE) date_joined = models.DateField(auto_now_add=True) role = models.CharField(max_length=30, default="member")然后在Group模型里声明members = models.ManyToManyField(User, through=Membership)。通过中间模型,可以在add关系时传额外字段:
group.members.add(user, through_defaults={"role": "admin"})这里有个新手容易懵的地方:使用through自定义中间表之后,many-to-many manager不再支持add()、remove()、set()这些快捷方法,你需要通过操作中间模型来管理关系,比如Membership.objects.create(user=user, group=group, role="admin")。文档里那句“自定义through后部分自动方法不可用”指的就是这个。
4. 字段定义引发的迁移事故:几次典型的排查现场
字段定义不是写完models.py就结束了,它和数据库之间还有一层迁移。新手在这块遇到问题时往往很慌,下面几种情况几乎每个项目都会遇到,我按排查思路整理出来。
4.1 模型改了,makemigrations却提示No changes detected
先说一个最让人沮丧的现象:明明改了models.py,运行python manage.py makemigrations却提示“No changes detected.”。新手第一反应通常是再运行一遍,结果还是一样。我的排查顺序是:
- 确认App真的在INSTALLED_APPS里注册了。新项目刚创建App时忘了注册,Django根本不会监听这个App的模型,自然会提示没有变化。
- 确认文件确实保存了,并且改的是当前App下的models.py。多App项目里改错了位置,表现也是一样的。
- 用
python manage.py makemigrations --dry-run看检测结果。如果仍旧没有输出,再回想一下你改的到底是什么。
第三种情况最隐蔽。Django的迁移系统比较的是模型状态的变化,而Meta里的ordering、verbose_name这类不影响表结构的元信息,多数情况下不会触发迁移生成。如果你只改了字段的verbose_name或者帮助文本,数据库结构没变,没有新迁移是正常的。
在实际项目里,我主张定一条团队约定:任何字段的增删改都伴随一次makemigrations和migrate,并且把迁移文件提交到代码仓库。迁移文件本质上就是数据库结构的代码版本记录,赶工时可以偷懒,一旦上线后需要回滚,就会明白它的价值有多大。
4.2 给已有数据的表新增必填字段:一个标准的加字段流程
这是生产环境最常见的场景:表里已经积累了几千行数据,现在要加一个非空的新字段。如果你直接写成name = models.CharField(max_length=50),运行makemigrations时Django会陷入两难,它无法替老数据决定这个字段填什么值,于是会弹出交互式提问,让你选择:一次性提供一个默认值,还是放弃这次迁移。
如果新字段本身就应该有固定默认值,比如status = models.CharField(max_length=20, default="draft"),问题简单,填draft即可。更多时候新字段的值需要根据老数据计算生成,这时候我惯用的流程分三步:
- 第一步:先加字段并允许为空,执行makemigrations和migrate。
- 第二步:写一个数据迁移脚本,读取老数据并计算填充新字段的值,再执行迁移。
- 第三步:去掉
null=True和blank=True(如果业务不允许为空),再次执行makemigrations和migrate。
三步之间存在缓冲,不会出现一次性默认值拍脑袋填错就收不回来的情况。这里要提醒的是:第二步的数据迁移脚本,最好先在本地用与生产相似的数据量跑一遍,别直接在生产环境执行长事务,否则锁表时间长,会拖累线上服务。
4.3 字段定义常见报错的速查表
把这几年带新人时高频出现的报错汇总成一张表,遇到时可以对号入座:
| 报错或现象 | 原因 | 解决思路 |
|---|---|---|
| CharField must define a 'max_length' | 漏写必填参数 | 补上max_length |
| DecimalField require 'max_digits' and 'decimal_places' | 未指定精度的两个参数 | 按业务实际指定 |
| FieldError: Cannot resolve keyword 'user_id' | 查询条件用了不存在的字段名 | ORM里外键用user查询,不用user_id |
| AttributeError: module has no attribute 'xx' | choices常量或TextChoices成员引用错误 | 检查枚举定义和导入路径 |
| 新增非空字段时迁移交互提问 | 老数据没有该字段值 | 按4.2流程处理 |
| Cannot assign 'xxx' must be a User instance | 给外键赋了整型而不是对象 | 用外键对象赋值,或用user_id属性赋值 |
| unique字段出现多个NULL | 唯一字段允许NULL,数据库对NULL不计数 | 看业务是否接受,通常避免这种组合 |
5. 字段设计经验和命名规范:让models.py好读也好改
前面聊的是字段本身,最后这部分更像是我个人在带团队时定的规矩。字段设计得好,不仅数据库结构清爽,后期改需求的成本也会明显降低。这些内容可能不在任何一个文档的“字段列表”里,但实战价值很高。
5.1 从业务实体拆字段,而不是从页面表单拆字段
新手最常见的做法是:网页注册页有哪些输入框,用户表就建哪些字段,昵称、头像、简介、性别、生日、城市全都堆在一张表里。这种思路短期没问题,等业务发展起来就会很尴尬。比如之后要接入企业认证,企业名称、营业执照、认证状态加到哪里?都往用户表塞,表结构会越来越杂。
我的建议是按实体和生命周期拆。用户的核心认证信息是一张表,比如username、password、email、is_active;用户的扩展资料放另一张表,比如昵称、头像、个人简介;如果需要第三方登录,再单独建一张绑定表。每张表只管自己那一类数据,外键关系清晰,加新需求时优先想到去扩展对应的表,而不是动老表。
订单类业务还要记住一个词:快照字段。用户下单时把收货地址、商品单价复制进订单表,而不是后续一直关联查询用户资料。因为用户以后改了地址、改了昵称,历史订单不应该跟着变。订单表看起来有点冗余,但正是这种冗余才是设计得当的体现。
5.2 抽象基类坐镇公共字段,减少重复代码
几乎每个业务表都需要创建时间和更新时间,有些还需要软删除标记。如果每张表都写一遍,不仅啰嗦,团队规范也很难统一。抽象基类是标准解法:
class BaseModel(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) is_active = models.BooleanField(default=True) class Meta: abstract = Trueabstract = True这行决定了Django不会为BaseModel单独建表,子模型继承后,字段全部落到子表上。迁移时你会看到每个子表都自带这几个列。这个模式建议从第一个项目就开始用,后面省下来的重复劳动相当可观。
唯一的提醒还是那个老坑:auto_now只会在Model.save()里自动维护,走queryset.update()批量更新时需要自己显式处理updated_at。项目里通常的做法是封装一个公共工具方法统一更新,而不是散落在各处手动拼。
5.3 字段命名规范:先统一,再谈优化
命名这件事看起来不如类型选择重要,但代码评审时最耗时间的就是到处不一致的命名。我这里只说三条硬规矩。第一,外键字段在ORM里用语义名,不要写user_id。定义一个user = models.ForeignKey(User, on_delete=models.CASCADE),数据库列自动叫user_id,ORM查询时想要数据库列也可以写user_id,但正常业务代码里直接操作user对象可读性最好。第二,布尔字段统一is_或has_开头,时间字段统一created_at、updated_at这类表述,比create_time、update_time顺手得多。第三,字段名不要用Python关键字或Django内置的objects、delete、save这类名字,否则会出现各种莫名其妙的属性冲突。
这些规矩不花成本,却在项目越写越大之后越来越值钱。Django的迁移系统非常成熟,改字段类型本身并不可怕,可怕的是每一处命名都不一样,连查找一个字段在哪些地方被使用都要全项目搜索半天。说句实在话,Django常用字段的内容多而杂,我不建议一次全背完。最有效的做法是找个真实的小项目写几个模型,建几张表,把null、blank、choices、外键删除策略这些参数一个个试一遍。数据库是诚实的,字段定义得对不对,跑一次迁移、打开表结构就全清楚了。