ORM 的存在感很强,写起来也顺手,filter()、annotate()、prefetch_related() 这些方法基本能覆盖八成以上的日常查询需求。但剩下那两成,往往才是让人头疼的地方------复杂的聚合逻辑、数据库专属的语法糖、或者干脆是为了把一条 SQL 的执行效率死死摁在自己能控制的范围内。Tortoise ORM 很清楚这一点,所以它并没有把开发者锁死在 QuerySet 的语法体系里,而是留了好几条通往原生 SQL 的口子 。下面就聊聊这些口子分别长什么样、什么时候该用它们,顺便给几个能直接抄的例子。
🧭 什么时候该考虑手写 SQL
先说结论性的判断标准,再展开细节。简单讲,遇到下面这几类情况,与其和 QuerySet 的链式语法死磕,不如直接上原生 SQL 来得痛快。
复杂查询表达力不够用
- 窗口函数、递归 CTE、复杂子查询嵌套 ------ QuerySet 的
annotate()和Function体系覆盖的是常见聚合场景,一旦涉及ROW_NUMBER() OVER (...)或者递归的WITH RECURSIVE,ORM 层面基本无能为力。 - 多表 UNION 或者跨表复杂 JOIN 组合 ------ 有些报表类查询天生就是拼接出来的,硬要用模型关系去描述反而绕远路。
数据库专属特性
- PostgreSQL 的
JSONB操作符(比如->、->>、@>)、全文检索tsvector、数组类型的ANY()查询,这些都是 Tortoise 字段系统没有直接封装的能力。 - MySQL 的
FULLTEXT索引查询语法,或者一些存储过程调用。
用了这些语法就意味着代码已经不可移植了,但很多项目从一开始就锁定单一数据库,可移植性本来就不是刚需,这时候手写反而更直接。
性能调优场景
ORM 生成的 SQL 有时候会带着一些冗余的 JOIN 或者不必要的字段选择,遇到高并发的热点查询,工程师往往需要手工打磨出一条执行计划最优的 SQL,直接绕开 ORM 的翻译层。
一次性脚本或者运维操作
数据修复、批量清洗、TRUNCATE、建索引这类操作,专门建模型去做纯属浪费,写一段 execute_script 跑完就完事了。

🔧 Tortoise 提供的几种手写 SQL 方式
Tortoise 并没有只给一个统一的 raw() 接口了事,而是根据使用场景拆成了好几种,各有各的脾气。
| 方法 | 返回类型 | 适用场景 |
|---|---|---|
conn.execute_query(sql, params) |
(row_count, list[Record]) |
需要拿到原始结果集,自己处理字段映射 |
conn.execute_query_dict(sql, params) |
list[dict] |
想要字典格式,省去手动 zip 字段名的麻烦 |
conn.execute_script(sql) |
无返回值 | 批量 DDL、多语句脚本、迁移操作 |
Model.raw(sql) |
RawSQLQuery,可继续链式调用 |
想复用 QuerySet 生态(比如分页、.values())但底层 SQL 是自己写的 |
这几个方法的具体签名和参数说明可以在官方 Query API 文档里找到,raw() 就定义在 tortoise.queryset.QuerySet 里,返回的 RawSQLQuery 对象依旧支持后续的方法链 。而 execute_query、execute_query_dict 属于数据库连接对象(BaseDBAsyncClient)本身暴露的能力,社区里也有开发者专门整理过这几个方法的用法对比 。
一个特别容易踩坑的地方是参数占位符在不同数据库里长得完全不一样。
- SQLite 用问号
? - MySQL 用
%s - PostgreSQL 用
$1、$2......这种带编号的形式
这个差异在 Tortoise 的 GitHub Issue 里被反复提起过,早期甚至有人把 PostgreSQL 的占位符误写成 &1,结果直接报错,后来社区纠正说正确写法应该是 $1 。所以写跨数据库兼容的手写 SQL 时,这一点必须格外留意,否则线上环境切换数据库的时候会栽跟头。
💻 实战示例
下面挑几个有代表性的场景,直接给可运行的代码。
示例一 查询结果直接拿字典,省去手动映射
python
from tortoise import Tortoise, run_async
from tortoise.transactions import in_transaction
async def run():
await Tortoise.init(db_url="sqlite://:memory:", modules={"models": ["__main__"]})
await Tortoise.generate_schemas()
conn = Tortoise.get_connection("default")
# 插入一条数据,注意 SQLite 用问号占位符
await conn.execute_query("INSERT INTO event (name) VALUES (?)", ["Foo"])
# execute_query_dict 直接返回字典列表,比 execute_query 好用不少
rows = await conn.execute_query_dict(
"SELECT id, name FROM event WHERE name = ?", ["Foo"]
)
print(rows) # >>> [{'id': 1, 'name': 'Foo'}]
if __name__ == "__main__":
run_async(run())
这种写法适合那种查询逻辑简单但字段命名和数据库表结构对不齐、懒得再定义一个模型的场景。
示例二 事务中批量执行,保证一致性
python
from tortoise.transactions import in_transaction
async def batch_insert():
async with in_transaction("default") as tconn:
await tconn.execute_query("INSERT INTO event (name) VALUES ('Moo')")
await tconn.execute_query("INSERT INTO event (name) VALUES ('Baa')")
# 只要 with 代码块正常结束,事务会自动提交
# 如果中途抛异常,则整体回滚
批量写入、数据迁移这类操作,一定要放进 in_transaction 里,否则半路出错留下脏数据会很麻烦 。
示例三 Model.raw() 配合 QuerySet 生态
python
from tortoise.models import Model
from tortoise import fields
class Event(Model):
id = fields.IntField(pk=True)
name = fields.TextField()
async def query_with_raw():
# raw() 依旧可以继续用 QuerySet 的方法链
events = await Event.raw(
"SELECT * FROM event WHERE name LIKE 'F%'"
).values("id", "name")
print(events)
这个方式最大的好处是手写的 SQL 依旧能享受到 QuerySet 生态里的 .values()、分页等便利方法,不用自己再写一遍序列化逻辑 。
示例四 PostgreSQL 的 JSONB 查询(数据库专属能力)
python
async def query_jsonb():
conn = Tortoise.get_connection("default")
# 注意 PostgreSQL 参数占位符是 $1,不是 ? 也不是 %s
rows = await conn.execute_query_dict(
"SELECT * FROM profile WHERE data->>'city' = $1", ["Shanghai"]
)
return rows
这种针对 JSONB 字段的查询,ORM 字段系统目前没有原生封装,只能靠手写 SQL 来实现 。
示例五 用 execute_script 跑一段 DDL
python
async def run_migration_script():
conn = Tortoise.get_connection("default")
await conn.execute_script(
"""
CREATE INDEX IF NOT EXISTS idx_event_name ON event (name);
ALTER TABLE event ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'active';
"""
)
这种场景基本就是运维脚本的定位,跑一次就完事,压根不需要走模型定义那一套流程。
⚠️ 用手写 SQL 时容易忽略的坑
- 参数化永远优先 ,千万别用 Python 的字符串拼接去构造 SQL,哪怕只是拼一个
WHERE id = {user_id},都是 SQL 注入的隐患。前面提到的所有示例都坚持用占位符加参数列表的形式,这不是习惯问题,是安全底线。 - 占位符风格跟着数据库走 ,同一段代码换了数据库部署环境,光是把
?换成$1这种小改动就可能被忘掉,建议在项目里做一层小小的封装函数统一处理。 - 事务边界要自己管 ,手写 SQL 绕过了 ORM 的自动事务托管,多条语句之间的一致性需要显式用
in_transaction包起来。 - 维护成本会上升,原生 SQL 失去了 ORM 的类型提示和字段重命名的联动能力,写多了之后代码可读性和可维护性都会打折扣,所以还是建议只在真正必要的地方才动用它。
📌 小结
手写 SQL 在 Tortoise ORM 里不是一个补丁式的存在,而是被认真设计过的逃生通道。日常八成的查询交给 QuerySet 去处理就足够省心,剩下那两成------复杂聚合、数据库专属语法、极致性能调优、一次性运维脚本------正是 execute_query、execute_query_dict、Model.raw()、execute_script 这几件工具该出场的时候。记住占位符风格因库而异,记住参数化查询是底线,剩下的就是根据场景挑趁手的那把刀。
参考资料
- Query API - Tortoise ORM v1.1.7 Documentation tortoise.github.io/query.html
- can I get data by execute raw sql · Issue #191 · tortoise/tortoise-orm github.com/tortoise/to...
- How do I execute native SQL with tortoise ORM - Stack Overflow stackoverflow.com/questions/6...
- Simple Examples - Manual SQL - Tortoise ORM v1.1.7 Documentation tortoise.github.io/examples/ba...