先说我为什么会写这篇东西。上个月给一个旧项目做资产盘点,目录嵌套了七八层,里面混着源码、第三方依赖、日志、临时文件、还有几个指向外部目录的软链接。我需要把所有子文件夹和文件路径完整拉出来,再统计体积、按修改时间排序看看哪些文件已经大半年没动。这个需求翻译过来就是标题这句——“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.listdir+os.path.isdir明显更快。
我的建议是,先按下面这张表对号入座,再决定用哪套方案。
| 使用场景 | 推荐方案 | 主要原因 |
|---|---|---|
| 快速列出目录和文件,不做复杂控制 | os.walk | 自带递归,流程最稳 |
| 需要动态跳过子目录(比如过滤缓存目录) | os.walk(topdown=True) | 可以在遍历时原地修改dirs列表 |
| 限定递归深度、自定义顺序、特殊错误处理 | 手写递归 +os.scandir | 每一层都自己控制,但代码量增加 |
| 代码库长期维护,希望路径操作更面向对象 | pathlib.Path.rglob | API简洁,Path对象方便拼接和转换 |
只匹配特定扩展名(如所有.py) | pathlib.Path.rglob("*.py") | 一个通配符搞定,省掉循环判断 |
有一类典型反面教材是:为了“显得很懂”,一上来就自己写递归,结果没有处理符号链接,目录里有个链接指向上级目录,程序跑着跑着就没完没了。用os.walk默认就不会有这个烦恼。所以我说,选对工具,比亲手实现工具更重要。这篇文章后面所有的经验教训,其实都是在这张表的基础上展开的。
2. os.walk的正确姿势:三个返回值、topdown裁剪与两个冷门参数
os.walk是我在Windows和Linux服务器上用得最多的方案,没有之一。原因是它足够稳,而且把“递归”这件事完全封装好了,你要做的只是一层循环。
2.1 最基本的配方
最基础的结构长这样:
import os folder = r"D:\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 topdown=True才是精髓
os.walk默认topdown=True,也就是从上往下遍历:先看到根目录,再看到第一层子目录,再往下钻。
topdown=True有个隐藏能力——你可以在循环体里直接修改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内部持有的还是原来的对象,过滤完全无效。我见过不止一个人在这行代码上折腾了一下午。
如果topdown=False,os.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, onerror=my_handler): ...注意,回调函数收到的是OSError实例,不是字符串。你可以从exc.filename里看到具体是哪个路径打不开。这个参数在实际生产环境里很重要,因为“静默跳过”和“明确跳过但我知道跳过了哪个”是完全两回事。
再讲followlinks。默认是False,也就是不跟随符号链接。这个设计非常正确。如果设置成True,一旦目录里有软链接指向上级的某个文件夹,os.walk会顺着链接钻进去,然后从那个文件夹里再发现另一个软链接,循环往复,程序就可能失控。我的建议是,除非你明确在写一个要追踪所有链接文件的小工具,否则永远保持False。
2.4 一个能直接用的汇总例子
把上面几个点合在一起,写一个“统计目录下各扩展名的总大小”的小工具,其实非常短:
import os from collections import defaultdict folder = r"D:\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(), key=lambda x: x[1], reverse=True)[: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_symlinks=False): 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万文件的目录,用listdir+isdir要比scandir慢20%到30%。平时小目录没感觉,文件量一大差距立现。另外entry.is_dir(follow_symlinks=False)这行也顺手解决了符号链接问题——它明确告诉Python,即使这个条目是个软链接,也不要因为它指向文件夹而把它当成目录。
3.4 为什么还是要手写递归
看到这里你可能会问:既然os.walk那么省事,手写递归的意义在哪?
意义在于,当你需要“控制”时,os.walk封装的接口反而碍事。比如我接过一个需求:只扫描指定目录下三层以内的结构,而且进入第三层时要把那一层的目录名记录到另一个清单里。os.walk只给一个整体的dirs列表,你要做深度判断就得自己维护层级关系,反而别扭。手写递归时,深度参数自然就能传进去:
import os def walk_with_depth(path, max_depth=3, current_depth=0): if current_depth > max_depth: return for entry in os.scandir(path): if entry.is_dir(follow_symlinks=False): 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(r"D:\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_symlinks=False): 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_symlinks=False),还是先用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(encoding="utf-8")Python 3.7以上支持reconfigure,低版本可以用sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-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(r"D:\logs").rglob("*.log"))如果你要的是更复杂的过滤,比如“找出所有大于100MB的视频文件”,用os.walk配合条件判断也很轻松:
import os folder = r"E:\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 = r"D:\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(reverse=True) 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_ctime,Windows上st_ctime是创建时间,Linux上则是inode变更时间,含义不一样。跨平台脚本里别太依赖这个字段。
6.3 清理所有空目录,体验 topdown=False 的威力
有一次我接到一个需求:清理一个下载目录里所有空文件夹。当时第一反应是os.walk配合os.rmdir,但在实际操作中发现一个问题:如果从上往下遍历,你删掉一个子目录后,父目录的逻辑状态其实已经变了,可你的遍历结果还是旧的。更麻烦的是,如果父目录里只有一个子目录,子目录删完父目录就变空了,但你从上往下遍历时早就把它当成非空目录跳过了,最后残留下一堆“尸体”。
解决方法是os.walk(topdown=False),自底向上扫。这样扫到父目录时,它的所有子目录都已经处理完,它是否为空是当时当下的真实状态:
import os folder = r"D:\downloads" removed = 0 for root, dirs, files in os.walk(folder, topdown=False): 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递归获取指定文件夹下的所有文件夹、文件”这个需求的理解,就会远超只会复制粘贴一段代码的水平。