1. 为什么“往单元格里塞列表”这件事值得单独拿出来讲
如果你平时用 pandas 处理数据,大概率遇到过这种场景:某一列需要存放的不是一个标量,而是一组值。比如一个用户对应多个标签、一个订单对应多个商品、一条记录对应多个时间点。很多人的第一反应是“那我直接df.loc[0, 'col'] = [1, 2, 3]不就行了”。实测下来,这一步确实能跑通,但接下来发生的事情往往就开始不受控制了——保存成 CSV 再读回来,列表变成了字符串;做groupby聚合,报错说不可哈希;想用explode展开,发现列里混着列表和NaN,直接抛异常。
这个标题看起来是个很小的操作问题,但它背后牵扯的是 pandas 一个非常核心的设计哲学:DataFrame 的每一列在底层是一个同质的 NumPy 数组或 ExtensionArray,它天然假设“一个格子 = 一个标量”。当你往格子里塞一个列表,本质上是在挑战这个假设。pandas 允许你这么干,是因为它把这一列的 dtype 降级成了object,也就是“一列 Python 对象指针”,它不再对里面装什么负责。理解这一点,你才能明白后面所有的坑是怎么来的。
这篇文章适合三类人看:第一类是刚上手 pandas、被“单元格里放列表”这个需求卡住的新手;第二类是已经能写出来、但被 dtype、序列化、聚合这些问题反复折磨的中级使用者;第三类是想搞清楚 pandas 对象列(object column)底层行为的人。我会从最直接的插入方法讲起,一路讲到 dtype 陷阱、批量赋值、序列化兼容、以及什么时候你其实不该用列表而该用别的结构。全程都是可以直接抄的代码,也会解释每一步为什么这么写。
先说结论:往单元格插列表,技术上就是一行赋值语句的事,但真正决定你项目会不会翻车的,是插入之后你怎么管理这一列的 dtype、怎么读写、怎么聚合。下面按这个逻辑一层层展开。
2. 三种插入写法,以及它们各自在什么场景下才成立
2.1 单点赋值:at/loc/iat的取舍
最直观的写法就是定位到具体格子然后赋值。pandas 提供了几个定位器,用哪个其实有讲究。
import pandas as pd df = pd.DataFrame({ 'user': ['A', 'B', 'C'], 'tags': [None, None, None] }) # 写法一:loc,按标签定位 df.loc[0, 'tags'] = ['new', 'active'] # 写法二:at,标量快速访问 df.at[1, 'tags'] = ['vip'] # 写法三:iat,按整数位置定位 df.iat[2, df.columns.get_loc('tags')] = ['trial', 'expired']这三种写法结果一样,但性能和使用场景不同。at和iat是标量访问器,设计目标是“快速读写单个值”,在循环里逐格赋值时比loc快不少。loc更通用,支持切片和布尔索引,但单点赋值时它内部要做更多的对齐和校验。如果你只是偶尔插一个列表,用哪个都无所谓;如果你要在几千行的循环里逐格塞列表,at会明显更省时间。
这里有个很多人不知道的细节:用at赋值一个列表时,pandas 不会尝试把这个列表拆开。它把整个列表当成一个 Python 对象存进去。这一点很关键,因为如果你用的是df.loc[0] = [...]这种整行赋值,pandas 会尝试把列表按列对齐,结果完全不是你想要的。我见过有人写df.loc[0] = ['A', ['x', 'y']],以为第二个元素会进第二列,实际上它会报长度不匹配或者把列表拆成两列。所以记住:整行赋值和单元格赋值是两套逻辑,别混用。
2.2 批量赋值:用列表推导式一次填一列
实际项目里更常见的是“根据某一列计算出一组列表,然后填进新列”。这时候逐格at就太慢了,正确做法是先构造一个 Python 列表(列表的列表),然后一次性赋给整列。
# 假设要根据 score 列生成等级标签列表 df = pd.DataFrame({'score': [95, 82, 67, 45]}) def make_labels(s): labels = [] if s >= 90: labels.append('excellent') if s >= 60: labels.append('pass') else: labels.append('fail') return labels df['labels'] = [make_labels(s) for s in df['score']]注意这里我用的是列表推导式而不是df['score'].apply(make_labels)。两者结果一样,但列表推导式在纯 Python 函数场景下通常更快,因为apply有额外的封装开销。当然,如果你的函数逻辑复杂、需要访问整行,apply(axis=1)更合适。选哪个的判断标准很简单:只依赖单列值就用列表推导式,依赖多列就用apply(axis=1)。
批量赋值还有一个隐藏好处:pandas 在一次性赋值整列时,会直接推断 dtype 为object,不会像逐格赋值那样反复触发 dtype 检查和可能的碎片化警告。如果你在循环里逐格给一个新列赋值,pandas 可能会抛出PerformanceWarning: DataFrame is highly fragmented,这就是碎片化的信号。
2.3 从字符串解析回来:ast.literal_eval的正确用法
很多时候列表不是凭空产生的,而是从别的地方读进来的字符串,比如"[1, 2, 3]"。这时候你需要把它解析成真正的列表再插入。
import ast df = pd.DataFrame({'raw': ['[1, 2, 3]', "['a', 'b']", '[]']}) df['parsed'] = df['raw'].apply(ast.literal_eval)这里必须强调:不要用eval。eval会执行任意 Python 代码,如果数据来源不可控,等于把执行权限交出去了。ast.literal_eval只解析字面量(列表、字典、元组、数字、字符串),安全得多。这是我在实际项目里反复强调的一条红线,尤其是处理用户上传的数据时。
如果字符串格式不规范,比如用了单引号混双引号、或者有nan这种非标准字面量,literal_eval会抛ValueError。稳妥的做法是包一层 try:
def safe_parse(x): try: return ast.literal_eval(x) except (ValueError, SyntaxError): return [] df['parsed'] = df['raw'].apply(safe_parse)这样即使个别行格式有问题,也不会让整个流程崩掉,而是降级成空列表,后续可以单独排查。
3. dtype 变成 object 之后,那些让人措手不及的连锁反应
3.1 object 列不是“列表列”,它只是“指针列”
很多人以为把列表塞进单元格后,这一列就变成了“列表类型”。不是的。pandas 里没有原生的 list dtype(截至现在的主流版本),它只是把 dtype 标成了object。object的含义是:这一列底层是一个 PyObject 指针数组,每个格子指向一个 Python 对象,至于这个对象是列表、字典还是字符串,pandas 不管。
这带来一个直接后果:你没法对这一列做任何向量化运算。df['tags'].str.upper()会报错,因为.str访问器只对字符串列有效。df['tags'] + 1也不行。所有操作都得走apply或者列表推导式,性能自然就下来了。
更隐蔽的问题是NaN。在 object 列里,缺失值可能是None、np.nan、float('nan')中的任意一种,取决于它是怎么产生的。当你写[x for x in df['tags'] if x]时,如果某个格子是np.nan,if np.nan的结果是True(因为nan是真值),你的过滤逻辑就错了。正确做法是用pd.isna()判断:
cleaned = [x if not pd.isna(x) else [] for x in df['tags']]这个坑我在早期项目里踩过不止一次,尤其是数据经过多次合并、填充之后,缺失值的形态会变得很混乱。
3.2 相等性判断和去重会给你惊喜
object 列里装列表,做drop_duplicates()或者==比较时,pandas 会逐个调用 Python 的==。列表的==是逐元素比较,所以[1, 2] == [1, 2]是True,这符合直觉。但问题是列表不可哈希,所以你不能把它当字典的 key,也不能直接groupby这一列。
# 这会报错:unhashable type: 'list' df.groupby('tags').size()如果你确实需要按列表内容分组,得先转成可哈希的形式,比如元组:
df['tags_tuple'] = df['tags'].apply(lambda x: tuple(x) if isinstance(x, list) else x) df.groupby('tags_tuple').size()转元组这个技巧很实用,因为元组和列表内容相同时,tuple([1,2]) == (1,2)为True,而且元组可哈希。代价是丢失了可变性,但对分组场景来说无所谓。
3.3 序列化:CSV 会背叛你,Parquet 和 pickle 才是朋友
这是最容易被忽视、也最容易在生产环境爆炸的一环。你把 DataFrame 存成 CSV,列表会被str()成字符串,读回来就是"[1, 2, 3]"这样的文本,dtype 变成object但内容是字符串,你得重新解析。如果列表里本身有逗号或者引号,CSV 的转义规则还会进一步搅乱格式。
df.to_csv('out.csv', index=False) back = pd.read_csv('out.csv') print(type(back.loc[0, 'tags'])) # <class 'str'>,不是 list正确的持久化选择有两个方向。如果只是自己项目内部流转,用 pickle 或 Parquet。pickle 能完整保留 Python 对象,读回来还是列表;Parquet 对 object 列的支持依赖底层实现,通常也能保留嵌套结构,而且压缩率高、跨语言好。如果要和其他系统交换数据,用 JSON,因为 JSON 原生支持数组,pd.read_json能正确还原成列表。
df.to_pickle('out.pkl') df.to_json('out.json', orient='records', force_ascii=False)我个人的经验是:中间过程用 pickle,最终交付用 JSON,绝对不用 CSV 存带列表的列。如果对方系统只认 CSV,那就得在导出前把列表join成字符串,导入后再split,并且明确约定分隔符不能出现在元素内部。
4. 展开、聚合与查询:把列表列真正用起来
4.1explode是列表列的第一公民
列表列最大的价值在于它可以被“炸开”成多行,这就是explode。它把每个列表元素变成独立的一行,其他列的值复制。
df = pd.DataFrame({ 'user': ['A', 'B'], 'tags': [['new', 'vip'], ['trial']] }) exploded = df.explode('tags') # user tags # A new # A vip # B trialexplode有几个必须知道的边界行为。第一,空列表[]会产生一行NaN,而不是被丢弃。如果你不想要这行,得手动dropna。第二,NaN本身也会保留成一行NaN。第三,explode之后索引会重复,如果你后续要做基于索引的操作,记得reset_index(drop=True)。
exploded = df.explode('tags').dropna(subset=['tags']).reset_index(drop=True)这个组合拳我在处理标签数据时几乎每次都用。先炸开、再去掉空值、最后重置索引,三步下来数据就规整了。
4.2 用apply做列表级聚合
反过来,如果你想把多行聚合成一个列表,groupby加apply(list)是标准做法:
df = pd.DataFrame({ 'user': ['A', 'A', 'B'], 'tag': ['new', 'vip', 'trial'] }) grouped = df.groupby('user')['tag'].apply(list).reset_index() # user tag # A [new, vip] # B [trial]注意这里apply(list)的结果列 dtype 是 object,符合预期。但如果你用的是agg(list),在某些 pandas 版本里行为略有差异,建议统一用apply(list)更稳。另外,如果分组后某组为空,apply可能返回空列表或者直接跳过,取决于版本,测试时要注意。
4.3 查询:isin和列表推导式的分工
想筛选“标签里包含 vip 的行”,不能用df['tags'] == 'vip',因为那是拿列表和字符串比。正确写法有两种:
# 写法一:apply + in mask = df['tags'].apply(lambda x: 'vip' in x if isinstance(x, list) else False) result = df[mask] # 写法二:先 explode 再筛选(适合需要保留展开结果的场景) result = df.explode('tags').query("tags == 'vip'")写法一适合“只要原行”的场景,写法二适合“要展开后的明细”。选哪个取决于你后续要不要保留列表结构。如果只是打个标记,写法一更直接;如果要统计每个标签的出现次数,写法二更自然。
这里有个性能提示:apply里的isinstance检查不能省。因为 object 列里可能混着NaN或其他类型,直接'vip' in x遇到NaN会抛TypeError。这种防御性写法在数据清洗阶段特别重要。
5. 那些年我踩过的坑,以及怎么绕过去
5.1 链式赋值警告:SettingWithCopyWarning的真实成因
当你从一个大 DataFrame 切片出子集,然后往子集的单元格里塞列表,pandas 会警告你可能在修改视图而不是副本。这个警告在 object 列上尤其常见,因为赋值操作更复杂。
sub = df[df['user'] == 'A'] sub.loc[0, 'tags'] = ['x'] # 可能触发警告根因是 pandas 无法确定sub是视图还是副本。解决办法很明确:在切片时显式.copy()。
sub = df[df['user'] == 'A'].copy() sub.loc[0, 'tags'] = ['x'] # 干净,无警告多花一点内存换来确定性,这笔账在数据量不是极端大的时候完全划算。我现在的习惯是:只要切片后要改,一律.copy(),不给自己留隐患。
5.2 列表里的元素是可变对象时的连锁修改
如果你往多个格子塞的是同一个列表对象,改一个会全改。这是 Python 引用语义的经典坑。
shared = [] df['tags'] = [shared] * 3 df.at[0, 'tags'].append('x') # 结果三个格子的列表都变成了 ['x']正确做法是每次生成新列表:
df['tags'] = [[] for _ in range(3)]这个坑在批量初始化时特别容易中招,因为[[]] * n看起来很像“创建 n 个空列表”,实际上是“创建 n 个指向同一个列表的引用”。记住这条:凡是可变对象,初始化时都要用推导式而不是乘法。
5.3 版本差异:不同 pandas 版本对 object 列的处理
pandas 在 1.x 到 2.x 之间对 object 列和字符串列做了不少调整。比如 2.0 引入了stringdtype 作为默认字符串类型,某些原本是 object 的字符串列现在会变成string,这会影响你判断“这一列是不是列表列”。稳妥的判断方式是检查元素类型而不是 dtype:
def is_list_col(s): return s.apply(lambda x: isinstance(x, list)).all()另外,explode在早期版本对空列表的处理和现在不同,升级 pandas 后建议回归测试一遍相关逻辑。我在升级到 2.x 时就遇到过explode后空列表行为变化导致下游统计对不上的情况,排查了半天才发现是版本差异。
6. 什么时候你不该用列表,而该换一种结构
列表列虽然方便,但它是有代价的:不可哈希、不可向量化、序列化麻烦、内存占用高(每个列表都是一个独立 Python 对象,有额外开销)。如果你的列表长度固定且较短,用多个标量列可能更好;如果列表很长且需要频繁查询,用独立的关联表(长表)才是正解。
判断标准可以简化成三条:列表长度是否固定、是否需要按列表元素查询、数据量是否大到内存敏感。三条里中了两条,就该考虑拆表而不是塞列表。比如用户标签这种典型的一对多关系,长表(user, tag 两列,多行)在查询、聚合、存储上都比列表列更优,只有在“展示时想一行看全”的场景下才需要临时聚合回列表。
我自己的做法是:存储用长表,展示用列表列。也就是数据落地时保持规范化,到了要输出报表或者喂给前端时,再用groupby + apply(list)聚合成列表列。这样既拿到了关系模型的查询效率,又拿到了列表列的展示便利。
# 存储态:长表 long_df = pd.DataFrame({ 'user': ['A', 'A', 'B'], 'tag': ['new', 'vip', 'trial'] }) # 展示态:聚合成列表列 display_df = long_df.groupby('user')['tag'].apply(list).reset_index()这个模式我在多个项目里反复用,基本没出过问题。核心思路就是让每种数据结构干它最擅长的事,而不是硬把一对多塞进一个格子里。
最后分享一个我常用的调试技巧:当你怀疑某列的列表结构有问题时,先跑一遍df['col'].apply(type).value_counts(),看看里面到底混了哪些类型。这一步能快速定位是NaN、字符串还是其他类型混进来了,比逐行打印高效得多。数据清洗阶段养成这个习惯,能省下大量排查时间。