
说实话这个需求我见得太多了。无论是整理下载目录、批量处理照片、给项目里的一堆文件做归档还是扫描磁盘看看哪个文件夹偷偷吃掉了大部分空间第一步几乎都是同一件事用 Python 把某个文件夹下面所有文件和子文件夹都遍历出来。你随便一搜就能看到os.walk的 Hello World 版本但真到了生产环境或者大批量数据处理时什么权限报错、文件被占用、目录名是乱码、遍历到一半卡死……这些问题一个接一个。这篇我把遍历文件夹这件事从头到尾聊透从标准库的几种姿势到过滤筛选、实战脚本、以及我踩过的坑一次性讲清楚。1. 先看清菜遍历文件系统的几种主流姿势1.1 os.listdir最基础的一层先看最传统的方式。os.listdir做且只做一件事把指定目录下的所有文件名和子目录名列出来不递归。import os target ./my_folder entries os.listdir(target) for name in entries: full_path os.path.join(target, name) if os.path.isfile(full_path): print(f[文件] {name}) elif os.path.isdir(full_path): print(f[目录] {name})这段代码看起来很直观但有一个性能细节很容易被忽略os.listdir只返回名字列表判断每个条目是文件还是目录时必须通过os.path.isfile/os.path.isdir再次发起一次stat系统调用。也就是说目录里有 1000 个条目你在listdir拿到列表之后为了判断类型又额外做了 1000 次stat。单层目录用这个没问题但如果你要手工写一个递归遍历我建议直接跳到 1.3 用os.scandir性能会好很多。1.2 os.walk递归遍历的经典方案os.walk是 Python 递归遍历文件夹的标配关键点在于它的返回值格式每次迭代返回一个三元组(root, dirs, files)其中root是当前目录路径dirs是当前目录下的子目录名列表files是当前目录下的文件名列表不包含子目录里的。import os for root, dirs, files in os.walk(./project): for file in files: file_path os.path.join(root, file) print(file_path)如果你只是想看所有文件这段代码就够了。但os.walk还有很多容易被人忽视的高级玩法。topdown参数默认是True表示自顶向下遍历。此时你可以通过原地修改dirs列表来剪枝也就是跳过某些子目录。这里有个特别容易踩的坑必须用dirs[:] ...这种切片赋值不能直接写dirs ...。因为os.walk内部会读取这个列表来决定下一步进入哪些子目录直接赋值只改了一个局部变量引用压根不影响遍历逻辑。for root, dirs, files in os.walk(./project): # 跳过常见的重型目录避免遍历到深处 dirs[:] [d for d in dirs if d not in (.git, node_modules, __pycache__, dist)] for file in files: if file.endswith(.py): print(os.path.join(root, file))followlinks参数默认是False也就是说默认不会跟进符号链接指向的目录。这个默认值非常安全后面 4.3 节我会专门展开讲符号链接的坑。如果是只读遍历但有目录权限不足不用怕os.walk直接崩掉可以通过onerror参数接管异常def handle_error(err): print(f无法访问 {err.filename}: {err.strerror}) for root, dirs, files in os.walk(./, onerrorhandle_error): ...1.3 pathlib 和 os.scandir更现代的写法如果你是 Python 3.6 之后开始接触的我强烈推荐优先使用pathlib.Path。它把路径抽象成对象处理跨平台分隔符、拼接、后缀名提取都非常自然。from pathlib import Path base Path(./my_folder) # 只遍历一层 for p in base.iterdir(): if p.is_file(): print(p.name) # 递归所有文件 for p in base.rglob(*): if p.is_file(): print(p) # 递归匹配某类文件 for p in base.rglob(*.jpg): print(p)rglob(*)就是递归遍历所有条目rglob(*.jpg)等价于glob(**/*.jpg)。它的可读性比os.walk的字符串拼接好得多遇到中文路径、空格路径也不容易出错因为Path对象在内部做了正确的路径处理。再来看os.scandir。它返回的是DirEntry对象而不是纯字符串。关键优势在于entry.is_file()和entry.is_dir()在大多数情况下不会额外发起stat系统调用类型信息是在扫描目录时顺带拿到的。如果你要统计文件大小、修改时间再调用entry.stat()也比os.path.getmtime(os.path.join(root, name))这种写法少一次路径拼接开销。import os total 0 with os.scandir(./data) as entries: for entry in entries: if entry.is_file(): total entry.stat().st_size print(f总大小约 {total / 1024 / 1024:.2f} MB)顺带说一句用with包住os.scandir可以确保底层句柄正确释放尤其在高频调用场景下这是一层很实惠的保护。把这几种方式放在一起对比会更清楚方式是否递归性能特点易用性适合场景os.listdir否快但判断文件类型需额外 stat简单单层目录浏览os.walk是中等支持剪枝和异常接管经典直接全量递归遍历、需要跳过目录pathlib.Path.rglob是便捷但不易剪枝非常高语义清晰的递归匹配os.scandir否高类型信息随扫描获得中等偏上单层高频统计、手写递归我的个人习惯是日常写脚本优先pathlib代码短、不容易出跨平台问题底层一些的批量统计工具用os.scandir手写递归遇到需要剪枝、跳过node_modules这种场景就回到os.walk。没有银弹按场景选。2. 从“全量拿到”到“精确筛选”过滤与排序细节2.1 按扩展名和文件名特征筛选遍历只是第一步实际上大多数需求都是“我只要某类文件”。比如找图片、找文档、找日志。用Path.suffix判断扩展名是最直接的方式from pathlib import Path IMG_SUFFIX {.jpg, .jpeg, .png, .gif, .bmp, .webp} base Path(./pictures) for p in base.rglob(*): if p.is_file() and p.suffix.lower() in IMG_SUFFIX: print(p)这里有两个细节。第一个p.suffix返回的是带点的小写扩展名比如.JPG会原样返回大写所以统一用.lower()做大小写归一。第二个对于.tar.gz这种复合扩展名suffix只会返回最后的.gz如果非要匹配整个.tar.gz直接用str(p).endswith(.tar.gz)或者用p.suffixes拿到完整列表再判断。还有一种很常见的需求是排除特定目录。如果你用Path.rglob要提前有个心理准备rglob会一路深入不能像os.walk那样方便地剪枝。你当然可以在循环里continue跳过但目录本身已经被扫描到了浪费时间不止有些权限异常的深层目录还会报错。大批量场景要剪枝还是得回到os.walkimport os for root, dirs, files in os.walk(./project): dirs[:] [d for d in dirs if d not in (.git, node_modules, __pycache__)] for file in files: if file.endswith((.py, .md)): print(os.path.join(root, file))2.2 按大小、修改时间等元信息过滤很多时候只筛名字不够还要按文件大小、修改时间过滤。比如“找出所有超过 100MB 的文件”或者“找出最近 7 天改过的文件”这在磁盘清理、增量备份场景里是刚需。from pathlib import Path import time base Path(./data) size_limit 100 * 1024 * 1024 # 100MB recent_limit time.time() - 7 * 24 * 3600 # 7天前的时间戳 for p in base.rglob(*): if not p.is_file(): continue st p.stat() if st.st_size size_limit: print(f大文件 {st.st_size / 1024 / 1024:.1f} MB {p}) if st.st_mtime recent_limit: print(f新文件 {time.strftime(%Y-%m-%d, time.localtime(st.st_mtime))} {p})要提醒的是stat()是一次真实系统调用对每个文件反复调用是有成本的。如果在一个循环里既想判断大小、又想拿修改时间、还想拿 inode那就第一次stat()后把结果存成变量复用别在同一个文件上调用三次。另外st_mtime是 Unix 时间戳想转成年月日字符串用time.strftime或datetime.fromtimestamp都可以但我个人喜欢datetime.fromtimestamp(st.st_mtime).strftime(%Y-%m-%d)语义更直白。2.3 遍历顺序与被遗忘的排序问题这是一个非常隐蔽的坑os.walk和rglob的遍历顺序是不保证的。不同的操作系统、不同的文件系统NTFS、ext4、APFS返回顺序都可能不同甚至同一目录在不同时间遍历结果也可能不同。如果你的输出结果需要稳定复现比如生成文件清单、做 diff、写进文档里一定要显式排序。from pathlib import Path base Path(./data) files [p for p in base.rglob(*) if p.is_file()] # 按文件名排序 files.sort(keylambda p: p.name) # 按修改时间倒序最新的在前 files.sort(keylambda p: p.stat().st_mtime, reverseTrue) # 按文件大小倒序 files.sort(keylambda p: p.stat().st_size, reverseTrue)注意一个性能问题上面这种写法每按一种规则排序就会调用一次stat()。如果你要同时按多种规则排或者排序的文件特别多更好的做法是先一次性把元数据取出来构造(大小, 修改时间, 路径)这样的元组列表再统一排序避免重复系统调用。中文文件名排序也值得一提。默认sort是按 Unicode 码点排的也就是说中文基本不是按拼音排的。很多人在 Windows 上以为见了鬼明明a、b、c排好序了中文文件名却乱七八糟。如果项目里真有按拼音排序的需求要么装pypinyin处理要么接受默认的字典序别去纠结。大多数场景下按修改时间或文件大小排序更实用。3. 实战案例三个能直接抄作业的脚本3.1 案例一目录空间占用统计与 Top 大文件扫描这个脚本是最常用的磁盘分析工具。思路很简单遍历所有文件把每个文件的大小累加到它所在的父目录上最后排序输出。from pathlib import Path from collections import defaultdict def scan_dir(root: str, top_n: int 10): base Path(root) dir_size defaultdict(int) top_files [] for p in base.rglob(*): if not p.is_file(): continue st p.stat() dir_size[p.parent] st.st_size top_files.append((st.st_size, p)) # 输出占用最大的子目录 print(f 目录大小 Top{top_n} ) for dir_path, size in sorted(dir_size.items(), keylambda x: x[1], reverseTrue)[:top_n]: print(f{size / 1024 / 1024:10.1f} MB {dir_path}) # 输出最大的文件 print(f 文件大小 Top{top_n} ) for size, path in sorted(top_files, keylambda x: x[0], reverseTrue)[:top_n]: print(f{size / 1024 / 1024:10.1f} MB {path}) if __name__ __main__: scan_dir(., 10)这个脚本有两个可以继续优化的点。第一是内存如果文件数量极其庞大几百万个文件把top_files列表堆内存里不现实更稳妥的做法是用heapq.nlargest维护一个固定大小的堆。第二是速度如果只是统计目录大小其实很多文件系统的元数据可以被os.scandir更高效地拿到有兴趣可以自己改一版对比一下。不过对普通场景上面的版本已足够清晰易懂。3.2 案例二按扩展名自动归档文件下载文件夹是重灾区。照片、PDF、安装包、压缩文件混在一起手动整理又烦又容易出错。有了遍历能力这件事完全可以自动化。import shutil from pathlib import Path RULES { .jpg: 图片, .jpeg: 图片, .png: 图片, .gif: 图片, .pdf: 文档, .doc: 文档, .docx: 文档, .txt: 文档, .zip: 压缩包, .rar: 压缩包, .7z: 压缩包, .exe: 安装包, .msi: 安装包, } SOURCE Path(./downloads) DRY_RUN True # 先试运行确认无误后改成 False def safe_move(src: Path, dest_dir: Path): dest_dir.mkdir(parentsTrue, exist_okTrue) target dest_dir / src.name # 如果目标已存在自动加序号 i 1 while target.exists(): target dest_dir / f{src.stem}_{i}{src.suffix} i 1 if DRY_RUN: print(f[dry-run] {src} - {target}) else: shutil.move(str(src), str(target)) print(f[moved] {src} - {target}) for p in SOURCE.rglob(*): if p.is_file() and p.suffix.lower() in RULES: category RULES[p.suffix.lower()] dest_dir SOURCE / category safe_move(p, dest_dir)这里我特意保留了目录结构逻辑的比较深的一种所有文件都移动到了归类目录下面如果源目录下还有子目录子目录里的文件也会按同样的方式归并过去。你当然可以简化成只处理SOURCE根目录下的文件但用rglob的好处是子目录里的文件也会被找出来不会漏。实际操作中有三个点要特别注意。第一跨磁盘移动非常慢。如果你的SOURCE在 C 盘目标放在 D 盘shutil.move会先复制再删除源文件。处理大文件时要有个心理准备。第二文件被占用会报PermissionError。尤其是 Excel 文件正在被打开、图片正被看图软件占用时。稳妥做法是用try/except捕获异常把失败的文件记录下来最后统一打印。第三先跑 dry-run。我所有文件操作类脚本第一版都是只打印计划不实际执行确认规则无误后再去掉保护。这个习惯帮我无数次避开了“误删/误移”的惨案。3.3 案例三给文件批量添加日期前缀拍照导出、聊天记录导出、下载文件很多文件名完全没有时间信息。用修改时间作为前缀把文件重命名是整理个人文件的一个非常实用的套路。import os import sys import time from pathlib import Path def rename_with_date(root: str, dry_run: bool True): base Path(root) for entry in os.scandir(base): if not entry.is_file(): continue st entry.stat() date_str time.strftime(%Y%m%d, time.localtime(st.st_mtime)) new_name f{date_str}_{entry.name} src Path(entry.path) dst src.with_name(new_name) if src dst: continue if dry_run: print(f[dry-run] {src.name} - {dst.name}) else: try: src.rename(dst) print(f[ok] {src.name} - {dst.name}) except OSError as e: print(f[错误] {src.name}: {e}) if __name__ __main__: base_dir sys.argv[1] if len(sys.argv) 1 else . rename_with_date(base_dir, dry_runTrue)这里用os.scandir而不是Path.iterdir()是有原因的在循环里需要对每个文件调用stat()拿修改时间os.scandir的DirEntry.stat()会复用扫描时已经拿到的元数据效率高一些。当然文件数量只有几十个时感觉不出来量大了就有差别。一个小细节Path.rename在 Windows 上如果目标文件已经存在会直接报FileExistsError在 Linux/macOS 上则会静默覆盖。所以我的脚本里先加了个if src dst的判断防止“已经带日期前缀的文件被反复加前缀”。4. 别踩坑权限、编码、符号链接与性能4.1 PermissionError 与不可遍历目录遍历一个大目录尤其是系统盘或者别人机器上的目录时遇到没权限访问的子目录是非常正常的。默认情况下os.walk碰到无法访问的目录会把异常传递给onerror回调如果你没设置它会忽略这个目录继续往下走不会直接崩溃。但如果你是用rglob或者手写递归一个PermissionError就能让整个脚本当场倒下。手写递归时最基本的防护长这样from pathlib import Path import os def safe_walk(path: Path): try: children list(path.iterdir()) except PermissionError: print(f[跳过无权限目录] {path}) return for child in children: if child.is_dir(): yield from safe_walk(child) else: yield child在 Windows 上除了权限问题还有一些目录因为文件被占用、系统保留等原因会抛OSError系列异常。我的建议是遍历这种不可控目录时统一捕获OSError而不是单独抓PermissionError覆盖面更全。你可以在日志里记录失败路径但不要让整个任务因为个别目录失败而中断。4.2 文件名编码与路径拼接的坑路径拼接是新手最容易犯的低级错误。很多人习惯这样干# 反面教材 path root / name这种写法在 Windows 上会混入反斜杠和正斜杠看起来没坏但遇到路径里有奇怪空格或特殊字符时就容易出问题。正确处理是path os.path.join(root, name) # 传统写法 path Path(root) / name # pathlib 写法第二个坑和编码有关。在 Linux 下文件名本质上是一串字节不一定合法 UTF-8。比如你从 Windows 拷贝过来的文件在解压时名字乱码Python 3 内部用surrogateescape处理这种字节序列文件本身可以正常访问但打印到控制台或写入数据库可能报UnicodeEncodeError。Windows 上则是另一个方向的坑Python 3 默认输出 UTF-8但 Windows 控制台默认编码往往是 GBK遇到中文路径直接print就会炸。解决办法很简单在脚本开头加一行import sys sys.stdout.reconfigure(encodingutf-8, errorsreplace)errorsreplace保证了哪怕遇到完全无法打印的字符也不会让脚本崩溃而是替换成占位符。如果你不想改全局配置也可以逐个字符串encode(utf-8, replace)后再打印。4.3 符号链接与递归循环风险符号链接Windows 上的快捷方式、目录联接Linux 上的 symlink是遍历时一个非常隐蔽的炸弹。最常见的问题一个软链接指向它的上级目录如果你的遍历逻辑会顺着目录走下去就会在交叉引用里无限循环直到RecursionError或者系统资源耗尽。os.walk默认followlinksFalse所以安全。但当你自己手写递归或者用某些第三方库时就要自己把关了。核心判断是判断目录时不要跟随符号链接。# 安全不会跟进符号链接目录 entry.is_dir(follow_symlinksFalse) # 危险如果 entry 是软链接且指向目录会返回 True entry.is_dir()清理目录时也要注意。shutil.rmtree默认不会跟随符号链接删除目标但某些自己实现的递归删除逻辑遇到符号链接“目录”会试图进到里面去删结果删的是链接指向的真实文件。这不仅危险而且很让人摸不着头脑。处理这类目录时先判断entry.is_symlink()是链接就直接删链接本身不要往里进。4.4 大目录下性能对比与优化前面提过os.scandir比os.listdir高效但在超大目录十万、百万级文件下真正的瓶颈往往不只是遍历而是每个文件都要stat()和open()。这里有三个优化方向。第一个方向减少重复 stat。用os.scandir拿到的DirEntry可以复用stat()结果Path.stat()没有缓存用pathlib遍历时能少调就少调。第二个方向用生成器而非列表。一次把所有文件路径放进列表里文件少无所谓文件多内存压力很大。改成生成器逐个产出配合for循环消费内存占用会平稳很多。from pathlib import Path def iter_files(base: Path): for p in base.rglob(*): if p.is_file(): yield p for file in iter_files(Path(./data)): process(file) # 逐个处理第三个方向用线程池处理 IO 密集任务。假如你要给 10 万个文件算 MD5、读取头部信息或者做图片压缩单线程一个个来太慢了。用concurrent.futures.ThreadPoolExecutor可以轻松把多文件 IO 并行化from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path import hashlib def md5_of(path: Path): h hashlib.md5() with open(path, rb) as f: for chunk in iter(lambda: f.read(65536), b): h.update(chunk) return h.hexdigest(), path base Path(./data) files [p for p in base.rglob(*) if p.is_file()] with ThreadPoolExecutor(max_workers8) as pool: futures [pool.submit(md5_of, p) for p in files] for future in as_completed(futures): digest, path future.result() print(digest, path)注意一点线程池适合 IO 密集任务比如读文件、网络请求。如果任务是纯 CPU 计算比如对大量图片做像素级处理因为 Python GIL 的存在多线程提升有限那得考虑multiprocessing。文件 IO 场景里8 个线程通常就有非常明显的提速但线程数也不用盲目拉高太多反而会因为磁盘寻道和句柄开销变慢。5. 高频问题速查与快捷片段5.1 高频问题速查表我把这些年遇到的遍历相关问题整理成一个速查表遇到问题先对号入座现象常见原因处理方法遍历到某个目录报 PermissionError系统目录或无权限目录用os.walk(onerror...)或手动捕获 OSErrorprint 中文路径报 UnicodeEncodeErrorWindows 控制台默认 GBK 编码sys.stdout.reconfigure(encodingutf-8)rglob 把 node_modules 全部遍历了rglob 不支持剪枝改用os.walk配合dirs[:] ...文件数量大时遍历很慢listdir 重复 stat改用os.scandir复用entry.stat()Windows rename 到已存在文件报错Windows 不做静默覆盖先dst.exists()判断再重命名路径里出现//或\\混乱手工拼接路径用Path /或os.path.join移动/删除文件报 PermissionError文件被占用关闭占用程序try/except 记录失败项遍历结果顺序每次不一样os.walk 不保证顺序收集后主动sort()遍历时进入死循环符号链接指向上级目录递归判断用follow_symlinksFalse5.2 几个多年攒下的快捷片段最后一节分享几个我日常常用的成品片段都是短小但很能救急的东西。找空文件夹。清理目录树时特别有用from pathlib import Path base Path(.) for p in base.rglob(*): if p.is_dir() and not any(p.iterdir()): print(f空目录: {p})删除项目里所有__pycache__缓存目录import shutil from pathlib import Path for p in Path(.).rglob(__pycache__): if p.is_dir(): print(f删除 {p}) # 确认无误后取消下一行注释 # shutil.rmtree(p, ignore_errorsTrue)按修改时间倒序列出最近修改的 20 个文件from pathlib import Path import datetime base Path(./data) files [(p.stat().st_mtime, p) for p in base.rglob(*) if p.is_file()] files.sort(reverseTrue) for mtime, p in files[:20]: dt datetime.datetime.fromtimestamp(mtime).strftime(%Y-%m-%d %H:%M:%S) print(f{dt} {p})复制目录结构但只保留指定类型文件import shutil from pathlib import Path src Path(./src) dst Path(./dist) exts {.py, .md} for p in src.rglob(*): if p.is_file() and p.suffix in exts: rel p.relative_to(src) target dst / rel target.parent.mkdir(parentsTrue, exist_okTrue) shutil.copy2(p, target)这些片段看着简单实际上每一段都是我踩过坑之后沉淀下来的。比如复制目录结构那段如果没有target.parent.mkdir(parentsTrue, exist_okTrue)第二层子目录一创建就报FileNotFoundError如果不用copy2而用copy文件的修改时间就丢了备份时根本分不清新旧。写了这么多如果只能记住一句话那就是先明确需求再选方案。只是几十个文件随便用哪种方式都行处理上百万文件的仓库选对遍历方式、注意异常处理和内存占用比什么都重要。另外所有涉及“移动、重命名、删除”的脚本第一版都加上 dry-run 参数先把操作清单打出来看一遍再动手。这个习惯帮我少删了不少不该删的文件也推荐你试试。