f-string 是 Python 里最顺手的语法糖,顺到很多人已经把它当成字符串拼接的默认方案。写 SQL 用 f-string,拼 HTML 用 f-string,打日志还是 f-string。
问题在于,这三件事本质上是同一件事:把不可信的运行时数据塞进一个有语法的文本结构里。而 f-string 的求值结果永远是一个扁平的 str——一旦求值完成,你再也分不清哪一段是程序写死的字面量,哪一段是用户传进来的值。这个信息的丢失,就是 SQL 注入、XSS 和各种密钥泄露日志的根因。
Python 3.14 正式版引入的 t-strings(PEP 750)改的就是这一点。
一、t-string 到底返回了什么
语法上只多了一个字母:
from string.templatelib import Template, Interpolation
name = "alice"
t = t"hello, {name}"
print(type(t)) # <class 'string.templatelib.Template'>
print(t) # 直接 print 会报错吗?不会,Template 有 __repr__
注意两件事。第一,t 不是字符串,是 Template 对象。第二,t 没有继承 str,所以你没法把它丢给 requests.post(url, data=t) 这种接口,它会在运行时报类型错误。
这个”不兼容”是故意设计的。t-string 想让你在把模板交给下游之前,先显式地做一次转换。
Template 的两个核心属性
t = t"SELECT * FROM users WHERE name = {user} AND age > {age}"
print(t.strings)
# ('SELECT * FROM users WHERE name = ', ' AND age > ', '')
print(t.interpolations)
# (Interpolation(...), Interpolation(...))
规律很清楚:strings 的长度永远是 interpolations 长度加一,中间交替排列。数组下标 i 对应的插值,正好夹在 strings[i] 和 strings[i+1] 之间。
每个 Interpolation 身上挂着四个东西:
item = t.interpolations[0]
print(item.value) # 原始对象,注意是原始对象,没有被 str() 过
print(item.expression) # 'user',源码里写的表达式文本
print(item.conversion) # None / 'r' / 's' / 'a'
print(item.format_spec) # '' 或 '>10' 之类的格式描述
value 是关键设计。在 f-string 里,f"{obj}" 会立刻调用 obj.__format__,你拿回来的已经是字符串了。t-string 不这么干,它把原始对象原封不动递给你,把”要不要转成字符串、怎么转”的决定权交还给调用方。
expression 是另一个被低估的属性。它保留了源码里那个表达式的字面文本,这件事 f-string 做不到。后面第三个实战例子会用到它。
Template 可以迭代
除了按下标访问,Template 本身是可迭代的,交替产出 str 和 Interpolation:
for part in t:
match part:
case str():
print("字面量:", part)
case Interpolation(value, expression, conversion, format_spec):
print("插值:", expression, "=", repr(value))
写解析器的时候,这种 match 写法比双下标循环好读不少。下面三个实战例子,我会根据场景混用两种写法。
二、实战一:把 t-string 编译成参数化 SQL
先看传统写法错在哪:
user_input = "'; DROP TABLE users; --"
sql = f"SELECT * FROM users WHERE name = '{user_input}'"
cursor.execute(sql)
这行代码执行的时候,数据库看到的是三条语句。正确做法是让驱动层处理参数,也就是占位符 + 参数列表。但很多人在写复杂查询时,会因为”占位符拼起来太麻烦”而退回到 f-string。t-string 刚好把这个心智负担抹掉了。
from string.templatelib import Template
def to_sql(t: Template) -> tuple[str, tuple]:
"""把 t-string 编译成 (带占位符的 SQL, 参数元组)"""
text = []
params = []
for idx, literal in enumerate(t.strings):
text.append(literal)
if idx < len(t.interpolations):
text.append("?") # sqlite / mysql 用 %s,psycopg 用 %s
params.append(t.interpolations[idx].value)
return "".join(text), tuple(params)
用起来是这样:
user_input = "'; DROP TABLE users; --"
query, args = to_sql(
t"SELECT * FROM users WHERE name = {user_input} AND status = {1}"
)
print(query)
# SELECT * FROM users WHERE name = ? AND status = ?
print(args)
# ("'; DROP TABLE users; --", 1)
cursor.execute(query, args)
用户输入被原样放进了参数列表,数据库驱动会把它当成纯数据,注入路径就此关闭。
这里有个细节值得注意:params 里存的是 value 本身,不是 str(value)。整数保持整数,datetime 保持 datetime——驱动层能拿到正确的类型信息,这比先转成字符串再让驱动猜类型要可靠得多。
如果同一个变量在 SQL 里出现两次,写成 {user} 和 {user} 两次就行,参数列表里自然会有两个值,不必给占位符编号。
三、实战二:自动转义的 HTML 构造器
HTML 场景比 SQL 更微妙。SQL 的规则是”所有插值都当参数”,非黑即白。HTML 里则存在”这段 HTML 我自己生成的,是安全的”这种情况,不能无脑全转义。
所以需要一个显式的安全标记类型:
from html import escape
from string.templatelib import Template
class Safe(str):
"""标记一段已经转义过、可以原样输出的 HTML 片段"""
__slots__ = ()
def html(t: Template) -> Safe:
chunks = []
for idx, literal in enumerate(t.strings):
chunks.append(literal)
if idx < len(t.interpolations):
value = t.interpolations[idx].value
if isinstance(value, Safe):
chunks.append(value) # 已被标记为安全,直接放行
else:
chunks.append(escape(str(value)))
return Safe("".join(chunks))
试一下跨站脚本:
name = '<img src=x onerror=alert(1)>'
page = html(t"<p>你好,{name}</p>")
print(page)
# <p>你好,<img src=x onerror=alert(1)></p>
再试嵌套,这是最容易出问题的地方——外层模板不能把内层已经转义过的内容再转一遍:
row = html(t"<li>{name}</li>") # 这里已经转义过了
list_html = html(t"<ul>{row}</ul>") # row 是 Safe,不会被二次转义
print(list_html)
# <ul><li><img src=x onerror=alert(1)></li></ul>
如果不用 Safe 标记,嵌套场景下 < 会被转成 &lt;,页面上就会直接显示出乱码般的实体字符。这个坑在 f-string 时代靠人工维护”哪些变量是安全的”来解决,项目一大就必然出错。t-string 把这件事变成了类型系统能管的问题。
四、实战三:日志脱敏,用上 expression 属性
前两个例子,说实话用自定义函数 + 手工参数列表也能做到。第三个例子才是 t-string 真正没有替代方案的地方。
想象一个 API 调用日志:
logger.info(f"调用支付接口 user={user} api_key={api_key} amount={amount}")
api_key = "sk-live-abcdefghijklmnopqrstuvwxyz"
密钥进了日志文件,然后被采集系统同步到某台不该有它的机器上。事后想从日志里清理,已经扩散了。
t-string 的 expression 能在不执行额外代码的前提下,知道”这个插值来自哪个变量名”:
import re
from string.templatelib import Template
SENSITIVE_NAMES = ("password", "passwd", "token", "secret", "api_key", "apikey", "credential")
VALUE_PATTERNS = [
(re.compile(r"sk-[A-Za-z0-9]{10,}"), "sk-***"),
(re.compile(r"(?i)(bearers+)[w-.]+"), r"1***"),
(re.compile(r"bd{16,19}b"), "***card***"),
]
def _mask_by_value(text: str) -> str:
for pattern, repl in VALUE_PATTERNS:
text = pattern.sub(repl, text)
return text
def redact(t: Template) -> str:
parts = []
for idx, literal in enumerate(t.strings):
parts.append(literal)
if idx >= len(t.interpolations):
continue
item = t.interpolations[idx]
varname = item.expression.lower()
# 第一层:按变量名判断
if any(key in varname for key in SENSITIVE_NAMES):
parts.append("<redacted>")
continue
# 第二层:按值的形态兜底
parts.append(_mask_by_value(str(item.value)))
return "".join(parts)
跑一下:
user = "alice"
api_key = "sk-live-abcdefghijklmnopqrstuvwxyz"
order_id = "202503170001"
print(redact(t"支付回调 user={user} api_key={api_key} order={order_id}"))
# 支付回调 user=alice api_key=<redacted> order=202503170001
这里的关键在于:item.expression 拿到的是源码里写的变量名 "api_key"。用 f-string 你拿不到这个信息——求值的那一刻,一切都已经变成字符串了,脱敏函数只能去正则匹配值的长相,猜错的概率很高。
当然,expression 也有局限:t"{config['key']}" 拿到的是 "config['key']" 这整段文本,t"{get_key()}" 拿到的是 "get_key()"。所以它是”变量名启发式”,不是万能的。两层规则(名字 + 值的形态)叠在一起才够用。
五、几个实际会踩到的坑
1. Template 不能相加
t1 = t"hello {name}"
t2 = t"world {age}"
t1 + t2 # TypeError
这是有意为之。如果允许拼接,解析器就没法在编译期判断边界。需要合并的话,得自己写个小工具把两个 Template 的 strings 和 interpolations 重新组装成一个新的 Template。
2. conversion 和 format_spec 不会自动生效
前面提过,value 是原始对象。这意味着 t"{x!r}" 里的 !r、t"{x:.2f}" 里的 :.2f 都只是被记录下来了,不会自己执行。要在自己的渲染函数里显式处理:
def resolve(item) -> str:
value = item.value
if item.conversion == "r":
value = repr(value)
elif item.conversion == "s":
value = str(value)
elif item.conversion == "a":
value = ascii(value)
if item.format_spec:
value = format(value, item.format_spec)
return str(value)
注意 format() 要放在转换之后,这和 f-string 的行为一致(f"{x!r:>10}" 是先 repr 再右对齐)。反过来说,如果你的场景里格式说明符没意义(比如 SQL 参数化),干脆忽略它,直接把 value 传给驱动。
3. 不要给 t-string 套一层自动 str 转换的封装
有人图省事,写个 class LazyStr(str) 让 Template 自动转成字符串。这一步一旦做了,t-string 的全部价值就归零了——你回到了 f-string 的起点,只是多了一层开销。
4. 版本检测
import sys
if sys.version_info < (3, 14):
raise RuntimeError("本项目依赖 Python 3.14 的 t-strings")
如果库需要同时支持旧版本,可以用 uv 的依赖分组或者 importlib 做条件导入,把 t-string 相关的实现放在单独模块里。
六、什么时候值得用
t-string 不是 f-string 的替代品。给一段普通文本插几个变量,f-string 依然是最好的选择,它更短、更快、更直观。
t-string 真正解决的是这一小类场景:字符串会被另一种语言解析,且其中一部分内容是外部输入。SQL、HTML、shell 命令、正则表达式、日志、模板引擎、序列化格式,都属于这一类。
判断标准很简单:如果你写完之后需要加一句注释解释”这里为什么不能直接拼”,那就是该用 t-string 的地方。
另外值得一提的是,t-string 的落地方式决定了它对现有代码的侵入性很低。你不需要重写整个项目,只需要在真正危险的那几个函数上,把参数类型从 str 改成 Template,然后在函数体里做一次解析。调用方从 f"..." 改成 t"..." 就行了,改一个字母。剩下的工作——转义、参数化、脱敏——全部收敛到那几个函数里。
这种”把安全性从人的注意力转移到类型签名上”的思路,大概是 t-string 相比 f-string 最实质的进步。

