1. 为什么说文件操作是Python进阶绕不开的一关
先聊一个很现实的问题:Python初学者最容易产生“我好像会了,但又啥都干不了”的感觉。学完了变量、循环、函数、列表、字典,写出来的脚本却只能在内存里打转,数据一关程序就没了。打破这个瓶颈的关键一步,就是文件操作。
文件操作本质上解决的是“数据的持久化”问题。程序运行时的数据都在内存里,进程一结束就烟消云散。文件操作就是把内存中的数据按一定格式写入磁盘,或者反过来把磁盘上的数据读进内存,让程序能够跨越时间、跨越进程地复用数据。你在网上看到的各种自动化脚本、数据处理工具、网站爬虫、日志分析系统,底层都离不开文件读写。甚至你每天用Word、Excel、记事本,本质上也在做文件操作,只是被图形界面包装了。
这篇文章面向两类人:一类是刚把Python基础语法学完、正准备向实战过渡的零基础学习者;另一类是已经会用open()和write(),但在编码、二进制、结构化数据、异常处理上吃过亏的进阶使用者。我会从文件打开与关闭的基本原理讲起,一直讲到CSV、JSON、二进制数据、大文件分段处理、路径库pathlib,每一部分都用真实场景说明“为什么这么做”,而不是只给一段能跑的代码。把这篇文章吃透,你对“程序和数据进行交互”这件事的理解会上一个台阶。
2. 入门核心:先搞懂open()里藏着的那几个细节
2.1 文件对象和“打开”这个动作到底发生了什么
很多新手第一次看到这么一段代码:
f = open('test.txt', 'r') data = f.read() f.close()代码能跑,但脑子里对open()的认知是模糊的。我得先把这个动作讲透。
open()是Python内建函数,作用是向操作系统发起一个系统调用,请求打开指定路径的文件。操作系统返回一个称为“文件描述符”的整数标识给Python,Python把这个描述符包装成一个文件对象,也就是我们拿到的f。之后我们调f.read()、f.write(),其实都是通过这个文件对象去跟操作系统交互,不是Python直接操作磁盘。
很多教科书只会让你记住“用完要close()”,但不讲原因。原因有两层:第一,文件对象占用操作系统资源,比如文件描述符,如果不关闭,打开的文件一多就会报“Too many open files”;第二,写入数据时,Python解释器为了性能,会把数据先放到缓冲区里,不一定会立刻刷到磁盘。只有调用close()、flush()或者程序正常退出时,缓冲区的内容才会真正落盘。如果你写完后不关闭文件,数据可能还躺在内存缓冲里,程序崩溃就全丢了。
我不建议用裸的open()加close(),因为它有个致命弱点:如果在read()或write()过程中抛出异常,close()那行根本执行不到,文件就一直开着。可靠的写法后面马上讲,但先要理解open()本身。
2.2 模式字符串:r、w、a、rb、wb这些组合到底怎么选
open()的第二个参数是模式字符串,很多人直接背表格,其实换个角度理解很简单:模式由两部分组成,一部分决定“打开文件的目的是读还是写”,另一部分决定“文件内容按什么形式对待”。
先看读取类:
- r:只读,文件必须存在,否则抛出FileNotFoundError。光标从文件开头开始。
- rb:以二进制方式只读,适用于图片、音频、视频、压缩包等非文本文件。
- r+:可读可写,文件必须存在,写入会从文件开头覆盖,不会截断文件。
- rb+:二进制方式的读写。
再看写入类:
- w:只写,文件存在则清空原有内容,不存在则新建。这是最常用的写入模式,也是最“危险”的,因为一个疏忽就可能把原有数据抹掉。
- wb:二进制方式只写。
- w+:可写可读,同样会清空原文件。
- wb+:二进制方式的写读。
最后是追加类:
- a:追加写入,文件不存在会新建。写入的内容追加到文件末尾。核心特点是光标永远在文件末尾,哪怕你用seek()去移动光标,写入时依然会跳到末尾。
- ab:二进制追加。
- a+:可追加可读,但读取时要注意光标位置,默认在末尾,想读内容得先seek(0)。
- ab+:二进制追加读写。
我自己见过不少新手在用w模式处理日志文件时把历史记录清空了,就是因为没意识到w模式会截断文件。如果你要保留原有内容,哪怕是只在末尾加几行,也一定要用a模式。操作前先想清楚:这个文件我要不要保留原内容?如果答案是要,那模式里绝不能出现w。
2.3 编码参数:为什么读中文会乱码
很多零基础学习者第一次遇到乱码问题,都是因为没有指定encoding参数。open()如果不指定编码,Python会用一个默认编码,通常是系统区域设置决定的编码。在中文Windows上可能是GBK,在绝大多数Linux服务器上可能是UTF-8。这就导致了一个很微妙的问题:同样的代码,在你自己电脑上跑得好好的,传到服务器上读同一个文件,结果乱码了。
我的建议很简单:凡是打开文本文件,永远显式指定encoding参数。比如:
with open('data.txt', 'r', encoding='utf-8') as f: content = f.read()写文件时同理:
with open('output.txt', 'w', encoding='utf-8') as f: f.write('你好,Python')那如何确定一个文件原本是什么编码呢?最笨也最可靠的方法是直接用编辑器或命令行工具查看。如果文件里有中文,用UTF-8打开正常,那就是UTF-8;如果用GBK打开正常,那就是GBK。如果你需要处理来源不明的文件,可以先用Python尝试解码:
raw = open('unknown.txt', 'rb').read() for enc in ['utf-8', 'gbk', 'gb2312', 'latin-1']: try: text = raw.decode(enc) print(f'可以用 {enc} 解码成功') break except UnicodeDecodeError: continue这里有个细节:latin-1永远能解码成功,因为它把所有字节都映射成了字符,所以它只能作为最后的兜底选项,不代表文件真的是latin-1。判断编码是件跨学科的事,没有绝对的银弹,遇到重要文件建议先备份再处理。
3. 从零基础到进阶:一套能直接抄的读写套路
3.1 用with open()来代替手动close()
现在正式给出我强烈推荐的标准写法:
with open('test.txt', 'r', encoding='utf-8') as f: content = f.read()with语句在这里的作用是上下文管理。文件对象实现了__enter__和__exit__方法,with会在进入代码块时调用__enter__获得文件对象,在代码块执行完或者抛出异常时,自动调用__exit__来关闭文件。这意味着无论代码中途发生什么,文件都会被正确关闭,不需要你手动写close(),也从根本上避免了文件描述符泄漏和缓冲区数据丢失的问题。
这套写法唯一要留意的是:with代码块结束后,文件对象虽然还能被引用,但句柄已经关闭,再调用read()会报ValueError: I/O operation on closed file。所以如果你需要在with之外使用读到的数据,请在with内部把数据保存到变量里。
3.2 读文件的三种姿势:read、readline、readlines
拿到文件对象后,怎么读才算合适?这取决于文件大小和你需要的结构。
read()不带参数时一次读完整个文件,把内容作为一个字符串返回。这种方式最简单,但只适合小文件。假设你现在要处理一个2GB的文本文件,用read()第一次读就把所有数据全塞进内存,机器直接开始卡顿甚至OOM。这不是Python不够高效,而是任何语言都在内存规模上有边界。
readline()每次读一行,包括行尾的换行符。用一个while循环可以逐行处理,内存占用很小,但需要自己控制循环结束条件。
with open('data.txt', 'r', encoding='utf-8') as f: while True: line = f.readline() if not line: break print(line.strip())readlines()一次性把所有行读进来,返回一个列表,每一行是列表里的一个元素。对需要随机访问行号的场景很方便,但如果文件很大,它会和read()一样吃内存。
真正处理大文件的推荐做法,是直接把文件对象本身放进for循环:
with open('data.txt', 'r', encoding='utf-8') as f: for line in f: process(line)这样做的好处是底层按行迭代,一次只把一行读到内存,而且代码非常简洁。文件对象实现了迭代器协议,for循环会按行自动迭代,不需要你去调用readline。这是我最常用的读文件方式,没有之一。
3.3 写文件的细节:write和writelines别用反了
写文件时有两个容易混淆的函数。write()接收一个字符串,把字符串原样写入。writelines()接收一个可迭代对象,比如列表,但它并不是在每行之间自动添加换行符。很多新手以为writelines(['a', 'b', 'c'])会输出三行,但实际上输出的是abc连在一起。如果你想让列表里的每个元素占一行,需要自己加换行符:
lines = ['第一行', '第二行', '第三行'] with open('out.txt', 'w', encoding='utf-8') as f: f.writelines([line + '\n' for line in lines])这里还有一个新手很容易踩的坑:换行符在不同操作系统上不一样。Windows用\r\n,Linux和macOS用\n。如果你在Windows上写了带\r\n的文件,然后拿到Linux上处理,一行结尾可能会多出一个\r字符。Python的默认行为是开启universal newlines模式:读文件时会把\r\n统一转换成\n,写文件时会把\n转换成当前平台的默认行结束符。但如果你用二进制模式rb或wb,这个转换不会发生。在处理跨平台文本文件时,记住这个差异会帮你省不少排查时间。
3.4 追加写入和文件指针的移动
写入模式中,a模式和w模式的区别体现在是否保留原内容。但a模式还有一个特性经常被忽略——即使你调用了seek(0),写入时还是会写到文件末尾。这是a模式的刻意设计,目的就是保护追加操作的原子性:你不用去管光标在哪,追加就是追加。
如果你确实想在文件中间某个位置插入内容,需要用到seek()。seek(offset, whence)中的whence有三个取值:0表示从文件开头计算偏移,1表示从当前位置,2表示从文件末尾。比如seek(0, 2)就是把光标移到文件末尾,通常配合读取末尾内容或确定文件大小使用。
但我要提醒一句:在文本模式下,seek()的行为并不总是精确的,因为文件内部可能有编码转换。比如UTF-8编码的中文字符占3个字节,文本模式下说seek(3)未必能跳到一个字符边界。如果要做精确的字节级操作,请切换到二进制模式。这算是我踩过的一个不大不小的坑。
4. 进阶必修:编码问题和路径陷阱,一个都不能放过
4.1 UnicodeDecodeError和UnicodeEncodeError的完整解法
文本处理最痛的体验就是编码异常。常见的报错分两类:读的时候报UnicodeDecodeError,写的时候报UnicodeEncodeError。原因几乎都是同一个:当前解码/编码用的字符集和文件内容不匹配。
解决方案有三个层面。第一个层面是确定文件真实编码,用第2.3节里的尝试解码法。第二个层面是读写时显式指定同一个编码,比如全程UTF-8。第三个层面是遇到个别坏字节时,给open()传入errors参数:
with open('dirty.txt', 'r', encoding='utf-8', errors='ignore') as f: content = f.read()errors='ignore'会静默丢弃无法解码的字节,适合读取强健性优先的场景。errors='replace'会把无法解码的位置替换成替字符(通常是),适合需要保留可读性的场景。errors='strict'是默认模式,遇到坏数据直接报错,适合需要严格校验的场景。
我的建议是:在开发阶段用strict,尽早暴露编码问题;到生产环境处理脏数据时再考虑ignore或replace。千万别因为图省事一律ignore,否则数据被悄悄丢弃了你都不知道。
4.2 路径拼接为什么不要用字符串加号
文件操作的另一大坑是路径。新手最喜欢这么写:
path = 'data/' + filename + '.txt'在特定环境里这能跑,但有个问题:Windows路径分隔符是反斜杠\,Linux/macOS是正斜杠/。如果你在代码里写死正斜杠,Windows其实也能识别,但一旦涉及到系统API或者某些特殊工具,反斜杠引起的问题会让人抓狂。更重要的是,用字符串拼接路径时,处理上级目录、绝对路径、路径存在性判断时都需要自己写逻辑,很容易出错。
正确的思路是使用标准库os.path或pathlib。我强烈推荐pathlib,它是Python 3.4起引入的面向对象路径库,用法非常直观:
from pathlib import Path base_dir = Path('data') path = base_dir / 'sub' / 'file.txt'这里的/运算符被重载了,用来拼接路径,不管在哪个系统上都能得到正确的分隔符。pathlib还能直接判断文件是否存在:
if path.exists(): print(path.read_text(encoding='utf-8'))甚至可以直接读写文本:
path.write_text('你好', encoding='utf-8') text = path.read_text(encoding='utf-8')这些方法内部都自动管理了文件打开和关闭,比手写with更简洁。对那些配合目录树遍历的场景,pathlib提供的glob()、rglob()能让你一行代码遍历匹配所有文件:
for p in Path('data').rglob('*.txt'): print(p.resolve())我是在处理一个多层目录的文件整理任务时彻底倒向pathlib的。当时要统计每个子目录下图片文件的数量,用os.path和os.listdir写了好几层嵌套,后来改成pathlib的rglob,代码量直接减半,可读性也高了很多。对新手来说,从一开始就用pathlib,能够少走很多弯路。
4.3 文件不存在和权限不足的常见报错
读写文件最常见的两个异常,一个是FileNotFoundError,一个是PermissionError。前者通常是因为文件路径拼错或者文件还没生成;后者通常是想往一个只读文件里写内容,或者是没有对应目录的写权限。
处理异常的方式不是层层if判断,而是用try-except把可能有问题的部分包起来:
from pathlib import Path path = Path('data.txt') try: text = path.read_text(encoding='utf-8') except FileNotFoundError: print('文件不存在,先创建再读') except PermissionError: print('没有访问权限')另一个实用技巧:在写文件前确保父目录存在,然后用open的w模式创建文件。很多人用open直接写一个不存在的路径时会遇到FileNotFoundError,原因往往不是文件不存在,而是路径里的目录不存在。确保目录存在的写法:
from pathlib import Path path = Path('output/2025/result.txt') path.parent.mkdir(parents=True, exist_ok=True) path.write_text('内容', encoding='utf-8')mkdir(parents=True, exist_ok=True)的意思是:递归创建所有缺失的父目录,如果目录已经存在,不报错。这是处理多级目录写入最稳的姿势,没有之一。
5. 再进一步:二进制文件与结构化数据的读写
5.1 二进制模式下读写到底在读写什么
文本模式和二进制模式的本质区别,在于后者不做任何编码转换。你在二进制模式下读到的是一串bytes对象,里面的每一个元素都是字节值(0到255)。这种模式下,没有“字符”的概念,也没有行结束符的转换。
用二进制模式的主要场景包括:图片缩放、文件复制、加密压缩、解析自定义协议数据包、读取Excel底层文件等。比如最简单高效的文件复制,不需要关心内容是什么:
with open('source.jpg', 'rb') as src: data = src.read() with open('copy.jpg', 'wb') as dst: dst.write(data)但把整个文件一次性读进内存再写出去,对大文件来说非常浪费。更好的做法是用固定大小的块循环复制:
CHUNK_SIZE = 1024 * 1024 with open('source.iso', 'rb') as src, open('copy.iso', 'wb') as dst: while True: chunk = src.read(CHUNK_SIZE) if not chunk: break dst.write(chunk)这里每次读到1MB数据,写完再读下一段。无论原文件多大,内存占用都稳定在1MB左右。这段代码是处理大文件复制、传输任务的地基版本。
5.2 CSV文件的正确打开方式:别再用readlines手撕
处理表格数据时最容易犯的错,就是用手动split字符串的方式解析CSV。CSV的全称是Comma-Separated Values,但真实世界的CSV远比“逗号分隔”复杂:字段内可能包含逗号、引号,字段本身可能被引号包裹,引号内还有转义的引号。自己用split(",")解析,遇到稍微复杂一点的数据就会裂开。
正确姿势是使用标准库csv模块。读CSV:
import csv with open('data.csv', 'r', encoding='utf-8', newline='') as f: reader = csv.reader(f) header = next(reader) for row in reader: print(row)注意三个细节。一是encoding要显式指定,二是newline=''要写上。如果不写newline='',在Windows上可能每行之间多出一个空行,这是Python文档和csv模块长期存在的交互坑。第三个细节是csv.reader会自动处理引号包裹的字段,你不需要自己做字符串清理。
写CSV时同样要处理换行问题:
import csv rows = [ ['张三', 25, '工程师'], ['李四', 30, '设计师'], ] with open('output.csv', 'w', encoding='utf-8', newline='') as f: writer = csv.writer(f) writer.writerow(['姓名', '年龄', '职业']) writer.writerows(rows)如果你需要按字典读写,可以用csv.DictReader和csv.DictWriter,以列名为键,操作起来更直观。遇到中文乱码问题,可以在写文件时用一个加上UTF-8 BOM的编码:encoding='utf-8-sig'。这样Excel打开生成的CSV时能正确识别中文。这是被无数人验证过的Windows兼容技巧。
5.3 JSON:跨语言数据交换的最常用格式
JSON是Web开发和配置文件场景里最频繁出现的格式。Python的json模块使用起来很简单:
import json data = { 'name': '张三', 'age': 25, 'skills': ['Python', 'SQL'], } text = json.dumps(data, ensure_ascii=False, indent=2) print(text)重点在ensure_ascii这个参数。默认情况下,json.dumps会把所有非ASCII字符转成\uXXXX形式。这不会导致数据错误,但可读性很差,而且跟UTF-8环境下的其他JSON工具生成的格式不一致。设置ensure_ascii=False后,中文会按原始字符输出。搭配indent=2缩进,生成的文件就有很好的可读性。
写文件时,最稳妥的方式是这样的组合:
with open('config.json', 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2)读文件:
with open('config.json', 'r', encoding='utf-8') as f: data = json.load(f)这里的json.dump和json.dumps区别是:dump直接写入文件对象,dumps返回字符串。load和loads同理。如果你在爬虫、接口开发或者写自动化脚本,这套读写模式基本够覆盖百分之九十的场景。
5.4 pickle:把任意Python对象存下来
有些时候你需要的不是跨语言格式,而是把Python对象原样保存,下次运行时直接还原。pickle模块就是干这个的。可以把列表、字典、自定义类的实例都序列化到文件里:
import pickle data = {'result': [1, 2, 3], 'valid': True} with open('data.pkl', 'wb') as f: pickle.dump(data, f) with open('data.pkl', 'rb') as f: loaded = pickle.load(f) print(loaded)pickle的优点是方便,缺点是只能Python用,而且存在安全性问题——永远不要用pickle.load去读取来源不可信的文件,因为恶意构造的pickle内容可以在反序列化时执行任意代码。如果你只是自己缓存中间结果,pickle很合适;如果你要和外部系统交换数据,还是用JSON更稳妥。
6. 实战演练:日志文件处理与临时文件管理
6.1 从零实现一个按天滚动的日志写入器
日志文件是文件操作最典型的应用场景之一。初级做法是每次打开文件用a模式追加一行。但生产环境里,日志文件如果一直不切割,会越涨越大,最后单文件几个GB,查看、备份都痛苦。
下面我给一个按天滚动切分的入门实现思路。核心逻辑是:写入之前判断今天的日期,如果日期变了,就关闭昨天的文件,用当前日期作为文件名的一部分新建文件。
import os from datetime import datetime class DailyLogger: def __init__(self, log_dir): self.log_dir = log_dir os.makedirs(log_dir, exist_ok=True) self.current_date = None self.f = None def _ensure_file(self): today = datetime.now().strftime('%Y-%m-%d') if self.current_date != today: if self.f: self.f.close() path = os.path.join(self.log_dir, f'app-{today}.log') self.f = open(path, 'a', encoding='utf-8') self.current_date = today def log(self, message): self._ensure_file() timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') self.f.write(f'[{timestamp}] {message}\n') self.f.flush() def close(self): if self.f: self.f.close() self.f = None注意里面的flush()调用。日志系统有个特殊要求:程序突然崩溃时,日志尽量不丢。如果只靠write(),数据可能还在缓冲区里。flush()会强制把缓冲区数据写到磁盘。虽然频繁flush会带来一点性能开销,但日志场景里可靠性优先。
这就是一个可用的mini日志框架地基。你可以在上面扩展文件大小切割、日志级别过滤、历史日志清理等能力。很多公司的日志处理工具库,底层逻辑也和这个相似。
6.2 大文件行数统计:一次循环搞定,别全读进内存
有时候你需要快速知道一个大文件有多少行。最直接的想法是:
with open('big.txt', 'r', encoding='utf-8') as f: lines = f.readlines() print(len(lines))但如果你处理的是几十GB的日志文件,readlines()直接内存爆炸。正确做法是逐行遍历并计数:
count = 0 with open('big.txt', 'r', encoding='utf-8') as f: for line in f: count += 1 print(count)这段代码的内存占用不会随着文件变大而增加,无论文件5MB还是5GB,都能稳定跑完。
如果你想更快,还有一种基于缓冲区的计数方式,用二进制模式读取固定大小的块,统计块里的换行符数量:
def count_lines_fast(path): CHUNK_SIZE = 1024 * 1024 count = 0 with open(path, 'rb') as f: while True: chunk = f.read(CHUNK_SIZE) if not chunk: break count += chunk.count(b'\n') return count这种方式的优势是省去了按行迭代时的字符解码和split开销,适合对极致性能有要求的场景。代价是没法对每行内容做业务处理,只能机械统计换行符个数。两者怎么选,取决于你的实际需求。
6.3 临时文件与自动清理:tempfile模块
在多个程序之间传递中间数据时,你可能需要一个用完就删的临时文件。标准库的tempfile模块专门解决这个问题。最常见的用法:
import tempfile with tempfile.TemporaryFile('w+t', encoding='utf-8') as f: f.write('临时数据') f.seek(0) data = f.read() print(data)TemporaryFile创建的文件在文件系统里没有名字(至少不对外暴露),关闭后自动删除。这种方式适合数据只在同一进程生命周期内需要中转的场景。如果你需要给其他进程传递这个临时文件的路径,使用NamedTemporaryFile:
import tempfile with tempfile.NamedTemporaryFile(delete=True, encoding='utf-8') as f: f.write('数据') f.flush() print(f.name) # 这里能拿到临时文件路径delete=True的含义是关闭后自动删除。如果设置delete=False,关闭后文件保留,但你需要自己负责后续清理。临时文件虽然小,但如果没人清理,日积月累也会占满目录。能交给tempfile自动管理的,就别自己造轮子。我自己在写数据处理管道的中间缓存时,这个模块省了不少心,程序跑完不会有残留文件,干净利落。
7. 一线排查:文件操作最常见的坑和对应解法
7.1 写入后立即读取,为什么读出来是空的
新手经常写这种代码:
with open('test.txt', 'w') as f: f.write('hello') with open('test.txt', 'r') as f: print(f.read()) # 有时能读到,有时读不到?这个场景大多数时候能读到,因为with语句退出时文件已经关闭。但如果在同一个文件对象里先写后读,就会遇到空内容:
with open('test.txt', 'w+') as f: f.write('hello') print(f.read()) # 空!原因在于写入后文件指针停在末尾,read()从末尾开始读,自然读不到东西。解决方法是写入后执行f.seek(0),再把指针移回开头再读。这是我见过的高频问题之一,原理说穿了非常简单,但不知道的人能卡一下午。
7.2 文件被占用,程序一运行就PermissionError
在Windows系统上,如果一个文件已经被Excel或者其他程序打开,Python尝试写入时就会报PermissionError。这类问题的难度不在于代码,而在于找到是谁占用了文件。
常规排查思路是:先关闭其他程序里打开的文件,再重新运行脚本。如果找不到占用者,可以重启资源管理器或者重启电脑。如果程序需要持续写入一个可能被其他程序打开的文件,架构上就要考虑使用不同文件的方案,或者用更底层的系统API处理文件锁。
这一点说出来有点“笨”,但确实是实操中最常见的情形。我在处理一个自动生成报表的定时任务时,就遇到过Excel没关导致脚本报权限错误的情况。从那以后,我写文件之前都会先确保目标文件没有被外部程序打开,并且在脚本里做了友好的异常提示。
7.3 明明用with了,为什么数据还没落盘
with语句保证的是文件对象被关闭,但不保证数据立刻写入磁盘的物理介质上。操作系统的文件系统有自己的缓存机制,close()时数据从Python缓冲区进入系统缓存,系统会在合适的时机再写入磁盘。
如果程序突然断电,数据可能还在系统缓存里,没有真正落盘。想要强制写入物理磁盘,需要调用os.fsync():
with open('important.txt', 'w', encoding='utf-8') as f: f.write('关键数据') f.flush() os.fsync(f.fileno())fsync()会通知操作系统把文件数据强制刷到硬盘。注意,这个操作比较慢,不适合每条日志都调用。但对那些“必须持久化”的关键数据,比如交易记录、配置变更,值得付出这个性能代价。
7.4 文件操作异常速查表
为了方便你对照排查,我把文件操作里的常见现象、原因和解法整理成一个速查表:
| 现象 | 可能原因 | 标准解法 |
|---|---|---|
| FileNotFoundError | 文件路径写错,或目录不存在 | 用pathlib检查路径,先创建父目录 |
| PermissionError | 文件被其他程序占用,或没有写权限 | 关闭占用程序,检查目录权限 |
| UnicodeDecodeError | 文件编码和encoding参数不匹配 | 尝试不同编码,或使用errors='replace' |
| UnicodeEncodeError | 写入字符超出目标编码范围 | 统一使用utf-8,设置ensure_ascii=False |
| 写入内容为空 | 写后未把指针移回开头 | 读之前调用seek(0) |
| 读中文乱码 | 未显式指定encoding | 打开文件时固定encoding='utf-8' |
| 读CSV多空行 | 未设置newline='' | open时加上newline='' |
| 路径拼接错 | Windows/Unix分隔符混用 | 改用pathlib.Path |
| 文件大小异常 | 二进制与文本模式混淆 | 明确文件类型,选对模式 |
| 大文件内存溢出 | 一次read()或readlines()全部读入 | 改用for逐行迭代或分块读取 |
这张表是我在实战中反复遇到过的问题集合。遇到异常时先对号入座,能省一大半排查时间。
8. 最后分享一点我长期使用的文件操作习惯
说几点实操心得,都是我在项目里每天在用的习惯。
第一,所有open()调用永远显式传encoding。我宁可在每个地方多打几个字,也不要让程序在不同机器上行为不一致。UTF-8在今天是绝对主流,新项目统一用UTF-8,历史遗留的GBK文件单独处理。
第二,路径处理永远用pathlib,不手写字符串拼接。Path对象之间用/连接,目录创建用mkdir(parents=True, exist_ok=True),判断存在用exists()。这些API不仅简洁,更重要的是能让代码在Windows、Linux、macOS上行为一致。
第三,文件读写的生命周期管理永远交给with语句。这是一个不讲条件的铁律。如果代码里有裸open,我会立刻审查是否有可能泄漏资源。代码评审时我第一眼看的就是with和encoding。
第四,写入重要数据时,先写临时文件再原子替换。这个技巧尤其适合配置文件:先把内容写到一个临时文件,确认写成功后,再通过os.replace()把临时文件替换为目标文件。这样即使写入过程中程序崩溃,原始文件也不会被写坏。下面是一个简短示例:
import os from pathlib import Path target = Path('config.json') tmp = target.with_suffix('.tmp') tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8') tmp.replace(target) # 原子替换replace()在同一个文件系统内是原子操作,不会出现“写到一半”的损坏状态。这个技巧救过我多次,尤其是程序被信号中断或者磁盘空间不足的时候。
最后想特别提醒一个很多人栽过的跟头:文件操作里的“追加模式”不等于“不会出错”。a模式只保证写入位置在末尾,但如果你在多个进程同时打开同一个文件并向其中写日志,还是可能出现内容交错。如果需要多进程安全的日志写入,建议用标准库logging模块,它自带文件锁和多进程的兼容处理。手动open写日志的脚本,在单进程任务里完全没问题,但到了多进程环境,请转用成熟方案。
文件操作看起来是Python最基础的能力,但越往深走越发现,它连接着编码、路径、内存、并发、操作系统协议这些底层概念。把这一层地基打牢,后面学数据处理、网络编程、自动化运维都会顺很多。希望这篇文章能陪你把这条路走扎实。