资讯动态

CSV批量更新SQLite数据:Python三种高效实现方案

发布时间:2026/10/22 15:28:02 来源:尧图企业网站定制
读写SQLite的人都知道这么个场景手头有一张CSV里面带着几十条甚至几千条新值需要更新到数据库的某张表里。如果用数据库管理工具一条条手工改或者用Excel复制粘贴一次两次还能忍数据量一大、字段一多效率低不说还容易手滑写错。我自己就遇到过这种尴尬——跑了一下午的更新第二天发现有五行漏改当场血压就上来了。后来把这块改成用脚本直接跑CSV更新才真正把这件事当成一个工程问题解决掉。这篇文章就围绕“sqlite之更新数据根据csv文件”这个场景展开讲清楚几种常用的实现方式从最简单的逐行UPDATE到用临时表做关联更新再到用executemany批量刷数据。每种方案我会给出可复用的代码、选择依据以及我自己踩过的坑。不管你是写数据脚本的初学者还是日常用它做数据清洗、数据迁移的兼职程序员这篇文章应该都能让你少走弯路。1. 整体设计先想清楚CSV更新SQLite的四件事1.1 需求拆解CSV更新到底包含哪些环节乍看“更新数据”就是SQL里的一条UPDATE语句但放在CSV文件场景里事情就复杂起来。一次完整的“根据CSV更新数据库”动作至少要拆成下面几个环节CSV的读取和解析确认文件编码、分隔符、是否有表头、是否有空行。数据的清洗和类型转换CSV里全是字符串得按目标列的类型转成int、float、date等格式。匹配逻辑设计CSV里的某列作为主键或唯一键去对应数据库表里的既有记录。SQL语句构造把CSV的数据值拼进UPDATE语句或者参数化绑定。异常处理CSV里有新增记录、被更新的记录不存在是跳过还是报错事务和性能控制逐条提交还是批量提交commit频率怎么定。很多教程只讲第三步的SQL怎么写忽略了前面CSV本身的坑和后面性能的坑。实际项目里数据处理失败的案例绝大多数不是因为UPDATE语法写错而是CSV里的脏数据和事务控制没处理好。所以我的习惯是先列一个数据更新流程清单把所有环节考虑周全再动手写脚本。1.2 方案选型为什么用脚本而不是数据库工具市面上很多SQLite管理工具都带“导入CSV”的功能比如把CSV导入为一张临时表再用SQL去更新目标表。这种做法对单次操作很方便但当我们面对的是自动化任务、周期性更新、以及需要嵌入其他业务逻辑的场景时脚本方案优势明显。用脚本我一般用Python的好处有几点可重复执行写好一个脚本以后换了新的CSV直接跑就行。参数可控可以指定CSV路径、表名、匹配字段、目标字段按需调整。逻辑友好复杂的清洗、校验、日志记录在代码里写比在工具里点按操作要灵活得多。版本可控脚本可以放进项目仓库别人也能看懂更新逻辑。当然不是所有场景都必须写脚本。如果只是临时更新三五条数据哪怕是直接在SQLite命令行里写几条UPDATE也没问题。我建议判断标准很简单数据超过五十行、更新频率超过一周一次以及更新逻辑需要复用这三条占任意一条就应该考虑脚本化。1.3 安全考虑更新前必须有备份无论采用哪种方案第一步都必须是备份数据库。这听起来像废话但每次都有人忘。SQLite的备份最简单的方式就是复制文件执行前先cp data.db data_backup.db或者用SQLite的在线备份APIPython自带sqlite3.Connection.backup()把数据库备份到另一个文件。我个人的习惯是备份文件带时间戳cp app.db app_$(date %Y%m%d_%H%M%S).db脚本里也可以加一层保护如果更新的行数超过表总行数的一半就提示确认。这种“防手滑”机制在批量更新场景里非常重要——CSV中主键有重复、或者匹配字段没对好可能会导致大面积误更新。我遇到过最惨的一次是匹配字段没加索引SQLite全表扫描了十几万行跑完发现所有记录都被更新成了同一个值。如果当时花一秒钟备份恢复也就几秒钟的事。2. 环境准备Python访问SQLite的三种靠谱姿势2.1 Python内置sqlite3模块够用且零依赖Python自带的sqlite3模块是我们操作SQLite最直接的工具。它实现了Python DB-API 2.0规范提供Connection和Cursor两个核心对象。用法很简单import sqlite3 conn sqlite3.connect(app.db) cursor conn.cursor() cursor.execute(SELECT 1) row cursor.fetchone() # 返回 (1,)这个模块的好处是“零安装”基本上每个Python环境都自带适合快速写脚本。缺点是对类型处理比较粗糙。比如CSV里读到的是007数据库里存储的是整数7如果你不显式转换SQLite会自动转换吗答案是不会它会按字段声明的类型进行存储类转换但因为SQLite是弱类型实际存进去的可能变成字符串007后续查询时就出幺蛾子。所以用sqlite3必须自己负责类型转换这是后面我们要重点处理的。2.2 pandas数据量大、字段多时的效率担当如果你经常处理数据pandas几乎成了标配。pandas.read_csv()可以一步完成CSV的读取、类型推断、缺失值处理再结合DataFrame.to_sql()或逐行迭代实现更新。比如这样import pandas as pd import sqlite3 df pd.read_csv(update_data.csv) conn sqlite3.connect(app.db) # 将CSV读入一个临时表 df.to_sql(temp_updates, conn, if_existsreplace, indexFalse)pandas的read_csv会自动识别列类型但我们总是要小心它“太聪明”比如一个本来应该全是数字的列偶然有行是1,000pandas会把它识别成字符串随后转int就会失败。所以在使用pandas做数据更新前我仍然会打印df.dtypes检查一遍。如果CSV规模达到几十万行以上pandas读入内存是可行的只是要注意内存占用必要时按chunk分批读。2.3 sqlite3命令行工具特殊场景备用Python脚本能解决90%的问题但有些场景我偏爱直接进sqlite3命令行比如一次性更新结构简单的小表或者需要快速验证SQL语法。在shell里执行sqlite3 app.db .headers on .mode csv .import update_data.csv temp_updates UPDATE main_table SET price (SELECT price FROM temp_updates WHERE temp_updates.id main_table.id);.import命令可以直接把CSV导入到临时表然后写一条UPDATE关联更新。这种方式需要手写SQL缺少脚本的灵活性和错误处理能力但非常适合快速验证。另外如果服务器上没有Python环境只有sqlite3二进制这种命令行方式就是唯一选择。3. 核心实现三种CSV更新SQLite的方式及选型3.1 方式一逐条UPDATE简单直观但性能垫底最直观的做法是遍历CSV每一行直接执行一条UPDATE语句import sqlite3 import csv conn sqlite3.connect(app.db) cursor conn.cursor() with open(update_data.csv, newline, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: cursor.execute( UPDATE products SET price ?, stock ? WHERE product_id ?, (row[price], row[stock], row[product_id]) ) conn.commit()这个小脚本很好理解CSV每一行对应一条UPDATE。如果只有几十行这个方案完全够用代码短逻辑直观。但它的问题也很明显每行一条UPDATESQLite要单独解析、执行、记录日志性能较差。如果CSV里某行的product_id在表中不存在UPDATE语句会静默成功但不影响任何行也就是“更新了个寂寞”。如果CSV本身有重复product_id后面的行会覆盖前面的很难察觉。对于几千行数据逐条UPDATE勉强能跑但几万行以上会明显感觉到卡顿。原因在于每一条UPDATE都需要单独的事务日志写入而且SQLite在WAL模式下每个事务的commit都需要同步日志开销很大。改进方式是手动控制事务不每行commit而是最后一起commit像上面的代码一样只commit一次。但即便只commit一次每条execute还是要解析SQL语句。如果想更快就要用executemany这是方式二。3.2 方式二executemany批量执行性能与可维护性的平衡点executemany是Python sqlite3提供的批量执行方法。它把同一条SQL语句应用到一个参数序列上底层经过优化后重复解析SQL的开销会显著降低。写法如下import csv import sqlite3 conn sqlite3.connect(app.db) cursor conn.cursor() params [] with open(update_data.csv, newline, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: params.append((row[price], row[stock], row[product_id])) cursor.executemany( UPDATE products SET price ?, stock ? WHERE product_id ?, params ) conn.commit()这里把CSV先读进列表再一次性塞给executemany。性能比逐条execute高一截尤其是几万行时差距非常明显。我自己实测过五万行更新的场景下逐条execute需要十几秒executemany只需要两三秒。executemany的缺点是数据必须先全部读进内存。如果CSV有几十GB内存会炸。这种情况就得用分块读取每读1万行就执行一次executemany同时把它们归到同一个事务里保证性能。还需要注意一个问题executemany遇到中间某行出错时整个批处理会中断但之前已经写入的数据不会自动回滚。要保证原子性必须在外部用一个事务包裹或者捕获异常后统一rollback。后面“常见问题”一节会专门说。3.3 方式三临时表关联更新复杂映射场景下的终极方案当CSV不仅有更新值还涉及多个关联条件、或者更新逻辑复杂比如值要经过计算、多表关联前面两种方式写起来会很别扭。这时候我会把CSV先导入到一张临时表然后用一条UPDATE...FROM或子查询完成关联更新。SQLite从3.33版本开始支持UPDATE...FROM语法这让关联更新方便很多。语法示例UPDATE products SET price t.price, stock t.stock FROM temp_updates AS t WHERE products.product_id t.product_id;在Python里执行整个过程import pandas as pd import sqlite3 conn sqlite3.connect(app.db) cur conn.cursor() df pd.read_csv(update_data.csv) df.to_sql(temp_updates, conn, if_existsreplace, indexFalse) cur.execute( UPDATE products SET price temp_updates.price, stock temp_updates.stock FROM temp_updates WHERE products.product_id temp_updates.product_id ) conn.commit() cur.execute(DROP TABLE temp_updates) conn.commit()这一段代码是我日常用得最多的方案因为它有两大好处数据导入和更新解耦。可以先对临时表做各种清洗、去重、校验再执行更新。可以清晰地在临时表里检查哪些行能匹配、哪些不能匹配。你甚至可以直接查询临时表和目标表的LEFT JOIN把未匹配的行导出成“无法更新”清单。临时表关联更新相比逐条UPDATE最大的排序区别是它整体是“集合思维”而逐条UPDATE是“循环思维”。SQL本来就是面向集合的语言用集合的方式解决问题效率和表达力天然占优势。老版本SQLite低于3.33不支持UPDATE...FROM可以用子查询替代UPDATE products SET price ( SELECT t.price FROM temp_updates AS t WHERE t.product_id products.product_id ) WHERE EXISTS ( SELECT 1 FROM temp_updates AS t WHERE t.product_id products.product_id );这种写法的缺点是临时表里如果有重复product_id子查询只会随机取一条其实是最先取到的那条更新结果变得不可预测。所以无论哪种写法导入临时表前都要去重。我用临时表方案的经验是所有匹配键重复时宁可在SQL里让程序报错也不要让它静默成功。4. 实操全流程从CSV到SQLite更新的完整脚本4.1 模拟一个更新场景商品价格与库存批量更新为了把全文串起来我设计一个具体的场景某公司有一个SQLite数据库shop.db里面有一张products商品表结构如下CREATE TABLE products ( product_id INTEGER PRIMARY KEY, product_name TEXT, price REAL, stock INTEGER, updated_at TEXT );现在有一份CSV文件price_update.csv包含两列product_id,new_price,new_stock 1001,29.9,120 1002,49.0,55 1003,19.5,80我们需要根据product_id把new_price和new_stock更新到products表中同时把updated_at更新成当前时间。这个场景覆盖了CSV读取、类型转换、关联更新、时间字段赋值足够说明问题。4.2 第一步核对CSV与数据库结构动手写代码前先做三件事看CSV表头、看数据库表结构、以及确认匹配键的唯一性。顺序错了后面全是坑。import csv import sqlite3 # 先看CSV前5行 with open(price_update.csv, encodingutf-8) as f: print(f.readline()) for i in range(3): print(f.readline())再看看数据库表结构conn sqlite3.connect(shop.db) for row in conn.execute(SELECT sql FROM sqlite_master WHERE nameproducts): print(row[0]) conn.close()这一步花费两分钟但能避免把price更新到stock列里。实际项目中我就遇到过CSV的列顺序和数据库不一致结果把价格赋给了库存库存变成小数价格变成几百甚至几千搞得数据一团糟。核对结构时要特别注意SQLite的列默认行为如果表里没有声明NOT NULL那么给了NULL值它也不会拒绝。CSV空值在更新时就会变成NULL这可能是好事也可能是坏事要提前想清楚。4.3 第二步清洗CSV数据处理好空值和类型先读取CSV逐行校验和转换。这一步我习惯单独写一个生成器函数方便复用import csv from datetime import datetime def parse_updates(csv_path): with open(csv_path, newline, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: product_id int(row[product_id]) price float(row[new_price]) stock int(row[new_stock]) if price 0 or stock 0: raise ValueError(f非法负值: {row}) yield product_id, price, stock, datetime.now().isoformat(timespecseconds)这里有几个易错点值得强调CSV里的空值如果new_price为空float()会直接抛异常。要么预先填充默认值要么跳过该行取决于业务需求。我习惯做一行校验日志而不是直接中断整个任务。字符串首尾空格 29.9这样的数据float可以处理前导空格但29.9 也可以处理倒是像29,90这种带逗号的数字没法直接转必须先把逗号去掉。重复product_id出现重复时应该保留哪一行我一般选择保留最后一行并在日志里记警告。使用csv.DictReader逐行遍历时就自然地“后行覆盖前行”。清洗逻辑完成后可以把数据列表打印一小部分出来确认类型和值正确再进入更新阶段。这一步叫“先看垃圾再进锅”能够显著减少更新后才发现数据错乱的痛苦。4.4 第三步用executemany完成核心更新对当前这个规模不大的场景用executemany已经足够高效。我们把更新语句和参数写好执行并提交事务import sqlite3 from datetime import datetime conn sqlite3.connect(shop.db) cur conn.cursor() updates list(parse_updates(price_update.csv)) cur.executemany( UPDATE products SET price ?, stock ?, updated_at ? WHERE product_id ? , [(price, stock, updated_at, pid) for pid, price, stock, updated_at in updates] ) conn.commit() print(f更新完成影响行数: {cur.rowcount}) conn.close()注意executemany的cur.rowcount在SQLite中有坑它不是总能准确返回累计影响行数更靠谱的办法是执行更新前后分别统计匹配数量或者直接查数据库记录数变化。比如before conn.execute(SELECT COUNT(*) FROM products WHERE price ! 0).fetchone()[0]然后更新后再次统计差值为更新成功数。如果CSV中存在数据库中不存在的product_id影响行数会少于CSV行数这个差异必须通过SQL去检查SELECT COUNT(*) FROM price_update_temp LEFT JOIN products USING(product_id) WHERE products.product_id IS NULL;用临时表方案时这个查询能直接把无法匹配的行列出来确认它们是该丢弃还是该当作新增处理。4.5 第四步加日志、加备份再把它变成可复用脚本真正开发时脚本不能像上面这么裸奔。我的模板一般是这么写的脚本开始前检查CSV存在性、数据库路径是否存在。备份数据库到带时间戳的新文件。读取配置CSV路径、表名、字段映射用参数传入。执行更新把每步耗时、影响行数、异常记录到日志文件。更新完成后再进行一次抽样验证。一个简化版的控制台输出实现import sqlite3, csv, os, logging from datetime import datetime logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) logger logging.getLogger(__name__) def update_from_csv(db_path, csv_path, mapping): # 1. 备份 backup_path f{db_path}.{datetime.now().strftime(%Y%m%d_%H%M%S)}.bak src sqlite3.connect(db_path) dst sqlite3.connect(backup_path) src.backup(dst) dst.close() logger.info(备份完成: %s, backup_path) # 2. 读取CSV并处理 updates [] with open(csv_path, newline, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: pid int(row[mapping[id]]) price float(row[mapping[price]]) stock int(row[mapping[stock]]) updates.append((price, stock, pid)) logger.info(CSV读取完成共 %d 条待更新, len(updates)) # 3. 执行更新 conn sqlite3.connect(db_path) conn.execute(BEGIN) try: cur conn.executemany( UPDATE products SET price ?, stock ? WHERE product_id ?, updates ) conn.commit() logger.info(更新完成脚本影响行数: %d, cur.rowcount) except Exception: conn.rollback() logger.exception(更新失败已回滚) raise finally: conn.close() if __name__ __main__: update_from_csv(shop.db, price_update.csv, { id: product_id, price: new_price, stock: new_stock })上面这段脚本是我在实际项目里的骨架。个人建议把字段映射放到配置项或命令行参数里而不是硬编码在SQL中。毕竟更新表结构稍微变化改代码不如改配置来得稳。5. 常见问题与排查技巧实录5.1 CSV和数据库的编码不一致CSV经常是Excel导出的默认可能是gbk或gb18030编码尤其在中文环境下而Python默认utf-8读取会直接报UnicodeDecodeError。处理方法是先探测编码with open(price_update.csv, rb) as f: head f.read(1024) import chardet enc chardet.detect(head)[encoding] print(enc)然后用检测到的编码打开。如果没有chardet库可以手动尝试gbk、utf-8-sig带BOM的utf-8Excel常见的坑。我建议规范团队导出的CSV统一使用csv.readline确认BOM。最稳妥的是在CSV文件生成端就统一指定UTF-8-SIG这样Excel双击打开也不乱码Python读取也不报错。5.2 UPDATE影响行数与CSV行数不一致这种情况太常见了。主要有三种原因CSV里有重复id导致后更新的行覆盖先更新的行影响行数小于CSV行数。CSV里有些id在目标表中不存在UPDATE没影响任何行。CSV里的值和目标表原有值相同SQLite不会更新所以影响行数为0比如CSV写price19.5原表已经是19.5SQLite不会产生rowcount。排查办法很简单用临时表方案做一次LEFT JOIN统计匹配不上的记录。如果是重复id直接对临时表做GROUP BY计数找出重复项。我以前踩过的一个坑是匹配字段不是主键而是普通字符串列且大小写不同。CSV里是“Abc”数据库里是“abc”等值匹配不上导致更新不生效。SQLite默认的等值匹配是区分大小写的除非在列上声明COLLATE NOCASE。所以遇到这种问题先SELECT DISTINCT product_id FROM products看看实际值什么样再判断要不要在UPDATE时做lower()处理。5.3 事务提交时机什么时候commit什么时候rollback如果你是初学者最容易犯的错误是每执行一条UPDATE就commit一次这样虽然保证了每条都落盘但性能极差而且一旦中途失败前面已提交的部分无法回滚。正确做法是把所有更新包在同一个事务里最后统一commitconn sqlite3.connect(db) conn.execute(BEGIN) try: cursor.executemany(...) conn.commit() except Exception: conn.rollback()这里要特别说明Python的sqlite3模块默认在execute之前会隐式开启事务只有commit后才结束。当你调用commit()后如果再execute一条DML又会自动开启新事务。因此如果打算一个事务包到底需要自己显式调用BEGIN或BEGIN IMMEDIATE。另外SQLite默认会给每个DML语句自动加锁如果同时有其他连接在写数据库可能会报“database is locked”。解决这类并发问题一个方向是使用WAL模式PRAGMA journal_modeWAL;WAL模式下读操作不会阻塞写操作写写之间依然互斥但并发读写的体验会好很多。另一个方向是给连接设置timeout参数sqlite3.connect(db, timeout10)这样遇到锁时SQLite会等待10秒而不是立刻抛错。5.4 更新后又想撤销如何快速恢复这就是备份的意义所在。恢复操作非常简单直接关闭数据库连接用备份文件覆盖原数据库文件即可。千万不要开着连接去覆盖文件否则SQLite可能已经把WAL日志里的数据写回了主库覆盖会导致数据库损坏。正确的恢复流程是sqlite3 shop.db .backup shop_before.bak # 备份 # 执行更新脚本... sqlite3 shop.db .restore shop_before.bak # 恢复或者直接用文件拷贝但务必先确保没有进程持有这个数据库连接。如果数据库处于WAL模式文件拷贝前最好先执行PRAGMA wal_checkpoint(TRUNCATE);把WAL内容合并到主库再备份主库文件。否则只备份主文件可能会漏掉WAL中的未合并数据。5.5 性能瓶颈大CSV更新怎么优化几万行的CSV用上面任意一种方式都没问题但数据量到几十万、上百万行就得考虑几个优化点建索引在匹配键比如product_id上建索引否则UPDATE时SQLite会全表扫描。索引能显著减少查找时间代价是更新时索引也要维护但通常索引维护比全表扫描便宜得多。减少逐行调用避免在Python循环里反复执行execute尽可能把所有数据打包给executemany或一次性导入临时表。缩短事务长度虽然我们推荐一个大事务但事务过大时回滚日志会很大写盘压力也大。一个可行的折中方案是每5万行commit一次这样能在批量性能和错误恢复之间找到平衡。关闭同步模式在导入大量数据时可以把synchronous设为OFFjournal_mode设为MEMORY或OFF。这样可以减少磁盘fsync次数速度提升明显但代价是断电时可能丢数据。生产环境慎用我的态度是宁可慢一点也不冒险丢数据。实测数据一百行的CSV逐条UPDATE和executemany几乎感觉不到差别一万行executemany快了三倍左右十万行executemany可能比逐条快五倍。临时表方案加上索引更是轻松几秒完成。6. 总结之外的几点经验最后分享几个我在实际项目中总结出来的习惯也不算总结就是一些散落在代码之外的小经验。先说说CSV更新逻辑本身的健壮性。我现在写这种脚本默认会加一个“dry-run”模式也就是不真正更新数据库只打印将要执行SQL的参数前20行。这样在正式跑之前可以确认CSV解析结果、类型转换和匹配键都符合预期。dry-run的开关可以是一个布尔参数dry_run True # 改成False才真正更新 if not dry_run: cur.executemany(sql, params) else: for p in params[:20]: logger.info(预演参数: %s, p)这样做的原因是只看SQL不带数据很容易漏掉“CSV里的dos行是负数”“product_id小于1000”“price是字符串NULL”这些奇葩数据。预演能把这些问题暴露在真正改动数据之前。第二个经验是有关字段映射的。我建议不要在SQL语句里硬编码CSV列的原始名称而是先做一层字典映射。如果你只是自己临时跑一次的确无所谓但如果你写的是要交付给别人的工具别人可能把CSV列名从new_price改成fresh_price你如果硬编码就会当场翻车。用mapping字典和配置化思路把这种风险隔离掉维护起来轻松很多。第三个经验也是最重要的更新数据时永远要留一个“回滚日志”。我指的不是数据库备份这种粗粒度的东西而是把本次更新涉及的所有旧值记录下来。比如在更新前把products表的id、旧price、旧stock导出成一份rollback_data.csv万一更新后发现新值算错了可以用这份CSV再反向更新回去。这种方式在业务数据更新中救过我很多次尤其是那种“当时没发现异常跑了两周才被线上反馈打脸”的情况。有了旧值快照我还原只是跑个脚本的事不然那个月的账就真的对不上了。sqlite的CSV更新看似平平无奇真写起来有太多细节可以琢磨。希望这篇文章能帮你少踩几个坑。如果你也有自己的一套更新流程或者遇到过更奇葩的CSV编码、脏数据问题不妨按上面的思路自己搭一个脚本框架下次就不用手工改了。毕竟数据更新的价值在于“准确且可追踪”能够放心地把任务交给脚本比什么都重要。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑