ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Python文件读写全攻略:open()参数、编码与异常处理一次讲透

Python文件读写全攻略:open()参数、编码与异常处理一次讲透 做Python开发这些年文件读写大概是open()被我骂得最多、又用得最勤的基础功能了。不少同学学到with open(...) as f就觉得自己会了结果一到真实项目里中文乱码、文件被占用、日志越写越大、读几个G的文件把内存打爆全是这些“基础”引出来的事故。这篇教程我不打算照念文档而是以实际开发中踩过的坑为线索把文本模式与二进制模式、open参数、读写的各种姿势、编码问题、异常处理这些内容一次讲透。适合刚学完Python基础语法、准备接触真实文件操作的人也适合写了一段时间但老在各种边缘case上翻车的同学回来补课。1. 在动手之前先弄明白Python里的“文件”到底是个什么东西1.1 文本模式与二进制模式一个是str一个是bytes别混着来很多人学文件读写时第一个被绕晕的概念就是“文本模式”和“二进制模式”。你调用open()的时候默认的mode是r也就是只读文本模式。这个r背后其实还藏了一个t全称是rt意思是“以文本模式读取”。文本模式到底做了什么事一句话总结它在帮你做编码和解码。磁盘上存储的永远是二进制的字节0和1文本模式打开文件后Python会把这些字节按照某种编码规则翻译成字符串str你从文件对象里读出来的就是字符串。反过来写入时Python会把字符串编码成字节再写进磁盘。二进制模式则完全跳过这层翻译。你用rb打开文件读出来的是bytes对象写入也要写bytes对象。你可以把文本模式理解为“请了个翻译在旁边”把二进制模式理解为“直接进仓库搬货”。这个区别在实际开发中有个非常经典的翻车场景有人用rb读了一个文本文件然后直接decode()发现没问题但反过来有人用r模式去读图片或者压缩包Python直接抛UnicodeDecodeError因为图片字节序列根本不是一个合法的文本编码序列。所以我的习惯是处理文本文件就明确写encodingutf-8处理图片、视频、压缩包、序列化文件就老老实实用rb和wb。别指望Python替你猜猜错的代价就是乱码或异常。1.2 文件路径的坑反斜杠、相对路径和当前工作目录第二个容易炸的地方是文件路径。Windows用户尤其容易踩。比如你复制了文件管理器上的路径C:\Users\admin\data\test.txt在Python字符串里直接写with open(C:\Users\admin\data\test.txt, r) as f: ...然后报错或者打开了完全不对的文件。原因不是路径写错了而是\U、\a、\t这些反斜杠序列在Python字符串里是转义字符。\U会被当成Unicode转义的开头直接抛出语法错误或替换成其他字符。解决办法有三个用原始字符串raw stringrC:\Users\admin\data\test.txt把反斜杠换成正斜杠C:/Users/admin/data/test.txtWindows API其实也认正斜杠再用一层转义C:\\Users\\admin\\data\\test.txt我个人的偏好是写正斜杠或者原始字符串因为\\写多了眼睛容易花。另一个坑是相对路径。相对路径是相对于“当前工作目录”CWD来解析的不是相对于.py脚本所在目录。如果你在脚本里写了open(config.txt)这个文件必须位于你运行命令时所在的目录而不是脚本目录。很多新手在PyCharm里能跑到命令行就报FileNotFoundError就是因为IDE默认把工作目录设置成了项目根目录而命令行里还在别的位置。如果希望脚本无论从哪里启动都能找到文件推荐基于脚本位置计算绝对路径from pathlib import Path BASE_DIR Path(__file__).resolve().parent file_path BASE_DIR / config.txtPath对象可以直接传给open()这也是Python 3推荐的做法。比手动拼字符串路径要稳得多。2. open()是所有文件操作的入口但这些参数值得先看一遍2.1 mode参数拆解r/w/a/x//b到底该怎么组合open()的第二个参数mode决定了你要用这个文件干什么也决定了如果文件不存在、文件已存在会有什么后果。很多人只用过r和w对其他模式一知半解这是很多数据覆盖事故的根源。模式含义文件不存在时文件已存在时r只读文件指针在开头抛FileNotFoundError正常读取w写入先清空文件内容创建新文件直接清空原内容a追加写入指针在末尾创建新文件保留原内容新内容追加在末尾x独占创建并写入创建新文件抛FileExistsError不会覆盖r读写指针在开头抛FileNotFoundError不截断可读可写w读写先清空创建新文件清空原内容a追加读指针在末尾创建新文件保留原内容可读可写b二进制模式修饰符常与上面组合视基础模式而定视基础模式而定很多人在代码评审里看到w就皱眉因为大多数人根本不需要同时读写w的语义是“截断后读写”你既没读到旧数据又白白承担了清空的风险。真要做文件内容的修改正确姿势一般是读进来、改内存里的数据、再写回去或者用一个临时文件替换原文件。x模式是我强烈建议日志类、导出类功能使用的。它保证“如果文件已经存在绝对不覆盖”这种“不覆盖”的语义在防止意外覆盖时非常有用比如导出报表时如果文件已经生成过业务上可能希望直接报错、换个文件名而不是悄无声息覆盖。2.2 encoding、newline、errors这些参数什么时候必须显式写大多数人接触open()就是从两个参数开始open(file.txt, r)。但处理真实世界的文件时还有三个参数几乎天天会遇到。encoding指定读写文本文件时的编码。如果不写Python会使用系统默认编码。Windows中文版默认是gbkmacOS和Linux默认是utf-8。这就导致同一段代码在Windows上能跑、在Linux上跑出乱码或者反过来。所以只要你处理的是文本文件我建议每次都显式写encodingutf-8。虽然麻烦但换来的是跨平台行为一致。newline控制通用换行符的翻译。默认情况下文本模式读取时Python会把\r\n、\r都翻译成\n写入时会把\n翻译成当前平台的默认换行符Windows是\r\nLinux是\n。这样在绝大多数场景下你感知不到换行差异。但如果你在处理CSV这类文件格式时CSV标准要求用\r\n作为行分隔符翻译反而会把事情搞乱。这时就需要newline让换行符原样保留交给CSV模块自己处理。errors编码错误处理策略。默认是strict遇到非法字符直接抛异常。你可以改成ignore忽略、replace用?替换、backslashreplace用转义表示。我曾用errorsignore清洗过一批脏数据效果还行但要注意这是有损的——被忽略的字符永远找不回来了。所以正式场景下我宁可先抛异常把问题暴露出来而不是让数据悄悄变坏。2.3 为什么with open是推荐写法上下文管理器并不只是省一行代码很多教程都会让你写with open(test.txt, w, encodingutf-8) as f: f.write(hello)有些人觉得这只是省去了手动f.close()少了点仪式感。但真实原因比省三行代码重要得多。文件对象是一种操作系统资源不是Python能完全托管的内存对象。如果你只调open()不调close()文件句柄会一直被占用。在Windows上最直接的表现是程序还开着文件你想在资源管理器里删除或重命名它就失败。在Linux上句柄泄漏会慢慢耗尽进程的文件描述符上限导致OSError: Too many open files。with上下文管理器保证了两件事即使with代码块内抛了异常__exit__也会执行文件总是会被关闭。这一点在写爬虫、批处理、长时间运行的服务时尤其重要。如果你确实不想用with那至少要在finally里做清理f open(test.txt, w, encodingutf-8) try: f.write(hello) finally: f.close()写这种代码又长又容易漏所以你看到所有Python老手都默认写with真不是随大流是被资源泄漏教育过。3. 读取文件的几种姿势一次性读、逐行读和大文件分段读3.1 read/readline/readlines/迭代读取的差异读取文件内容的方法有四种很多人混着用其实它们各有各的适用场景。read()不带参数时会把整个文件内容读成一个字符串read(size)会读取最多size个字符文本模式或字节二进制模式。readline()一次读一行返回的字符串包含行尾换行符。readlines()把整个文件按行拆成一个字符串列表。而直接for line in f:则是逐行迭代每次从缓冲区取一行。看个简单对比# 一次性读取整个文件 with open(poem.txt, r, encodingutf-8) as f: content f.read() print(content) # 逐行读取第一行 with open(poem.txt, r, encodingutf-8) as f: line f.readline() print(line) # 读取所有行组成列表 with open(poem.txt, r, encodingutf-8) as f: lines f.readlines() print(lines) # 迭代逐行 with open(poem.txt, r, encodingutf-8) as f: for line in f: print(line)这四个方法真正的差异在内存占用和响应速度上。read()和readlines()都会把整个文件加载进内存区别只是前者返回一个字符串后者返回一个字符串列表。如果文件很小几十KB无所谓。如果文件有2GB直接read()等于把2GB文本全放进内存机器内存不够就直接卡死或MemoryError。readline()和for line in f都是按行读取不会一次性加载全部内容。但readline()需要你手动写循环而for line in f内部用的是迭代器Python会在需要时才从文件对象里取下一行。所以在绝大多数场景下for line in f是读取文本文件的首选。这里还有个小细节readlines()返回的列表里每行末尾会带有\n打印时会多一个空行很多人第一次用的时候会疑惑。处理时可以用strip()去掉但要注意strip()不只是去换行符它会把字符串首尾的空格、\t也都去掉如果要保留行首行尾的空格就要用rstrip(\n)而不是strip()。3.2 大文件读取的内存问题与read(size)的正确用法之前我在处理一个约8GB的Nginx日志文件时一开始图省事用readlines()程序跑了不到一分钟内存飙到6GB然后被操作系统杀掉了。后来换成for line in f内存占用稳定在几十MB几分钟就处理完了。这就是逐行迭代最大的价值它不会缓存全部行每次只在内存里保留一行。但逐行迭代也有局限——如果文件里有一行特别长比如一个没有换行符的10MB字符串for line in f会把这10MB一次读进内存。处理这种单行超大文件时更稳的方式是分块读取with open(big_file.log, r, encodingutf-8) as f: while True: chunk f.read(8192) if not chunk: break process(chunk)f.read(8192)每次读取8192个字符文本模式或字节二进制模式内存占用量完全可控。处理二进制文件也是同理复制文件时可以按64KB一块一块搬避免一次性把整个文件装进内存with open(source.zip, rb) as src, open(dest.zip, wb) as dst: while True: chunk src.read(65536) if not chunk: break dst.write(chunk)这里有个非常容易踩的坑read(size)在文本模式下是按“字符”数读取在二进制模式下是按“字节”数读取。中文字符在UTF-8编码下占3个字节所以read(1024)在文本模式可能读1024个汉字在二进制模式可能只读1024个字节约341个汉字。别把两者的语义搞混否则分块处理时很容易在字符边界上拆出半个中文字符后续解析就会出问题。4. 写入文件的模式陷阱与“落盘”时机4.1 w/a/x三大写入模式的真实后果写入文件时最常见的错误就是用了w模式把之前辛苦积累的数据全清了。w模式的语义是“打开文件并截断为0字节”它在文件已存在时不会报错直接清空。如果你本来只是想追加内容却用了w相当于把旧数据全洗了。区分这三个模式有个很实用的口诀你要“从零开始写”一个文件用w。你要“往已有文件尾部追加”用a。写成a同样会自动创建不存在的文件。你要“防止覆盖已有文件”用x。x模式在需要保证“绝不覆盖”的场景非常好用。比如定时任务每次导出日报告文件命名report_2025-01-01.csv如果当天已经生成过业务上应该报错提醒而不是静默覆盖。用x文件存在时会抛FileExistsError你在外面捕获并处理就行。追加模式下指针位置也容易被忽略。a模式下文件指针固定在末尾哪怕你调seek(0)往前跳了写入时还是会回到末尾。这是很多人在实现“文件某个位置插入内容”时屡屡失败的根因。文本文件的“插入”本质上都要重写整个文件或足够大的临时文件不要试图直接原地插。4.2 缓冲区、flush()和with退出时到底发生了什么很多初学者以为写了f.write(hello)数据立刻就写到磁盘上了。实际上不是。Python的文件对象默认带缓冲区write()的字符串会先进入内存缓冲区等缓冲区满、显式调用f.flush()、或者调用f.close()时缓冲区的数据才真正交给操作系统写入磁盘。with块结束时Python会帮你做两件事先flush()再close()。所以绝大多数情况下with正常退出后数据一定在磁盘上。但有个边角情况需要注意如果程序在with块执行完之前被强制杀掉比如kill -9、断电、os._exit()缓冲区里的数据可能没机会落盘文件最后一段内容就丢了。遇到“必须每写一条日志立刻就落盘”的场景比如审计日志、交易记录建议在关键写入后调用f.flush()。但flush()只是把缓冲区数据交到操作系统操作系统可能还在自己的页缓存里极端要求下还需要os.fsync(f.fileno())强制刷到物理磁盘。fsync会显著拖慢性能日常日志记录不必用只有在数据安全优先级远高于性能时才上。这也能解释一个常见现象你用f.write()写了内容在另一个终端里cat这个文件有时候看不到最新行。不是文件坏了是数据还在缓冲区里。在这个坑上我浪费过不少时间排查现在早就养成了“写完后主动想一下是否需要flush()”的习惯。4.3 print也能写文件但别滥用写入文件还有一个很多人不知道的姿势print其实有个file参数。默认地print输出到sys.stdout也就是控制台你把它指向一个文件对象print就能当作写文件用。with open(output.txt, w, encodingutf-8) as f: print(第一行, filef) print(第二行, filef)有两点值得注意print自带换行每行末尾默认会加一个\n想不换行就传end另外print在写入前会调用str()做字符串转换所以传数字、列表都没问题。如果你在写一个需要格式化输出的脚本print(filef)比手动拼\n.join(...)再write要直观得多少记一个换行符的坑。5. 中文乱码的根源编码参数缺失和各平台默认编码差异5.1 为什么Windows上中文读出来是乱码中文乱码是文件读写里出现频率最高的问题没有之一。而且它有个非常迷惑人的特点在大多数人自己的电脑上跑得好好的同一份代码换台电脑、换个环境就乱码了。核心原因只有一个读取和写入时用的编码不一致或者压根没指定编码Python用了系统默认编码。Windows中文系统的默认编码是gbk而绝大多数现代文件、配置文件、线上数据用的是utf-8。如果你在Windows上写with open(data.txt, r) as f: content f.read()Python会尝试用gbk去解码一个UTF-8文件。UTF-8里一个中文字符占3字节GBK用2字节去解基本会错位出来的就是“锟斤拷”之类的东西。反过来也一样你用默认GBK写入的文件拿到Linux上UTF-8环境读照样乱。解决方案就是前面反复强调的一句话所有文本文件读写都显式指定encoding。with open(data.txt, r, encodingutf-8) as f: content f.read() with open(data.txt, w, encodingutf-8) as f: f.write(中文内容)这看起来只是多写了几个字符但能让你从“平台默认编码”这口大锅里跳出来。我自己的项目里编码参数永远是写满三件套encodingutf-8。5.2 UnicodeDecodeError、UnicodeEncodeError与utf-8-sig的BOM问题有时候乱码还没发生程序直接抛异常这也是家常便饭。读取时抛UnicodeDecodeError说明文件里的字节序列不符合你指定的编码。常见于文件是GBK编码你指定UTF-8去读或者文件里有非法字节比如非UTF-8的残缺字符。遇到这种情况我通常先搞清楚文件原始编码用chardet或者cchardet库探测import chardet with open(unknown.txt, rb) as f: raw f.read(10000) result chardet.detect(raw) print(result[encoding])先读一小段字节检测出编码后再用对应的encoding去打开。注意chardet只是猜测不是100%准。对于关键数据最好还是找到文件生成方确认编码。写入时抛UnicodeEncodeError说明你试图把一些字符用指定编码写出去但编码不支持这些字符。典型的例子是Windows下用默认GBK编码写入一个含生僻字、emoji的字符串GBK根本没有这个字的码位直接抛异常。解决办法同样是明确指定encodingutf-8。还有一个很隐蔽的坑是BOMByte Order Mark。UTF-8有两种常见写法一种带BOM文件开头有3个字节的EF BB BF常见于Windows记事本保存的UTF-8文件另一种不带BOM是大多数Linux/Web场景的默认。用encodingutf-8去读带BOM的文件前三个字节会被读成一个不可见字符\ufeff如果这段内容拼进JSON字符串或去做数据匹配结果大概率不对。应对方案是读取时用encodingutf-8-sigPython会自动把开头的BOM剥掉with open(from_windows.txt, r, encodingutf-8-sig) as f: content f.read()写入时如果生成的CSV需要用Excel打开且避免中文乱码有时需要写utf-8-sig而不是utf-8因为Excel对无BOM的UTF-8识别经常出错。这是我做数据导出时踩过的坑之一在项目里写清楚“给谁消费这个文件”比“理论上哪个编码更标准”更重要。6. 实战中常见的报错与我的惯用写法6.1 文件不存在、权限不足、被占用等常见报错处理真实项目中文件操作几乎没有一次成功的。常见的异常就那么几类逐个说清楚比到时候靠搜索引擎现查要高效。FileNotFoundError最常见的文件异常。除了路径写错之外还有一个高频原因你要写文件的上级目录不存在。比如open(/new_dir/report.txt, w)但/new_dir这个目录不存在Python不会帮你自动建目录直接抛FileNotFoundError。处理办法是用os.makedirs(..., exist_okTrue)先把目录建好from pathlib import Path output_path Path(data/output/report.txt) output_path.parent.mkdir(parentsTrue, exist_okTrue) with open(output_path, w, encodingutf-8) as f: f.write(report)PermissionError用户没有权限读写文件或者文件正在被其他进程占用。Windows上最常见的就是你已经在Excel里打开了这个表格Python再去写时就会报权限错误。排查这种问题没什么捷径先确认文件有没有被别的程序打开再确认当前用户对目录有没有写权限。IsADirectoryError你把一个目录路径直接传给open()了。比如open(/home/user/docs)docs是一个文件夹而不是文件open会明确拒绝。这听起来低级但当你用变量拼路径时很容易出现。UnicodeEncodeError和UnicodeDecodeError编码问题已在第5章详细说过。一种稳健的惯用写法是区分可预期异常和不可预期异常。可预期的比如文件已存在、目录不存在用业务判断或特定异常处理不可预期的异常只做兜底记录不要吞掉。6.2 一个覆盖本章知识点的文件复制与写入脚本最后写一个综合示例把前面讲的知识点串起来。这个脚本的功能是复制一个文本文件把里面的某个关键词替换成新词输出到一个新文件同时处理目录缺失、编码、日志记录。from pathlib import Path def replace_in_file(src_path: str, dst_path: str, old: str, new: str, encodingutf-8) - bool: src Path(src_path) dst Path(dst_path) # 1. 源文件不存在就直接报错不要等open的时候才发现 if not src.exists(): print(f[错误] 源文件不存在: {src}) return False # 2. 目标目录提前建好避免 FileNotFoundError dst.parent.mkdir(parentsTrue, exist_okTrue) # 3. 检查目标文件是否已存在防止误覆盖这里选了覆盖策略按需修改 if dst.exists(): print(f[警告] 目标文件已存在将被覆盖: {dst}) try: # 4. 用 with 同时管理读和写两个文件对象 with open(src, r, encodingencoding, errorsstrict) as fin, \ open(dst, w, encodingencoding, newline) as fout: for line in fin: fout.write(line.replace(old, new)) print(f[完成] {src} - {dst}) return True except (IOError, OSError, UnicodeError) as e: print(f[错误] 处理文件时发生异常: {e}) return False if __name__ __main__: replace_in_file(config.template.txt, output/config.actual.txt, {{NAME}}, Python)这个例子用到了Path处理路径、exists()预检、mkdir(parentsTrue, exist_okTrue)建目录、with管理多文件、逐行迭代替换、显式encoding和newline、异常的兜底捕获。另外一个很实用的习惯是多文件处理时把src和dst两个文件对象写在同一行with里。我之前曾经分开写两个嵌套的with代码丑而且第二个文件打开失败时第一个文件虽然会关闭但排查逻辑很不清晰。同一行with只要两边都用上下文管理器任何一个抛出异常都会全部释放干净很多。6.3 我在实际文件操作里养成的几个小习惯最后分享一点个人实战沉淀。第一凡是写文件先想想“如果文件已经存在我希不希望它被覆盖”。如果答案是“不希望”直接上x模式或者做个存在性判断而不是指望自己记住“下次改用a”。这种防御性编程省过的后悔药次数比想象中多。第二所有文本文件读写都固定写encodingutf-8。也许你短期内没有跨平台需求但代码一旦被复用、被部署到服务器、被放在别人的Windows电脑上跑编码就是第一个炸的点。与其事后查乱码不如一开始就把参数写全。第三文件路径能用pathlib.Path就尽量用不要拼字符串。Path的/运算符在Windows和Linux上都能正确处理分隔符Path(__file__).resolve().parent可以稳定拿到脚本目录这比到处找os.path.dirname要清晰得多。我自己已经把项目里所有os.path相关的代码都换成pathlib了可读性提升非常明显。文件读写是那种看着基础、实际全是细节的领域。把字符编码、文件模式、资源释放、路径解析这几件事想明白再配合with和encoding这两个固定习惯绝大多数文件相关的坑都能提前绕开。如果你正在写自己的第一个文件处理脚本不妨直接按这个代码里的写法起步能省不少折腾时间。
返回列表