先说我为什么会写这篇东西。上个月给一个旧项目做资产盘点目录嵌套了七八层里面混着源码、第三方依赖、日志、临时文件、还有几个指向外部目录的软链接。我需要把所有子文件夹和文件路径完整拉出来再统计体积、按修改时间排序看看哪些文件已经大半年没动。这个需求翻译过来就是标题这句——“Python递归获取指定文件夹下的所有文件夹、文件”。听起来很简单但你真去搜会看到一堆写法有人说用os.walk、有人说用pathlib的rglob、还有人硬给你贴一段手写递归。我全试了一遍也踩了不少坑这篇文章就把几种方案的适用边界、完整代码、以及真实环境下会遇到的问题一次说清楚。1. 动手前先想清楚三种主流思路的适用边界很多人一看到“递归获取文件”就直接开写写完才发现要么把权限不足的目录搞崩了要么把软链接绕成了死循环要么文件顺序乱得没法用。我现在的习惯是动手前先把需求问清楚就五个问题。输出物是什么。只要文件夹列表只要文件列表还是两者都要并且要区分这直接决定你是用dirs、files还是两个都处理。路径形式是什么。要绝对路径、相对路径还是只要文件名如果你只是按名字做集合运算那返回值用os.path.basename截一下就行如果你要拿去打开文件、做去重、做对比那必须用完整路径不然在不同目录下同名文件会互相覆盖。递归深度。是只遍历当前一层还是要钻到所有子目录。如果只要当前一层os.listdir就够了根本不用递归如果限定三层以内手写递归加个深度参数比os.walk更顺手如果没限制os.walk和rglob随便选。符号链接怎么处理。目录里如果有指向父目录或兄弟目录的快捷方式程序跟随链接就会无限绕圈。os.walk默认不跟随手写递归则要主动避开这块后面我会单独展开。文件量级有多大。100个文件和50万个文件是完全不同的写法。小目录随意大到几十万个文件时底层调用次数和内存占用都要开始算账此时os.scandir会比os.listdiros.path.isdir明显更快。我的建议是先按下面这张表对号入座再决定用哪套方案。使用场景推荐方案主要原因快速列出目录和文件不做复杂控制os.walk自带递归流程最稳需要动态跳过子目录比如过滤缓存目录os.walk(topdownTrue)可以在遍历时原地修改dirs列表限定递归深度、自定义顺序、特殊错误处理手写递归 os.scandir每一层都自己控制但代码量增加代码库长期维护希望路径操作更面向对象pathlib.Path.rglobAPI简洁Path对象方便拼接和转换只匹配特定扩展名如所有.pypathlib.Path.rglob(*.py)一个通配符搞定省掉循环判断有一类典型反面教材是为了“显得很懂”一上来就自己写递归结果没有处理符号链接目录里有个链接指向上级目录程序跑着跑着就没完没了。用os.walk默认就不会有这个烦恼。所以我说选对工具比亲手实现工具更重要。这篇文章后面所有的经验教训其实都是在这张表的基础上展开的。2. os.walk的正确姿势三个返回值、topdown裁剪与两个冷门参数os.walk是我在Windows和Linux服务器上用得最多的方案没有之一。原因是它足够稳而且把“递归”这件事完全封装好了你要做的只是一层循环。2.1 最基本的配方最基础的结构长这样import os folder rD:\workspace\demo_project for root, dirs, files in os.walk(folder): for name in files: print(os.path.join(root, name))这段代码输出的是该目录下所有文件的完整路径包括每一层子目录里的文件。如果文件夹和文件都要那就两个循环都写上如果只要文件夹不看文件那把files循环去掉就行。这里有个新手很容易忽略的点dirs和files里面存的只是“名字”不是路径。你要拿到完整路径必须os.path.join(root, name)自己拼一次。很多人直接拿name去open()结果报FileNotFoundError就是忽略了这一步。root则取决于你调用os.walk时传入的参数。你传入相对路径.那root就是相对路径你传绝对路径root就是绝对路径。别小看这个差异如果你的代码里后续要把路径写进日志或数据库统一成绝对路径能少掉很多麻烦。2.2 topdownTrue才是精髓os.walk默认topdownTrue也就是从上往下遍历先看到根目录再看到第一层子目录再往下钻。topdownTrue有个隐藏能力——你可以在循环体里直接修改dirs列表从而让os.walk跳过某些子目录完全不进入它们。我实际用来跳过.git、node_modules、__pycache__这类目录的写法是这样的skip {.git, __pycache__, node_modules, dist} for root, dirs, files in os.walk(folder): dirs[:] [d for d in dirs if d not in skip] for name in files: print(os.path.join(root, name))注意这里必须是dirs[:] ...不能直接dirs ...。你直接赋值等于把局部变量换了个新列表os.walk内部持有的还是原来的对象过滤完全无效。我见过不止一个人在这行代码上折腾了一下午。如果topdownFalseos.walk会从最底层的目录开始向上遍历。什么时候用最典型的就是“先删除子目录再删除父目录”这种场景后面第6章我会拿清理空目录当例子。2.3 onerror和followlinks被大多数人忽略os.walk还有两个冷门到几乎没人提的参数onerror和followlinks。先讲onerror。默认情况下如果你扫到一个没有读权限的目录比如Linux下的/proc、/sys或者别人服务器上权限没放开的子目录os.walk会静默跳过。很多情况下这确实合理但如果你希望至少记个日志以便发现“咦这里有个目录没扫到”就需要传回调函数def my_handler(exc): print(f扫描出错: {exc}) for root, dirs, files in os.walk(folder, onerrormy_handler): ...注意回调函数收到的是OSError实例不是字符串。你可以从exc.filename里看到具体是哪个路径打不开。这个参数在实际生产环境里很重要因为“静默跳过”和“明确跳过但我知道跳过了哪个”是完全两回事。再讲followlinks。默认是False也就是不跟随符号链接。这个设计非常正确。如果设置成True一旦目录里有软链接指向上级的某个文件夹os.walk会顺着链接钻进去然后从那个文件夹里再发现另一个软链接循环往复程序就可能失控。我的建议是除非你明确在写一个要追踪所有链接文件的小工具否则永远保持False。2.4 一个能直接用的汇总例子把上面几个点合在一起写一个“统计目录下各扩展名的总大小”的小工具其实非常短import os from collections import defaultdict folder rD:\data size_by_ext defaultdict(int) skip {node_modules, .git} for root, dirs, files in os.walk(folder): dirs[:] [d for d in dirs if d not in skip] for name in files: path os.path.join(root, name) try: ext os.path.splitext(name)[1].lower() size_by_ext[ext] os.path.getsize(path) except OSError: # 文件可能在遍历过程中被移走或被权限限制 continue for ext, total in sorted(size_by_ext.items(), keylambda x: x[1], reverseTrue)[:10]: print(f{ext or (无扩展名)}: {total / 1024 / 1024:.2f} MB)这个例子把topdown的动态裁剪、onerror的思想try/except兜底都融进去了你在实际项目里可以直接改改用。3. 手写递归生成器真正理解每层 join、isdir 与 yield from用os.walk的坏处之一是“太省事”很多人用了几年也没搞懂它背后到底干了什么。但如果你的需求超出了os.walk的可控范围——限定递归深度、按文件名规则提前终止、在进入目录前后各做一些事——那手写递归就是必修课。3.1 最简版手写递归import os def walk_all(path): for name in os.listdir(path): full_path os.path.join(path, name) if os.path.isdir(full_path): yield full_path # 目录也作为结果产出 yield from walk_all(full_path) # 递归进入子目录 else: yield full_path这段代码能同时产出所有文件夹和文件。核心逻辑就一句话拿到一个路径如果是目录先把它自己yield出去再递归进里面如果是文件直接yield。这里的yield可能让初学者懵。简单理解yield让这个函数变成一个生成器调用的时候不会立刻执行而是每迭代一次才算一次。好处是大目录遍历时不会把所有路径一次性堆在内存里而是一个一个吐出来速度不变但内存占用低。yield from walk_all(full_path)则是“把子目录递归产出的每个结果原封不动转交给外面”的语法糖等价于for item in walk_all(full_path): yield item。有个经常翻车的细节是忘记os.path.join。看着是小事但一旦拼错后面所有路径都会错位。我见过有人直接写os.path.isdir(path / name)这在Windows上就会出问题因为Windows路径分隔符是反斜杠。用os.path.join是最稳妥的跨平台做法。3.2 递归深度的隐形炸弹手写递归有个硬性限制Python默认递归上限是1000层。目录嵌套超过1000层的情况在正常业务里几乎不存在但也不是完全没有——某些自动化测试会生成很深的临时目录结构、某些旧系统导出的文件树又深又怪。一旦超过上限你会看到RecursionError整个程序直接崩溃。你当然可以调大上限import sys sys.setrecursionlimit(5000)但我个人不建议为了遍历目录这么干。调高递归上限会让Python的C调用栈跟着变深一旦真的超过极限进程会直接段错误崩溃连异常都来不及抛。比起调高递归上限更稳妥的方案是把递归改成显式栈的迭代写法import os def walk_all_iterative(path): stack [path] while stack: current stack.pop() for entry in os.scandir(current): if entry.is_dir(follow_symlinksFalse): stack.append(entry.path) else: yield entry.path这段代码用stack模拟了函数调用栈完全绕开递归深度限制。注意stack.pop()弹出的是最后一个元素所以它是深度优先遍历但顺序和listdir返回的顺序是反的。你如果希望顺序更直观可以在push之前先sorted(...)或者改用collections.deque做成广度优先。3.3 用os.scandir而不是os.listdir看上面的代码你可能发现我在迭代版本里用了os.scandir而不是第一款代码里的os.listdir。原因很简单os.listdir只返回名字列表你想知道它是文件还是目录还得再调一次os.path.isdir()等于对每个条目做了两次系统调用。os.scandir返回的是DirEntry对象这个对象自带is_dir()、is_file()方法底层在读取目录时就顺带把文件类型带回来了不需要二次stat。我实测过一个大概20万文件的目录用listdirisdir要比scandir慢20%到30%。平时小目录没感觉文件量一大差距立现。另外entry.is_dir(follow_symlinksFalse)这行也顺手解决了符号链接问题——它明确告诉Python即使这个条目是个软链接也不要因为它指向文件夹而把它当成目录。3.4 为什么还是要手写递归看到这里你可能会问既然os.walk那么省事手写递归的意义在哪意义在于当你需要“控制”时os.walk封装的接口反而碍事。比如我接过一个需求只扫描指定目录下三层以内的结构而且进入第三层时要把那一层的目录名记录到另一个清单里。os.walk只给一个整体的dirs列表你要做深度判断就得自己维护层级关系反而别扭。手写递归时深度参数自然就能传进去import os def walk_with_depth(path, max_depth3, current_depth0): if current_depth max_depth: return for entry in os.scandir(path): if entry.is_dir(follow_symlinksFalse): yield entry.path yield from walk_with_depth(entry.path, max_depth, current_depth 1) else: yield entry.path这种“每一层都由我来做主”的感觉只有在亲自递归一次之后才能建立起来。所以即便你最终用os.walk我也建议你把这段递归至少亲手写一遍、跑一遍。4. 换一种写法pathlib.rglob 与现代 Path 操作如果你的Python版本是3.4以上pathlib是另一个绕不开的模块。它的思维方式跟os完全不同os是一堆函数操作字符串pathlib是一堆对象操作路径。对很多新项目来说pathlib的代码读起来更顺。4.1 rglob一行收集所有文件最典型的写法from pathlib import Path base Path(rD:\data) all_files [p for p in base.rglob(*) if p.is_file()] all_dirs [p for p in base.rglob(*) if p.is_dir()]就这没了。rglob(*)会递归匹配该目录下所有后代条目你再配合is_file()或is_dir()过滤出你想要的东西。相比os.walk这个写法最大的优点是一行代码直接得到结果列表不需要写循环。它返回的是Path对象你可以直接调用.name、.suffix、.parent、.stat()这些方法。4.2 为什么用rglob而不是glob(**/*)有经验的读者会问base.glob(**/*)好像也能递归为什么推荐rglob原因是历史版本里glob(**/*)的行为在不同平台上并不一致。有的平台返回结果里会带上根目录自身有的不会有的版本在递归到一部分子目录后会提前停止。而rglob(*)的语义是“以任意路径深度匹配所有条目”行为更稳定。加上Python 3.11对pathlib.glob做了一轮重构但既然rglob完全够用就没有必要去赌那一点兼容性差异。顺带说一句如果你要用模式匹配特定文件类型rglob还支持通配符递归# 找出所有Python源文件 py_files list(base.rglob(*.py)) # 找出所有Markdown文件 md_files list(base.rglob(*.md))这比os.walk写过滤条件要直观得多。4.3 relative_to拿到相对路径的利器在处理大目录时我经常需要输出“相对根目录的路径”而不是完整的绝对路径。比如你要把文件清单写到报告里绝对路径太长不美观或者你要做打包需要知道每个文件相对于根目录的位置。Path对象有个方法叫relative_to正好干这个for p in base.rglob(*): if p.is_file(): rel p.relative_to(base) print(rel)输出效果类似于subdir/subsubdir/notes.txt非常干净。如果要在os.walk里做同样的事你得先记录根目录再手动os.path.relpath相比之下pathlib就优雅很多。4.4 什么时候选pathlib什么时候选os.walk我自己的取舍标准很简单脚本是一次性工具、要快速跑通、逻辑范围有限用os.walk因为不用引入额外概念代码量也少。项目要长期维护、路径运算频繁、代码库整体风格已经偏向pathlib就用pathlib。比如很多现代框架里到处是Path对象你再用os.walk返回字符串就跟整个工程风格脱节。有个讨巧的办法需要的时候两者混着用也行。Python标准库的绝大多数函数既接受字符串路径也接受Path对象所以你可以用pathlib遍历拿到Path对象后直接传给os.path.getsize之类的函数完全没问题。5. 真实目录环境里的几类坑权限、符号链接、编码与超长路径理想情况下的目录遍历很简单但真实环境里的目录往往很“脏”。这是我实际处理过几十个目录后总结出来最常踩的几类坑每一类都能让你的程序在别人机器上莫名其妙崩溃。5.1 权限不足导致遍历中断在Linux服务器上扫目录时最典型的就是碰到/proc、/sys这种系统目录或者某用户的主目录权限是700而你是另一个用户。os.walk默认会吞掉这种错误跳到其他目录继续扫但如果你用了手写递归里的os.listdir权限不足会直接抛PermissionError整个程序咔一下就停了。处理方式有两种。一是用os.walk的onerror回调前面已经写过二是手写递归时自己包一层异常import os def walk_safe(path): try: entries os.scandir(path) except OSError as e: print(f跳过无法访问的目录: {path} ({e})) return for entry in entries: if entry.is_dir(follow_symlinksFalse): yield from walk_safe(entry.path) else: yield entry.path记住一个原则目录遍历永远要有“这一层读不了就跳过”的兜底逻辑否则你做出来的脚本到生产环境就是定时炸弹。5.2 符号链接无限循环前面提到os.walk默认不follow符号链接所以它很安全。问题多半出现在手写递归时因为os.path.isdir会无条件跟随符号链接判断类型。假如你有一个目录结构data/ current - ../data # 软链接指向自己所在目录然后你写if os.path.isdir(full_path): yield from walk_all(full_path)程序就会顺着current这个链接重新回到data再看到current再回来……直到递归栈爆掉。正确的思路是不跟随软链接进入。无论用os.scandir的entry.is_dir(follow_symlinksFalse)还是先用os.path.islink判断一下都必须显式声明“不要因为链接指向目录就把它当目录”import os def walk_no_follow(path): for entry in os.scandir(path): if entry.is_symlink(): continue # 跳过所有符号链接条目 if entry.is_dir(): yield entry.path yield from walk_no_follow(entry.path) else: yield entry.path这里直接continue掉所有符号链接是最保守也最不容易出事的方案。如果你确实要收集链接指向的文件路径可以单独存一份但绝对不要拿它当递归入口。5.3 Windows下的中文乱码和重定向输出Windows控制台默认编码是GBK而文件系统通常返回UTF-8。很多时候代码本身没问题只是print一个中文路径就报UnicodeEncodeError。这个问题的坑在于在交互式命令行里跑可能不报错因为你用的是终端窗口的默认输出一旦你把脚本输出重定向到文件stdout编码就变了立刻崩给看。稳妥做法是在脚本开头强制指定标准输出编码import sys sys.stdout.reconfigure(encodingutf-8)Python 3.7以上支持reconfigure低版本可以用sys.stdout io.TextIOWrapper(sys.stdout.buffer, encodingutf-8)。这个坑非常隐蔽因为你第一次跑没事第二次用python scan.py result.txt导出结果时它就炸了。再补充一个Linux侧的问题Linux文件名允许含有非UTF-8字节比如某些历史遗留文件名带着乱码编码的字节序列。Python 3内部会用surrogateescape机制保护这类文件名所以遍历一般不会崩但一旦把这种名字打印出来照样抛异常。这种极端情况不多真遇到就用repr(name)先看一眼也别试图强行解码。5.4 超长路径与Windows的260字符限制Windows的经典限制是MAX_PATH 260个字符。只要路径总长度超过它很多系统API就直接失败Python代码会收到OSError: [Errno 22]或者[Errno 3]你连文件列表都拿不全。在Windows 10较新版本上如果系统开启了“启用长路径策略”Python 3.6以上的很多内置函数是可以自动支持的。但作为通用脚本我不建议依赖这个开关。更可靠的临时方案是遍历前先检查路径长度发现问题时把根目录映射成网络盘符或改用短路径把整体长度压下来。真到了路径超长那一步也别硬用Python去折腾先把目录结构简单化才是正经。5.5 海量小文件时的性能瓶颈最后一个是性能问题。扫一个包含几十万个JSON碎片文件的目录时如果性能优化不到位脚本可能跑几分钟甚至更久。常见的性能杀手有三个用os.listdir后挨个os.path.isdir每个文件多一次系统调用。换成os.scandir可解决。在循环里频繁做os.path.getsize、os.path.getmtime等stat操作每个stat又是一次系统调用。能少做就少做实在需要就只对最终结果列表做而不是对每个文件都做。把所有结果一次性收集到一个大列表再getsize统计。文件多时列表内存也可能吃掉几百MB能用生成器就用生成器能边扫边统计就不要先收集。实测20万级别的小文件scandir方案比listdir方案整体快20%到30%内存占用也能明显低一截。如果你要扫描的对象经常是这种量级建议直接选os.scandir路线。6. 三个高频扩展再加工过滤、排序与清理空目录基础遍历会了坑也知道了接下来就是实战价值最高的部分。分享三个我经常接到的高频需求以及对应的实现套路。6.1 按扩展名过滤并统计标签分布很多场景下你不需要所有文件只需要所有图片、所有日志、所有临时文件。比如要清理一个项目目录先把所有.log文件找出来from pathlib import Path log_files list(Path(rD:\logs).rglob(*.log))如果你要的是更复杂的过滤比如“找出所有大于100MB的视频文件”用os.walk配合条件判断也很轻松import os folder rE:\media big_videos [] for root, dirs, files in os.walk(folder): for name in files: if not name.lower().endswith((.mp4, .mkv, .mov)): continue path os.path.join(root, name) try: if os.path.getsize(path) 100 * 1024 * 1024: big_videos.append(path) except OSError: continue如果文件量很大可以先按扩展名粗筛再在后续处理时做精确过滤。不要在最底层循环里写一堆嵌套条件那样代码很快就没法看了。6.2 按最后修改时间排序找到近期活跃文件做自动同步或增量备份的时候经常要找出“最近一周被改过的文件”。os.path.getmtime搭配sorted就能完成import os import time folder rD:\data cutoff time.time() - 7 * 24 * 3600 recent_files [] for root, dirs, files in os.walk(folder): for name in files: path os.path.join(root, name) try: if os.path.getmtime(path) cutoff: recent_files.append((os.path.getmtime(path), path)) except OSError: continue recent_files.sort(reverseTrue) for mtime, path in recent_files: print(time.strftime(%Y-%m-%d %H:%M:%S, time.localtime(mtime)), path)注意这里我先把(mtime, path)组成元组存起来再排序这样列表末尾就是最老的开头就是最近的。如果你只关心“最近N个文件”可以排序后取前N条如果要输出报表就在循环里把时间格式化好再写入文件。还有一个细节如果你要排的是“目录建立时间”os.walk本身不提供目录的创建时间需要你自己去os.stat(root).st_ctimeWindows上st_ctime是创建时间Linux上则是inode变更时间含义不一样。跨平台脚本里别太依赖这个字段。6.3 清理所有空目录体验 topdownFalse 的威力有一次我接到一个需求清理一个下载目录里所有空文件夹。当时第一反应是os.walk配合os.rmdir但在实际操作中发现一个问题如果从上往下遍历你删掉一个子目录后父目录的逻辑状态其实已经变了可你的遍历结果还是旧的。更麻烦的是如果父目录里只有一个子目录子目录删完父目录就变空了但你从上往下遍历时早就把它当成非空目录跳过了最后残留下一堆“尸体”。解决方法是os.walk(topdownFalse)自底向上扫。这样扫到父目录时它的所有子目录都已经处理完它是否为空是当时当下的真实状态import os folder rD:\downloads removed 0 for root, dirs, files in os.walk(folder, topdownFalse): for d in dirs: dir_path os.path.join(root, d) try: os.rmdir(dir_path) # 只删空目录非空会抛OSError removed 1 print(f已删除空目录: {dir_path}) except OSError: # 目录非空跳过 pass这个例子也呼应了第二章提过的topdown参数。很多教程只会写“topdown控制遍历方向”但实际为什么需要自底向上的方向只有真去清空目录时才体会得出来。处理需要级联变更的场景时先想清楚依赖关系再决定遍历方向能少走很多弯路。写在最后这篇文章基本把我做目录遍历时用的所有招数都掏出来了。我实际项目里用得最多的还是os.walk因为它稳、快、上下文清晰手写递归则帮我解决过深度限制、自定义层级这种特殊需求pathlib.rglob在我写一次性统计脚本和工具类代码时特别顺手。如果你刚开始学我建议把三种方案都在自己的机器上跑一遍故意制造一个带软链接回环、带无权限子目录、带中文文件名的测试目录然后用三种方案去扫亲眼看看它们各自会出什么情况。这套流程走完你对“Python递归获取指定文件夹下的所有文件夹、文件”这个需求的理解就会远超只会复制粘贴一段代码的水平。