f-string 是 Python 里最顺手的语法糖之一,用久了会产生一种错觉:拼接字符串是安全的。真到 SQL、HTML、shell 命令这些地方,把一个用户可控的值直接嵌进去再交给下游解析,出事几乎是必然的。过去的解法是把值拆出来单独传,但语法上没有任何约束,谁写快了谁就漏。
Python 3.14 引入的 t-string(PEP 750)把这件事做成了语法层面的东西:t"..." 不会立刻变成一个字符串,而是变成一个 Template 对象,静态文本和插值点分开摆在那里,由接收方决定怎么处理。接收方如果是个懂 SQL 的库,它就能把插值点转成占位符;接收方如果是个 HTML 渲染器,它就能顺手做转义。
这篇文章不聊设计哲学,直接拿两个真实场景把 t-string 用一遍:一个防注入的 SQL 参数化构造器,一个 HTML 自动转义渲染器。过程中会碰到的问题也一并写出来。
先确认版本
t-string 是 3.14 的新语法,低版本直接报语法错误,连模块都加载不进去。先跑一条命令:
python -V
# Python 3.14.0
如果本地是 3.13 或者更早,用 uv 起一个隔离环境最快,不用动系统 Python:
uv python install 3.14
uv run --python 3.14 demo.py
后面所有代码都在标准库范围内,不需要装任何第三方包。
t-string 到底长什么样
先看看它和 f-string 在运行时的差别:
name = "世界"
s = f"你好,{name}"
print(type(s)) # <class 'str'>
print(s) # 你好,世界
from string.templatelib import Template
t = t"你好,{name}"
print(type(t)) # <class 'string.templatelib.Template'>
print(t.strings) # ('你好,', '')
print(t.interpolations) # (Interpolation('世界', 'name', None, ''),)
关键在这里:f-string 一旦写出来,插值点就消失得无影无踪,剩下的只是一个普通的 str。t-string 不同,它把结构保住了——strings 是 N+1 个静态片段,interpolations 是 N 个插值对象,两者交替排列。
每个 Interpolation 上有四个属性:
value:插进去的实际值,也就是name求值的结果expression:源码里的表达式文本,方便报错时定位,是"name"这个字符串conversion:!r!s!a三种转换,没写就是Noneformat_spec:冒号后面的格式说明符,没写就是空字符串
而且 Template 本身是可迭代的,会交替吐出一个 str 和一个 Interpolation。这是写自定义处理函数最舒服的入口,不用自己去对齐两个元组的下标。
第一个最小可运行的渲染器
动手之前先写个纯拼接版本,把结构摸清楚:
from string.templatelib import Template, Interpolation
def render(template: Template, /) -> str:
out = []
for item in template:
if isinstance(item, str):
out.append(item)
elif isinstance(item, Interpolation):
value = item.value
if item.conversion:
from string.templatelib import convert
value = convert(value, item.conversion)
if item.format_spec:
value = format(value, item.format_spec)
out.append(str(value))
else:
raise TypeError(f"模板里出现了意外类型:{type(item).__name__}")
return "".join(out)
试一下:
price = 1299.5
print(render(t"总价:{price:.2f} 元"))
# 总价:1299.50 元
行为上和 f-string 基本一致,唯一的区别是求值时点往后挪了——值先被装进 Interpolation,什么时候转成文本由 render 说了算。这一点就是后面所有安全控制的立足点。
实战一:SQL 参数化构造器
写 SQL 时最危险的动作就是把用户输入拼进语句字符串。即使你手动做了转义,只要有一处漏了单引号处理,整张表就交代了。参数化的核心思路是:SQL 的结构和值分开走两条路,数据库驱动只负责把值填进占位符。
t-string 恰好就长成这个形状。静态片段拼成 SQL,插值点全变成 ?,值进列表:
from string.templatelib import Template, Interpolation
class InjectionError(ValueError):
"""t-string 里出现了会破坏参数化前提的写法。"""
class Query:
__slots__ = ("sql", "params")
def __init__(self, sql: str, params: list[object]) -> None:
self.sql = sql
self.params = params
def __repr__(self) -> str:
return f"Query(sql={self.sql!r}, params={self.params!r})"
def sql(template: Template, /) -> Query:
if not isinstance(template, Template):
raise TypeError(
f"sql() 只接受 t-string,收到的是 {type(template).__name__}。"
"f-string 在传参之前就已经求值成 str,参数化的前提在那一步就丢了。"
)
chunks: list[str] = []
params: list[object] = []
for item in template:
if isinstance(item, str):
chunks.append(item)
elif isinstance(item, Interpolation):
if item.conversion is not None:
raise InjectionError(
f"不允许使用 !{item.conversion} 转换:值在进驱动之前被改写过,"
"类型和内容都不再是你传进去的那个"
)
if item.format_spec:
raise InjectionError(
f"不允许使用格式说明符 {item.format_spec!r}:"
"格式化把值转成了字符串,驱动拿到的不再是原始类型"
)
chunks.append("?")
params.append(item.value)
else:
raise TypeError(f"模板里出现了意外类型:{type(item).__name__}")
return Query("".join(chunks), params)
跑几个用例看看效果。先看正常查询:
user_id = 42
q = sql(t"SELECT id, name FROM users WHERE id = {user_id}")
print(q)
# Query(sql='SELECT id, name FROM users WHERE id = ?', params=[42])
再看经典的注入串:
name = "' OR '1'='1"
q = sql(t"SELECT * FROM users WHERE name = {name}")
print(q)
# Query(sql='SELECT * FROM users WHERE name = ?', params=["' OR '1'='1"])
SQL 的骨架没变,攻击串被完整地关进了参数列表里。对比一下手动拼接会变成什么:
bad = f"SELECT * FROM users WHERE name = '{name}'"
print(bad)
# SELECT * FROM users WHERE name = '' OR '1'='1'
条件恒真,整张表就出去了。
还有几个边界情况值得留意。sql() 收到普通字符串时会直接抛 TypeError,这是有意的——sql("SELECT 1") 和 sql(f"SELECT {x}") 都必须失败,否则整个保护形同虚设:
sql("SELECT 1")
# TypeError: sql() 只接受 t-string,收到的是 str ...
sql(f"SELECT {user_id}")
# 同样抛 TypeError,f-string 在这之前已经变成 str 了
而 sql(t"SELECT {user_id}") 正常通过。类型系统在这一层帮你把误用挡住了。
为什么禁用转换和格式说明符
刚才那两个 InjectionError 不是为了防注入本身,而是为了保住这个构造器的契约:值原样传给驱动。
!r 会让驱动收到一个 repr() 之后的字符串,{n:03d} 会让驱动收到 "042" 而不是 42。绝大多数情况下驱动还能处理,但类型推断会走岔路——比如某些驱动看到字符串会自动加引号,看到整数不会。既然这个构造器的卖点就是”值和结构严格分离”,那就干脆一点,任何在 Python 侧改写值的写法都不放行。
如果你确实需要格式化,把它挪到参数外面:先算好再传进去。
实战二:HTML 自动转义渲染器
第二个场景是 HTML 输出。用户提交的评论、昵称、地址一旦被直接塞进页面,XSS 就来了。这里的要求和 SQL 不同:不做占位符,而是对每个插值点做 HTML 转义,同时允许已经安全的片段原样通过。
from html import escape
from string.templatelib import Template, Interpolation
class SafeHTML:
"""明确标记:这段内容已经处理过,不需要再转义。"""
__slots__ = ("_raw",)
def __init__(self, raw: str) -> None:
self._raw = raw
def __str__(self) -> str:
return self._raw
def html(template: Template, /) -> SafeHTML:
if not isinstance(template, Template):
raise TypeError("html() 只接受 t-string")
out: list[str] = []
for item in template:
if isinstance(item, str):
out.append(item)
elif isinstance(item, Interpolation):
value = item.value
if item.format_spec:
value = format(value, item.format_spec)
if isinstance(value, SafeHTML):
out.append(str(value))
else:
out.append(escape(str(value)))
else:
raise TypeError(f"模板里出现了意外类型:{type(item).__name__}")
return SafeHTML("".join(out))
注意 SafeHTML 这个包装类型。默认行为是”没打过标记的一律转义”,只有显式包过的才放行。这个默认值很重要——开发者不需要记住”哪些字段要转义”,忘掉的东西会被默认兜住。
看效果:
comment = '<script>alert("xss")</script>'
print(f"<div>{comment}</div>")
# <div><script>alert("xss")</script></div> ← 浏览器会执行
print(html(t"<div>{comment}</div>"))
# <div><script>alert("xss")</script></div>
组合场景也很自然。拼接一段已经安全的内容时,它不会被二次转义:
nickname = "<b>管理员</b>"
badge = html(t"<span>{nickname}</span>") # 转义一次
page = html(t"<div class='card'>{badge}</div>") # badge 是 SafeHTML,不再转义
print(page)
# <div class='card'><span><b>管理员</b></span></div>
这套机制能成立,靠的就是 t-string 没有提前求值——如果 badge 是个普通 str,html() 就分不清它到底是原始文本还是已处理的 HTML,只能一律再转一遍。
几个容易踩的地方
t-string 必须在源码里字面书写
和 f-string 一样,它不是一个”把字符串转成模板”的函数。下面这种写法不成立:
raw = "SELECT * FROM users WHERE id = {uid}"
sql(t"{raw}") # 这只会把 raw 整个当成一个参数值,不会展开
模板是编译期构造的,预处理发生在解析阶段。所以如果你的 SQL 片段是从配置里读出来的动态字符串,t-string 帮不上忙,得换别的手段(白名单校验、查询构造器 API 等)。
别把 f-string 混进来
有一种写法很容易写错:
sql(t"SELECT * FROM {table} WHERE id = {user_id}")
# 如果 table 是个 t-string,这里只会把它当普通值丢进参数列表
表名、列名这类标识符在参数化里是没有位置的——占位符只能出现在值的位置。所以表名该走白名单校验,不该指望 t-string。
性能别想太多
t-string 比 f-string 多一点开销,因为要构造 Template 和一组 Interpolation 对象。在普通业务代码里这点差异可以忽略,但如果你在一个每秒调几十万次的热点循环里做字符串拼接,还是老实用 f-string。t-string 的价值在安全和结构,不在速度。
嵌套不要太深
t-string 里可以嵌 f-string,也可以嵌另一个 t-string,但两种混在一起读起来相当难受:
sql(t"SELECT * FROM users WHERE {condition} AND name = {f'{prefix}_{name}'}")
这种写法能跑,但三个月后没人愿意改。把嵌套的部分先算出来放到变量里,再丢进模板。
收个尾
把两个渲染器放在一起看,会发现它们共享同一个骨架:遍历模板、静态片段直接追加、插值点按业务规则处理。区别只在于”插值点怎么处理”这一件事——SQL 换成占位符,HTML 做转义,如果哪天要拼 shell 命令,换成 shlex.quote 就行。
这就是 t-string 真正有意思的地方。过去我们靠约定和代码审查来保证”用户输入必须处理”,现在可以把这条规则编码进类型签名:接收 Template 的函数自动获得了拦截时机,而传 str 进来的调用会在运行时直接失败。约束从文档挪到了代码里,漏掉的概率就小多了。
如果你手上维护着老项目,短期内也没必要全量改写。可以先把新写的 SQL 和 HTML 生成函数换成 t-string 版本,老的慢慢替换。两者可以共存,不会互相干扰。

