写Python这么多年,我发现自己最常被问到的不是那些花哨的框架用法,反而是最基础的字符串格式化问题。尤其是%-formatting这套老语法,翻老代码时避不开,在日志配置里躲不掉,甚至很多第三方库的源码里还在大量使用。很多人学了f-string之后就再也不想碰它,但遇到这类代码时又读不懂那串%,只能靠猜。这篇文章想把这套语法从头到尾讲清楚——它怎么工作、有哪些占位符、格式化参数怎么组合、实战中怎么用,以及最容易踩的坑都分布在哪儿。无论你是刚入门Python、正在做爬虫数据处理,还是被公司老项目里的%代码折磨,这篇文章都能帮上忙。
1. 字符串格式化:为什么现在我们还要讲%-formatting
1.1 老代码里最常碰到的一套语法
%-formatting是Python从最早版本就带过来的字符串格式化方式,它的语法设计直接参考了C语言里的printf格式化输出。你可以把它理解为“在字符串模板里挖几个洞,再用%运算符把数据填进洞里”。比如一个最简单的写法:
name = "小明" print("你好,%s。" % name)这段代码运行时,%s会被替换成变量name的值,最终输出“你好,小明。”在f-string出现之前,这是Python最主流的字符串拼接手段。现在的Python新代码确实更推荐用f-string,但大量存量代码、企业内部运维脚本、各类教程和第三方库实现里,%的身影仍然无处不在。尤其是logging模块的日志模板,官方设计上就一直沿用%的格式,这个细节我们到实战部分再展开。
很多刚从爬虫、数据分析入门Python的朋友,会遇到两种典型场景:第一,在网上找到一段老代码,里面有大量"模板字符串 % 变量"的写法;第二,自己写的代码本身没问题,但一用%就报TypeError,根本不知道错在哪里。这两种情况都和%-formatting的基础概念没吃透有关。所以这篇文章不准备绕开历史,直接把这套语法作为主角来讲透。
1.2 %是运算符,不是装饰符号
要理解%-formatting,首先得建立一个核心认知:这里的%不是单纯的格式标记,而是一个真正的运算符,作用在字符串左右两侧。左侧是模板字符串,右侧是要插入的值。如果你只插一个值,右侧直接写这个值就行;如果要插多个值,右侧必须是一个元组。
# 单个值 print("当前进度:%d%%" % 80) # 多个值必须用元组包裹 name = "张三" score = 92.5 print("学生:%s,成绩:%.1f" % (name, score))这个运算符的行为很像把“模板”——也就是带有%占位符的字符串——和“数据”打包在一起,交给Python内部的格式化逻辑去处理。处理过程大致分三步:先解析模板里的占位符,再把右侧的值按顺序对应到占位符位置,最后执行类型转换和精度处理,生成最终的字符串。
这里有两点值得注意。第一,右侧的值数量必须和模板里的占位符数量完全一致,多一个少一个都会抛TypeError。第二,%运算符左侧必须是字符串,如果搞反了或者混用了类型,解释器会直接报错。理解了“%是运算符”这一点,后面排查各种报错就有了方向。
2. 格式化语法拆解:占位符与转换类型
2.1 五个高频占位符
%-formatting里的占位符,统一格式是在%后面跟上类型字母,表示“我要在这个位置放什么类型的数据”。我把实际项目里最常用的几个整理成一张表:
| 占位符 | 转换目标 | 典型使用场景 |
|---|---|---|
| %s | 字符串,自动调用str() | 打印任意对象,最万能 |
| %d / %i | 十进制整数 | 数量、ID、状态码 |
| %f | 浮点数,默认保留6位小数 | 价格、时间开销、评测指标 |
| %e / %E | 科学计数法 | 极大或极小的数值 |
| %x / %X | 十六进制 | 内存地址、颜色码、哈希值 |
| %% | 转义成单个百分号 | 显示百分比符号本身 |
用得最多的是%s。它厉害的地方在于,不管右侧传进来的是整数、浮点数、对象还是列表,它都会先调用str()把数据转成字符串,再填充到模板里。所以很多老代码会直接用%s处理所有变量,图个顺手。但如果你对输出格式有精确要求,比如价格必须保留两位小数、ID必须是整数形式,那就得用%f或者%d。
%r也是一个值得知道的占位符,它对应repr()转换。和%s的区别在于,%r打印出来的是对象的“调试形态”,字符串会带上引号,比如repr("abc")的结果是"'abc'"。写日志或调试时用%r,你能一眼看出变量本来是什么类型。这个细节在排查数据异常时特别好用。
2.2 格式化参数的排列组合
占位符除了类型字母,还能在%和类型字母之间插入一系列控制参数,用来控制宽度、对齐、补零、精度。完整的格式是:
%[标志][最小宽度][.精度]类型字母用大白话说就是:先决定要不要补符号、要不要左对齐,再告诉Python“这个位置至少要占多少个字符”,如果需要小数精度,再用点号加数字指定。比如:
print("%10.2f" % 3.14159) # 输出: 3.14 print("%-10d" % 42) # 输出:42 print("%+d" % 42) # 输出:+42 print("%05d" % 42) # 输出:00042第一个例子%10.2f表示总宽度10个字符,小数保留2位,3.14159四舍五入成3.14后,前面补空格凑满10个字符。第二个例子%-10d里的负号代表左对齐,42会靠左,后面补空格。第三个例子%+d强制显示正号。第四个例子%05d用0填充,凑满5位。
这些控制参数还可以组合使用,比如左对齐加宽度:%-8s,在很多报表输出场景里,就是靠这种组合把列对齐的。还有两个不太常用但偶尔会遇到的标志:
- %#x会输出0x前缀,比如%#x % 255得到"0xff"。
- 宽度和精度作用于字符串时,精度代表最多截取多少字符,比如%.3s会取字符串前3个字符。
另外,模板里还可以用字典的键名来指定占位符,写法是%(键名)类型。这种写法不依赖位置顺序,模板的可读性高很多,尤其在配置文件模板场景下特别实用:
info = {"name": "李四", "age": 20} print("姓名:%(name)s,年龄:%(age)d" % info)我自己的体会是,%-formatting虽然表达力比不上f-string那么直观,但胜在格式紧凑、老代码兼容性好。你只要能在脑子里把“%m.n类型”这样的片段拆解成“宽度、精度、类型”三块,读任何老代码都不会犯怵。
3. 几种典型实战场景
3.1 日志输出:回归原始格式化
如果你维护过定期运行的后台任务,或者写过爬虫脚本,一定会用到logging模块。就是这个最常见的场景,对字符串格式化有特殊要求。看这段代码:
import logging logging.basicConfig( level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s" ) logger = logging.getLogger("app") user = "admin" status_code = 200 cost_time = 0.4567 logger.info("user=%s login status=%d cost=%.2fms", user, status_code, cost_time)注意两个细节。第一,basicConfig里的format参数用了%(asctime)s这种字典形式的占位符,这是logging模块自己的格式语法,它沿用了%-formatting的写法。第二,logger.info的第一个参数是模板字符串,后面的user、status_code、cost_time作为参数分别传进去,而不是先用%运算符拼接好再传给日志。
这样做是有原因的。logging在设计上支持“惰性格式化”:只有当这条日志确实需要输出时,模板里的占位符才会被替换成实际数据。换句话说,如果你的日志级别是WARNING,而某条INFO级别的日志根本没有被记录,那么字符串格式化这个成本就被省掉了。在高频日志场景里,这个性能优势非常明显。反过来说,如果你手贱用f-string提前拼接字符串,那不管日志级别是多少,格式化动作都已经执行了。
另一个是代码可读性问题。日志模板里保留%s、%d这种占位符,日志主体结构一目了然,数据单独放在后面,改动输出内容时不需要重新拼接一大串字符串。这个习惯在项目里特别好用,尤其当你要加一个字段时,只需要在模板末尾加占位符,再追加参数即可。
3.2 数据库查询参数:安全性与可读性
在写爬虫或数据处理脚本时,大家几乎都会遇到数据库操作。这里有一个必须严肃对待的问题:字符串格式化绝对不能直接用来拼接SQL语句。
看这个错误示范:
# 危险:直接把用户输入拼进SQL name = input("请输入用户名:") query = "SELECT * FROM users WHERE name = '%s'" % name cursor.execute(query)当用户输入的内容是"abc'; DROP TABLE users; --"这样的字符串时,query就会变成:
SELECT * FROM users WHERE name = 'abc'; DROP TABLE users; --'这就是经典的SQL注入。你辛辛苦苦写的表可能一条命令就没了。正确做法是用参数化查询,把%s当成占位符,但数据由数据库驱动来绑定:
# 安全:参数化查询 query = "SELECT * FROM users WHERE name = %s" cursor.execute(query, (name,))注意这里的两个区别。第一,模板里的%s不带引号,引号由数据库驱动根据字段类型决定加还是不加。第二,值通过元组传给execute方法,而不是用%运算符拼进SQL字符串。参数化查询会把值和SQL语句分开传给数据库,数据库先编译SQL模板,再单独处理参数值,这样即使参数里包含特殊字符,也只会被当作普通文本,不会改变SQL结构。
我在实际项目中见过很多新手踩这个坑,包括我自己早期也犯过。对%-formatting来说,真正的定位是处理“展示层”的字符串,比如输出报表、组合提示信息、生成文件名;凡是涉及外部输入、数据库、命令行参数拼接的地方,都不能让%直接参与构建可执行语句。记住这个界限,你的代码安全等级会高很多。
3.3 表格对齐与报表生成
%%-formatting在报表输出上的能力被很多人低估了。宽度控制、精度控制、左右对齐组合起来,完全可以在终端里生成漂亮的文本表格。我做数据统计时经常这样写:
print("%-12s %8s %10s" % ("名称", "销量", "金额")) print("-" * 34) products = [ ("苹果", 156, 1872.5), ("香蕉", 89, 534.0), ("猫山王榴莲", 12, 3599.99), ] for name, qty, amount in products: print("%-12s %8d %10.2f" % (name, qty, amount))运行结果长这样:
名称 销量 金额 ---------------------------------- 苹果 156 1872.50 香蕉 89 534.00 猫山王榴莲 12 3599.99为什么用%-12s?因为中文和英文混排时,如果列宽不够,数据会挤在一起,加上负号左对齐之后,所有名称都从第一列开头整整齐齐排下来。金额列用了%10.2f,表示总宽10个字符、保留两位小数,数字部分右对齐,小数点自然对齐在同一竖线上,视觉上非常干净。
这个技巧在做命令行小工具、导出文本报告、给运维脚本打统计日志时都非常实用。你不需要引入prettytable这类第三方库,就靠内置的%语法,几十行代码就能搞定一个合格的文本报表。如果你在做数据分析可视化,有时需要把中间结果打印到终端确认,用这套对齐方式也会比直接print一坨数据舒服得多。
4. 常见问题与排查技巧实录
4.1 TypeError系列:参数数量不匹配
Python的%-formatting报错,十有八九是TypeError,而且错误信息集中在两个:
第一个是"not enough arguments for format string",意思是模板里的占位符数量比实际提供的参数多。比如模板里有3个%s,但右边只给了2个值。这个错很好理解,就是“洞挖多了,数据不够填”。
第二个是"not all arguments converted during string formatting",意思是参数给多了,模板里的占位符用不完。比如模板只有2个占位符,右边却传了3个值。有些新手会习惯性地把整段数据都丢给%,结果就触发这个错误。
处理这种问题的方法很简单:数占位符的个数,再数右侧元组里的元素个数,两边对齐即可。如果模板里的占位符太多,我通常建议先重构代码,把模板拆小,不要一个模板里塞十几二十个动态值。我之前维护过一个老项目,一个SQL模板里堆了三十几个%s,后来改业务时数错参数,排查了大半天,从那以后我就定了个规矩:模板里超过5个占位符,就必须换一种格式化方式。
4.2 元组陷阱:一个值也要加逗号
关于元组,有个经典坑必须要单独拿出来讲。当你模板只有一个占位符时,可以直接写:
name = "小明" print("你好,%s" % name)但如果你把单值写成元组形式,一定要记得加逗号:
name = "小明" print("你好,%s" % (name,)) # 正确,这是单元素元组 print("你好,%s" % (name)) # 其实没加逗号,只是用括号包了一下,还是字符串不加逗号的(name)和name没有任何区别,Python解释器不会把它当成元组。这个写法的坑在于:当模板有多个占位符时,你正常写下(name, age),但如果某个变量本身是元组,就出问题了。
举例来说:
point = (10, 20) print("坐标:%s" % point)这行代码会直接报TypeError,原因是%运算符看到右边是元组,就默认把它当成多个值按顺序去填充占位符,模板里只有一个%s,但point元组里有两个元素,于是触发了"not all arguments converted"。
解决办法有两种:要么把整个元组再包一层,写成(point,),让它成为一个“元组里的元组”;要么手动转成字符串,写成str(point)。我个人的偏好是后者,因为更能表达明确的意图。
4.3 百分号转义与日期格式的混淆
如果你想在输出中显示一个实际的百分号,比如"完成度:80%",直接写会报错或者输出不对,必须用%%转义:
print("完成度:%d%%" % 80) # 输出:完成度:80%这里的%%会被解释成一个纯百分号,不会引发格式化错误。这个知识点在报表输出里尤其常见,如果你要同时显示比例和数值,千万别漏掉这个双写。
另外要注意不要把%-formatting和datetime的strftime格式搞混。strftime里的%Y代表四位年份,%m代表月份,%d代表日期,这些是日期库自己定义的模板语法,虽然长得很像%占位符,但两者是完全不同的体系。如果你在日期格式化之外用了%Y,Python会直接抛出ValueError。而且strftime只能用在datetime对象上,不能和字符串%操作混在一起。我在刚开始写脚本的时候,有一次把日志文件名里的日期和字符串拼接混着用,结果程序一启动就报错,排查了好久才意识到是两种语法打架了。
4.4 类型不匹配的隐性问题
%s因为会自动调用str()做转换,几乎接收任何类型都不会报错。但%d和%f就没那么宽容了:传入浮点数给%d,Python会先做一次向下取整,而不是四舍五入,比如%d直接格式化3.99会得到3,而不是4。传入字符串给%d,则会直接TypeError,除非那个字符串能转成数字。
这里有个实际场景值得警惕。写爬虫时,从HTML或接口返回的数据在Python里大多是字符串类型,如果直接用%d去格式化像"156"这样的字符串,就会报错。正确姿势是先int("156"),再做格式化。反过来,如果只是想在输出里显示一个数字,那么直接用%s也没问题,因为%s不关心类型,它总是先转成字符串再插进去。我自己一般的原则是:不关心显示精度时用%s,关心数字格式时先转换为正确的数字类型,再用%d或%f。
5. 和str.format()、f-string的横向对比
5.1 三套方案各有什么优势
把%-formatting、str.format()和f-string放在一起对比,你才能看清楚各自的定位。我把常用写法列成一张表:
| 方案 | 示例写法 | 核心优势 | 主要劣势 |
|---|---|---|---|
| %-formatting | "%s,成绩%.1f" % (name, score) | 兼容老代码、日志模板、配置模板 | 参数一多,占位符容易对不上 |
| str.format() | "{},成绩{:.1f}".format(name, score) | 支持索引、关键字、复用参数 | 模板过长时代码显得啰嗦 |
| f-string | f"{name},成绩{score:.1f}" | 写法直观、运行高效、所见即所得 | 只能用于字面量模板,模板不能来自变量 |
f-string作为Python 3.6引入的方案,在性能上是最优的,因为它是在编译阶段就把模板解析好的,不像%-formatting那样在运行时去解析模板。语法上f-string直接在大括号里写表达式,对于处理字典、列表索引非常舒服:
info = {"name": "王五", "score": 88.5} print(f"姓名:{info['name']},成绩:{info['score']:.1f}")但f-string有一个限制:它只能直接写在代码里,模板本身必须是字面量字符串。如果你的格式化模板是从配置文件读进来的,或者由用户输入动态生成,那f-string就用不上了,str.format()和%-formatting反而更合适。
5.2 日志场景为什么仍然选%-formatting
回到开头提到的日志问题。logging模块的官方设计里,format参数使用的就是%风格的占位符,而且logger方法本身支持惰性格式化。即使你非常喜欢f-string,在日志这个场景里也不应该把字符串提前拼接好再传给logger。f-string虽然在大多数场景下更优,但它没办法做到“日志不输出就不格式化”这个惰性特性。
所以在实际项目里,我往往同时使用两种方案:写业务代码、构造普通提示信息时用f-string,既直观又高效;写日志模板、做配置替换、维护老项目时保留%-formatting,遵守现有代码的约定。这里不存在“谁替代谁”的问题,更像是不同场景选不同工具。
如果你想迁移老项目中的%-formatting代码,我的建议是分步走:先把那些涉及日志模板和配置模板的代码留下来,只把纯业务输出里的%改成f-string。改的时候要注意百分号转义和字典键名语法的区别,逐文件测试输出结果是否一致。我自己曾经一次改过上百处,后来发现有一处%.2f和另一处%r的输出形态发生了变化,还好用例覆盖到了,不然线上就会出乱子。
6. 几个扩展的冷门知识
6.1 %r在调试中的价值
%s、%d、%f都有明确的展示目的,而%r的定位是“给程序员看”。它调用repr(),会尽量还原对象在Python中的字面表示。同样的变量,用%s和%r输出,结果差异可能不小:
s = "hello" print("%s" % s) # hello print("%r" % s) # 'hello'字符串用%r输出时会带引号,这个特性在排查“字符串里混入了看不见的空格或换行符”时特别好用。列表、字典、日期对象用%r输出时,也能保留更完整的类型信息。如果你在日志里看到数据结构,又拿不准它原本的类型,用%r格式化临时调试一下,通常能发现端倪。
6.2 动态构造格式化模板
前面提到f-string只能用于字面量模板,而%-formatting和str.format()都支持模板字符串来自变量。这个特性在开发配置系统时非常有用。比如说,你有一个多语言配置模板:
template = "欢迎 %(name)s 回来,您有 %(count)d 条未读消息。" message = template % {"name": "张三", "count": 5}模板本身可以从配置文件、外部参数甚至用户设置里加载,运行时再填充数据。这种写法虽然性能不如f-string,但在灵活性和解耦性上优势明显。现在很多框架的日志格式、消息通知模板、导出模板,底层都是类似机制实现的。
6.3 性能上的一点补充
有人问过我对%-formatting和f-string的性能怎么看。常规业务代码里,性能差异很小,基本可以忽略。但在循环次数很高、日志量很大的程序里,f-string因为编译期就完成了解析,确实比%-formatting要快一些。如果你正在写一个每秒打上万条日志的后台程序,或者在大数据量循环里反复做字符串拼接,选f-string会有意义。不过更关键的点是:在循环里避免重复构造格式化模板。模板如果能提到循环外,就尽量提出来,这样即使使用%-formatting,损失也不会特别大。
7. 围绕%-formatting的学习路径建议
如果你是想彻底掌握这套语法的新手,我建议按照这个顺序来练:先把%s、%d、%f三个最常用占位符的转换规则记牢,再对照“宽度、对齐、精度”三个控制参数做几组排列组合实验,接着把字典键名方式的占位符用熟,最后再动手处理元组参数。
听起来不多,但要练到手熟,还需要配合实战。你可以在自己写的爬虫脚本里,把每一个 print 语句都改成%-formatting风格,比如把“第{}页,共{}页”改成“第%d页,共%d页”,把保留两位小数的价格用%.2f输出。这样坚持两三个项目,这套语法基本就变成肌肉记忆了。
我也建议你在读开源项目源码时,多留意字符串模板里%的使用方式。很多经典库,比如早期版本的Django、requests、paramiko,源码里都有大量的%格式化写法。读多了你会发现,%,其实不只是一个格式化工具,它也在一定程度上反映了Python老代码的演变历史。
8. 一个日志模块里的小细节
最后再分享一个实际工作中遇到的细节。如果你在logging的format参数里写自定义字段,用到了字典键名的%写法,键名必须和实际传入的key完全一致。比如:
logging.basicConfig(format="%(username)s - %(message)s") logger.info("登录失败", extra={"username": "tester"})少传一个键,或者键名拼写不一致,logging会在运行时抛KeyError。这个错误信息不会直接告诉你“缺了username”,而是报格式化异常。排查起来比较费劲。所以在定义日志模板时,我习惯把需要的字段名集中写在一个地方,和实际传参一一对应,避免模板和代码各写各的。
像这种小坑,文档里不会特意提,但踩一次就能让人记住一整天。%-formatting看起来简单,真正用熟之后,你会发现它的边界、它的优势、它的坑都已经牢牢印在脑子里了。写新代码时选择f-string也好,继续用%也好,只要你能解释清楚自己为什么这样选,就说明你真的理解Python字符串格式化这件事了。