处理文件和目录的脚本,几乎绕不开一个需求:把指定文件夹下的所有文件夹和文件都列出来,最好还是递归的。前阵子帮团队做一个归档工具,第一步就是要把一个几千层嵌套的素材库完整扫一遍,输出层级结构。看似简单的需求,写起来踩的坑倒是不少——递归深度、权限报错、排序不一致、路径分隔符,哪一个不注意都能让你线上脚本半夜报警。所以这篇就把我用过的几种做法整理出来,从最稳妥的到最灵活的,连同参数细节和绕坑经验一起说透。
1. 先把需求拆明白:递归遍历的三种典型痛点
1.1 别急着写函数,先想清楚你的目录长什么样
很多人一上来就写os.listdir加循环,结果跑几天之后发现某些目录没扫全,或者扫出来的结果顺序乱糟糟的,排查半天才明白是递归逻辑有疏漏。我在实际项目里发现,第一步应该问自己三个问题:
- 你拿到的是相对路径还是绝对路径?
- 目录里有没有符号链接、隐藏文件夹、临时文件?
- 扫描结果是要立刻全量加载进内存,还是只统计数量、只列出部分层级?
这三个问题直接决定你用os.walk还是自己写递归,也决定后续要不要加过滤规则。如果只是把目录结构完整拿下来存成清单,用os.walk最省心,它天生就是为这种需求设计的;如果是做同步工具、备份工具,需要在遍历过程中控制顺序、跳过某些目录,那可能得自己维护一个递归函数,处理逻辑更透明。
1.2 三种常见需求形态决定了你的实现方式
我归纳下来,大多数项目无非这三种形态:
- 全量拉取:拿到所有文件夹路径和所有文件路径,不关心层级关系,只关心“有哪些”,后续丢给下一步处理。
- 深度受限:只需要往下展开两层、三层,超过就停,避免海量底层文件拖垮内存。
- 规则过滤:只保留特定后缀名、排除某些目录名、忽略隐藏文件,遍历的同时完成筛选。
有意思的地方在于,这三种形态用同一套代码其实都能覆盖,只是写法重点不一样。全量拉取最简单;深度受限要在记录函数时多加一个参数;规则过滤则要考虑过滤时机——递归进入子目录之前先判断,能省下大量无意义扫描,这个细节性能差距很大,后面我会用代码具体说明。
2. 首选方案:os.walk 的正确用法与三个细节
2.1 为什么第一推荐就用 os.walk
os.walk是 Python 标准库里专门用来遍历目录树的函数,它内部已经处理好递归逻辑,你不需要自己写“进入子目录”的代码,只需要关注每一层返回什么。这个函数每次遍历到一个目录,会返回三个值:当前目录路径、当前目录下的子目录名列表、当前目录下的文件名列表。注意,它默认是自顶向下遍历的,也就是说先访问根目录,再访问根目录的每个子目录,依次往下。
我一开始也觉得这有什么难的,直到我意识到os.walk有一个容易被忽略的特性:子目录列表和文件列表里存的全是名字,不是完整路径。你需要用os.path.join把当前目录路径和名字拼起来才是完整的可操作路径。很多人第一次用就卡在这,打印出来的全是文件名,找不到完整路径,以为是 bug,其实只是少了拼接这一步。
2.2 完整代码:递归获取所有文件夹和文件的三种写法
下面这份代码可以直接跑,输出格式是分别列出文件夹清单和文件清单:
import os def list_all_items(root_dir): folders = [] files = [] for current_dir, sub_dirs, file_names in os.walk(root_dir): # 把子目录名拼成完整路径 for sd in sub_dirs: folders.append(os.path.join(current_dir, sd)) # 把文件名拼成完整路径 for fn in file_names: files.append(os.path.join(current_dir, fn)) return folders, files if __name__ == "__main__": root = "./data" folders, files = list_all_items(root) print("文件夹数量:", len(folders)) print("文件数量:", len(files)) for f in folders[:10]: print("[目录]", f) for f in files[:10]: print("[文件]", f)如果你不想要完整路径,只要文件名或者相对路径,可以改成用os.path.relpath:
for fn in file_names: rel = os.path.relpath(os.path.join(current_dir, fn), root) files.append(rel)relpath的好处是后续如果要打印树形结构,不用再去掉公共前缀,代码逻辑会清晰不少。
2.3 隐藏目录、指定后缀与空目录的过滤技巧
实际项目中,素材库经常有一堆.git、__pycache__、.DS_Store这类杂碎。os.walk有一个参数topdown=True时,你可以直接原地修改sub_dirs列表,把不想遍历的目录移除,后续就不会递归进去,这个机制比“先遍历完再过滤”高效得多。看例子:
import os def list_filtered(root_dir, skip_dirs=None, valid_exts=None): if skip_dirs is None: skip_dirs = {".git", "__pycache__", ".idea", "node_modules"} if valid_exts is None: valid_exts = (".py", ".md", ".txt") result_folders = [] result_files = [] for current_dir, sub_dirs, file_names in os.walk(root_dir): # 原地过滤:直接在遍历前移除跳过目录 sub_dirs[:] = [d for d in sub_dirs if d not in skip_dirs and not d.startswith(".")] for sd in sub_dirs: result_folders.append(os.path.join(current_dir, sd)) for fn in file_names: if fn.endswith(valid_exts): result_files.append(os.path.join(current_dir, fn)) return result_folders, result_files这里有个关键点:sub_dirs[:] = ...这种写法是原地修改原列表,os.walk在内部会检查这个列表的内容来决定遍历哪些子目录。如果你直接写sub_dirs = ...重新赋值,等于把“告诉 walk 往哪走”的引用断了,过滤就不生效。这个细节我见过太多人写错,过滤没效果还以为代码其他部分有问题。
另外,valid_exts用元组而不是列表,是因为str.endswith接收的可以是元组,性能也略微好一点。
2.4 顺序问题:强制排序,否则输出随机
os.walk返回的目录顺序依赖文件系统自己返回的顺序,这个顺序在不同操作系统上表现不一样。Windows 上通常是按名称排的,Linux 上则不一定,取决于目录项在磁盘上的排列方式。如果你要拿清单去比对文件,或者生成给用户看的树形图,一定要先排序。
最稳妥的办法是在遍历前对sub_dirs和file_names都做一次sorted,保证每次执行结果一致:
for current_dir, sub_dirs, file_names in os.walk(root_dir): sub_dirs[:] = sorted(sub_dirs) file_names = sorted(file_names)如果你希望目录优先显示在文件前,甚至可以把sub_dirs做大小写不敏感排序,或者按创建时间排序。只要记住,任何需要稳定输出的场景,别指望遍历天然有序。
3. 严谨派的选择:自己写递归函数
3.1 递归三步法:终止条件、进入子目录、收集结果
用os.walk虽然简单,但有时候我们确实需要手写递归,比如你不想引入文件系统层面的“目录优先还是文件优先”的困惑,想完全控制遍历过程;又或者在递归过程中需要维护一些上下文状态,比如当前层级。手写递归我总结为三步:
- 终止条件:判断路径是否为文件,或者是否不再存在,直接返回。
- 遍历当前目录:用
os.listdir或者pathlib获取子项。 - 对于每个子目录,递归调用自身,把结果汇总到外部容器。
这里终止条件其实是“不能把文件当成目录再进一层”,所以要在函数门口判断一次os.path.isdir。最经典的错误就是写到最后忘了isdir判断,然后递归进入了一个文件路径,报NotADirectoryError。
import os def scan_recursive(current_path, folders=None, files=None, depth=0): if folders is None: folders = [] if files is None: files = [] # 终止条件:路径不存在或不是目录则直接返回 if not os.path.exists(current_path) or not os.path.isdir(current_path): return folders, files for item in os.listdir(current_path): full = os.path.join(current_path, item) if os.path.isdir(full): folders.append(full) # 递归进入子目录 scan_recursive(full, folders, files, depth + 1) else: files.append(full) return folders, files这里用folders=None这种传参技巧,是为了避免多个调用共享同一个可变默认参数。如果你写成def scan_recursive(current_path, folders=[], files=[]),第二次调用这个函数时,folders会保留上一次调用的数据,结果越加越多,这是个特别隐蔽的坑,见过有人排查半天最后发现是函数默认参数的问题。
3.2 深度限制:给递归加一个 max_depth 参数
需要限制扫描层数时,思路很简单:在进入下一层之前判断当前depth是否已达上限。我一般会加一个哨兵判断,depth >= max_depth就直接返回不再递归:
def scan_with_depth(current_path, folders=None, files=None, depth=0, max_depth=2): if folders is None: folders = [] if files is None: files = [] if not os.path.exists(current_path) or not os.path.isdir(current_path): return folders, files if depth > max_depth: return folders, files for item in os.listdir(current_path): full = os.path.join(current_path, item) if os.path.isdir(full): folders.append(full) scan_with_depth(full, folders, files, depth + 1, max_depth) else: files.append(full) return folders, files调用时传max_depth=2,就只会遍历到根目录下第二层子目录为止。这个参数在业务里很常用,比如权限系统只允许用户看到自己目录下一层级的子文件夹,多一层都不行。
3.3 内存隐患:海量文件下别用全局列表存所有结果
手写递归有个性能隐患:当一个目录下有几十万个文件时,把明细全部拼成路径字符串存进列表,内存占用会非常夸张。我之前处理过一个素材库,单层就有四五万张图片,没加过滤就全量收集,程序直接吃掉了 1GB 多内存,最后触发 OOM。
正确思路是用生成器yield,边遍历边产出结果,避免一次性全部落内存:
import os def iter_files(root_dir): for current_dir, sub_dirs, file_names in os.walk(root_dir): yield current_dir for fn in file_names: yield os.path.join(current_dir, fn)外层调用时只需for path in iter_files(root_dir),按需消费。如果你需要同时统计数量和收集部分样本,也建议在生成器外部维护一个计数器,而不是把完整列表留在内存里。
3.4 递归调用顺序:目录优先还是文件优先
手写递归另一个可控点就是你自己的收集顺序。os.walk默认是每到一个目录,先把当前目录下的文件都扫完,再去子目录,配合topdown=True是标准的“广度优先思想”,但手写递归天然是“深度优先”的——先进第一个子目录,一路深入到底,再返回上一层看第二个子目录。
如果你的输出格式要求“层级感强”,比如缩进展示树状结构,深度优先会更自然,因为它一次性打印出了整条链路的缩进关系。如果只是列清单,两种方式区别不大,但要注意不同方案打印结果的前后顺序可能完全不一样,这常常成为测试用例断言差异的来源。
4. 现代写法的优先级:pathlib 的迭代器思维
4.1 pathlib 比 os.path 更合适吗?
Python 3.4 之后推荐的路径操作方式是pathlib,它把路径封装成了对象,很多字符串拼接的活儿它内部替你做了。递归遍历方面,pathlib.Path.rglob和glob模式非常顺手,代码写起来比os.walk干净不少。
比如想拿所有.py文件,一行就能解决:
from pathlib import Path root = Path("./project") py_files = list(root.rglob("*.py"))这里rglob是“递归 glob”的缩写,匹配模式下可以跨越任意层级目录。它适合规则明确、模式简单的场景。
4.2 但 rglob 有一个隐藏的坑
用rglob需要特别小心:如果你写成root.rglob("*"),它会匹配所有文件、所有目录以及隐藏目录。在某些场景,这可能不是你想要的结果。另外rglob遇到无权限访问的子目录会直接抛PermissionError,而os.walk默认会忽略这一点,继续跑其他目录。如果你期望脚本具备极强的容错性,这一步差异就是选择方案的关键依据。
具体增强容错可以这样包一层:
from pathlib import Path def safe_rglob(root_path, pattern="*"): root = Path(root_path) try: yield from root.rglob(pattern) except PermissionError: return但注意,这个写法只能捕获最表层异常,嵌套深处遇到权限错误还是可能抛。想彻底容错还是得回到os.walk,用onerror参数接住错误。
4.3 用 Path.iterdir 自己实现轻量递归
如果不想引入rglob的匹配语义,又想用面向对象的写法,可以考虑Path.iterdir手动递归。iterdir返回的是当前目录下所有子项的Path对象,配合is_dir()和suffix属性,递归逻辑非常直观:
from pathlib import Path def scan_pathlib(current: Path, folders=None, files=None, max_depth=3, depth=0): if folders is None: folders = [] if files is None: files = [] if depth > max_depth: return folders, files for child in current.iterdir(): if child.is_dir(): folders.append(str(child)) scan_pathlib(child, folders, files, max_depth, depth + 1) else: files.append(str(child)) return folders, files这里直接用str(child)转成普通字符串路径。pathlib好处是操作对称,你不用关心当前是 Windows 还是 Linux,它内部会按系统自动选择路径分隔符。
4.4 三种方案在真实项目里怎么选
我自己长期维护的一个习惯是:
- 如果是快速写脚本,一次性跑完就扔,优先
os.walk; - 如果代码要长期维护、可能跨平台运行,优先
pathlib; - 如果有复杂的过滤条件,或者要动态控制遍历对象,手写递归更透明。
三者并不互斥,同一份代码里也经常混用。我很多函数是os.walk拿路径,转成Path对象再做后缀匹配,再也不用管endswith的大小写问题。
5. 实操实录:扫完目录后还能做什么
5.1 统计文件夹和文件数量时别忘把根目录扣除
很多人在用os.walk统计文件夹数量的时候,会把根目录也当成一个文件夹算进去。比如扫描./data,os.walk第一次返回的current_dir就是./data,这时候如果你一开始就folders.append(current_dir),统计出来的文件夹数量会比真实子目录数量多 1。
排查这个问题最简单的方式是打印第一轮结果对比一下。我建议的做法是,只在sub_dirs循环内部收集,不要单独去加current_dir——除非你的业务确实需要把根目录也当成一个条目输出。
5.2 拼接完整路径时,用 os.path.join 还是字符串直接加
有人图省事,用current_dir + "/" + name拼路径,这在 Windows 上就出问题了,因为 Windows 路径分隔符是反斜杠。虽然 Python 在很多场景下能兼容正斜杠,但跟外部程序交互时,反斜杠才是 Windows 的默认规范。os.path.join就是专门解决这个问题的,它在不同平台上自动用正确的分隔符拼接。
如果你想统一输出为 Unix 风格路径,可以拿到完整路径后调用path.replace(os.sep, "/"),或者在pathlib语境里直接使用/运算符构造路径:
from pathlib import Path root = Path("./data") full = root / "subdir" / "file.txt"这种方式写起来舒服,顶层就是“把Path当路径积木一样拼”。
5.3 遍历的同时计算总大小:注意 block 与 st_size
扫描目录顺便统计总大小,也是一个高频需求。用os.path.getsize就能拿到字节数,但要注意它只计算文件 payload 大小,不包含文件系统块分配的大小。大批量小文件场景下,磁盘实际占用会远高于getsize统计的结果,因为每个文件至少占一个扇区。
统计脚本示例:
import os def dir_size(root_dir): total = 0 file_count = 0 for current_dir, sub_dirs, file_names in os.walk(root_dir): for fn in file_names: fp = os.path.join(current_dir, fn) if os.path.exists(fp): total += os.path.getsize(fp) file_count += 1 return total, file_count如果你要拿这个结果跟du -sh比较,数字不一样是完全正常的,不用怀疑代码写错了,只是统计口径不同。
5.4 输出树形结构:缩进拼接的艺术
拿到全量路径后,转成树形结构打印,是很多管理端控制台的需求。逻辑就是对每个路径计算它相对于根目录的层级,然后乘以两个空格作为缩进:
import os def print_tree(root_dir, root_depth=0): for current_dir, sub_dirs, file_names in os.walk(root_dir): level = current_dir.replace(root_dir, "").count(os.sep) indent = " " * (level + root_depth) print(f"{indent}[目录] {os.path.basename(current_dir)}") sub_indent = " " * (level + root_depth + 1) for fn in file_names: print(f"{sub_indent}{fn}")这里层级计算用的是路径字符串中路径分隔符的个数,简单有效。但要注意,如果路径本身带空格或特殊字符,在 CSV 输出时必须转义,否则下游解析会错位。
5.5 并发遍历能提速吗
当目录数量极大并且分散在多个磁盘分区时,可以考虑用线程池并发遍历根目录下的不同子目录。但并发处理同一目录树的递归很容易出问题,会遇到重复遍历和竞争写列表。我的建议是先用单线程跑通逻辑,确证结果无误之后,再把并行处理放在“根目录的第一层多个 sub_dirs”粒度,各自返回独立的列表再合并。这样既享受并发收益,又避免同一目录下的深度依赖。
6. 常见问题与排查技巧实录
6.1 递归深度超限:RecursionError 详解
手写递归时,如果目录层级特别深(比如超过一千层),Python 默认递归深度上限sys.setrecursionlimit(1000)会被触发,程序直接抛RecursionError。
实际项目中真遇到过极深的目录结构,当时是第三方客户端解压了一个异常嵌套的包,生生叠了八百多层。解决办法有两种,一种是在程序开头调大限制:
import sys sys.setrecursionlimit(5000)另一种更稳妥:把递归改成显式栈循环。用列表模拟后进先出的遍历逻辑,完全不受递归深度限制:
def scan_iterative(root_dir): folders = [] files = [] stack = [root_dir] while stack: current = stack.pop() try: entries = os.listdir(current) except PermissionError: continue for entry in entries: full = os.path.join(current, entry) if os.path.isdir(full): folders.append(full) stack.append(full) else: files.append(full) return folders, files这种栈式写法在目录结构未知深度的生产环境里更安全,我后来做数据清洗工具时都是直接上这版,不再依赖递归深度配置。
6.2 权限不足导致的 PermissionError
os.walk在遇到没有访问权限的目录时会默认忽略,但手写递归和pathlib.rglob则可能直接抛异常中断。排查权限问题时,先用os.access(path, os.R_OK)判断可读性,再做后续遍历。若确实需要跳过,放在try/except里记录日志,别让整个任务因为个别异常目录崩掉。
6.3 千万别在遍历的同时直接删除目录
有次我写清理脚本,想着递归遍历完顺手把目录删了,结果同一个os.walk遍历还没结束,底下的目录已经被删掉,后续回调直接抛错。正确思路是:第一遍遍历只收集路径,第二遍再按需删除。如果一定想边遍历边删,可以考虑先收集所有叶级文件,再从深层向浅层删除,但也建议用topdown=False,让os.walk先从底层开始返回,这样删除操作不会干扰还未遍历的目录。
6.4 编码问题:打印带中文路径时别忽略控制台编码
Windows 控制台默认编码可能不是 UTF-8,含中文文件名打印时容易UnicodeEncodeError。脚本开头加一句:
import sys sys.stdout.reconfigure(encoding="utf-8")或者在输出到文件时,统一用encoding="utf-8"打开,避免落盘时乱码。这个问题在 macOS 和 Linux 上很少遇到,但在 Windows 运维环境里是高频踩坑点。
6.5 符号链接会导致目录重复遍历
如果目录里存在符号链接指向上级目录,递归遍历可能陷入死循环。os.walk默认并不跟踪目录符号链接,但手写递归里用os.path.isdir判断时,符号链接的目录会被当做真实目录处理。想彻底避免,就加一道判断:
if os.path.isdir(full) and not os.path.islink(full): # 只递归进入真实目录这一步很重要,尤其当你的项目会放在网盘同步目录里,网盘客户端很可能生成三种以上不同类型的链接文件。
结尾
前前后后在多个脚本里反复踩这些坑之后,我现在写递归遍历的第一步一定是先确认目录结构里有没有隐藏目录、符号链接和权限限制,然后选os.walk还是手写递归。os.walk灵活又安全,适合 90% 的常规场景;手写递归能提供完全可控的遍历顺序和深度参数,不出错但要多写几层防御逻辑。最后再分享一个小技巧:在正式跑批量任务之前,先用一个只有几个文件的临时目录做冒烟测试,把路径拼接、过滤规则、输出格式都验证一遍,能省下大量的返工时间。如果你有整理归档脚本或同步工具的需求,这份经验可以直接复用,结合自己的目录特点调整过滤规则就行。