Django ORM的单表实例,其实是很多人入门Django时最先接触、但往往又没真正吃透的一块内容。网上讲CRUD的文章一抓一大把,但大部分都是把代码贴一遍,告诉你“这样写就能跑”,至于为什么这么写、底层发生了什么、遇到问题怎么排查,基本是一笔带过。这篇文章我想换个角度,直接从单表操作入手,把ORM的模型定义、增删改查、查询集的懒加载机制、常见坑点都揉碎了讲一遍。这也算是Django的一站式教程里最基础也最关键的一环,适合刚学完Python基础、准备上手Django做web应用开发实战的新手,也适合写过一些Django代码但总觉得ORM“差点意思”的开发者。
1. 先搞清楚ORM到底在替你做什么
很多新手学Django ORM,上来就背语法:objects.all()查全部、objects.get()查单条、save()保存、delete()删除。语法背得滚瓜烂熟,但一旦换个场景就懵了,比如“我要按条件更新怎么办?”“查出来的数据为什么改了不生效?”“为什么明明删了记录,get()还是能查到?”这些问题归根结底,都是因为没有理解ORM的本质。
1.1 ORM不是“把数据库变没了”,而是“给你一个翻译官”
ORM全称是对象关系映射,听起来很高大上,其实干的事特别朴素:把数据库表映射成Python类,把表里的行映射成类的实例对象,把字段映射成对象的属性。你操作对象,就等于操作数据库里的记录。
打个比方,数据库是一间仓库,SQL是仓库管理员使用的语言,而Python是你的语言。ORM就是一个翻译官,你说“我要把编号为1的那箱货搬出来”,翻译官就转译成管理员能听懂的指令,去仓库里执行,再把结果翻译回你熟悉的格式递给你。
在Django里,这个翻译官的核心就是Model基类和Manager管理器。Model负责定义“仓库长什么样”——也就是表结构;Manager(默认叫objects)负责执行“搬货指令”——也就是查询和操作。
from django.db import models class Article(models.Model): title = models.CharField(max_length=200) content = models.TextField() created_at = models.DateTimeField(auto_now_add=True)这段代码定义了Article表,包含标题、正文、创建时间三个字段。你没写任何建表SQL,但Django会在你执行makemigrations和migrate时,自动生成并执行建表语句。这就是ORM最直接的价值——屏蔽了数据库方言差异。
1.2 为什么Django选ORM而不是直接写SQL
有人会说,直接写SQL不是更灵活、性能更好吗?这话没错,但对大部分web应用开发来说,维护成本才是最大的敌人。
- 跨数据库迁移:你开发时用SQLite,上线用MySQL,如果手写SQL,至少得检查一遍语法兼容性。Django ORM生成的SQL会自动适配当前数据库引擎,你几乎不用关心底层是哪种数据库。
- 防注入:ORM的查询参数都会经过参数化处理,从根本上规避了SQL注入风险。手写SQL时一旦忘记转义或者用了字符串拼接,就是给攻击者留后门。
- 对象与数据联动:你修改一个对象的属性,再调用
save(),Django会自动生成UPDATE语句,而且只更新被修改的字段(在特定条件下)。这种“对象即数据”的体验,写业务代码时效率极高。
当然,ORM也不是万能的。复杂报表查询、海量数据批量操作,该上原生SQL还是得上。但单表场景下,ORM的效率和安全性远超手写SQL,这也是为什么Django官方文档把ORM作为核心卖点之一。
2. 单表模型的建模与准备工作
项目标题是“单表实例”,那我们就从一张最简单的表开始。我这次用的例子是博客文章的Article表,理由很简单:字段类型覆盖了CharField(标题)、TextField(正文)、DateTimeField(时间)、BooleanField(是否发布),足够演示ORM的常见操作,又不至于复杂到分散注意力。
2.1 模型字段选型:不只是“选个类型”那么简单
很多初学者在建表时,字段类型随手选,觉得“反正能存数据就行”。其实字段类型直接影响数据库层面的存储方式、索引策略、校验规则,甚至影响后续的查询性能。
以Article为例,我推荐的字段设计是:
from django.db import models class Article(models.Model): title = models.CharField(max_length=200, verbose_name="标题") content = models.TextField(verbose_name="正文") status = models.BooleanField(default=False, verbose_name="是否发布") views = models.IntegerField(default=0, verbose_name="浏览量") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") class Meta: db_table = "article" ordering = ["-created_at"] def __str__(self): return self.title这里有几个容易忽略的点:
第一,CharField必须指定max_length,而且这个长度会影响数据库索引。MySQL里InnoDB的索引最大长度有限制,如果max_length设得太大,后面想给这个字段建索引会失败或需要前缀索引。
第二,auto_now_add和auto_now的区别。auto_now_add=True只在记录创建时写入当前时间,之后不再变动,适合created_at;auto_now=True每次调用save()都会刷新为当前时间,适合updated_at。这个设计比手动写timezone.now()干净得多,也不会出现“忘了更新更新时间”的问题。
第三,db_table可以自定义表名。如果不设置,Django默认表名是“app名_模型名”,比如blog_article。设置成article更简洁,但要注意团队规范,别每个模型都随便起名,后面维护起来会想骂人。
第四,Meta.ordering影响默认查询顺序。设置了ordering = ["-created_at"]之后,所有不带显式order_by的查询结果都会按创建时间倒序排列。这个设计的好处是列表页直接Article.objects.all()就是最新的在前,坏处是如果你某次查询想按其他字段排序,必须显式order_by覆盖,否则会困惑“为什么这个查询结果是这个顺序”。
2.2 迁移:让模型变成真正的数据库表
模型定义好之后,需要两步操作才能让数据库里真的出现这张表:
python manage.py makemigrations python manage.py migratemakemigrations会根据模型变化生成迁移文件,放在app的migrations目录下。这个文件是给Django用的“变更记录”,记录了从上一个状态到当前状态需要执行哪些数据库操作。migrate才是真正执行,把变更应用到数据库。
这里分享两个实战经验:
- 迁移文件要提交到版本库,不要ignore。团队协作时,别人拉代码后执行
migrate就能同步数据库结构,不需要手动去数据库执行SQL。而且迁移文件是数据库结构变更的历史记录,出问题可以回溯。 - 改模型后,不要手动改数据库,一定要重新生成迁移。我见过有同事为了省事,直接在数据库管理工具里加了个字段,结果模型里没同步,代码一跑直接报
column does not exist。更要命的是,后来执行makemigrations时,Django会尝试把模型状态和数据库实际状态做对比,然后产生一堆莫名其妙的迁移。
2.3 shell环境:快速验证模型的“试验台”
模型建好了,不要急着写视图,先在Django shell里把CRUD撸一遍。shell是Django提供的交互式环境,可以让你直接操作ORM,非常适合验证逻辑、调试代码。
python manage.py shell进入shell之后,你可以一行一行地执行Python代码,实时看到结果。这一步对新手特别友好,因为你可以立刻确认“这个查询返回到底是什么”“filter和exclude的结果对不对”,不用等整个项目跑起来。
3. 单表核心操作:增删改查的落地实现
这一节是全文的干货重心。我会按“创建、查询、修改、删除”四个维度逐一展开,每个操作都讲清楚“怎么写”和“底层发生了什么”。
3.1 创建对象:两种方式,适用不同场景
创建记录有两种主流写法:
方式一:实例化对象后调用save()
article = Article(title="Django ORM入门", content="这是一篇教程") article.save()方式二:使用objects.create()
article = Article.objects.create(title="Django ORM进阶", content="这是第二篇")两者最终都会生成INSERT语句,区别在于方式一允许你在save()之前对对象做更多处理,比如根据某个字段动态计算另一个字段的值;方式二则是典型的“一步到位”,适合创建参数已经齐全的场景。
还有一种经常被忽略的创建方式:get_or_create。它先尝试按条件查询,如果找不到则创建,返回一个元组(对象, 是否新建的布尔值)。
article, created = Article.objects.get_or_create( title="Django ORM实战", defaults={"content": "默认正文"} )这个方法的经典应用场景是“初始化数据”“幂等写入接口”。比如用户第一次登录时创建个人资料,重复请求不会产生重复记录。
注意,get_or_create查询条件的字段和defaults里给的字段是分开的。查询字段用于匹配已有数据,defaults里的字段只在创建时写入。如果你把title放在查询条件里,又想在defaults里重复指定,Django会直接报错。
3.2 查询操作:all、get、filter的区别和正确用法
单表查询是Django ORM使用频率最高的操作,也是新手最容易混淆的地方。
all():返回所有记录,返回类型是QuerySet
all_articles = Article.objects.all()get():返回单个对象,如果查询不到或查到多条都会抛异常
article = Article.objects.get(id=1)get()有两个经典的坑:DoesNotExist(查不到时抛出)和MultipleObjectsReturned(查到多条时抛出)。所以在用get之前,你要先确认查询条件是唯一的,比如主键id、唯一字段。如果条件不唯一,更稳妥的做法是用filter加.first()。
filter():按条件过滤,返回的是QuerySet,一条或多条都可能
published = Article.objects.filter(status=True)从使用场景来说:all()用于列表页,get()用于详情页(按主键取单条),filter()用于各种带条件的列表和统计。
filter的参数写法是字段名=值,但条件不止“等于”一种,Django提供了双下划线扩展语法:
| 写法 | 作用 | 示例 |
|---|---|---|
field=value | 等于 | title="Django" |
field__gt=value | 大于 | views__gt=100 |
field__gte=value | 大于等于 | views__gte=100 |
field__lt=value | 小于 | views__lt=10 |
field__lte=value | 小于等于 | views__lte=10 |
field__contains=value | 包含 | title__contains="ORM" |
field__icontains=value | 包含(忽略大小写) | title__icontains="orm" |
field__in=value | 在列表内 | status__in=[True, False] |
field__isnull=True | 为空 | content__isnull=True |
以日期查询为例,实际开发中经常用到的created_at__date、created_at__year、created_at__month语法,也是双下划线扩展的一部分。
# 查询2024年创建的已发布文章 Article.objects.filter(created_at__year=2024, status=True) # 查询今天创建的文章 from django.utils import timezone today = timezone.now().date() Article.objects.filter(created_at__date=today)这里有个理解难点:双下划线并不是Python语法,而是Django ORM约定的“字段路径分隔符”。created_at__year表示“created_at字段的年份属性”,Django会把它翻译成SQL里的EXTRACT(YEAR FROM created_at)或者YEAR(created_at),具体取决于数据库引擎。
3.3 修改更新:两种策略,性能和语义差别很大
更新操作主要分两类:对象级更新和查询集级更新。
对象级更新:先查后改,语义清晰但多一次查询
article = Article.objects.get(id=1) article.title = "修改后的标题" article.save()这种方式先执行一次SELECT,把对象加载到内存,修改属性后再执行一次UPDATE。优点是你可以同时修改多个字段,还可以在save()之前对值做校验或加工。缺点是多一次查询,如果是一批记录都要改,性能开销明显。
查询集级更新:直接更新,高效但“看不见改动后的对象”
# 所有已发布文章浏览量加1 Article.objects.filter(status=True).update(views=models.F("views") + 1)这段代码只执行一次UPDATE,不会有SELECT。F表达式让数据库在SQL层面完成“当前值+1”,避免了先把值读到Python内存再写回去的竞争问题。这在并发场景下尤为重要,因为如果用“先查再加再存”的方式,两个请求同时读到的views都是100,分别加1后写回,最终结果是101而不是102,数据就丢了。
使用QuerySet.update()有一点要记住:它不会触发模型的save()方法,也就不会触发auto_now对updated_at的更新(实际上Django 2.0+会自动处理updated_at,但自定义逻辑不会跑)。如果模型里有些字段依赖save()里的逻辑,就必须用对象级更新。
另一个实用场景是按条件批量修改不同值。比如批量发布一组文章:
ids = [1, 2, 3] Article.objects.filter(id__in=ids).update(status=True)这种写法比for循环逐个save()快得多,也是典型的高性能更新方式。
3.4 删除对象:单条删除与批量删除的边界
删除操作同样分两种:
单条删除:先查到对象,然后调用delete()
article = Article.objects.get(id=1) article.delete()批量删除:直接用QuerySet的delete()方法
Article.objects.filter(status=False).delete()这里有几个重点提醒:
- 批量
delete()同样不会触发单个对象的delete()方法里的自定义逻辑,只执行SQL级的DELETE。 - 如果有外键关联,Django默认会执行级联删除(
on_delete=models.CASCADE),也就是删掉父表记录时,子表里的关联记录也会一并删除。这在单表场景不存在,但在实际项目中一定要留意,误删一片是灾难。 - 有些“删除”其实是“软删除”。比如给
Article加一个is_deleted字段,删除时只是把is_deleted置为True。这种做法在需要保留审计数据的业务里很常用,代价是每次查询都要带上is_deleted=False条件。要不要引入django-safedelete这类第三方库,取决于项目对数据保留的要求,个人建议前期别引入,等真正需要时再重构。
4. 查询的艺术:让单表查询也能玩出花样
单个表查询,听起来没什么难度,但实际开发中,仅仅一个Article表就能玩出很多需求:分页、去重、统计、条件组合。这些需求如果依赖Python内存里处理,数据量一大就崩;如果依赖ORM正确的查询集操作,效率和代码可读性都能兼顾。
4.1 QuerySet是懒加载的:什么时候真正执行SQL
这是Django ORM最核心的机制之一,理解它,你写查询时就会少踩一半的坑。
qs = Article.objects.filter(status=True) # 此刻没有执行SQL qs = qs.filter(views__gt=100) # 还是没有执行SQL print(qs) # 此刻才执行SQLfilter()只是构建一个查询条件对象,真正的SQL在“求值”的那一刻才执行。所谓求值,就是遍历QuerySet、调用len()、用list()转换、访问索引下标、判断布尔值等操作。
这个设计的价值有两个:
- 链式filter可以不断累加条件,灵活组合,最后统一生成一条SQL,不会每条filter都单独发一次请求。
- 可以延迟到真正需要数据时才访问数据库,比如先拼好条件,再决定要不要执行。
懒加载也带来一个常见坑:如果你在代码里先创建了一个QuerySet,然后修改了模型实例的字段值,再去遍历这个QuerySet,得到的结果可能和预期不一致。因为普通QuerySet不缓存结果,每次求值都会重新查询数据库。如果你需要多次使用同一批数据,建议强制求值一次并转成列表:
articles = list(Article.objects.filter(status=True))4.2 聚合查询:count、annotate的基础用法
单表查询里,count()是最高频的统计操作。它返回的是整数而不是QuerySet,所以可以单独赋值给变量或作为接口返回值。
total = Article.objects.count() published_count = Article.objects.filter(status=True).count()这里有个性能细节:count()在SQL层面执行SELECT COUNT(*),不会加载记录内容。而如果你先all()再len(),Django会把所有记录加载到内存,数据量大时内存直接飙升。所以永远用count()而不是len(all())。
annotate是分组统计的利器。比如统计每天发布的文章数:
from django.db.models.functions import TruncDate from django.db.models import Count daily_counts = Article.objects.annotate( day=TruncDate("created_at") ).values("day").annotate( count=Count("id") ).order_by("day")这里TruncDate("created_at")把created_at截断到日期精度,values("day")按天分组,然后annotate(count=Count("id"))统计每组的记录数。这类查询在后台管理页、运营报表里非常常见。
annotate还有一个重要用途:给查询结果附加计算字段。比如给每篇文章算一个“字数”字段:
from django.db.models.functions import Length articles_with_length = Article.objects.annotate( title_length=Length("title") ) # 此后每篇文章对象都有title_length属性 for article in articles_with_length: print(article.title, article.title_length)注意,annotate添加的字段只存在于查询结果中,不会改到数据库。
4.3 Q对象:让OR条件不再别扭
filter()默认是AND关系,多个条件并列就是“且”。但实际需求经常要“或”。比如“标题包含Django,或者正文包含ORM的文章都要”。
from django.db.models import Q articles = Article.objects.filter( Q(title__contains="Django") | Q(content__contains="ORM") )Q对象用|表示OR、用&表示AND,还可以用~表示NOT,组合出非常复杂的查询条件。一个经典场景是“搜索功能”:
keyword = "Django" articles = Article.objects.filter( Q(title__icontains=keyword) | Q(content__icontains=keyword), status=True )注意,这里status=True和前面的Q表达式用逗号分隔,仍然是AND关系。如果你想让整个条件变成“状态为真,且(标题或正文包含关键词)”,这个写法是对的。如果想调整逻辑,可以把status=True也放进Q里组合:
articles = Article.objects.filter( Q(status=True) & (Q(title__icontains=keyword) | Q(content__icontains=keyword)) )Q对象还可以动态拼接。比如根据用户选择的筛选条件,循环构建Q再组合,写法上比一长串filter链式调用更清晰。
4.4 排序与去重:order_by、distinct的注意点
order_by基本是列表查询的标配:
# 按浏览量降序,浏览量相同时按创建时间升序 articles = Article.objects.all().order_by("-views", "created_at")这里-views的负号表示降序,多个排序字段按优先级从左到右。要注意的是,order_by如果模型Meta里定义了ordering,默认会带上,但你显式order_by后可以覆盖。另外,order_by对annotate出的字段同样生效,比如按统计的count排序。
distinct()用于去重。单表场景下,如果查询没有join,distinct()基本用不上;但一旦涉及多表或某些数据库的values()分组结果,不加distinct()可能会出现重复行。新手阶段需要记住:distinct()是对查询结果去重,但并不是所有数据库都支持所有字段的去重,使用前最好先看生成的SQL。
单表去重还有一种常见场景:查询所有出现过的不重复状态值。
Article.objects.values("status").distinct()返回的是字典列表,每个字典里包含一个状态值,这在做筛选下拉框时很好用。
5. 实际开发中最容易踩的坑与排查思路
写单表ORM,语法不难,难的是排查问题。我整理了几个自己踩过、也看同事踩过的坑,每一个都有明确的现象和排查方法。
5.1 字段名与Python保留字、字段名冲突
模型字段名如果跟Python的关键字或Django的内部属性冲突,会导致一些很诡异的问题。比如object这种字段名,虽然语法上不报错,但调用Article.objects时,可能会被Python解释成实例属性访问,逻辑混乱。保险起见,字段名要避开objects、pk、save、delete这类Django内部使用的名称,也不要直接用Python关键字,比如class、import这种。
5.2get()报错:DoesNotExist和MultipleObjectsReturned
这是单表查询最高频的报错。get()的语义是“查询唯一一条记录”,但数据一旦不满足唯一性,异常就来了。
DoesNotExist:你查不到,Django抛出这个异常。注意,这个异常是Article.DoesNotExist,是Django在模型上动态生成的。捕获时要写Article.DoesNotExist,不要写成DoesNotExist,否则可能捕获不到,或者捕获到其他模型的同名异常。MultipleObjectsReturned:查询条件匹配了多条。很多新手改了一通,发现数据库里确实有重复数据,但代码逻辑没问题。这时候要回到数据本身,清理重复记录,或者在代码层面用filter().first()代替get()。
推荐的稳妥写法:
article = Article.objects.filter(pk=1).first() if article is None: # 处理不存在的情况 pass else: # 正常使用 passfirst()的好处是即使匹配到多条,也只会返回第一条,不会抛异常。代价是如果逻辑上应该唯一,它可能会悄悄掩盖重复数据,需要根据业务场景权衡。
5.3 更新字段后没有保存:对象级修改的经典坑
新手最容易犯的错误:从数据库查了一个对象,改了属性,但忘了调save()。结果页面刷新,数据还是原来的。
这种错误往往出现在“先查询-再赋值-然后还有其他逻辑”的流程里。写代码时建议:
- 修改字段后立刻
save(),不要拖到后面; - 可以用
print(article.title)之类的调试手段确认是否已在内存中修改; - 更稳妥的方式是使用
QuerySet.update(),一步到位完成数据库更新。
5.4 时区问题:created_at和updated_at差8小时
Django的时区设置是新手绕不开的坑。settings.py里的USE_TZ=True时,Django会以UTC时间存储DateTimeField,展示时再转换到TIME_ZONE指定的时区。如果你在中国,TIME_ZONE建议设成'Asia/Shanghai'。如果不设置,很可能出现“数据库存的是UTC时间,页面上打出来比北京时间少8小时”的情况。
排查顺序:
- 确认
USE_TZ和TIME_ZONE的配置; - 确认写入时用的是
timezone.now()而不是datetime.now(); - 确认前端展示时有没有做时区转换。
Django模板里默认是开启时区转换的,但序列化输出时,如果直接调.isoformat(),会带上+00:00或Z后缀,前端如果不处理,显示的时间还是不对。规范做法是:后端返回UTC时间带时区标识,前端负责转成用户本地时间;或者后端统一转成Asia/Shanghai的字符串再返回。
5.5 不是所有“查询”都该用ORM:什么时候回到原生SQL
单表查询里,ORM游刃有余;但一旦涉及复杂报表、递归查询、跨表子查询,ORM生成的SQL可能又臭又长还慢。这时候不要硬扛,直接使用RawSQL、extra或者connection.cursor()执行原生SQL。
from django.db import connection with connection.cursor() as cursor: cursor.execute("SELECT COUNT(*) FROM article WHERE status = %s", [True]) row = cursor.fetchone()注意,原生SQL的调试成本更高,而且会有SQL注入风险,使用时要保证参数化查询,不要拼接字符串。我的习惯是:视图和常规业务逻辑用ORM,复杂的统计报表用原生SQL或数据库视图(View),两者结合才是平衡之道。
6. 从单表走向多表:这个基础决定你的天花板
文章写到这里,其实都在讲单表。但很多人真正卡住的地方,是“单表还没吃透,就急着上外键、上多表联查”,结果一遇到select_related、prefetch_related完全晕头转向。
我特别想强调,单表ORM是Django数据层的全部基础。你在单表阶段建立的每一个查询习惯——用filter而不是用get、用update而不是循环save、用count而不是len、用Q组合条件而不是一堆if——都会直接复用到多表场景。
举例来说,假设文章表加了一个外键author,你查询文章时带出作者姓名:
articles = Article.objects.select_related("author").all()select_related利用SQL的JOIN一次性拿到关联对象,避免每篇都单独查一次作者(这就是著名的N+1查询问题)。这个概念,如果你单表阶段就理解了“ORM惰性求值”“查询要尽量减少数据库交互”,就很容易接受。
反过来,如果你单表阶段就没搞明白QuerySet和对象列表的区别,多表阶段面对related_name、prefetch_related带来的各种“有列表有对象”的组合,只会更乱。
所以,我建议每一位学Django的开发者,都不要急着跳过单表实例,把这一章练透,再上一层楼。
7. 调试ORM的必备小工具
前面讲了理论和实操,最后分享几个我平时调试ORM的“独门武器”,可以显著提升排错效率。
7.1 查看SQL:query属性和connection.queries
每当你怀疑“ORM到底生成了什么SQL”时,直接打印QuerySet.query:
qs = Article.objects.filter(status=True) print(qs.query)输出类似:
SELECT "article"."id", "article"."title", ... FROM "article" WHERE "article"."status" = true这样你就知道ORM实际翻译成了什么,方便和手写SQL做对比。
如果想看整个请求过程中执行了哪些SQL、耗时多少,在settings.DEBUG=True时使用:
from django.db import connection print(connection.queries)输出是一个列表,每个元素包含sql和time字段,非常适合排查N+1查询和慢查询。
7.2 使用django-debug-toolbar查看页面SQL
开发环境下,装上django-debug-toolbar,页面右侧会显示一个调试面板,里面包含当前请求执行的所有SQL、耗时、重复次数。这个工具是Django调试的“神器”级别存在,强烈建议新手从第一天就装上,养成“看SQL”的习惯。
7.3shell是用来试验的,不要客气
我几乎每个项目都会在shell里做大量的“试验性查询”,确认返回类型、确认SQL、试各种filter组合,再写进视图或服务层。这比边写边跑服务、断点调试的效率高得多。
8. 结语
Django ORM的单表实例,说到底是“用对象的方式操作数据”这一思想的小缩影。你学会的filter、get、update、delete,底层全是SQL;你掌握的懒加载、查询集、聚合、F表达式,迁移到多表场景也完全适用。
我个人在实际操作中的体会是:遇到了ORM“行为奇怪”时,不要急着怀疑是框架的bug,先打印一下SQL,看看数据,很多问题就清楚了。把单表操作练熟、把调试工具用熟、把常见的坑记熟,这三件事做到位,Django的数据层就算真正入门了。后面写多表关联、复杂业务逻辑时,你会发现这些基础功给你省下了无数时间。