1. 从一次凌晨的数据迁移说起
在 Python 的 os 模块里,os.makedirs和os.walk是我用得最频繁的两个函数。大概一年前我接了个数据迁移的小活儿:把某个业务系统的历史目录,按原样搬到新机器上。需求本身不难,真正的麻烦在于源目录里藏了几百个空目录和隐藏目录,老脚本用os.path.exists加os.mkdir一级一级地建,跑到一半就断了,日志里全是 FileNotFoundError。
后来我把整个逻辑换成这两个函数重写,代码量直接砍掉一大半,稳定性也上来了。这篇就借这个场景,把os.makedirs和os.walk拆开揉碎聊聊:参数陷阱、遍历次序的逻辑、操作真实文件系统时的各种边界问题,以及几个我踩过的坑。适合刚接触目录处理的初学者,也可以当作老手的查漏补缺。
2. makedirs创建目录时的那些“没想到”
2.1 递归创建只是最表面的一层
os.makedirs的签名是:
os.makedirs(name, mode=0o777, exist_ok=False)最直观的能力是递归创建中间目录:执行os.makedirs('data/2024/12/report'),如果data、data/2024、data/2024/12都不存在,它会一口气全部建出来。这等效于 shell 里的mkdir -p。
很多初学者分不清它和os.mkdir:os.mkdir只允许创建最后一级目录,父目录不存在就直接抛FileNotFoundError。打个比方,os.makedirs像施工队整栋楼连地基一起盖,os.mkdir只是把钥匙插进锁孔——楼还没盖好就干不了活。
不过参数细节里有几个坑,实践当中踩中概率很高。先看exist_ok的语义:默认 False 时,如果目标目录已经存在,会抛FileExistsError。很多人以为exist_ok=True就是“路径存在也不报错”,这个理解不完整——它只对“目录已存在”宽容。如果路径的最终端是一个文件,或者中间某个组件是文件,exist_ok=True照样会抛FileExistsError。
我之前被这个行为坑过一次:脚本里判断“反正最终目录存在就行”,结果目标路径被某个进程占了个同名文件,程序每天凌晨固定崩一次。正确的做法是捕获异常后二次判断:
try: os.makedirs(target, exist_ok=True) except FileExistsError: # 路径存在,但不一定是目录 if not os.path.isdir(target): raise2.2 mode参数和umask的恩怨
mode=0o777这个默认参数,经常让人误以为创建的目录一定是rwxrwxrwx。实际上最终权限是mode & ~umask。大多数系统的 umask 是022,所以0o777实际创建出来的是0o755,也就是rwxr-xr-x。
如果你确实需要创建某些特殊权限的目录,比如临时共享目录、需要组内其他用户可写的工作目录,光传 mode 没用,得先调整 umask:
old_umask = os.umask(0o002) try: os.makedirs(shared_dir, mode=0o770, exist_ok=True) finally: os.umask(old_umask)这里有个很容易被忽略的点:os.umask是进程级的,影响后续所有文件创建。如果不把它恢复回去,你会在接下来的几十行代码里莫名其妙地创建出一堆权限过宽或过窄的文件。用try/finally包裹是必须的,改完忘恢复等于埋雷。
2.3 创建到一半失败时的残留问题
os.makedirs还有一个“部分成功”的行为,很多资料不会提。它创建目录时是一级一级往下建,如果建到中途碰到权限拒绝、磁盘满这类错误,前面已经成功创建的目录不会自动回滚。
举个例子:创建a/b/c/d,结果a/b权限被改成只读,d没建成,但a和a/b可能已经留在磁盘上了。异常抛出来时,你根本不知道哪几级成了、哪几级没成。处理这类问题,我一般是先捕获PermissionError,再顺着路径从下往上检查哪些目录已存在,把半成品能清理就清理掉,避免下一次运行被这些残留目录干扰。
3. walk函数是怎么在目录树里“走路”的
3.1 返回值三元组的拆解
os.walk的基础用法是:
for root, dirs, files in os.walk('/some/path'): print(root, dirs, files)它会返回三元组:root是当前目录的完整路径字符串;dirs是当前目录下子目录的名字列表;files是当前目录下普通文件的名字列表。需要注意,dirs和files里存的都是“名字”,不是完整路径,要拼接必须用os.path.join(root, name)。
顺序问题总是有人踩。文件系统返回的目录项顺序并不保证有序,直接遍历出来的结果在不同机器、不同文件系统上可能都不一样。如果希望输出稳定,得自己排:
for root, dirs, files in os.walk('/some/path'): dirs.sort() for name in sorted(files): full = os.path.join(root, name) print(full)目录树打印、差异比对、生成文件清单这类场景,排序是必须做的,否则你没法断言“两次遍历结果一致”。
3.2 topdown方向的意义
topdown=True是默认值,表示先处理父目录再进入子目录;改成False则反过来,先处理所有子目录,最后回到父目录。
官方文档举的例子是删除目录树:如果用topdown=True,你在处理父目录时就把目录删了,下一步就走不进子目录了。虽然日常删除直接用shutil.rmtree,但当你需要手动按层级清理、或者在删除前统计每个子目录的大小时,理解这个方向就很有用。
我自己最常用的场景是“先子后父”地汇总占用空间:
total_size = {} for root, dirs, files in os.walk(base_dir, topdown=False): size = sum(os.path.getsize(os.path.join(root, f)) for f in files) size += sum(total_size[os.path.join(root, d)] for d in dirs) total_size[root] = size这样算出来的total_size是自底向上累加的,父目录的值天然包含所有子目录,逻辑非常干净。
3.3 原地修改dirs,把过滤做在源头
os.walk有个被低估的机制:在遍历时原地修改dirs列表,能影响后续要进入的子目录。比如你想跳过所有以.开头的隐藏目录:
for root, dirs, files in os.walk(src): dirs[:] = [d for d in dirs if not d.startswith('.')] # 处理其余文件注意必须是切片赋值dirs[:] = [...],而不是直接dirs = [...]。dirs是内部维护的引用,重新赋值不会改变 walk 的遍历计划,只有原地修改才有效。这个细节我见过不少人翻车:写完之后过滤逻辑完全没生效,目录照样被遍历。
同样的技巧可以用来跳过node_modules、__pycache__、.git这类又大又没价值的目录,能省掉大量无意义的stat和文件系统交互。配合dirs.sort()还能决定分支的访问顺序,在做一致性比对时非常实用。
3.4 followlinks与符号链接
os.walk(top, topdown=True, onerror=None, followlinks=False)里最容易被忽视的是followlinks。
默认False表示不跟随目录符号链接,但有个反直觉的行为:指向目录的符号链接,在 walk 的输出里不是出现在dirs,而是出现在files里。这是很多人在做目录同步时才发现的问题——明明是个子目录,怎么按文件处理了。
如果设置followlinks=True,遇到符号链接指向上级目录,遍历可能陷入无限循环。walk 本身不检测链接环,文档也明确提醒过这一点。使用场景确实存在:某些环境里数据目录本身就是由一串符号链接组织的,不跟随就没法访问真实文件。我个人的做法是维护一个已访问路径集合,用os.path.realpath去重:
visited = set() for root, dirs, files in os.walk(src, followlinks=True): real = os.path.realpath(root) if real in visited: dirs[:] = [] continue visited.add(real) # 继续处理这样既能吃到跟随链接带来的便利,又不会被环活活拖死。
4. 组合拳:做一个只搬运目录结构的镜像工具
4.1 为什么不用shutil.copytree
回到开头的迁移场景。有人可能会问:直接用shutil.copytree不是更省事吗?它确实能复制整棵目录树,但想“只重建结构、不复制文件内容”、或者“带条件地选择性复制某些文件”,它就不合适了。
copytree的ignore参数虽然能过滤,但写起来要构造忽略函数,动态规则一多就变得很绕。而os.walk配合os.makedirs,结构上完全是“遍历一棵树 + 重建一棵树”的分工,逻辑透明,想加过滤条件也就是在循环里加一个if的事。
4.2 一个能直接用的镜像脚本
下面这个函数会把src的完整目录结构复刻到dst,包括空目录和隐藏目录,但不复制任何文件内容:
import os def mirror_tree(src, dst): src = os.path.abspath(src) dst = os.path.abspath(dst) if not os.path.isdir(src): raise NotADirectoryError(src) # 防止 dst 嵌套在 src 里导致遍历爆炸 if os.path.commonpath([src, dst]) == src: raise ValueError(f"dst 不能位于 src 内部: {dst}") os.makedirs(dst, exist_ok=True) for root, dirs, files in os.walk(src): rel = os.path.relpath(root, src) target = dst if rel == '.' else os.path.join(dst, rel) os.makedirs(target, exist_ok=True) # 如果打算跳过某些目录,这里先主动建出来以保留结构 for d in dirs: os.makedirs(os.path.join(target, d), exist_ok=True)os.makedirs(dst, exist_ok=True)保证目标根目录就位;然后 walk 每走到一个目录,就把对应的相对路径在目标端重建出来。空目录也会出现在 walk 的root里,所以天然被保留。
有个设计点值得说说:for d in dirs里提前创建子目录,看起来有点多余,因为子目录之后也会作为root被遍历到。但如果你在遍历开头对dirs做了过滤(比如跳过某个大目录),被过滤的目录就不会再作为root出现,那时候提前创建就有意义了——它可以帮你保留整个骨架,只是不再深入内部。
4.3 先收集还是边遍历边创建
上面的实现是边遍历边创建。这个方案在多数场景下没问题,但如果dst被设置成src的子目录(比如dst = src/backup),walk 会把刚创建的目录也扫进遍历范围,导致目录结构无限膨胀。
工程上更稳妥的做法是两阶段:第一遍只收集目录到set,第二遍再统一创建。代价是多扫描一遍目录树,但好处是“遍历”和“修改”彻底分离,不会因为创建动作影响遍历结果。处理超大目录树时,我倾向于两阶段,宁可慢一点也要避免跑出几万个垃圾目录。
needed_dirs = set() for root, dirs, files in os.walk(src): rel = os.path.relpath(root, src) needed_dirs.add('.' if rel == '.' else rel) for rel in sorted(needed_dirs): target = dst if rel == '.' else os.path.join(dst, rel) os.makedirs(target, exist_ok=True)5. 真实文件系统里的边界问题:权限、顺序、编码和长度
5.1 权限不够时的表现差异
os.makedirs在权限不够时抛出PermissionError,它不会尝试修改已有父目录的权限来“硬闯”。如果你的脚本本身有提权能力,又需要创建某个受保护目录,记得先确认调用方的权限,而不是试图通过传 mode 绕过。
os.walk的行为则更“野蛮”:它默认静默跳过不可读的目录。也就是说,权限不足时你不会看到任何异常,只是某个目录的dirs和files为空,看起来像“这个目录下没有内容”。如果你依赖 walk 做同步,这种静默会直接导致数据漏拷贝。
解决办法是把onerror参数用起来:
def report_error(err): print(f"遍历出错: {err}", file=sys.stderr) # 注意:不抛出异常,walk 会继续其他分支 for root, dirs, files in os.walk(src, onerror=report_error): ...如果onerror回调里抛出异常,walk 会中断。想“记下错误继续跑”就只记录不抛;想“一有权限问题就停止”就直接 raise。这个开关是对遍历策略的显式控制,生产脚本里不要省。
5.2 遍历期间的目录树变化
os.walk读取的是某一时刻文件系统的快照,而且是分批读取的。遍历过程中如果别的进程新增或删除了目录,结果是不确定的;一边遍历一边删除自己正在访问的目录,更容易出问题——你删除了某个root,后续基于旧的dirs列表往下走时,可能遇到已经不存在的路径。
我的一般原则是:walk 只负责“读”,所有“写”操作(删除、改名、移动)收集到列表后统一在第二阶段做。这样既避免遍历行为受到自身改动影响,排查问题时也更容易复现。
5.3 超大规模目录的性能与操作系统限制
我构造过一个大概 8 万目录、40 万文件的目录树做过测试。机械硬盘上os.walk完整遍历大约要十几秒,瓶颈主要是readdir和stat等待;换到 SSD 能明显加快。它底层用的是os.scandir,可以批量返回目录项,性能已经相当能打。
过滤一旦写在dirs原地修改里,收益非常明显:不进入被过滤目录,就省掉那一整棵子树的stat。反过来,如果你在遍历之后再逐条判断跳过,系统的开销已经付出了,过滤的意义就大打折扣。
还有个常被问的问题:目录层级特别深会不会爆递归栈?os.walk的实现是迭代式的,内部维护一个待遍历的目录列表,不是靠 Python 递归逐层进入。所以层级再深也不会遇到RecursionError。真正现实的限制是路径长度:Linux 上大多数系统调用受PATH_MAX限制(通常是 4096 字节),Windows 上默认还有 260 字符的限制,超过会直接报错。处理超长路径时要走 Windows 的扩展路径语法,或者重构成更扁平的存储设计。
5.4 文件名编码和无法打印的字符
Linux 下文件名可以是任意字节序列,未必是合法的 UTF-8。Python 3 处理这类文件名时,会使用surrogateescape编码策略,路径拼接、打开文件都没问题,但直接print文件名可能触发UnicodeEncodeError。
如果你的脚本要输出包含乱码文件名的清单,可以用os.fsencode/os.fsdecode显式转换:
for root, dirs, files in os.walk(src): for name in files: raw = os.fsencode(name) # 对字节串做后续处理,避免编码问题这类问题在普通项目里很难触发,但做目录备份、批量迁移这类“全量扫描”任务时,只要树里有那么一两个特殊文件名,就能让你的脚本当场崩溃。处理越“底层”的数据任务,越要把编码当作头等公民看待。
6. 老组合与pathlib的选择
6.1 Path.mkdir 与 os.makedirs 的等价关系
pathlib普及之后,很多新代码不再直接 import os,而是写:
from pathlib import Path Path('/some/dir/with/sub').mkdir(parents=True, exist_ok=True)实际上Path.mkdir(parents=True, exist_ok=True)底层调用的就是os.makedirs,参数一一对应。二者行为完全等价,区别只在于你拿到的是一串字符串还是一个Path对象。新项目里如果到处要拼接路径、检查文件类型,Path的链式 API 确实写起来舒服得多。
6.2 Path.rglob 与 os.walk 的真实取舍
Path.rglob(pattern)也能递归遍历,但它返回的是拍平后的路径列表。比如Path(src).rglob('*.py')只会给你匹配文件,拿不到“每个目录下有哪些子目录”这种层级结构。如果你想按层处理、批量操作每个目录里的元数据,walk 的三元组更顺手。
更重要的是os.walk支持原地修改dirs来控制后续遍历,rglob没这个能力。它内部有自己维护的遍历栈,过滤只能靠事后判断。想要“跳过 node_modules”这种需求,用 walk 是零成本的;用 rglob 写筛选逻辑就会绕一些。
两个函数适合的场景可以简单对比:
| 需求 | os.walk | Path.rglob |
|---|---|---|
| 按层级批量处理目录 | 直接 | 不太方便 |
| 过滤不想深入的分支 | 原地修改 dirs | 不支持,只能事后过滤 |
| 找符合某种模式的文件 | 需自己判断后缀 | 直接传 pattern |
| 输出结果的风格 | 字符串路径 | Path 对象,便于链式操作 |
我自己的习惯是:新项目能用pathlib读写单文件就尽量用;但凡是“需要控制遍历方向、按目录层级批量操作、过滤不想进入的分支”这类逻辑,我直接回os.walk。这两者不是替代关系,是不同抽象层面的工具,配合os.makedirs一起用,覆盖目录树操作的大半需求基本没有压力。
7. 一次完整实操:把镜像工具跑出效果
前面给的镜像脚本,我实际用起来还有一个补充步骤:迁移时往往不只是重建结构,还得把某些小文件一起拷过去。比如只同步配置文件、脚本文件,跳过数据库文件和大附件,那就在 walk 循环里加一个条件:
for root, dirs, files in os.walk(src): rel = os.path.relpath(root, src) target = dst if rel == '.' else os.path.join(dst, rel) os.makedirs(target, exist_ok=True) for name in files: if not name.endswith(('.conf', '.py', '.md')): continue src_file = os.path.join(root, name) dst_file = os.path.join(target, name) # 这里再加一层判断:目标文件是否存在且大小一致,再决定是否复制加上这层按后缀过滤,迁移工具就从“结构镜像”升级成了“定向同步”。文件是否要复制的判定,建议先看大小,再看修改时间,避免无脑拷贝。这样处理几十万文件的任务,实际跑起来比全量复制快一个量级。
这套逻辑我在几个不同的数据迁移、归档项目里反复用过。核心思路其实很简单:一次 walk 把目录结构读出来,用 makedirs 把结构物化下来,再按业务条件决策文件动作。os.makedirs和os.walk都不是新东西,但组合得好,能省掉大量模板代码,也能避开很多隐蔽的文件系统边界问题。如果你正准备处理大批量目录,可以先把我的镜像脚本拿到临时目录里用一个小数据集跑通,再放到真实环境里。遇到奇怪文件名的目录,永远比你预想的多。