itertools.groupby 分组的预排序陷阱与替代写法

itertools.groupby 是 Python 标准库里最容易被误用的函数之一。它的签名看起来像 SQL 的 GROUP BY,但语义完全不同:它只合并相邻的相同键,不做全局汇总。于是「忘了先排序」成了高频事故——数据没报错、结果却悄悄少了行或者多了组。这篇教程把 groupby 的真实语义、四种常见翻车现场、排序键的挑选技巧,以及 sort + gr

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。