先说说我最近被问得最多的一个问题:很多人手里攒着一堆日志、导出文件、接口返回的文本,想转成 pandas 数据表做分析,结果第一步就卡住了。原因五花八门——分隔符不对、编码乱码、JSON 嵌套太深、明明有表头却认不出来……其实“结构化文本转 pandas 数据表”这件事,本身没有多高深,真正考验人的是文本里的“脏”和“乱”。今天就把我从日常数据清洗里踩过的坑、总结出来的套路,一次性讲清楚。
这篇东西适合谁看?刚接触 pandas 的新手、每天跟导出报表打交道的运营和开发、以及任何需要把“不是表格”的文本硬生生变成“表格”的人。我会从文本格式的分类讲起,把 read_csv 的核心参数拆开揉碎,再讲 JSON、HTML、自定义文本的解析思路,最后用一个混合脏文本的实操案例,带你走一遍完整的数据表构建流程。
1. 先认清你手里的文本:结构化格式的真实差异
在动手写代码之前,我建议你先花 30 秒回答一个问题:这份文本到底是“规则表格”还是“半规则文本”?这两类东西的解析难度完全不同,处理思路也不一样。
1.1 常见文本格式的本质区别
按我平时遇到的频率,最常见的结构化文本大概分四种:
CSV/TSV 这类用分隔符组织的文本,是最接近表格的形态。每一行是一条记录,每行里的字段用逗号、制表符或者竖线隔开。比如某系统导出的用户表、订单明细,基本都是这种。注意,CSV 并不等于“用逗号分隔的文件”,它的分隔符可以自定义,这也是很多问题的根源。
JSON 和 XML 属于“嵌套型”结构。它们不是天然的行列表格,而是树状结构。接口返回的数据大多是这种形态。转 pandas 时最大的难点不是读取,而是“展平”——把嵌套的字典、数组拍平成二维表。
键值对文本(如配置文件、日志片段、key=value 形式)属于“松散型”。每一行可能有不同的键,或者同一个键在不同行出现多次。这种文本直接用 read_csv 读不出来,需要先做规整。
HTML 表格则是个特例。网页里的<table>标签本身就是表格结构,pandas 的 read_html 能直接抽取,但抽取出来往往混着样式、合并单元格、多余的链接文字,清洗量不小。
举一个我实际遇到过的例子。某业务的服务器日志,每一行长这样:
2024-03-15 10:23:45 INFO order_id=10001 user_id=7788 amount=99.50 status=paid channel=app这条日志里没有逗号也不是标准 CSV,但它的key=value结构非常清晰。市面上有专门解析日志的库,但很多人不知道,这种文本用 pandas 处理起来也很快——先按空格切分,再把“等号分隔”的键值对拆开,就能放入 DataFrame。核心思路是先变成“一行为一条记录”的结构,再做字段拆分。
1.2 技术选型的底层逻辑:为什么 read_csv 是绝对主力
pandas 里和文本解析相关的函数不止一个,但 read_csv 90% 的场景都在用它。你可能会问,JSON 用 read_json、HTML 用 read_html,各干各的不好吗?为什么说 read_csv 是主力?
原因在于,很多文本的“真身”其实是 CSV。比如某些报表导出工具,表面上是.txt文件,里面却是竖线分隔的表格;某些接口返回的 JSON 数据,其实一条记录就是一行,行内是逗号分隔。只要你能把非标准格式“翻译”成标准的行式文本,read_csv 几乎通吃。
读文本之前,先搞清楚三件事:分隔符是什么、有没有表头、编码是什么。这三个答案没确定之前,不建议急着跑代码。我第一次处理某老旧系统导出的数据时,直接按默认逗号读,结果一整列全被拆散,场面相当惨烈——后来才发现分隔符是 Tab,而且文件用了带 BOM 的编码,读进来的第一列列名莫名其妙多了几个看不见的字符。这就是没做前置检测的下场。
2. 大部分场景一把梭:read_csv 参数调优实战
read_csv 的参数有几十个,但真正天天用到的也就五六个。把它们吃透,就足够应付绝大多数“文本转 DataFrame”的需求。
2.1 sep、delimiter 与 quotechar:分隔符正确切分的组合拳
sep 和 delimiter 在 pandas 里是同一个参数,都是指定字段分隔符。最常见的取值是逗号、制表符、竖线。
但真实数据里,分隔符往往没这么老实。比如某电商平台导出的订单数据,金额字段长这样:"1,299.00"。钱数里有逗号,如果直接按逗号切,这列数据会被活生生切两半。这种时候需要用到quotechar参数,告诉 pandas:“被引号包起来的内容,即使里面有分隔符,也别切”。
举个具体配置:
import pandas as pd df = pd.read_csv( 'orders.csv', sep=',', quotechar='"', thousands=',' )这个例子里,thousands 参数把"1,299.00"里的逗号识别为千分位符号,最终读进来就是数值 1299.00。三个参数配合起来,才算是真正把带引号、带千分位逗号的 CSV 处理干净。
解析带转义的文本也常遇到。字段里如果有引号,通常用两个连续引号表示一个引号,CSV 标准里这叫 escaped quote。pandas 默认支持这个行为,但你最好先打开原始文本肉眼确认一下,别让转义符把字段边界弄乱。
2.2 encoding 与 encoding_errors:别让乱码毁掉整张表
乱码是文本解析里最烦人的问题,而且出现得毫无征兆。某次我读一个由旧版办公软件导出的文件,控制台显示中文全部变成“锟斤拷”一类的东西,当时的我并不知道这是 UTF-8 被当成 GBK 解码的经典症状。
现在我的习惯是,拿到文本先做一个编码预检测:
with open('report.txt', 'rb') as f: raw = f.read(1024) # 只读前1KB,够判断了 if raw.startswith(codecs.BOM_UTF8): print('UTF-8 with BOM') elif b'\x00' in raw: print('UTF-16')文件开头如果是\xef\xbb\xbf,代表 UTF-8 带 BOM。带 BOM 的 UTF-8 文件直接用encoding='utf-8'读,第一列列名会多出一个不可见字符,很多人排查半天才发现是列名不对。正确姿势是encoding='utf-8-sig'。
如果源文件是用 GBK 编码的,但里面偶尔混了一个非法字节,整行解析会直接报错。这种情况可以给 encoding_errors 参数一个宽容选项:
pd.read_csv('data.csv', encoding='gbk', encoding_errors='ignore')ignore 会让 pandas 跳过无法解码的字节,而不是中断。代价是有可能丢信息,但比整个文件读不进来要好得多。如果丢的字节正好在关键字段里,后果会很隐蔽——所以我一般建议先用 ignore 跑通流程,再做数据质量校验,确认关键列没有异常短小的值。
2.3 dtype 与 parse_dates:别让类型推断替你闯祸
pandas 默认会对每一列做类型推断。比如一列纯数字,它会自动读成 int64;一列看起来像日期的文本,读进来还是字符串。这种“自动”在干净数据上没啥问题,但在真实数据里经常翻车。
最典型的例子:订单编号是 001、002、003 这种带前导零的字符串。如果这列恰好全是数字,pandas 会把它读成整数,前导零全丢。某次我就因为这个,把编号 08721 读成了 8721,事后和业务方核对数据时才发现少了一位。
解决办法是在读取时显式指定类型:
df = pd.read_csv( 'orders.csv', dtype={ 'order_id': str, # 编号当字符串读,保前导零 'user_id': 'Int64', # 可空整数类型 'amount': float, }, parse_dates=['create_time'] )dtype 是字典,列名对应目标类型。parse_dates 则接收一个列名列表,让 pandas 在读取时就把指定列转换为 datetime 类型——这一步省去了后面到处调 to_datetime 的麻烦。
类型推断还有一个隐形坑:某列大部分行都是数字,但极少数行混入了文本。pandas 会“聪明”地把整列推断为字符串,而不是报错。你如果没意识到,后面做数学运算时就会得到一堆字符串拼接的诡异结果。所以读取之后,做一个df.dtypes检查是必须的。
2.4 表头处理与坏行策略:skiprows、header、on_bad_lines 的协同用法
真实报表文件里,表头往往不在第一行。有的文件前两行是标题和生成时间,第三行才是真正的列名;有的文件开头有十几行说明文字。
这时候用 header 和 skiprows 组合:
df = pd.read_csv( 'data.csv', skiprows=2, # 跳过前2行垃圾信息 header=0, # 第3行当表头 )留意 skiprows 和 header 的配合逻辑。skiprows=2 是先砍掉前两行,然后 .csv 里剩下的部分里,header=0 表示取“当前文件”的第一行作为表头。如果文件更乱,表头在中间某行,就得先把表头行之前的行全部跳过。
坏行处理是我每次批量处理数据都要确认的点。旧系统导出的文件里,偶尔会出现某行字段数量比其他行多几个。pandas 默认行为是报错中断。在批量跑数的时候,中断意味着你可能要守着程序随时处理异常,很被动。
所以我现在会在读大文件时显式声明:
df = pd.read_csv( 'big_file.csv', on_bad_lines='warn', engine='python' )on_bad_lines 有几种取值:报错(默认)、跳过、打印警告但不中断。warn 模式会把坏行信息打成 warning,然后继续往下读。engine='python' 则启用更灵活但稍慢的解析器,对非标准文件处理更好。谨慎点说,跳过坏行可能造成数据缺失,所以该模式只适合“先看整体情况”的阶段,不适合最终产出。
3. JSON、HTML 与非标文本:read_系列之外的处理套路
read_csv 解决的是“行式文本”。要是数据源头是接口返回的 JSON、网页里的 HTML 表格、或者完全没有风格的日志文本,就得换个思路了。
3.1 JSON 的四种典型结构:orient 参数决定成败
JSON 转 DataFrame,最简单的场景是“数组套对象”:
[ {"name": "A", "age": 20}, {"name": "B", "age": 21} ]这种直接用pd.read_json(data)就能出来一张两列的表。但真实接口远没这么简单。常见的复杂形态有三种。
第一种是 records 形态,每一行是一个对象,但对象里有嵌套字段。比如用户信息里带一个 address 子对象,里面又有 city 和 street 两个字段。直接用 pd.read_json,会得到一列 address,里面装的是字典。要看懂数据,得用 json_normalize 把它摊平:
from pandas import json_normalize data = [ {"name": "A", "address": {"city": "某市", "street": "某街"}}, {"name": "B", "address": {"city": "另一市", "street": "另一街"}} ] df = json_normalize(data) # 得到 name, address.city, address.street 三列这个函数会给嵌套字段自动起一个“父级.子级”的列名,非常直观。多层嵌套也能递归展开,比如 address 里还有 location 对象,会继续生成 address.location.x 这样的列。
第二种是 split 形态,数据里有单独的列名列表和数据行列表:
{ "columns": ["name", "age"], "data": [["A", 20], ["B", 21]] }这种格式pd.read_json(data)默认读不出来,需要orient='split':
df = pd.read_json(data_json, orient='split')第三种是 index 形态,以字典的键作为索引:
{"row1": {"a": 1, "b": 2}, "row2": {"a": 3, "b": 4}}对应orient='index',读出来行索引就是 row1、row2。我建议拿到 JSON 之后,先把它当成 Python 字典打印出来看看结构,再决定用什么 orient。别硬猜。
3.2 read_html 抽取网页表格:看似省事,实则清洗才开始
read_html 是个很神奇的函数,它直接用 pandas 内置的 HTML 解析器,把页面里所有的<table>标签读成 DataFrame 列表。页面里有几个表格,返回列表里就有几个 DataFrame。
我某次从某网页抓一张价格对比表,read_html 一行就搞定了。但出来的表有几个问题:第一,表头往往不在<tr>的第一行,而是藏在<th>里,读出来行索引是 0、1、2,实际该当表头的却是第 3 行;第二,合并单元格(rowspan/colspan)会被填充成重复值;第三,链接文字和可见文字都混在里面,烦得很。
我的做法是 read_html 只做粗读,后面固定接三步清洗:
tables = pd.read_html(url) df = tables[0] df.columns = df.iloc[0] # 第一行当列名 df = df.drop(0).reset_index(drop=True) df = df.dropna(how='all') # 去掉全空行这不是标准答案,但适合大多数粗糙的 HTML 表格。如果 html 里还有 style 颜色标记、class 属性垃圾,建议先用 HTML 解析库(比如 read_html 依赖的 lxml 或 BeautifulSoup)做一轮预处理,再把干净的 table 传给 read_html。记住,read_html 是抓取工具,不是清洗工具。
3.3 非标文本:正则表达式 + DataFrame 构造函数的兜底方案
上面所有方法都失效的时候,就轮到正则表达式和原生 DataFrame 构造函数出场了。这条路虽然原始,但能解决 99% 的文本解析难题。
所谓“结构化文本”,只要你能从里面抽取出“一条记录的边界”和“一条记录里的字段”,就能转成表。最通用的两步法:
先用正则把每条记录切出来,再把每条记录里的字段拆出来。
举例,假设有一段任务执行日志:
[INFO] 2024-01-01 12:00:00 task_id=12345 user=abc status=success cost=0.32s [ERROR] 2024-01-01 12:05:00 task_id=12346 user=def status=failed cost=1.02s我想把它转成 ts、level、task_id、user、status 五列的表。解析逻辑:
import re import pandas as pd pattern = re.compile( r'\[(?P<level>\w+)\]\s+' r'(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+' r'task_id=(?P<task_id>\d+)\s+' r'user=(?P<user>\w+)\s+' r'status=(?P<status>\w+)' ) rows = [] for line in log_lines: m = pattern.search(line) if m: rows.append(m.groupdict()) df = pd.DataFrame.from_dict(rows)这种写法的好处是,字段顺序不用管,groupdict 天然按键存储。万一某一行格式不匹配,你就丢掉它,同时可以打印出来人工检查。
如果连正则都懒得写,也可以用更暴力的做法:每行按空格切分,得到[INFO]、2024-01-01等碎片,再用pd.Series(str.split()).str.split('=', expand=True)做宽表,最后 pivot 成一列一个字段。这个方法处理“每行键不完全一致”的数据时特别好用。
4. 一整套实操实录:把混合脏文本清洗成标准化数据表
理论讲了这么多,来点真家伙。某天我从客户那边拿到一份“订单数据.txt”,打开一看真是五味杂陈——里面有三种格式混在一起,没有统一的表头,中文、英文、金额、布尔值混搭。我用一整套流程把它转成了干净的标准表。这个流程完全可以复用。
4.1 原始数据长什么样
文件叫 orders_raw.txt,开头是这样的:
# 某系统订单流水 # 导出时间:2024-03-15 订单号|用户名|下单时间|金额|状态 ORD10001|张三|2024-03-14 10:00:00|199.00|已支付 ORD10002|李四|2024-03-14 10:05:00|59.90|已取消 {"order_no": "ORD10003", "user": "王五", "timestamp": "2024-03-14 10:10:00", "amount": 299, "status": "paid"} ORD10004|赵六|2024-03-14 10:15:00|88.50|已支付这个文件前两行是注释,第三行是表头,但分隔符是竖线;第五行是 JSON 格式;其余是标准的竖线分隔。这种“混合体”直接 pd.read_csv 肯定不行,因为中间那行 JSON 会破坏整个表格结构。处理思路是分而治之:先判断每一行是什么格式,再各自解析后合并。
4.2 完整解析流程与代码
我的处理代码分四步走。
第一步,读入全部文本,逐行分类:
import pandas as pd import json import re from io import StringIO with open('orders_raw.txt', 'r', encoding='utf-8') as f: lines = [line.strip() for line in f if line.strip()] data_lines = [] json_lines = [] skip = False header_line = None for line in lines: if line.startswith('#'): continue if line.startswith('订单号'): header_line = line continue if line.startswith('{'): json_lines.append(line) else: data_lines.append(line)第二步,处理竖线分隔部分。注意 header_line 本身也是竖线分隔,我们把它当作列名文件头,把 data_lines 当数据体,用 StringIO 拼成完整 CSV 给 read_csv 读:
csv_text = header_line + '\n' + '\n'.join(data_lines) df_tab = pd.read_csv( StringIO(csv_text), sep='|', dtype={'订单号': str}, encoding='utf-8' )第三步,处理 JSON 行。这里每条 JSON 就是一个订单,我用 json_normalize 直接展平,并把字段名改成和表头一致,方便后面合并:
json_records = [json.loads(line) for line in json_lines] df_json = pd.json_normalize(json_records) df_json = df_json.rename(columns={ 'order_no': '订单号', 'user': '用户名', 'timestamp': '下单时间', 'amount': '金额', 'status': '状态' })第四步,合并两张表,统一数据类型。这里特别注意,JSON 里的金额是数值 299,竖线文件里的金额是字符串 “199.00”,所以必须换成同一种类型再 concat,否则合并后会出现一列 object 混杂数值的情况:
df_tab['金额'] = df_tab['金额'].astype(float) df_json['金额'] = df_json['金额'].astype(float) df_json['订单号'] = df_json['订单号'].astype(str) df_all = pd.concat([df_tab, df_json], ignore_index=True) df_all['下单时间'] = pd.to_datetime(df_all['下单时间'])这还没完,竖线分隔那段里“订单号”列首有隐藏空格(出现ORD10001前多了空格),我顺手做了一次 strip:
df_all['订单号'] = df_all['订单号'].str.strip()最后输出的 df_all 是五行六列的整洁表,每个字段类型都对。顺便说一句,最后我还加了一步去重和排序,防止同一订单被导两次的情况。去重键用的是订单号:
df_all = df_all.drop_duplicates(subset='订单号').sort_values('下单时间').reset_index(drop=True)4.3 为什么这种分而治之的方法靠谱
有人可能想问:为什么不直接写一个超级正则,一次性匹配所有行?我的回答是:能,但不是最优解。
混合格式文本的每一类格式,都有各自的解析规则。如果强行用正则做全文本匹配,表达式会复杂到难以维护,而且一旦格式变一点,整个正则就得重写。分而治之的好处是:每一种格式的解析逻辑是独立的,改动其中一种,不会影响另外两种;而且每一步都可以单独打印检查,定位问题特别快。
比如刚才那份数据,如果后续客户说“JSON 里金额可能带小数”,我只改 json_normalize 后面的类型转换即可,其它部分不动。这种可维护性,在真实项目中比“性能最强”重要得多。
顺便说一句,解析流程里还有一个小技巧:遇到无法分类的行,我最开始的做法是直接丢进一个 orphan_lines 列表,最后统一打印。因为有些时候数据文件里藏着完全意想不到的格式,如果不打印出来,你永远不知道它长什么样。这个习惯帮我避免过好几次“丢失数据而不自知”的尴尬。
5. 高频报错排查与独家避坑清单
最后整理一下我在解析结构化文本过程中反复踩的坑,和对应的排查步骤。这部分本来是想留给自己备忘的,但写完发现受用面还挺广,就一起放出来。
5.1 高频错误对照速查表
| 报错信息 | 根本原因 | 解决思路 |
|---|---|---|
ParserError: Error tokenizing data. C error | 行字段数量不一致,或分隔符选错 | 检查是不是真有逗号在字段内容里;尝试 sep 改成\t、` |
UnicodeDecodeError: 'utf-8' codec can't decode | 文件不是 UTF-8 编码 | 试encoding='gbk'、encoding='gb18030'、encoding='latin1';用 chardet 先检测 |
ValueError: Length mismatch: Expected axis has N elements | 表头列数和数据列数不一致 | 检查表头行有没有合并单元格,或数据行有没有多余分隔符 |
KeyError: '某列名' | 列名有不可见字符(如 BOM、尾部空格) | 读取后打印df.columns.tolist(),肉眼检查;用df.columns.str.strip()清理 |
TypeError: float() argument must be a string or a number | 类型转换时列里有非数字内容 | 先df[col].astype(str).str.extract(...)把数字部分扣出来,再转 float |
json.JSONDecodeError: Expecting value | JSON 行里混入了别的文本 | 逐行打印,配合正则提取{...}段落再 json.loads |
这张表不能覆盖所有情况,但按我经验,日常八成报错都在这几类里。排查顺序永远是:先看原始文件长得像不像你想象的样子,再看解析参数对不对,最后才怀疑代码逻辑。数据问题比代码问题常见得多。
5.2 我自己总结的四条避坑习惯
第一条,读取完成后立刻做完整性校验。不校验就往下分析,后面发现问题再回头查数据,成本高得多。最简单的校验是行数比对——原始文件有多少行非空内容、解析后 DataFrame 有多少行,两者应该对得上。对不上,说明有行在解析中被吞了。
第二条,所有“看起来是数字但带前导零或超长位数”的列,一律先按字符串读。手机号、单号、身份证号、卡号都属于这类。先读成字符串不会错,后面真需要计算再转数值也来得及。相反,如果一开始被 pandas 自动转成了整数,那前导零就再也回不来了。
第三条,中文列名不背锅,但要注意空格。pandas 完全支持中文列名,但很多人从 Excel 导出的列名里藏了全角空格、换行符。读取后我固定执行一行:
df.columns = df.columns.str.strip().str.replace('\n', '', regex=False)这步看起来无脑,却能省掉后面大量“列名对不上”的排查时间。
第四条,大文件解析先抽样再全量。文件特别大时,别急着读全量。先nrows=1000读前一千行跑通解析逻辑,确认格式没问题,再放开读全量。否则遇到一个坏行,你要么中断,要么用 warn 模式把大量警告刷屏。抽样读还可以顺带观察列的类型推断结果,提前调整 dtype。
5.3 一个关于“性能”的真实体会
最后说个性能方面的事。很多人以为处理大文本用 pandas 很慢,其实大部分慢不是因为 pandas,而是因为把文本读成了 Python 字符串后才做逐行处理。逐行循环是性能杀手。更好的做法是尽可能用 pandas 的向量化字符串方法或者正则批量处理。
比如某种文本需要从每行里提取一个 id,正则循环一万行可能要 2 秒,用str.extract做同样的事,几十毫秒就完成。数据量大到几百万行时,这个差距会被放大到难以接受的程度。
我在处理某次千万级日志时就吃过这个亏。最初的脚本用了逐行 re.search,跑了一个多小时。后来改成按行先拼出一个字符串列表,再用 pandas 的 str 方法整体提取,整个流程压缩到三分钟。这个经验让我养成了一个习惯:凡是能用 pandas 原生字符串方法解决的,绝不轻易写 Python 循环。
写在最后的真实建议
解析结构化文本这件事,做多了就会发现,大部分精力其实不在“读取”上,而在“理解文本的真实结构”上。拿到文件先别急着写代码,打开看前五十行,搞清楚分隔符、表头位置、编码、是否有注释行、是否有嵌套结构,再决定用 read_csv、read_json 还是混合方案。
我自己现在处理新文件的顺序已经完全固化:先文本预览,再小样本解析,然后做列名和行数校验,确认无误后才进入全量读取。这套流程帮我少踩了无数坑。如果你现在正被一个乱七八糟的文本文件折磨,不妨按照上面的步骤走一遍,多半能把问题解决在读取阶段。真正到了清洗阶段,反而没那么容易出事了。