Python SQLite 更新避坑:用 UPSERT 代替 REPLACE,保留关联记录
文章目录
定时任务想按名称更新状态,执行 INSERT OR REPLACE 后没有报错,关联的报表记录却消失了。原因是 SQLite 的 REPLACE 在唯一键冲突时会删除旧行,再插入新行;它不是只改几个字段的 UPDATE。SQLite 冲突处理说明
本文面向初级到中级读者,用 Python 标准库在内存数据库中复现问题,再改用 UPSERT,检查关联记录、创建日期和失败回滚。示例不接受数据库路径,不接触现有数据库。
1. 方法:按唯一键更新,不替换整行
jobs.name是主键;每条任务有状态与创建日期。results.job_name引用任务,演示表故意设置ON DELETE CASCADE。父行删除时,子记录也会删除;这是声明的外键动作,不是 Python 丢了数据。SQLite 外键动作- UPSERT 用
ON CONFLICT(name) DO UPDATE处理名称冲突,只更新status。不在 SET 中的created_at保持原值;新名称仍正常插入。
excluded.status 表示本来准备插入的状态。冲突目标必须对应主键、UNIQUE 约束或合适的唯一索引;UPSERT 不会替你忽略 NOT NULL 或外键错误。SQLite UPSERT 文档
若外键开关尚未核对,先看 SQLite 外键检查与整批回滚。不要靠关闭外键来让 REPLACE 看起来正常。
2. 准备:检查 Python 实际链接的 SQLite
需要 Python 3.11 或更新版本及 SQLite 3.24.0 或更新版本,无需安装 pip 包或独立数据库服务。本文在 Linux、Python 3.11.4、SQLite 3.41.2 下测试。SQLite 的版本门槛来自 UPSERT 首次加入的版本,不是 Python 版本号的推断。UPSERT 版本说明
在同一终端按顺序操作,重复实验时换一个新目录名:
python3 --version
python3 -c 'import sqlite3; print(sqlite3.sqlite_version)'
mkdir sqlite-upsert-demo
cd sqlite-upsert-demo
将下一节保存为 upsert_demo.py,也可下载完整演示脚本。默认模式是 upsert,replace 仅作为内存实验对照。
3. 完整实现:保留关系和创建日期
"""Compare REPLACE and UPSERT in a fresh in-memory database. Python 3.11+."""
import argparse
from contextlib import closing
import json
import sqlite3
import sys
STATEMENTS = {
"replace": """INSERT OR REPLACE INTO jobs(name, status, created_at)
VALUES (?, ?, ?)""",
"upsert": """INSERT INTO jobs(name, status, created_at) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET status = excluded.status""",
}
def open_demo():
if sqlite3.sqlite_version_info < (3, 24, 0):
raise RuntimeError("SQLite 3.24.0+ is required")
options = {"isolation_level": None}
if sys.version_info >= (3, 12):
options["autocommit"] = True
con = sqlite3.connect(":memory:", **options)
try:
con.execute("PRAGMA foreign_keys = ON")
if con.execute("PRAGMA foreign_keys").fetchone() != (1,):
raise RuntimeError("foreign keys are not enabled")
con.execute("""CREATE TABLE jobs (
name TEXT PRIMARY KEY NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL
)""")
con.execute("""CREATE TABLE results (
id INTEGER PRIMARY KEY,
job_name TEXT NOT NULL REFERENCES jobs(name) ON DELETE CASCADE,
value TEXT NOT NULL
)""")
con.execute("INSERT INTO jobs VALUES (?, ?, ?)",
("nightly", "queued", "2026-01-01"))
con.execute("INSERT INTO results VALUES (?, ?, ?)",
(1, "nightly", "report.csv"))
return con
except Exception:
con.close()
raise
def write_job(con, mode, name, status, created_at):
statement = STATEMENTS[mode]
con.execute("BEGIN")
try:
con.execute(statement, (name, status, created_at))
con.execute("COMMIT")
except Exception:
if con.in_transaction:
con.execute("ROLLBACK")
raise
def snapshot(con):
return {
"jobs": [list(row) for row in con.execute(
"SELECT name, status, created_at FROM jobs ORDER BY name")],
"results": [list(row) for row in con.execute(
"SELECT id, job_name, value FROM results ORDER BY id")],
}
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("--mode", choices=STATEMENTS, default="upsert")
args = parser.parse_args()
try:
with closing(open_demo()) as con:
before = snapshot(con)
write_job(con, args.mode, "nightly", "done", "2026-02-01")
result = {"mode": args.mode, "before": before, "after": snapshot(con)}
print(json.dumps(result, ensure_ascii=True, sort_keys=True))
return 0
except (sqlite3.Error, RuntimeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
连接在 Python 3.11 使用 isolation_level=None;3.12 及更新版本额外显式传入 autocommit=True,都由 SQL 的 BEGIN、COMMIT、ROLLBACK 管理这次写入。不要把这种连接策略混入已经打开事务的连接。参考 Python 3.11 事务控制与新版 autocommit 说明。
所有值通过 ? 参数绑定,SQL 结构只来自固定语句表。连接退出时由 closing() 关闭;外键开关在事务前设置并立即核对。示例只更新非键字段,不讨论改变父键时的级联策略。
4. 运行对照:成功退出不等于数据保留
python3 upsert_demo.py --help
python3 upsert_demo.py --mode replace
python3 upsert_demo.py --mode upsert
两次运行各自创建同样的新数据库,都应退出 0。输出是单行 JSON,重点比较 after:
| 模式 | status | created_at | results |
|---|---|---|---|
| replace | done | 2026-02-01 | 空数组 |
| upsert | done | 2026-01-01 | 原来的 report.csv 记录 |
REPLACE 的 SQL 可以完全成功,同时违背业务上保留报表的要求。PRAGMA foreign_key_check 也不会报告这次级联删除,因为删除后的数据库并没有孤儿记录。验收必须检查预期数据,而不只是退出码或约束检查。
5. 自动验证:新增、重复更新和错误回滚
将下面保存为 test_upsert.py,或下载完整测试脚本,与主脚本放在同一目录:
"""Run beside upsert_demo.py; all databases are disposable and in-memory."""
from contextlib import closing
import json
from pathlib import Path
import sqlite3
import subprocess
import sys
from upsert_demo import open_demo, snapshot, write_job
script = Path(__file__).with_name("upsert_demo.py")
for mode in ("replace", "upsert"):
run = subprocess.run([sys.executable, str(script), "--mode", mode],
text=True, capture_output=True, check=True)
assert run.stderr == ""
report = json.loads(run.stdout)
assert report["before"]["results"] == [[1, "nightly", "report.csv"]]
after = report["after"]
assert after["jobs"][0][:2] == ["nightly", "done"]
if mode == "replace":
assert after["results"] == []
assert after["jobs"][0][2] == "2026-02-01"
else:
assert after["results"] == report["before"]["results"]
assert after["jobs"][0][2] == "2026-01-01"
with closing(open_demo()) as con:
# New key inserts, the same key updates only the requested field.
name = "daily'中文"
write_job(con, "upsert", name, "queued", "2026-03-01")
write_job(con, "upsert", name, "done", "2099-01-01")
write_job(con, "upsert", "nightly", "done", "2099-01-01")
write_job(con, "upsert", "nightly", "done", "2099-01-01")
before = snapshot(con)
assert before["jobs"] == [
[name, "done", "2026-03-01"],
["nightly", "done", "2026-01-01"],
]
assert before["results"] == [[1, "nightly", "report.csv"]]
try:
write_job(con, "upsert", "nightly", None, "2099-01-01")
except sqlite3.IntegrityError:
pass
else:
raise AssertionError("NOT NULL failure was expected")
assert snapshot(con) == before
assert not con.in_transaction
assert con.execute("PRAGMA foreign_key_check").fetchall() == []
for args, expected in [(["--help"], 0), (["--mode", "unknown"], 2)]:
run = subprocess.run([sys.executable, str(script), *args], capture_output=True)
assert run.returncode == expected
print("PASS: REPLACE loss, UPSERT preservation, insert, repeat, rollback, CLI")
python3 test_upsert.py
预期输出:
PASS: REPLACE loss, UPSERT preservation, insert, repeat, rollback, CLI
测试核对同名更新保留子行与创建日期、新名称插入、重复执行、引号与中文名称、NOT NULL 失败后事务关闭及数据不变。它不覆盖并发写入或磁盘故障;这两个模式都只使用内存,因此不会留下待清理的数据库文件。
6. 排错与接入现有任务
| 现象 | 具体检查与处理 |
|---|---|
near "ON": syntax error |
输出 sqlite3.sqlite_version,核对实际 SQLite 是否支持 UPSERT,不要只看系统 sqlite3 命令的版本。 |
| 冲突目标不匹配约束 | 检查建表语句和唯一索引;复合唯一键必须按实际设计填写冲突目标,不要随意给脏数据加唯一约束。 |
NOT NULL constraint failed |
修正输入并保留失败状态;UPSERT 不会把缺失值自动变成合法值。 |
| 子记录仍消失 | 检查真正执行的 SQL、删除操作和触发器;本例仅证明指定表结构与语句的行为。 |
database is locked |
按 SQLite 锁冲突与短事务教程排查重叠写入;改 UPSERT 不会增加写并发。 |
接入真实数据库前,先做 SQLite 备份与恢复验证,在副本中核对表结构、触发器和业务断言。把 SQL 改回 REPLACE 只会恢复旧行为,不会恢复已经级联删除的记录;丢失数据需要从经过验证的备份或其他可靠来源恢复。
7. 小结
按唯一键修改少数字段时,用明确的 UPSERT 更新列表,不要把 REPLACE 当成 UPDATE 的缩写。通过主表字段和子表内容断言确认业务结果,再测试错误后的回滚。内存实验通过后,仍需在备份副本中验证真实表结构与任务流程。
- 原文作者:yanzhi
- 原文链接:https://www.bobobk.com/python-sqlite-upsert-not-replace.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。非商业转载请注明作者与原文链接,商业转载请联系作者获得授权。