
1. 从一次真实的 pymysql 报错说起为什么 % 会变成格式化陷阱如果你在用 Python 往 MySQL 里写数据尤其是写那种带百分号的字段比如折扣率85%、进度50%、LIKE 模糊匹配%关键字%那你大概率见过这个报错TypeError: not enough arguments for format string这个报错最迷惑的地方在于它看起来像是 Python 字符串格式化的问题跟数据库八竿子打不着。很多人第一反应是去查%的转义结果越查越乱。我试过在一个批量导入脚本里被它卡了整整一个下午最后发现根因根本不在 SQL 语法而在execute和executemany的用法混淆。先把结论摆出来pymysql 的execute(sql, args)里sql字符串中的%是占位符语法任何字面量的百分号都必须写成%%同时args的元组元素个数必须和占位符数量严格相等。只要这两条有一条不满足就会抛出not enough arguments for format string。这个错误适合谁看适合所有用 pymysql 做增删改查、做数据同步、写 ETL 脚本的 Python 开发者尤其是刚从小白阶段过渡、开始处理真实业务数据的人。因为真实业务数据里%太常见了而教程里的示例 SQL 往往干干净净根本不会提醒你这件事。我们分几个层面拆先看错误是怎么被触发的再看 pymysql 内部到底做了什么然后给出可复制的正确写法最后把 TaoToken 统一 Key 的配置骨架也一并给你方便你在多模型、多环境的项目里统一管理密钥。先看一个最小复现。假设你有一张商品表要更新折扣import pymysql conn pymysql.connect(host127.0.0.1, userroot, passwordxxx, databaseshop) cursor conn.cursor() discount 85% sql UPDATE product SET discount %s WHERE id 1 % discount cursor.execute(sql)这段代码会直接报TypeError: not enough arguments for format string。原因很简单UPDATE ... %s ... % discount这一步Python 先做了一次字符串格式化discount是85%格式化之后 SQL 变成了UPDATE product SET discount 85% WHERE id 1。注意此时 SQL 里出现了一个孤零零的%后面跟着 WHEREPython 的%格式化器会认为这是一个新的格式说明符于是继续找参数找不到就报错。更隐蔽的情况是executemany被误用。很多人写批量插入时第二个参数传了一个元组而不是列表套元组cursor.executemany( INSERT INTO t (a, b) VALUES (%s, %s), (x, y) # 错误应该是 [(x, y)] )executemany期望第二个参数是「可迭代的序列每个元素是一个元组」你传了(x, y)它会尝试把x当成一组参数去匹配两个%s自然报not enough arguments。这个坑和%转义坑经常同时出现排查时要一起看。2. TaoToken 前置统一 Key 配置为什么值得先做在讲修复之前我想先插一段配置层面的东西。因为很多人的 pymysql 脚本不是孤立的它往往跑在一个更大的项目里项目里同时还要调用大模型 API、做数据清洗、做智能补全。这时候密钥管理就变成一个真问题数据库密码、模型 Key、各种 token 散落在不同文件里改一次要翻半天。TaoToken 在这里的角色是「统一入口」。它的官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。你可以把它理解成一个统一的 Key 管理和调用层把模型调用相关的配置收敛到一处而不是每个脚本里硬编码。为什么要在 pymysql 的文章里提这个因为当你把数据库操作和模型调用放在同一个数据管道里时配置文件的组织方式会直接影响你排查问题的效率。一个清晰的config骨架能让你在遇到TypeError时快速排除「是不是配置串了」这种低级可能。下面是一个我常用的配置骨架用 JSON 写路径放在项目根目录的config/settings.json{ database: { host: 127.0.0.1, port: 3306, user: app_user, password: your_db_password, database: shop, charset: utf8mb4 }, taotoken: { base_url: https://taotoken.net/api, api_key: sk-your-taotoken-key, default_model: claude-sonnet-4-5, timeout: 60 } }读取的时候用一个小的 loaderimport json def load_config(pathconfig/settings.json): with open(path, r, encodingutf-8) as f: return json.load(f) cfg load_config() db_cfg cfg[database]这样你的 pymysql 连接就可以写成conn pymysql.connect( hostdb_cfg[host], portdb_cfg[port], userdb_cfg[user], passworddb_cfg[password], databasedb_cfg[database], charsetdb_cfg[charset], cursorclasspymysql.cursors.DictCursor )注意charsetutf8mb4这一项处理中文和 emoji 时必须加否则插入%之外还会遇到编码报错两个问题叠在一起更难查。cursorclass用DictCursor可以让查询结果直接是字典后续处理更顺手。如果你需要生成或管理 TaoToken 的 Key可以走 API Keys 页面https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。这两个链接建议收藏配置类问题基本都能在里面找到对应说明。把配置收敛好之后我们再回到 pymysql 本身。因为接下来要讲的参数化查询本质上也是一种「把数据和语句分离」的思路和配置分离是同一个哲学。3. 可复制配置execute 与 executemany 的正确写法与转义规则这一节是全文的核心我尽量把每种情况都写成可以直接复制运行的代码。3.1 单条操作execute 的参数化写法pymysql 的参数化查询占位符统一用%s不管字段是字符串、数字还是日期都用%s。第二个参数必须是一个元组tuple元素个数等于占位符个数。import pymysql conn pymysql.connect( host127.0.0.1, userapp_user, passwordyour_db_password, databaseshop, charsetutf8mb4 ) cursor conn.cursor() # 正确参数化%s 是占位符args 是元组 sql UPDATE product SET discount %s WHERE id %s cursor.execute(sql, (85%, 1)) conn.commit()注意这里85%是作为参数传进去的不是拼进 SQL 字符串的。pymysql 会负责转义%会被安全处理不会触发格式化错误。那如果 SQL 里本身就需要一个字面量的%比如 LIKE 查询呢这时候要写%%# 查询名字里包含 abc 的记录 keyword abc sql SELECT id, name FROM product WHERE name LIKE %s cursor.execute(sql, (f%{keyword}%,)) rows cursor.fetchall()这里%{keyword}%是作为参数值传进去的参数值里的%不需要转义因为参数化不会对它做格式化。真正需要%%的是 SQL 字符串字面量里的%比如# SQL 字面量里带 %必须写 %% sql SELECT id FROM product WHERE discount LIKE 85%% cursor.execute(sql)这一条是很多人踩坑的地方参数值里的%不用管SQL 文本里的%要写%%。3.2 批量操作executemany 的参数结构executemany用于批量插入或批量更新第二个参数必须是「列表套元组」data [ (A001, 商品A, 85%), (A002, 商品B, 90%), (A003, 商品C, 75%), ] sql INSERT INTO product (code, name, discount) VALUES (%s, %s, %s) cursor.executemany(sql, data) conn.commit()如果你只插一条用execute就够了不要用executemany。反过来如果你有一批数据用execute循环调用性能会差很多因为每次都是一次网络往返。executemany会把多条合并发送效率高得多。这里给一个对照表方便你快速判断该用哪个场景函数第二个参数类型示例单条增删改查execute元组(85%, 1)批量插入executemany列表套元组[(A, 85%), (B, 90%)]无参数查询execute省略cursor.execute(SELECT 1)带字面量 % 的 SQLexecute元组或省略LIKE 85%%3.3 一个容易忽略的细节元组只有一个元素时要加逗号# 错误这不是元组是字符串 cursor.execute(SELECT * FROM product WHERE id %s, (1)) # 正确加逗号才是单元素元组 cursor.execute(SELECT * FROM product WHERE id %s, (1,))(1)在 Python 里就是字符串1pymysql 会把它当成可迭代对象逐字符去匹配占位符只有一个%s时可能侥幸不报错但多个占位符时就会报not enough arguments。这个坑非常隐蔽建议养成习惯单元素元组永远加逗号。3.4 把配置和 SQL 串起来的完整示例结合第 2 节的 config写一个完整的更新脚本import json import pymysql def load_config(pathconfig/settings.json): with open(path, r, encodingutf-8) as f: return json.load(f) cfg load_config() db cfg[database] conn pymysql.connect( hostdb[host], portdb[port], userdb[user], passworddb[password], databasedb[database], charsetdb[charset], cursorclasspymysql.cursors.DictCursor ) try: with conn.cursor() as cursor: sql UPDATE product SET discount %s WHERE code %s affected cursor.execute(sql, (85%, A001)) print(affected rows:, affected) conn.commit() except Exception as e: conn.rollback() print(error:, repr(e)) finally: conn.close()这段代码可以直接跑把config/settings.json里的数据库信息换成你自己的即可。注意with conn.cursor()会自动关闭游标try/except里做了回滚这是生产脚本的基本素养。4. 验证请求一次连接与写入的成功结果确认配置写完之后不要急着上业务逻辑先做一次最小验证。验证分两步先确认连接通再确认带%的写入成功。第一步连接验证import pymysql conn pymysql.connect( host127.0.0.1, userapp_user, passwordyour_db_password, databaseshop, charsetutf8mb4 ) with conn.cursor() as cursor: cursor.execute(SELECT VERSION()) print(cursor.fetchone()) conn.close()如果这一步能打印出版本号说明连接配置没问题。如果报Access denied那是账号密码问题如果报Cant connect那是 host/port 或网络问题跟本文的TypeError无关。第二步带%的写入验证。建一张临时表CREATE TABLE IF NOT EXISTS discount_test ( id INT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32), rate VARCHAR(16) );然后跑import pymysql conn pymysql.connect( host127.0.0.1, userapp_user, passwordyour_db_password, databaseshop, charsetutf8mb4 ) try: with conn.cursor() as cursor: cursor.execute( INSERT INTO discount_test (code, rate) VALUES (%s, %s), (T001, 85%) ) cursor.execute( INSERT INTO discount_test (code, rate) VALUES (%s, %s), (T002, 90%) ) conn.commit() with conn.cursor() as cursor: cursor.execute(SELECT code, rate FROM discount_test WHERE rate LIKE %s, (%85%,)) print(cursor.fetchall()) finally: conn.close()预期输出是((T001, 85%),)这样的元组。如果这一步成功说明你的参数化写法完全正确%既没有触发格式化错误也正确存进了数据库。再验证一下executemanydata [(T003, 70%), (T004, 60%)] with conn.cursor() as cursor: cursor.executemany( INSERT INTO discount_test (code, rate) VALUES (%s, %s), data ) conn.commit()跑完之后查一下总数应该是 4 条。如果这里报not enough arguments回头检查data是不是列表套元组。验证通过之后你还可以顺手确认一下 TaoToken 的 Key 是否可用。如果你的数据管道里还要调用模型做字段补全可以用模型对话页面快速测一下https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 。这一步不是必须的但如果你打算把数据库清洗和模型调用串起来提前验证能省很多事。5. 本篇常见错排查对照真实报错逐条定位这一节我把常见的报错和对应原因列出来你遇到问题时可以直接对号入座。报错一TypeError: not enough arguments for format string这是本文的主角的。三种可能第一种SQL 字符串里用了%做 Python 格式化比如SELECT * FROM t WHERE name %s % name然后name里带%。修复方式是改成参数化cursor.execute(SELECT * FROM t WHERE name %s, (name,))。第二种execute的第二个参数元组元素个数少于占位符个数。比如 SQL 有两个%s你只传了一个值。检查占位符和参数是否一一对应。第三种executemany的第二个参数不是列表套元组。比如传了(a, b)而不是[(a, b)]。这是 excerpt 里提到的那个坑非常典型。报错二TypeError: %d format: a number is required, not str这个通常是因为你在 SQL 里用了%d而不是%s。pymysql 的占位符只有%s不管字段类型是什么。把%d全部改成%s即可。报错三pymysql.err.ProgrammingError: (1064, You have an error in your SQL syntax)这个往往和%转义有关。如果你在 SQL 文本里写了LIKE %abc%而没有参数化pymysql 会把它当成占位符处理导致语法错误。要么改成参数化LIKE %s传%abc%要么把字面量%写成%%。报错四pymysql.err.OperationalError: (1045, Access denied for user)这是账号密码或权限问题跟%无关。检查 config 里的 user/password以及该用户是否有目标库的权限。报错五local proxy failed或连接超时如果你在调用模型 API 时看到类似local proxy failed的提示那通常是网络层或代理配置的问题不是 pymysql 的问题。检查你的 base_url 是否写成了https://taotoken.net/api以及网络是否可达。注意不要使用任何非正规的网络工具保持环境干净。报错六reading choices相关错误这类错误一般出现在解析模型返回时说明返回结构和你预期的字段不一致。先打印原始返回确认字段名再调整解析逻辑。这跟数据库操作是两条线排查时要分开。报错七OAuth 或鉴权失败如果你用的是需要 OAuth 的模型服务检查 token 是否过期。TaoToken 的 Key 管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 可以在这里重新生成或核对。一个通用的排查顺序先看报错类型是TypeError就往参数化方向查是ProgrammingError就往 SQL 语法和转义查是OperationalError就往连接和权限查。把这三类分开效率会高很多。另外提醒一句如果你在项目里同时用了 Cline MCP、Codex 的auth.json或者 Claude Code 的配置记得把三件套写全——Base URL、Key、Model ID。缺任何一个都可能导致鉴权失败而失败信息有时会伪装成其他错误增加排查难度。Base URL 统一用https://taotoken.net/apiModel ID 按你实际使用的模型填。6. 把 Key 配置和 SQL 参数化一起收进你的项目骨架写到这里核心的修复方法已经讲完了。最后我想把两件事串起来SQL 参数化和 Key 配置本质上都是「把可变部分从固定结构里抽出来」。SQL 参数化是把数据从语句里抽出来交给驱动去安全处理Key 配置是把密钥从代码里抽出来交给配置文件去管理。两者做对了你的脚本就从「能跑」变成「可维护」。如果你正在搭一个长期运行的数据管道建议把 Coding Plan 也纳入考虑它适合需要持续调用模型做代码补全或 Agent 任务的场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。控制台入口在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 可以在这里查看用量和配置。回到 pymysql给你一个我自己的习惯所有 SQL 都写成参数化永远不用%拼接所有execute的第二个参数都写成元组单元素加逗号所有executemany的第二个参数都写成列表套元组。这三条守住not enough arguments for format string基本就跟你无缘了。最后留一个可以直接抄走的工具函数把连接和参数化封装在一起import pymysql def get_conn(cfg): return pymysql.connect( hostcfg[host], portcfg[port], usercfg[user], passwordcfg[password], databasecfg[database], charsetcfg[charset], cursorclasspymysql.cursors.DictCursor ) def upsert_discount(conn, code, rate): sql UPDATE product SET discount %s WHERE code %s with conn.cursor() as cursor: return cursor.execute(sql, (rate, code))调用的时候upsert_discount(conn, A001, 85%)%安安稳稳地进了数据库不会再变成格式化炸弹。