这类 Bug 看日志很烦。
接口明明只改了一个订单的扩展字段,结果缓存里的原始订单也跟着变了。更恶心的是,打印 id() 看第一层对象还真不一样。
像这样:
import copyraw_order = {"order_no": "A20260510001","items": [ {"sku": "P1001", "count": 2}, {"sku": "P2008", "count": 1}, ],"tags": ["new_user"]}draft_order = copy.copy(raw_order)draft_order["order_no"] = "A20260510001_TMP"draft_order["items"][0]["count"] = 99draft_order["tags"].append("manual_check")print(raw_order)
输出大概是这样:
{'order_no': 'A20260510001','items': [{'sku': 'P1001', 'count': 99}, {'sku': 'P2008', 'count': 1}],'tags': ['new_user', 'manual_check']}
第一眼我就不太信这个 copy.copy()。
order_no 没变,说明最外层字典确实被复制了一份。
但 items 和 tags 变了,说明里面那几个列表、字典根本没复制,只是引用还指向原来的对象。
这就是浅拷贝。
浅拷贝只复制最外面一层容器。里面装的对象,不管是列表、字典,还是自定义对象,基本都还是原来的引用。
可以直接把地址打出来看:
import copyraw_order = {"items": [{"sku": "P1001", "count": 2}],"tags": ["new_user"]}draft_order = copy.copy(raw_order)print(id(raw_order), id(draft_order))print(id(raw_order["items"]), id(draft_order["items"]))print(id(raw_order["items"][0]), id(draft_order["items"][0]))
第一行不一样,后面两行大概率一样。
这地方很多人容易误判,因为他们只看了最外层对象。
如果数据只有一层,浅拷贝没问题:
origin = {"name": "order_job", "retry": 3}copied = origin.copy()copied["retry"] = 5print(origin) # {'name': 'order_job', 'retry': 3}print(copied) # {'name': 'order_job', 'retry': 5}
这种场景用 dict.copy() 就够了,别上来就 deepcopy。我一般看到有人在很大的配置对象上无脑 deepcopy,会多看两眼。不是不能用,是它可能把你不想拷贝的东西也递归扫一遍,性能和行为都容易变得不受控。
深拷贝就是另一套玩法。
它不只复制第一层,还会继续往里面走,把嵌套对象也复制出来:
import copyraw_order = {"order_no": "A20260510001","items": [ {"sku": "P1001", "count": 2}, {"sku": "P2008", "count": 1}, ],"tags": ["new_user"]}draft_order = copy.deepcopy(raw_order)draft_order["items"][0]["count"] = 99draft_order["tags"].append("manual_check")print(raw_order)print(draft_order)
这次 raw_order 不会被带着改。
这才是很多业务里想要的“复制一份出来随便改”。
比如导入订单时,先拿原始数据做一份临时草稿,清洗字段、补默认值、打标记,最后再决定要不要入库。这个时候用浅拷贝就很危险,因为你以为自己在改草稿,实际把原始数据污染了。
我现场一般会这么写,不会把清洗逻辑直接怼在原对象上:
import copydefbuild_order_preview(order_payload: dict) -> dict: preview = copy.deepcopy(order_payload)for line in preview.get("items", []): line["count"] = int(line.get("count") or0) line["amount"] = round(line["count"] * float(line.get("price") or0), 2) preview.setdefault("check_flags", [])ifnot preview.get("receiver_mobile"): preview["check_flags"].append("missing_mobile")return preview
这里用 deepcopy 是有理由的。
因为 items 是列表,列表里又是字典。后面还要改 count、补 amount。这要是浅拷贝,原始请求参数也会被改掉。后面你再打日志排查,会发现入参日志和用户传过来的数据对不上,能把人绕进去。
不过深拷贝也不是银弹。
有些对象不适合随便深拷贝,比如文件句柄、数据库连接、线程锁、一些客户端对象。业务对象里如果混了这些东西,deepcopy 可能直接报错,也可能复制出一个看着能用但状态很怪的对象。
看个自定义类的例子:
import copyfrom dataclasses import dataclass, field@dataclassclassImportTask: task_id: str rows: list[dict] errors: list[str] = field(default_factory=list)task = ImportTask( task_id="import_20260510", rows=[{"line": 1, "mobile": "13800000000"}])task_copy = copy.copy(task)task_deep = copy.deepcopy(task)task_copy.rows[0]["mobile"] = "invalid"task_copy.errors.append("mobile_format_error")print(task.rows)print(task.errors)
这个 task_copy 看着是新对象,实际上 rows 和 errors 还连着原来的。
用在导入任务里就很坑。你本来想复制一个任务出来做预校验,结果错误信息挂回了原任务,后面状态流转就乱了。
如果对象里有些字段你明确不想深拷贝,可以自己控制:
from dataclasses import dataclass, fieldimport copy@dataclassclassPriceCalcContext: batch_no: str rules: list[dict] cache_client: object = None logs: list[str] = field(default_factory=list)defclone_for_retry(self):return PriceCalcContext( batch_no=self.batch_no, rules=copy.deepcopy(self.rules), cache_client=self.cache_client, logs=[] )
这里我只深拷贝 rules,因为重试时规则可能会临时改。
cache_client 不拷贝,继续复用。
logs 直接给新的空列表,避免上一次重试的日志混进来。
这个比无脑 deepcopy(self) 更稳。
平时判断用哪个,我一般就看一句话:后面会不会改嵌套对象。
只改第一层,用浅拷贝。
要改里面的列表、字典、对象,用深拷贝。
对象里混了连接、锁、客户端、上下文句柄,就别偷懒,自己写一个明确的复制方法。
Python 这块最容易坑人的地方,不是 copy.copy 和 copy.deepcopy 名字记不住,而是你以为复制了,其实只复制了壳。
壳换了,里面还是同一套东西。线上很多脏数据,就是这么悄悄写进去的。