itertools and collections
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.pyOutput:
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.pyOutput:
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.pyOutput:
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.pyOutput:
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.pyOutput:
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.pyOutput:
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
countmust be bounded (zip,islice,break).chain(oryield from) instead of building a combined list you do not need.- Sort before
groupby. Treat each group as a one-shot iterator. Counterfor tallies.dequefor a line.defaultdictwhen missing keys have an obvious factory.- Stay in the standard library until a profiler or a real gap says otherwise.
Try this
- In
ticket_ids.py, start at 500 and print only two tickets (zipwith a slice ofnames). - Replace
chaininchain_shifts.pywith a generator thatyield froms both lists. Same output. - Rewrite
by_table.pywithdefaultdict(list)and no sort. Printdict(tabs). - Give the
dequeamaxlen=3, append five tickets, and print what remains.