Python文件操作三剑客:os、pathlib、shutil用法详解与避坑指南

发布时间:2026/10/7 17:51:17
Python文件操作三剑客:os、pathlib、shutil用法详解与避坑指南 做Python开发这些年几乎每一个项目都会碰到文件和目录操作os、pathlib、shutil这三个标准库模块可以说是我工具箱里最常用的家伙。很多新手上来就搜“删除文件夹”“批量改名”的代码然后复制粘贴跑不通其实是没有理解这三个模块各自的定位。这篇文章我想用实际项目中的经验把Python操作文件和目录这件事掰开揉碎讲清楚什么时候用os、什么时候用pathlib、什么时候必须靠shutil包括路径拼接、遍历、复制移动、权限错误这些日常绕不开的细节。内容本身不算难但坑是真多适合刚入门不久或者写了好几年却一直靠复制粘贴解决问题的开发者也适合想把手头脚本写得更健壮的老手。很多教程喜欢把os和pathlib对立起来好像有了pathlib之后os就该淘汰了。实际工作里完全不是这么回事。我更愿意把这三个模块当成一套组合拳来用各管一段配合着完成文件系统操作。1. 先从设计思路说起为什么需要三个模块同时用1.1 三个模块的定位差异如果只让选一个模块操作文件我不会选os而是选pathlib。这不代表os和shutil可以被丢掉。实际开发中我惯用的分工是pathlib负责描述路径和做常规判断shutil负责搬文件、删目录这种“重体力活”os则负责那些只有系统调用才能干的事比如读环境变量、拿进程PID、处理文件描述符。这个分工不是拍脑袋定的而是来自三者设计定位的真实差异。os是Python对操作系统接口的封装几乎什么都能干从创建目录到读取环境变量从获取CPU核数到直接调用底层stat。但功能太全是优点也是缺点它的文件路径操作大多基于字符串写起来啰嗦稍不注意还会踩到跨平台分隔符的坑。os.path则是对路径字符串做处理的一组函数属于os的补充很多老代码里用的是os.path.join和os.path.exists。而pathlib带来的进步是革命性的它把路径抽象成对象用/运算符拼接路径代码读起来就像在描述自然语言而且Path对象自带exists()、is_file()、read_text()这些方法能把一部分文件读写和路径判断合并到同一层。shutil名字来自“shell utility”定位就是高层次的、面向普通用户场景的文件操作。它不需要你去了解文件描述符或底层系统调用直接给出copy、move、rmtree这类函数。比如删除一个非空目录os.rmdir只能删空目录shutil.rmtree就能一步到位。但它的代价是封装度很高出错时返回的错误信息有时不够直观需要你自己加日志和异常处理。我把三者的差异整理成一张表日常选型时扫一眼基本就有数模块核心定位典型场景os操作系统接口环境变量、底层文件描述符、创建单级目录os.path路径字符串函数传统代码中的拼接、判断、归一化pathlib面向对象路径用Path对象统一路径处理、目录遍历、轻量读写shutil高级文件操作复制、移动、删除目录树、打包解包1.2 我推荐的组合方式我自己的实践习惯是业务代码里永远以pathlib为主只有碰到shutil的专属能力时才切换过去os则用来处理环境变量和系统信息。这段代码是一个很典型的组合示例from pathlib import Path import shutil import os src Path(data) / raw.csv dst_dir Path(backup) / 2025 dst_dir.mkdir(parentsTrue, exist_okTrue) shutil.copy2(src, dst_dir / src.name) print(os.environ.get(PYTHONPATH)) print(Path.cwd())为什么这么组合首先是可读性。Path(data) / raw.csv比os.path.join(data, raw.csv)更直接别人读代码时一眼能看出层级关系。其次是方法集成。创建目录直接用mkdir(parentsTrue, exist_okTrue)一行搞定在os里你得先判断目录是否存在再用os.makedirs逻辑上是绕了一下。最后是生态趋势。新写的第三方库和官方文档越来越倾向pathlib坚持用旧写法会让代码越来越像“上个时代”的东西。但这不等于os没用。比如你想读取环境变量os.environ仍然是唯一正经的入口想判断当前工作目录Path.cwd()底层还是os.getcwd()想操作文件描述符、做os.fork、os.kill这种系统级操作pathlib根本做不到。所以我的结论是三者不是替代关系而是不同抽象层次。os是底层地基pathlib是把地基包成好用接口的漂亮房子shutil是房子里干重活的管家。2. 路径处理从字符串到对象的思维转变2.1 pathlib 的核心用法Path 对象与 / 运算符路径处理是所有文件操作的地基这里踩的坑最多。用pathlib之后最直观的改变是路径不再是一串容易出错字符串而是有类型、有方法的对象。看一眼这个对比# 传统写法 import os path os.path.join(home, user, data, test.txt) dirname os.path.dirname(path) filename os.path.basename(path) print(os.path.splitext(filename)) # pathlib 写法 from pathlib import Path path Path(home) / user / data / test.txt print(path.parts) # (home, user, data, test.txt) print(path.parent) # home/user/data print(path.name) # test.txt print(path.stem) # test print(path.suffix) # .txt对比一下就能看出来pathlib把路径拆解得很优雅。parts可以直接拿到每一级目录parent定位父目录stem和suffix帮你拆文件名和扩展名。这些能力如果用os.path做也能实现但代码会多出好几行而且容易漏掉边界情况。比如你想拿到“不带扩展名的完整文件名”os.path需要先basename再splitext而pathlib直接stem。Path.resolve()也很实用它会把相对路径转成绝对路径并且解析掉..和符号链接。比如你代码里写Path(config) / .. / app.yaml调用resolve()之后会得到干净的绝对路径。这一点在日志输出和调试时能省很多事。2.2 os.path 仍然有用的场景与注意事项看到这里可能有人问那os.path是不是可以彻底不用了我的答案是大部分新代码确实不用了但你不能不认识它。现实情况是公司里总有历史遗留项目第三方依赖也可能在内部还在用os.path你接手代码时看不懂就麻烦了。os.path里有几个函数至今没有直接被pathlib替代。比如os.path.commonpath()和os.path.commonprefix()前者用于获取一组路径的公共目录后者获取公共字符串前缀。虽然可以用Path对象自己做但标准库现成函数更稳妥。再比如os.path.getmtime()、os.path.getsize()这类获取文件元信息的函数pathlib虽然有stat()方法但函数式写法在某些回调场景里更好用。还有os.path.expanduser(~)可以快速展开用户目录pathlib要用Path.home()两者都行但旧代码里都是expanduser。使用os.path时最容易犯的错误是把它和字符串语法混用。看过很多代码这么写os.path.join(data, backup) / filename。这种混合拼接方式在Windows上会出问题因为os.path.join已经根据系统生成了分隔符你再手动加一个/最后可能得到data/backup\2025/file.txt这种两边不讨好的路径。我建议要么纯os.path.join一路走到底要么直接用pathlib的/绝不要手动拼。2.3 踩坑实录相对路径、绝对路径和路径拼接路径处理最常见的坑是“相对路径到底相对谁”。很多新手以为相对路径相对的是脚本所在目录实际上它相对的是当前工作目录也就是你执行Python命令时所在的目录。举个例子脚本放在D:\project\script.py你在C:\Users\me下运行python D:\project\script.py那么脚本里写的Path(data)指向的是C:\Users\me\data不是D:\project\data。这个问题不搞清楚后面读文件全是FileNotFoundError。我处理这个问题的标准做法是在脚本入口处固定一个锚点from pathlib import Path BASE_DIR Path(__file__).resolve().parent DATA_DIR BASE_DIR / data用__file__拿到当前脚本路径resolve()转成绝对路径然后取parent作为项目根目录。这样无论从哪里执行DATA_DIR都稳定指向脚本旁边的data目录。这个写法在写命令行工具、定时任务、测试脚本时特别管用我已经数不清多少个生产事故是因为有人改了启动目录导致文件找不到的。另一个坑是在Windows上硬编码路径分隔符。比如Path(C:/Users/me)其实没问题因为Python在Windows上能自动把正斜杠转成反斜杠但如果你用字符串拼接C:\\Users\\me \\data代码一拿到Linux就崩。pathlib的/运算和os.path.join都帮我们处理了分隔符所以你应该尽量把“路径就是字符串”的念头从脑子里删掉。3. 文件和目录的日常增删改查实操3.1 遍历目录os.walk 与 Path.rglob 的选择遍历目录是文件操作里最常写的逻辑。最常见的是os.walk它返回三个值当前目录路径、子目录列表、文件列表。默认是递归遍历而且你可以直接修改dirs列表来剪枝比如跳过node_modules和.git目录。import os for root, dirs, files in os.walk(project): dirs[:] [d for d in dirs if d not in {node_modules, .git, __pycache__}] for f in files: if f.endswith(.py): print(os.path.join(root, f))注意这里dirs[:]的写法。直接dirs [...]在外面赋新列表并不会影响os.walk内部的遍历逻辑因为它是通过修改原列表来控制递归的。我见过有人在这里犯迷糊导致被剪掉目录根本没有生效。语法上很细节但实际排错时要花好一阵子。pathlib的Path.rglob(pattern)用起来更省事。比如想找项目里所有*.log文件直接from pathlib import Path for p in Path(logs).rglob(*.log): print(p)rglob返回的是Path对象路径可以直接用比os.walk再拼一次字符串要舒服。但要注意rglob在遇到无权限访问的子目录时会直接抛PermissionError而os.walk默认会把错误吞掉继续往下走。所以用rglob时一定要包try/except否则一个大目录里有一个文件访问不到整个脚本就中断了。我一般这样取舍只需要按扩展名找文件用rglob需要做复杂剪枝、或者要控制遍历顺序用os.walk如果目录特别大、文件特别多用os.scandir做单层遍历性能差别很明显。3.2 创建、复制、移动、改名、删除shutil 是主力创建目录时pathlib已经足够优雅from pathlib import Path p Path(backup/2025/01) p.mkdir(parentsTrue, exist_okTrue)parentsTrue会一次性创建多级目录exist_okTrue让目录已存在时不报错。千万别用os.mkdir去创建多级目录它只能建单层parents缺失会导致FileNotFoundError。复制和移动文件优先用shutil。shutil.copy2(src, dst)会把文件内容和权限元数据一起复制过去shutil.copytree(src, dst)会递归复制整个目录shutil.move(src, dst)既能移动也能重命名而且自动处理跨设备问题。import shutil shutil.copy2(report.pdf, backup/report.pdf) shutil.copytree(data, backup/data, dirs_exist_okTrue) shutil.move(temp.txt, final.txt)三个函数我都会聊聊。copy2比copy多保留了修改时间和访问时间更接近“克隆”做备份时基本都用copy2。copytree默认目标目录不能已存在Python 3.8之后加了dirs_exist_okTrue参数设为True就可以复制到已存在目录。move是智能的如果目标路径是一个目录它会移动进去如果目标是一个具体文件名它就会把原文件改名成这个名字。删除时普通文件用Path.unlink()空目录用Path.rmdir()非空目录树用shutil.rmtree()。再次强调rmtree删了就没了不会有回收站。要在删除前写日志或者用try/except保护重要数据这是我的底线。3.3 批量重命名与归档场景实战批量重命名是很多人的刚需。比如把一组图片按日期重新命名或者给所有.txt文件加前缀。一个比较完整的例子from pathlib import Path folder Path(downloads) for i, p in enumerate(folder.glob(*.jpg), start1): new_name folder / fphoto_{i:03d}{p.suffix} p.rename(new_name)这个脚本把downloads下所有.jpg改成photo_001.jpg、photo_002.jpg这样的名字。i:03d保证编号三位数排序时不会出现“10”排在“2”前面的问题。如果你需要更精确的排序可以用sorted(p for p in folder.glob(*.jpg) if p.is_file())按文件名排序。归档备份也是shutil的强项。比如每天要把一个目录打包成zipimport shutil from pathlib import Path today Path(backup) / fbackup_{date.today().isoformat()} shutil.make_archive(str(today), zip, root_dirdata)make_archive的root_dir指的是要打包的根目录打包出来的zip里所有路径都会相对于它。解包用shutil.unpack_archive(archive_path, extract_dir)会自动识别zip、tar、gztar等格式。注意make_archive返回的是压缩包路径字符串不是Path对象需要再手动包一层Path()才能用pathlib的方法这个细节容易坑人。4. 权限、错误处理与跨平台细节4.1 拒绝访问OSError 5的典型场景与排查思路文件操作中我遇到最多的问题就是权限。在Windows上经常看到这样的报错PermissionError: [Errno 5] 拒绝访问。: C:/xxx/file.txt后面还常跟着“另一个程序正在使用此文件”之类的提示。[Errno 5]在Windows上对应的是ERROR_ACCESS_DENIED在Linux上则一般是[Errno 13] Permission denied。虽然错误号不同但本质都是权限不够或文件被占用。我总结的排查思路是先看文件是不是只读属性再看文件是否被其他进程占用比如Excel打开着同一个xlsx或者文本编辑器没关闭然后看当前Python进程所属用户是否有目录写权限如果都不对再看是不是杀毒软件或系统保护策略拦住了。很多时候其实是第二步Windows对文件占用非常敏感不像Linux可以轻松覆盖已打开的文件。代码层面一定要做好保护from pathlib import Path import shutil p Path(important.txt) try: p.unlink() except PermissionError: print(f没有权限删除 {p}检查文件是否被占用或只读) except FileNotFoundError: print(f{p} 不存在忽略)记住一个原则永远不要在没有任何异常处理的情况下删除、覆盖文件。别看是小操作真正跑到生产环境里权限问题、文件占用问题、磁盘空间问题都会冒出来没有异常处理的脚本就是个定时炸弹。4.2 文件已存在、目录非空时的处理策略除了权限另一个经典问题是目标已存在。比如你往backup目录复制文件结果目录里已经有一个同名文件shutil.copy2默认会直接覆盖这在某些场景下是好事但也可能静默丢掉旧版本。如果你希望保留旧版本最好这么做from pathlib import Path import shutil src Path(data.csv) dst_dir Path(backup) dst_dir.mkdir(exist_okTrue) dst dst_dir / src.name if dst.exists(): counter 1 while dst.exists(): dst dst_dir / f{src.stem}_{counter}{src.suffix} counter 1 shutil.copy2(src, dst)这段代码会在同名文件后加序号生成data_1.csv、data_2.csv而不是盲目覆盖。类似的Path.mkdir(exist_okTrue)可以容忍目录已存在Path.unlink(missing_okTrue)可以容忍文件不存在。missing_okTrue是Python 3.8加入的如果你还在跑老版本Python就只能先判断exists()再删。目录非空的情况也很常见。shutil.rmtree是可以删除非空目录的但风险极高。如果你只想清空目录内容而保留目录本身可以遍历删除子项但一定要事先确认目录路径不是根目录或重要系统路径别手滑把家目录删了。4.3 跨平台路径差异Windows / Linux / macOS跨平台是团队项目绕不开的话题。Windows路径用反斜杠\还带盘符C:Linux和macOS用正斜杠/没有盘符概念。macOS默认大小写不敏感但可以开启大小写敏感Linux基本都敏感。这些差异如果用字符串拼接写死迟早出事。pathlib最大的价值之一就是帮你抽象掉这些差异。Path(data) / file.txt在Windows上会得到data\file.txt在Linux上得到data/file.txt你根本不用手动管。但在跨平台场景里还是要注意几点不要直接拿路径字符串去和环境变量拼不要在路径里硬编码/或\判断文件是否存在时要考虑系统对大小写的敏感差异符号链接在Windows和Linux上的处理方式也不一样Path.resolve()在某些平台会解析符号链接在Windows上还要小心UNC路径。用os.name判断平台是一种常见姿势os.name nt表示Windowsposix表示Linux/macOS。如果某个逻辑必须在不同平台有不同实现可以像这样分支处理但尽量让路径操作本身保持平台无关只在必要的系统调用层面做区分。5. 常见问题排查与性能优化速查5.1 高频报错与对应解法我把日常踩到的高频异常整理成了表格方便收藏后快速对照错误信息常见原因解决方案FileNotFoundError路径不存在或者相对路径基准搞错用Path(__file__).resolve().parent固定锚点先判断exists()PermissionError: [Errno 13]Linux/macOS权限不够检查文件权限和运行用户必要时chmodPermissionError: [Errno 5]Windows文件只读或被占用去掉只读属性关闭占用程序或捕获异常重试FileExistsError创建目录/文件时目标已存在mkdir(exist_okTrue)复制前做同名处理NotADirectoryError你把它当目录但它其实是文件先is_dir()判断再决定用os.walk还是直接读文件IsADirectoryError你试图用打开文件的方式打开目录对目录用iterdir()或rglob()不要open()OSError: [Errno 28] No space left on device磁盘满了清理磁盘或检查是否有大文件被堆在临时目录排查这些错误时我一般会先把异常信息完整打出来import traceback try: shutil.copytree(src, dst) except Exception: traceback.print_exc()不要用except Exception: pass糊弄至少要把异常信息打出来不然线上出问题连从哪下手都不知道。5.2 大目录遍历的优化思路目录里文件数量一旦超过几万个遍历性能就会变得肉眼可见地慢。rglob(*)虽然方便但内部会做大量stat调用。如果只是想要文件列表可以优先用os.scandir它不会像os.listdir那样把所有条目都加载到内存而且是流式返回DirEntry对象性能和内存都更好。遍历大目录时还要注意不要在每个文件上都调用Path.exists()。很多人写循环时习惯先判断再操作但如果目录结构本身已经通过glob得到这些判断就是多余的系统调用。正确做法是先把符合条件的路径收集好再统一处理避免反复stat。我还习惯在遍历时做“剪枝”。比如要删除缓存目录但不想进入隐藏目录或忽略列表就用os.walk修改dirs列表而不是把所有东西都扫一遍再过滤。复制大目录时shutil.copytree可以直接用但如果你想跳过某些子目录可以给它传ignore函数或者在复制前先shutil.rmtree旧目录。移动大文件时同一文件系统内os.rename是原子操作瞬时完成跨文件系统则会走复制删除这时用shutil.move更稳妥它会自动判断。5.3 小技巧原子写入、临时文件与文件锁最后一个环节分享三个提高脚本健壮性的小技巧。第一个是原子写入。写配置文件或日志时为了防止程序崩溃导致文件只写一半应该先写临时文件再用os.replace覆盖目标文件。os.replace在Linux/Windows上都是原子操作要么旧文件还在要么新文件完全替换成功不会出现中间状态from pathlib import Path target Path(app.yaml) tmp target.with_suffix(.tmp) tmp.write_text(new content) os.replace(tmp, target)第二个是临时文件。临时目录、临时文件不要手动拼/tmp/xxx应该用tempfile模块它会自动选一个合适的临时位置并在合适时候清理。第三个是文件锁。多进程同时写同一个文件时基本都会出问题。Windows和Linux的锁机制不一样Python标准库没有跨平台锁我一般会用filelock第三方库来统一处理或者减少锁的粒度把写文件的逻辑放到一个单独进程里别让多个任务同时碰同一个路径。这些小技巧看起来零碎但真正能把文件操作写到“在生产环境不炸”的程度靠的都是这些细节。做项目这么多年我自己最大的体会是文件操作不是背几个函数就能过关的重要的是理解每个模块的设计边界以及知道异常发生后该怎么排查。工具只是工具真正稳妥的代码往往来自一次次踩坑后积累下来的防御式写法。希望这篇经验总结能让你少踩一点这些坑。