itertools.groupby 是 Python 标准库里最容易被误用的函数之一。它的签名看起来像 SQL 的 GROUP BY,但语义完全不同:它只合并相邻的相同键,不做全局汇总。于是「忘了先排序」成了高频事故——数据没报错、结果却悄悄少了行或者多了组。这篇教程把 groupby 的真实语义、四种常见翻车现场、排序键的挑选技巧,以及 sort + groupby、defaultdict、pandas、SQL 等替代写法一次讲清,代码全部可直接粘贴运行。
一、groupby 到底做了什么:相邻才是关键
先看官方文档那句容易被跳过的描述:Make an iterator that returns consecutive keys and groups from the iterable. 关键词是 consecutive(连续的)。groupby(iterable, key=None) 返回的是 (key, group_iterator) 二元组流,每当 key 发生变化时就切出一个新组,仅此而已。
下面这段代码把陷阱暴露得非常直接:
from itertools import groupby
rows = [
("math", "Alice"),
("physics", "Bob"),
("math", "Cathy"),
("physics", "David"),
("math", "Eve"),
]
for subject, group in groupby(rows, key=lambda r: r[0]):
print(subject, [name for _, name in group])
# 输出:
# math ['Alice']
# physics ['Bob']
# math ['Cathy']
# physics ['David']
# math ['Eve']
5 条记录被切成了 5 个组,math 出现了三次。groupby 没有做任何跨行聚合,它只是一台「相邻去重切分机」。如果业务代码假设它会像 dict 一样把同名学科合并,最终统计出的选课人数就是错的,而单元测试如果只断言「返回值非空」,这个 bug 可以潜伏几个月。
另一个容易被忽略的点是:一旦 key 发生变化,前一个组的迭代器就失效了。想保留分组结果,必须在进入下一轮迭代前把它消费掉(list() 或 dict() 快照),否则拿到的会是一个空的可迭代对象。这是 groupby 第二个高频坑,稍后细说。
二、四个高频翻车现场与真实报错
2.1 忘记排序:结果静默错误
这是最常见的场景。日志按时间写入时天然有序,但一旦被 concurrent.futures 多线程或多进程写入,顺序就被打乱。groupby 拿到乱序输入不会抛异常,只是切出的组数和预期不符。防御性写法是在分组前加一道断言:
from itertools import groupby
def group_logs(records, key):
records = sorted(records, key=key)
result = {k: list(g) for k, g in groupby(records, key=key)}
assert sum(len(v) for v in result.values()) == len(records), "分组丢数据"
return result
求和校验几乎零成本,却能立刻抓住「输入被预分组过又被合并」这类问题。
2.2 组迭代器被延迟消费:结果全空
很多人在循环里先把 (key, group) 收集起来,回头再消费:
from itertools import groupby
data = [("a", 1), ("a", 2), ("b", 3)]
pairs = [(k, g) for k, g in groupby(data, key=lambda x: x[0])]
print([(k, list(g)) for k, g in pairs])
# 输出:[('a', []), ('b', [])]
原因是列表推导式在取下一个 (k, g) 的那一刻,上一个 g 已经推进到了下一键,底层源迭代器已经越过了旧组的所有元素。正确姿势是在 loop 内立刻物化:
pairs = [(k, list(g)) for k, g in groupby(data, key=lambda x: x[0])]
print(pairs) # [('a', [('a', 1), ('a', 2)]), ('b', [('b', 3)])]
如果确实需要惰性,用 itertools.tee 也不行——tee 只是在位置层面分流,groupby 的失效是键切换导致的语义失效,不是消费竞争。
2.3 排序键与分组键不一致
sorted 与 groupby 必须用同一个键函数,而很多人把「排序权重」写成了复合键:
from itertools import groupby
people = [{"dept": "RD", "age": 30}, {"dept": "QA", "age": 25}, {"dept": "RD", "age": 22}]
# 错误:按 (dept, age) 排序,却只按 dept 分组
for dept, g in groupby(sorted(people, key=lambda p: (p["dept"], p["age"])),
key=lambda p: p["dept"]):
print(dept, len(list(g)))
# RD 2, QA 1 —— 本例碰巧正确
这个例子「碰巧」没问题,因为同部门内部再按年龄排序不会把部门块拆开。但如果排序键里包含了会打破部门连续性的字段,问题就出现了:
people = [{"dept": "RD", "age": 30}, {"dept": "QA", "age": 25}, {"dept": "RD", "age": 22}]
# 按年龄升序排(与分组键无关),RD 会被切成两段
for dept, g in groupby(sorted(people, key=lambda p: p["age"]), key=lambda p: p["dept"]):
print(dept, [p["age"] for p in g])
# RD [22] / QA [25] / RD [30]
规则很简单:排序键的第一个元素必须等于分组键。排序键可以带次级字段,但主键必须是分组键,比如 key=lambda p: (p["dept"], p["age"])。
2.4 输入是生成器,想排序却 TypeError
真实报错长这样:
TypeError: '<' not supported between instances of 'dict' and 'dict'
出现这种错误多半是因为把字典列表直接扔给了 sorted() 却没给 key。还有一种是输入本身不可重复遍历(比如从文件句柄读出来的行流),先调用 sorted() 可以正常工作,但如果想在排序前先 groupby 一遍做「分段处理」,流已经被消耗掉,第二遍读到的是空。解决方式:小规模数据直接 list(stream) 物化;大规模数据要么用 tee 分流,要么改成单遍扫描的写法(见第四节)。
三、正确的 sort + groupby 模板与排序键技巧
把排序和分组封装成一个函数,是避免重复犯错的最简办法:
from itertools import groupby
from operator import itemgetter
def sorted_groups(iterable, key):
"""先整体排序再分组,返回 (key, list) 的生成器。"""
key_func = key if callable(key) else itemgetter(key)
items = sorted(iterable, key=key_func)
for k, g in groupby(items, key=key_func):
yield k, list(g)
rows = [
{"dept": "QA", "name": "Bob"},
{"dept": "RD", "name": "Alice"},
{"dept": "QA", "name": "Cathy"},
]
print(dict(sorted_groups(rows, key="dept")))
# {'QA': [{'dept': 'QA', 'name': 'Bob'}, {'dept': 'QA', 'name': 'Cathy'}],
# 'RD': [{'dept': 'RD', 'name': 'Alice'}]}
这里用 operator.itemgetter 替代 lambda,一是快(C 层实现),二是 sorted 和 groupby 共用同一个对象,天然杜绝键函数写岔的风险。
3.1 多字段分组:用元组键
按「部门 + 年份」分组,键函数返回元组,排序键保持一致即可:
from itertools import groupby
logs = [
{"dept": "RD", "year": 2024, "cost": 120},
{"dept": "RD", "year": 2023, "cost": 80},
{"dept": "RD", "year": 2024, "cost": 200},
]
key = lambda x: (x["dept"], x["year"])
for (dept, year), g in groupby(sorted(logs, key=key), key=key):
print(dept, year, sum(x["cost"] for x in g))
# RD 2023 80
# RD 2024 320
3.2 自定义对象:区分 __eq__ 与排序键
如果分组依据是 Person.dept,但需要按 dept 加 salary 排序,键必须写成 lambda p: (p.dept, p.salary),不能只写 attrgetter("dept")。另外 groupby 用 == 比较键,sorted 用 <;类型实现不完整时(比如 __eq__ 定义了但没定义 __lt__),排序会抛 TypeError,而分组不会报错,这种不对称很容易让人误以为数据没问题。
3.3 稳定性带来的额外好处
sorted 和 list.sort 都是稳定排序,相同键的元素保持原相对顺序。这意味着在组内想按原始读入顺序输出时,无需额外写次级排序键,直接复用输入顺序即可。例如按时间排序后再按用户分组,组内时间顺序天然保留。
四、替代写法:单遍扫描、defaultdict 与 pandas
4.1 内存吃紧:单遍扫描的生成器
如果输入本身已经有序(比如数据库按主键游标读取、日志文件按时间追加),不要为了 groupby 而整表排序。既然已经有序,groupby 就完全正确且零额外内存:
from itertools import groupby
def stream_by_key(iterable, key):
for k, g in groupby(iterable, key=key):
yield k, list(g)
# 输入是有序流时,groupby 是 O(n) 时间、O(max_group) 空间的
for user, items in stream_by_key(sorted_events_from_db, key=lambda e: e["user_id"]):
handle(user, items)
反过来,如果输入无序且数据量远大于内存,sorted() 在 CPython 里会产生完整列表副本(Timsort 需要额外空间),几百万条字典就会把内存吃满。这种场景应该把分组下推到数据源(SQL 的 GROUP BY、pandas 的分块读),而不是在 Python 层硬扛。
4.2 一次性按值分组:defaultdict 更直观
如果不需要「连续」语义,只是想得到一个 dict[key, list],defaultdict 的代码更短、更不容易出错:
from collections import defaultdict
rows = [("math", 90), ("physics", 85), ("math", 76)]
buckets = defaultdict(list)
for subject, score in rows:
buckets[subject].append(score)
print(dict(buckets)) # {'math': [90, 76], 'physics': [85]}
对比三者的差异:
| 写法 | 是否要求有序 | 内存 | 返回类型 | 适用场景 |
|---|---|---|---|---|
groupby(无排序) |
是,相邻才合并 | O(1) 额外 | 迭代器流 | 流式遍历有序数据 |
sorted + groupby |
否 | O(n) 副本 | 迭代器流 | 需要按分组顺序输出 |
defaultdict(list) |
否 | O(n) | dict | 分组即可,顺序无所谓 |
pandas.groupby |
否 | 大 | DataFrame/Series | 结构化数据分析 |
4.3 结构化数据:pandas 与字典累积
当数据已经是 DataFrame,pandas.DataFrame.groupby 默认不要求排序(sort=True 只影响输出顺序),语义更接近直觉,且支持一次性聚合多个统计量:
import pandas as pd
df = pd.DataFrame({
"dept": ["RD", "QA", "RD", "QA"],
"salary": [100, 80, 120, 90],
})
print(df.groupby("dept")["salary"].agg(["count", "sum", "mean"]).to_dict())
# {'count': {'QA': 2, 'RD': 2}, 'sum': {'QA': 170, 'RD': 220}, 'mean': {'QA': 85.0, 'RD': 110.0}}
如果不想引入 pandas,可以用字典手工累积,适合需要同时计算多个指标的场景:
stats = {}
for dept, salary in zip(df["dept"], df["salary"]):
item = stats.setdefault(dept, {"count": 0, "sum": 0})
item["count"] += 1
item["sum"] += salary
print(stats)
# {'RD': {'count': 2, 'sum': 220}, 'QA': {'count': 2, 'sum': 170}}
setdefault 在这里比 defaultdict 更简洁,因为它一次完成「取默认值 + 更新」的动作。
4.4 SQL 已有 GROUP BY 时不要在 Python 里重做
从数据库读出来再在 Python 里分组,往往是多此一举。把 GROUP BY 交给数据库,还能利用索引和谓词下推。典型反例是 SELECT * FROM orders 拉全表再 groupby(orders, key=...),内存和网络都被白吃。判断标准:只要数据源支持聚合且无需在 Python 里做自定义逻辑,就先在 SQL 里 group。
五、小结
groupby合并的是相邻相同键,不是全局聚合,前置sorted()几乎是必须的。sorted与groupby必须共用同一个键函数;多字段分组用元组键,且元组首元素必须等于分组键。- 组迭代器必须在进入下一轮迭代前物化(
list()或dict()),否则会得到空组。 - 输入本身有序时,
groupby是 O(1) 额外空间的流式分组,是最优选择;无序大表交给数据库或 pandas。 - 只要能接受
dict返回类型,defaultdict(list)通常比sort + groupby更短、更少踩坑。
六、FAQ
Q1:为什么 groupby 不默认排序?
因为它被设计成纯流式迭代器,不缓存全部数据,才能处理无限流或超大文件。排序需要 O(n) 内存,这与设计目标冲突。
Q2:groupby 的 key 可以返回 None 吗?
可以。当 key=None(默认值)时,groupby 直接按元素本身比较,等价于 key=lambda x: x。但如果传递了一个总是返回 None 的函数,所有相邻元素会被分进同一个键为 None 的组。
Q3:groupby 产出的 group 是列表吗?
不是,是 itertools._grouper 对象,只能遍历一次。需要切片、len()、多次遍历时必须先 list(),否则会出现「第一次能打印、第二次为空」的怪象。
Q4:sorted() 排序自定义对象时报 TypeError: '<' not supported between instances of 'X' and 'X' 怎么办?
说明对象没有定义 __lt__,或定义的顺序比较对当前字段不适用。给 sorted 显式传 key=,例如 key=lambda x: x.created_at,不要试图给类临时打猴子补丁,容易污染全局行为。
Q5:分组结果顺序能否保证与输入顺序一致?
groupby 输出组的顺序由输入中键首次出现的顺序决定;配合稳定排序时,组内元素保持排序前同键元素的相对顺序。想按键本身排序输出,用 sorted(groups) 再遍历即可。
Q6:itertools.groupby 与 more_itertools.groupby_transform 有什么区别?
groupby_transform 在分组的同时对组内元素做值映射(类似 map 与 groupby 的组合),适合边分组边转换格式的管道场景。它是第三方库函数,不随 CPython 分发,使用前需 pip install more-itertools。