你有没有遇到过这种情况——帮朋友写个小脚本,想把一个文件夹里的照片复制一份备份,脑子却开始打架:到底用 shutil.copy 还是 copy2?还是 copyfile?这三个名字长得像亲兄弟,参数还各不相同。我见过太多新手第一天学文件操作,就被这堆长得几乎一样的函数劝退,复制个文件夹都要先去搜一遍「python 复制文件 用哪个」。文件操作是写脚本的日常第一步——备份日志、整理照片、搬配置文件,几乎每个小程序都绕不开。可偏偏就是这一步,最容易把人劝退。
先说清楚:shutil 那三个 copy 到底怎么分
在 Python 3.14 之前,复制文件这件事几乎只能交给 shutil 模块,而它一口气给了你好几个长得像的函数,新手最容易在这翻车:
import shutil
# 复制文件内容 + 权限位
shutil.copy("a.txt", "b.txt")
# 复制文件 + 权限 + 所有时间戳
shutil.copy2("a.txt", "b.txt")
# 只复制内容,连权限都不要
shutil.copyfile("a.txt", "b.txt")
看出来问题了吗?copy 和 copy2 只差一个元数据,copy 和 copyfile 又只差一个权限,三个函数挤在一起,新手根本记不住,还特别容易选错——比如想保留修改时间却用了 copyfile,结果时间戳全丢了,过两天自己都分不清哪份是最新的。
更坑的是,你在编辑器里打 shu 自动补全,这三个函数会排成一排,名字又只差一两个字母,眼睛一花就选错。很多人干脆每次都去翻文档,或者无脑用 copy2,反正「最全」,但代价是每次都要先 import 一个额外模块。
Python 3.14 直接给 Path 加了 copy()
这个痛点 Python 官方也看不下去了。3.14 给 pathlib 的 Path 类一口气加了一整套文件操作的方法,复制文件现在一行就能搞定,而且完全不用再 import shutil:
from pathlib import Path
# 一行复制,不用 import shutil
Path("a.txt").copy("b.txt")
# 目录树也能直接复制
Path("src").copy("dst")
是不是清爽多了?以前要写 import shutil 再调函数,现在直接在你本来就有的 Path 对象上点一下 copy 就完事。更妙的是,同一个 copy 方法,传文件就复制文件,传目录就递归复制整棵目录树,不用再纠结该用 copy 还是 copytree。
底层其实很聪明:复制大文件时它会用操作系统的「写时复制(CoW)」语义,几乎零额外磁盘开销;而且跨平台不用你管,Windows 的反斜杠和 Linux 的正斜杠它自己处理。你现有的老代码继续用 shutil 完全没事,升级到 3.14 之后无感切换就行。
其实 Path 早就很能打了:读文件有 read_text(),写文件有 write_text(),建目录有 mkdir()。现在把复制、移动、删目录树也补齐,等于「一个 Path 对象走天下」,你不用再在 shutil、os、pathlib 之间来回切换记忆。
不过也要说句公道话:Path.copy() 适合「手边已经有个原文件,要备份或搬运它」的场景。如果你只是想新建一个文件、或者覆盖写一段内容,那直接用 write_text() 更顺手,根本轮不到 copy 出场。选对工具,比记工具更重要。
不止复制,move / copytree / rmtree 一起打包
3.14 这次不是只加了一个 copy,而是把日常文件操作最常见的几件事全补齐了。移动、复制进目录、甚至删目录树,都变成了 Path 上的方法:
# 移动(相当于改名)文件
Path("old.txt").move("new.txt")
# 复制进一个已经存在的目录
Path("a.txt").copy_into(Path("backup"))
# 移动进已经存在的目录
Path("data").move_into(Path("archive"))
这里有个小细节值得记住:copy 和 move 是「复制到 / 移动到你写的目标路径」,而 copy_into 和 move_into 是「复制 / 移动进一个已经存在的目录里」。新手最常踩的雷,就是把 copy_into 当成 copy 用,结果把整个目录当文件覆盖掉了。
来个真实场景:一键整理下载文件夹
光说方法有点虚,我们直接来一个你八成遇到过的需求:下载文件夹里混了一堆图片和文档,你想把所有的 .jpg 挪到一个 photos 子目录里。用新的 Path 方法,几行就完事:
from pathlib import Path
downloads = Path.home() / "Downloads"
for f in downloads.iterdir():
if f.suffix.lower() == ".jpg":
f.move_into(downloads / "photos")
你看,遍历用 Path 自己的 iterdir(),判断后缀用 suffix,移动用 move_into,全程没有一个 shutil。整个小脚本读下来像在说人话,而不是在拼一堆模块名。
三个新手最容易踩的坑
方法虽好,但也有几个默认行为和老习惯不一样的地方,提前知道能省下不少调试时间:
坑一:preserve_metadata 默认是 False。也就是说,默认情况下 copy 不会保留原文件的时间戳和权限,只复制内容。下面两段,效果天差地别:
# 默认不保留时间戳
Path("a.txt").copy("b.txt")
# 想要和旧 shutil.copy2 一样的效果
Path("a.txt").copy("b.txt", preserve_metadata=True)
我踩过这个坑:复制完发现文件修改时间变成了今天,结果用 Git 看改动时按时间排序全乱了,还以为自己改了好多文件。后来才知道要加 preserve_metadata=True 才能把原来的时间戳一起搬过去。
坑二:follow_symlinks 默认是 True。如果你复制的路径里包含软链接,默认会把链接指向的真实文件内容复制过去。想原样保留那个软链接本身,就要设 follow_symlinks=False。
这坑更隐蔽:我朋友为了省空间,把一个 5GB 的大文件做成软链接。结果一复制,5GB 真实内容全被复制了一份,磁盘瞬间炸了。如果当时设了 follow_symlinks=False,复制的就只是那个几 KB 的链接本身。
顺带一句:follow_symlinks 默认 True 其实对大多数人是省心的——你十有八九就是要复制链接指向的真实内容。只有像我朋友这种「故意用软链接省空间」的特殊场景,才需要把它关掉。别一上来就乱设参数,默认行为已经替你想过大概率情况了。
坑三:删目录树用 rmtree,而且要特别小心。3.14 还给 Path 加了 rmtree,用来删除整个目录树(以前只能用 shutil.rmtree):
# 递归复制整个目录树
Path("src").copy(Path("dst"))
# 删除整个目录树,慎用!
Path("junk").rmtree()
rmtree 是真的会把目录连锅端掉,跑之前一定确认路径写对了,最好先 print 出来看一眼,别把项目根目录给扬了。建议养成习惯:删之前先打印路径,确认无误再执行。
还有人会问:那 copytree 呢,Path 上怎么没有叫这个名字的方法?其实不用单独记——你调 Path("src").copy(Path("dst")) 时,只要传的是目录,它内部就会自动递归复制整棵树,和老 shutil.copytree 是一个效果。方法名少了,要记的东西也跟着少了。
一张表看懂:新旧写法怎么对上
最后用大白话对个座,以后看到老代码也能秒懂:
想复制单个文件,老写法是 shutil.copy / copy2,新写法是 Path(...).copy();想递归复制目录,老写法是 shutil.copytree,新写法是 Path(...) 直接传目录;想移动文件,老写法是 shutil.move / os.rename,新写法是 Path(...).move();想删目录树,老写法是 shutil.rmtree,新写法是 Path(...).rmtree()。
一句话总结:以前要记四个模块名,现在只认 Path 一个对象。新人最大的好处不是少写几行,而是心智负担轻了——路径对象自己就知道怎么复制、移动、删除自己。
那问题来了:要是你的项目还得跑在 3.13 甚至更老的版本上,用不了 Path.copy() 怎么办?两个办法:要么继续用 shutil,老代码不用急着改;要么在调用前用 sys.version_info 判断一下版本再决定走哪条路。新功能虽好,上线前先确认运行环境支不支持,这是新手最容易忽略的一步。比如公司生产环境还锁在 3.11,你本地用 3.14 写着爽,一上线就报 AttributeError,这种事我见得太多了。
你现在用的 Python 是哪个版本?如果还没升到 3.14,这篇可以先收藏起来,等升级那天直接拿出来用。顺手说一句:升 3.14 没那么麻烦,官方安装包一路下一步,或者用 uv python install 3.14 一条命令就能并行装好,完全不干扰你现在的版本。你有没有被 shutil 那堆 copy 搞晕过?或者复制、整理文件夹时踩过什么坑?评论区聊聊,我挑几个典型问题下篇接着讲。