第一次看到 PEP 750 的时候我没什么感觉——又是一个字符串前缀,能有多大区别。真正让我改变看法的是看了一段自己写的旧代码:一个拼接 SQL 的函数,先 sql = "SELECT ... WHERE status = '" + status + "'",后来改成 f-string,再后来被安全扫描揪出来,改成手写占位符加参数列表。三次重构,每次都在同一个地方摔跤:字符串一旦拼好,值就变成文本了,再想区分哪些是代码、哪些是数据,只能靠人眼。
t-string 干的事情很简单,它不允许你直接拼。你写下的插值在运行时是一堆对象,怎么变成字符串由你决定。听起来是个小改动,实际上把一类反复出现的 bug 从「靠纪律避免」变成了「结构上不可能」。
先看 f-string 和 t-string 的差别在哪
user = "alice"
age = 30
s = f"hello {user}, {age}岁"
# s 就是 str,类型信息在这一步已经没了
from string.templatelib import Template, Interpolation
tpl = t"hello {user}, {age}岁"
# tpl 不是 str,是一个 Template 对象
变量 tpl 身上挂着两组东西:
tpl.strings
# ('hello ', ', ', '岁')
tpl.interpolations
# 两个 Interpolation 对象,分别对应 user 和 age
关键约束是:len(tpl.strings) == len(tpl.interpolations) + 1。字面量把插值夹在中间,天然就是一对一对的关系,遍历的时候可以按下标配对,不用自己搞状态机。这个设计看着朴素,用起来非常顺手。
Template 和 Interpolation 里到底有什么
每个 Interpolation 有四个字段,这四个字段决定了你能写出什么样的处理器:
value:表达式求值后的真实对象。注意是对象,不是字符串,数据库连接对象也能塞进来。expression:源码里的表达式文本,比如"user"、"order.total"。适合做变量名、日志字段名。conversion:!r/!s/!a的标志,没写就是空。format_spec:冒号后面的格式说明,比如:.2f。没写就是空。
这里有个必须记住的行为:conversion 和 format_spec 只是被记录下来,不会被自动应用。也就是说,t"{x:.2f}" 里的 value 仍然是原始的浮点数,:.2f 躺在 format_spec 里等你处理。如果你写的渲染器忘了看这个字段,用户写的格式化会悄无声息地消失,不报错、不警告。这是 t-string 引入的一类全新 bug,下面会专门说怎么防。
案例一:一个不会再被注入的 SQL 构建器
先写消费方。思路是遍历模板,字面量原样进 SQL,插值一律换成占位符,真实值进参数列表。
from string.templatelib import Template
class Ident:
"""标记:这个值要作为标识符内联进 SQL,不走参数绑定"""
__slots__ = ("name",)
def __init__(self, name: str) -> None:
if not name.isidentifier():
raise ValueError(f"不是合法标识符: {name!r}")
self.name = name
def quote_ident(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def sql(tpl: Template) -> tuple[str, tuple]:
if not isinstance(tpl, Template):
raise TypeError("这里只接受 t-string,不要传普通字符串")
text: list[str] = []
params: list = []
for i, literal in enumerate(tpl.strings):
text.append(literal)
if i == len(tpl.interpolations):
continue
it = tpl.interpolations[i]
if it.conversion:
raise ValueError(f"SQL 模板里不要用 !{it.conversion}")
if it.format_spec:
raise ValueError(f"SQL 模板里不要用 :{it.format_spec},格式化请在取值之后做")
if isinstance(it.value, Ident):
text.append(quote_ident(it.value.name))
else:
params.append(it.value)
text.append("?")
return "".join(text), tuple(params)
用起来像这样:
table = Ident("orders")
status = "paid"
limit = 50
query, args = sql(
t"SELECT id, amount FROM {table} WHERE status = {status} LIMIT {limit}"
)
# query: 'SELECT id, amount FROM "orders" WHERE status = ? LIMIT ?'
# args: ('paid', 50)
注意 status 那段。哪怕调用方传进来的是 "paid' OR '1'='1",它也只是参数列表里的一个字符串,永远进不了 SQL 文本。想要把它塞进 SQL,唯一的入口是把值包成 Ident,而 Ident 的构造函数只放行 isidentifier() 通过的字符串。
这个设计里我特意保留了一个「不方便」:Ident("schema.orders") 会直接抛异常,因为点号不是合法标识符的一部分。有人觉得这是限制,我恰恰觉得是好处——带 schema 的表名、带别名的列名,需要写多段的时候,你就得显式写一个支持分段的构造函数,等于强迫你在那一行停下来想一秒。SQL 注入最有名的入口就是动态表名,多一秒钟的停顿是划算的。
另外,主动 raise 掉 conversion 和 format_spec 也是刻意的。假设有人写 t"WHERE amount = {x:>10}",他大概以为这是在格式化数字。但数据库层做格式化本身就是错的,正确的做法是先取值再格式化。这里直接报错,比默默忽略强得多。
案例二:会转义的 HTML 渲染器
HTML 拼接的问题和 SQL 一模一样:值一旦进入文本,就得靠 escape 保命,而 escape 经常漏。t-string 的渲染器天生可以把两件事绑在一起:取值的同时转义。
from html import escape
from string.templatelib import Template
def render(tpl: Template) -> str:
if not isinstance(tpl, Template):
return str(tpl)
out: list[str] = []
for i, literal in enumerate(tpl.strings):
out.append(literal)
if i == len(tpl.interpolations):
continue
it = tpl.interpolations[i]
value = it.value
# 嵌套模板先递归渲染
if isinstance(value, Template):
value = render(value)
# 转换标志显式处理,绝不放任它被忽略
if it.conversion == "r":
value = repr(value)
elif it.conversion == "a":
value = ascii(value)
elif it.format_spec:
value = format(value, it.format_spec)
out.append(escape(str(value), quote=True))
return "".join(out)
调用:
comment = '<script>alert(1)</script>'
render(t"<p>{comment}</p>")
# '<p><script>alert(1)</script></p>'
渲染器只有二十行,但它保证了「所有插值都经过 escape」这一条不变量。不管后面谁来改这个模板,加多少个插值,都不可能漏掉转义。这就是把规则写进结构里,而不是写在注释里。
还有一个细节值得说:!r 在这里的语义是「先 repr 再转义」,而不是「转义后加引号」。这个顺序很重要,因为 repr 出来的引号也是要被转义的。it.conversion == "s" 和没写转换其实是一回事,所以那个分支可以省掉。
案例三:日志的文本和数据第一次分开了
这是我认为 t-string 最有意思的用法,也是我实际改动最多的一处。
以前写日志是这样的:
logger.warning(f"订单 {order_id} 支付失败,重试 {retry} 次,耗时 {cost_ms} ms")
问题是这条日志的「模板」和「数据」被煮成了一锅。想按 order_id 聚合?得正则抽。想把耗时做成指标?得再写一遍解析。想给日志加结构化字段?得把变量名再抄一遍成 dict。
用 t-string 可以把两样东西一次产出:
from string.templatelib import Template
def log_event(tpl: Template) -> tuple[str, dict]:
message: list[str] = []
fields: dict = {}
for i, literal in enumerate(tpl.strings):
message.append(literal)
if i == len(tpl.interpolations):
continue
it = tpl.interpolations[i]
key = it.expression.strip()
# 同一个表达式出现两次时避免覆盖
if key in fields:
key = f"{key}#{i}"
fields[key] = it.value
message.append("{" + key + "}")
return "".join(message), fields
跑一下:
msg, fields = log_event(
t"订单 {order_id} 支付失败,重试 {retry} 次,耗时 {cost_ms} ms"
)
# msg: '订单 {order_id} 支付失败,重试 {retry} 次,耗时 {cost_ms} ms'
# fields: {'order_id': 88231, 'retry': 3, 'cost_ms': 412}
消息文本变成了固定模板,同一类事件永远长得一模一样,日志聚合工具可以直接按模板分组,不需要再写正则。retry 是整数就一直是整数,进 Elasticsearch 就是数值字段,能直接做范围查询。
用起来跟标准库的 extra 配合得很好:
logger.warning(msg, extra=fields)
有人会问:那 fields 的 key 用 it.expression 靠谱吗?我只用它做调试用途的字段名,它来自源码而不是外部输入,不会带来注入风险。但我还是会在生产代码里加一个长度上限,因为有人可能写 t"{very_long_dependency.current_order_status.value}",那串表达式直接变成字段名,索引会很难看。这种细节没有对错,取决于你团队的日志规范。
写渲染器时容易踩的三个坑
坑一:以为 Template 就是 str
它真不是。json.dumps(tpl)、cursor.execute(tpl)、"".join(tpl) 这些操作要么报错,要么得到一个你并不想要的结果。上手第一周我就在一个调试分支里把没渲染的模板打进了日志,输出的是一堆对象描述,排查了半天才反应过来是忘了调渲染函数。
我的做法是所有渲染器函数第一行都写 isinstance 检查,要么抛 TypeError,要么明确地 str() 兜底。含糊的失败比干脆的失败贵得多。
坑二:转换标志被静默忽略
这是最主要的新坑,前面提过,但值得再说一遍具体会怎么出事。你写了一个渲染器,忘了处理 format_spec。同事在模板里写了 {price:.2f},页面上显示的是 19.999999999999998。测试环境没人注意,线上被用户截图投诉。
防法很简单:渲染器里显式处理四件事——conversion、format_spec、嵌套 Template、以及 str() 兜底。不需要的就在函数开头断言为空并 raise,但绝对不要什么都不做。
坑三:想在渲染器里做 eval
看到「表达式文本」这个词,第一反应是这东西能不能求值。不用求。value 已经是求值结果了,expression 只是给人看的标签。一旦你在渲染器里引入 eval 或者 compile,你就亲手把 t-string 唯一的优势——值在进入文本之前已经被隔离——给扔掉了。
它替代不了 Jinja2,也用不着替代
用了几周下来,我的判断很明确:t-string 解决的是「短、局部、值已就绪」的拼接场景。SQL 片段、HTML 片段、日志消息、错误信息、CLI 输出,全都是这个类别。
它不解决模板继承、宏、过滤器管线、异步渲染、模板缓存这些事。项目里那些几十上百行的页面模板,继续用 Jinja2,不要因为新特性很酷就往那边挪。真正该挪的是那些散落在各个模块里、每次改动都让人提心吊胆的字符串拼接。
性能上也别多想。字面量和表达式在编译期就分好组了,运行时不存在解析模板字符串这一步,比 string.Template 和正则替换那一类方案都直接。收益不在速度快,在于你不再需要为每个拼接点单独写一遍安全逻辑。
怎么开始用
语法依赖解释器,需要 Python 3.14 及以上。t 前缀和 f 前缀不能混用,也不能靠 __future__ 或者 pip 包在旧版本上解锁——这类语法层面的特性补不回来,第三方能提供的只是形如函数调用的近似写法,两者写法完全不同。
想先试水,可以挑一个模块里最丑的那个拼接函数改掉,通常就是 SQL 或者日志。让我意外的是,改完之后删掉的代码比新增的多:以前那个函数里塞满了转义、类型判断、占位符编号,现在只剩一个遍历循环,而且这东西全项目共用一份。
最后留一个判断题。当你的字符串里含外部输入,而且这个字符串最终会变成另一种语言的代码——SQL、HTML、shell、正则——那你需要的其实是一个编译器,而不是一个格式化工具。t-string 不会自动帮你做编译,但它会把「哪些是代码、哪些是数据」这件事,从你的记忆里搬到类型系统里。

