Python's itertools.groupby Consecutive-Only Trap: The Aggregator That Reports One of Everything

2026-07-13

This function is supposed to count records by status. It works on the developer's laptop with test fixtures, passes code review, and ships. A week later, the dashboard shows every order status has a count of 1 — even though the database has thousands of them.

from itertools import groupby

def count_by_status(records):
    """Count records by status. Returns {status: count}."""
    result = {}
    for status, group in groupby(records, key=lambda r: r['status']):
        result[status] = sum(1 for _ in group)
    return result

records = [
    {'id': 1, 'status': 'pending'},
    {'id': 2, 'status': 'shipped'},
    {'id': 3, 'status': 'pending'},
    {'id': 4, 'status': 'shipped'},
    {'id': 5, 'status': 'pending'},
]

print(count_by_status(records))
# Expected: {'pending': 3, 'shipped': 2}
# Actual:   {'pending': 1, 'shipped': 1}

The Bug

itertools.groupby is not the Python analogue of SQL's GROUP BY. It groups only consecutive elements that share a key, exactly like Unix's uniq. If the input isn't sorted by the key, you get one "group" per run of identical values, not one group per distinct value.

For the sample data, groupby yields five groups: ('pending', 1), ('shipped', 1), ('pending', 1), ('shipped', 1), ('pending', 1). The dictionary assignment then overwrites each key four times, leaving the last count seen for each status — which, coincidentally, is always 1.

Why this passes tests: developers write fixtures with pre-sorted data ("pending, pending, shipped, shipped") because it reads naturally. In that shape, groupby works correctly and the bug hides. Production data — records streaming in by insertion time — rarely arrives sorted by status. The test fixture is the bug's camouflage.

There's a second, quieter hazard: even if you iterate groupby correctly, the group iterator is consumed lazily and shares state with the outer iterator. If you advance the outer loop before draining the inner group, the group's remaining items are silently discarded. Storing groups in a list for later use produces empty groups.

The Fix

If you actually need grouping semantics, either sort first or reach for the correct tool: collections.Counter or collections.defaultdict. For counting, Counter is the one-liner:

from collections import Counter

def count_by_status(records):
    return Counter(r['status'] for r in records)

If you specifically want groupby — say, to build lists of records per status — sort by the key first:

from itertools import groupby
from operator import itemgetter

def group_by_status(records):
    key = itemgetter('status')
    sorted_records = sorted(records, key=key)
    return {status: list(group) for status, group in groupby(sorted_records, key=key)}

Reserve groupby for cases where consecutive-run semantics are what you actually want: collapsing repeated log lines, run-length encoding, chunking a stream at boundary changes. For "aggregate by category," use Counter or defaultdict — they can't be fooled by input order.

Key Takeaway: Python's itertools.groupby is uniq, not SQL's GROUP BY — it only groups consecutive runs, so unsorted input produces one tiny group per element and silently wrong aggregates.

All newsletters