itertools and collections

Updated

September 8, 2026

itertools and collections

The standard library already walks, groups, counts, and queues. itertools builds iterators. collections builds specialised containers. The boring default is these two modules, not a new class, when the job is “number tickets,” “stick two shifts together,” “group by table,” “tally statuses,” or “serve from the front of the line.”

Mental model

itertools functions return iterators. They do not copy the source unless the algorithm has to. count never ends on its own — bound it. chain walks one iterable after another. groupby walks consecutive equal keys, so sort first if you want every table together.

collections.Counter counts hashable things. collections.deque is a list that pops from the left in constant time. collections.defaultdict calls a factory for missing keys so you do not write if key not in d.

No extra packages. If you need something these do not do, write a short function.

Worked examples

Case 1: count numbers the rail

Save as ticket_ids.py. The desk issues ids starting at 100. zip stops when the names stop, so count does not run forever.

# ticket_ids.py
from itertools import count


def main():
    names = ["soup", "grill", "bar"]
    for number, name in zip(count(100), names):
        print(f"T-{number} {name}")


if __name__ == "__main__":
    main()

Run:

uv run python ticket_ids.py

Output:

T-100 soup
T-101 grill
T-102 bar

count(100, 5) would step by five. A for n in count(100): without a break or a zip is a live lock. Bound the work.

Case 2: chain joins shifts

Save as chain_shifts.py. Morning and evening stay as two lists. The loop sees one stream.

# chain_shifts.py
from itertools import chain


def main():
    morning = ["T-11", "T-12"]
    evening = ["T-21"]
    for ticket in chain(morning, evening):
        print(ticket)
    print(list(chain.from_iterable([morning, evening])))


if __name__ == "__main__":
    main()

Run:

uv run python chain_shifts.py

Output:

T-11
T-12
T-21
['T-11', 'T-12', 'T-21']

chain.from_iterable is for a list of lists. chain(morning, evening) is for a handful of named iterables. Do not concatenate with morning + evening if you only need to walk them.

Case 3: groupby needs a sort

Save as by_table.py. Tickets arrive mixed. groupby only clusters neighbours with the same key. Sort by table first.

# by_table.py
from itertools import groupby


def main():
    tickets = [
        {"id": "T-1", "table": 4},
        {"id": "T-2", "table": 2},
        {"id": "T-3", "table": 4},
        {"id": "T-4", "table": 2},
    ]
    ordered = sorted(tickets, key=lambda t: t["table"])
    for table, group in groupby(ordered, key=lambda t: t["table"]):
        ids = [t["id"] for t in group]
        print(f"table {table}: {ids}")


if __name__ == "__main__":
    main()

Run:

uv run python by_table.py

Output:

table 2: ['T-2', 'T-4']
table 4: ['T-1', 'T-3']

Consume group before the next iteration. It is an iterator over the current run, not a list you can keep. If you skip the sort, table 4 appears twice.

Case 4: Counter tallies statuses

Save as tally.py. A night of tickets, then a count.

# tally.py
from collections import Counter


def main():
    statuses = ["open", "paid", "open", "void", "paid", "open"]
    counts = Counter(statuses)
    print(counts["open"])
    print(counts.most_common(2))
    counts.update(["paid", "paid"])
    print(dict(counts))


if __name__ == "__main__":
    main()

Run:

uv run python tally.py

Output:

3
[('open', 3), ('paid', 2)]
{'open': 3, 'paid': 4, 'void': 1}

Counter is a dict. Missing keys read as 0, not KeyError. most_common is the report the close-of-shift wants.

Case 5: deque and defaultdict

Save as line_and_tabs.py. The line is served from the left. Tabs accumulate by table without an if table not in tabs block.

# line_and_tabs.py
from collections import defaultdict, deque


def main():
    line = deque(["T-11", "T-12", "T-13"])
    line.append("T-14")
    print(line.popleft())
    print(list(line))

    tabs = defaultdict(int)
    for table, pence in [(4, 1200), (2, 800), (4, 400)]:
        tabs[table] += pence
    print(dict(tabs))
    print(tabs[9])


if __name__ == "__main__":
    main()

Run:

uv run python line_and_tabs.py

Output:

T-11
['T-12', 'T-13', 'T-14']
{4: 1600, 2: 800}
0

tabs[9] created a 0. That is the deal with defaultdict. If a missing table should be an error, use a plain dict.

deque(maxlen=3) drops from the left when you append past the bound. Use that for a short recent-ticket log, not as a clever database.

The trap

Save as groupby_unsorted.py. Same tickets as Case 3, no sort. groupby reports table 4, then 2, then 4, then 2.

# groupby_unsorted.py
from itertools import groupby


def main():
    tickets = [
        {"id": "T-1", "table": 4},
        {"id": "T-2", "table": 2},
        {"id": "T-3", "table": 4},
        {"id": "T-4", "table": 2},
    ]
    for table, group in groupby(tickets, key=lambda t: t["table"]):
        ids = [t["id"] for t in group]
        print(f"table {table}: {ids}")


if __name__ == "__main__":
    main()

Run:

uv run python groupby_unsorted.py

Output:

table 4: ['T-1']
table 2: ['T-2']
table 4: ['T-3']
table 2: ['T-4']

That is consecutive grouping, not a merge. Sort, or use defaultdict(list) and append.

The boring rule

  • count must be bounded (zip, islice, break).
  • chain (or yield from) instead of building a combined list you do not need.
  • Sort before groupby. Treat each group as a one-shot iterator.
  • Counter for tallies. deque for a line. defaultdict when missing keys have an obvious factory.
  • Stay in the standard library until a profiler or a real gap says otherwise.

Try this

  1. In ticket_ids.py, start at 500 and print only two tickets (zip with a slice of names).
  2. Replace chain in chain_shifts.py with a generator that yield froms both lists. Same output.
  3. Rewrite by_table.py with defaultdict(list) and no sort. Print dict(tabs).
  4. Give the deque a maxlen=3, append five tickets, and print what remains.