Python Cheatsheet

itertools and collections

Use this Python reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

itertools Reference

Every one of these returns a lazy iterator, not a list. Nothing is computed until you iterate, and each is one-shot. Wrap in list() to see the values.

CallYieldsExample →
count(start=0, step=1)infinite counter, floats allowedcount(10, 2) → 10, 12, 14, …
cycle(iterable)repeats forever, caches a copycycle("AB") → A, B, A, B, …
repeat(obj, times=None)obj again and againrepeat("x", 3) → x, x, x
chain(*iterables)one flat stream of all argschain("AB", "CD") → A, B, C, D
chain.from_iterable(it)flattens one level, lazily[[1,2],[3]] → 1, 2, 3
zip_longest(*its, fillvalue=None)pads to the longest"AB", "xyz" → Ax, By, -z
islice(it, stop) · (it, start, stop, step)a slice of any iteratorislice(count(), 2, 8, 2) → 2, 4, 6
takewhile(pred, it)items until pred is first falsen<3 on 1,2,3,1 → 1, 2
dropwhile(pred, it)items after pred is first falsen<3 on 1,2,3,1 → 3, 1
filterfalse(pred, it)the inverse of filterbool on 0,1,0 → 0, 0
compress(data, selectors)data where the selector is truthy"ABCD", [1,0,1,0] → A, C
starmap(fn, it)fn(*args) per tuplepow on (2,5),(3,2) → 32, 9
tee(it, n=2)n independent iteratorssee the caveat below
pairwise(it) 3.10+overlapping adjacent pairs"ABC" → AB, BC
batched(it, n) 3.12+tuples of length n, last may be short"ABCDE", 2 → AB, CD, E
accumulate(it, fn=add, *, initial=None)running totals1,2,3,4 → 1, 3, 6, 10
groupby(it, key=None)(key, group) on consecutive runssee below
from itertools import (count, cycle, repeat, chain, zip_longest, islice,
                       takewhile, dropwhile, filterfalse, compress, starmap,
                       tee, pairwise, batched, accumulate, groupby)
import operator

list(islice(count(1), 5))                   # [1, 2, 3, 4, 5]
list(chain.from_iterable([[1, 2], [3], []]))# [1, 2, 3]
list(zip_longest("AB", "xyz", fillvalue="-"))
# [('A', 'x'), ('B', 'y'), ('-', 'z')]
list(batched("ABCDE", 2))                   # [('A','B'), ('C','D'), ('E',)]
list(batched("ABCDE", 2, strict=True))      # 3.13+, raises if the tail is short
list(accumulate([1, 2, 3, 4], operator.mul, initial=1))  # [1, 1, 2, 6, 24]
list(accumulate([3, 1, 4, 1, 5], max))      # [3, 3, 4, 4, 5]

tee buffers everything the slowest branch has not consumed yet, so it is only a win when the branches advance together. Do not touch the original iterator after teeing it. For two full passes over a big source, a list is usually cheaper.

Combinatorics

Lengths grow fast, so guard the input size before materializing.

CallCount"ABC", r=2 →
product(a, b)len(a) × len(b)full cartesian pairs
product(a, repeat=r)n**rAA AB AC BA BB BC CA CB CC
permutations(a, r)n! / (n−r)!AB AC BA BC CA CB
combinations(a, r)n! / (r!(n−r)!)AB AC BC
combinations_with_replacement(a, r)(n+r−1)! / (r!(n−1)!)AA AB AC BB BC CC
from itertools import product, permutations, combinations
from itertools import combinations_with_replacement as cwr

["".join(p) for p in permutations("ABC", 2)]  # ['AB','AC','BA','BC','CA','CB']
["".join(c) for c in combinations("ABC", 2)]  # ['AB', 'AC', 'BC']
["".join(c) for c in cwr("ABC", 2)]           # ['AA','AB','AC','BB','BC','CC']
list(product([0, 1], repeat=3))               # 8 binary triples
list(product("ab", range(2)))  # [('a',0), ('a',1), ('b',0), ('b',1)]

# product replaces nested loops over a grid of settings
for lr, batch in product([0.01, 0.1], [16, 32]):
    ...

permutations and combinations treat items by position, not value, so a duplicate in the input produces duplicate output tuples. Order comes out lexicographic only when the input is already sorted.

groupby, Sort First

groupby groups consecutive equal keys. Unsorted input silently produces fragmented groups, and this is the single most common itertools bug.

from itertools import groupby
from operator import itemgetter

rows = [{"dept": "eng", "n": 1}, {"dept": "ops", "n": 2},
        {"dept": "eng", "n": 3}]

# WRONG, eng appears twice
[(k, len(list(g))) for k, g in groupby(rows, key=itemgetter("dept"))]
# [('eng', 1), ('ops', 1), ('eng', 1)]

# RIGHT, sort by the same key first
rows.sort(key=itemgetter("dept"))
{k: [r["n"] for r in g] for k, g in groupby(rows, key=itemgetter("dept"))}
# {'eng': [1, 3], 'ops': [2]}

The group iterator is shared and invalidated by advancing to the next group, so list(g) inside the loop or nothing at all.

groups = list(groupby("aabbb"))          # [('a', <it>), ('b', <it>)]
[list(g) for _, g in groups]             # [[], []]  every group is empty now

# Materialize as you go
{k: list(g) for k, g in groupby("aabbb")}   # {'a': ['a','a'], 'b': ['b','b','b']}

# Run-length encode
[(k, len(list(g))) for k, g in groupby("aaabbc")]   # [('a',3), ('b',2), ('c',1)]

If you only need a mapping and not runs, a defaultdict(list) is one pass and needs no sort.

Counter

A dict subclass mapping element → count. A missing key reads as 0 instead of raising, but it is not a defaultdict (reading does not insert).

from collections import Counter

c = Counter("mississippi")        # Counter({'i': 4, 's': 4, 'p': 2, 'm': 1})
c = Counter(["a", "b", "a"])      # from any iterable
c = Counter({"a": 2}, b=1)        # from a mapping or kwargs

c["z"]                   # 0, no KeyError, and 'z' is NOT added
c.most_common(2)         # [('i', 4), ('s', 4)]  ties in insertion order
c.most_common()[::-1]    # least common first
c.total()                # 11, sum of counts (3.10+)
list(c.elements())       # each key repeated count times
c.update("pi")           # ADDS counts (dict.update would overwrite)
c.subtract("pi")         # subtracts, allows zero and negatives
+c, -c                   # keep only positive · only negative, flipped
c.pop("m"); del c["p"]   # plain dict removal
OpResultNote
a + bsummed countsdrops non-positive
a - bdifferencedrops non-positive, unlike subtract
a & bper-key minintersection
a | bper-key maxunion
Counter("aab") - Counter("ab")            # Counter({'a': 1})
Counter("aab") & Counter("abb")           # Counter({'a': 1, 'b': 1})
sum(Counter(line.split()) for line in lines)   # merge word counts

defaultdict, ChainMap, OrderedDict

from collections import defaultdict, ChainMap, OrderedDict

d = defaultdict(list)             # factory is called with NO arguments
d["a"].append(1)                  # {'a': [1]}, key created on read
defaultdict(int)["x"] += 1        # counters
defaultdict(set)                  # unique members per key
defaultdict(lambda: defaultdict(int))   # nested, works to any depth

d["missing"]           # creates an empty list. Reading MUTATES the dict.
d.get("missing")       # None, and does not create anything
dict(d)                # plain dict, drops the factory

dict.setdefault is the no-import alternative, but it evaluates the default on every call, so defaultdict wins in a loop.

plain = {}
plain.setdefault("a", []).append(1)     # same effect, allocates [] each time

ChainMap layers mappings for lookup without merging or copying. Writes always land in the first map.

cfg = ChainMap(cli_args, env_vars, defaults)   # first hit wins
cfg["debug"]
cfg.maps                        # the underlying list, mutable
cfg.new_child({"tmp": 1})       # push a scratch layer
cfg.parents                     # everything except the first map
dict(cfg)                       # flatten to one plain dict

OrderedDict still has a reason to exist even though plain dicts keep insertion order since 3.7: order-sensitive ==, move_to_end, and popitem(last=False).

OrderedDict(a=1, b=2) == OrderedDict(b=2, a=1)   # False, order matters
dict(a=1, b=2) == dict(b=2, a=1)                 # True
od.move_to_end("a")             # or last=False for the front
od.popitem(last=False)          # FIFO pop, an LRU building block

deque

A doubly linked list with O(1) append and pop at both ends, where a list is O(n) at the front. Indexing the middle is O(n), so it is not a list replacement.

from collections import deque

q = deque([1, 2, 3], maxlen=3)
q.append(4)            # deque([2, 3, 4]), 1 is silently DISCARDED
q.appendleft(0)        # pushes 4 off the right instead
q.pop(), q.popleft()   # both O(1)
q.extend([7, 8]); q.extendleft([9])   # extendleft REVERSES its argument
q.rotate(1)            # right by 1: [a,b,c] → [c,a,b]
q.rotate(-1)           # left by 1
q.maxlen               # read-only, None when unbounded
UseIdiom
FIFO queueappend + popleft
LIFO stacka plain list is faster
last N linesdeque(f, maxlen=100)
sliding windowmaxlen=n, read after each append
round robinrotate(-1) each turn
with open("app.log") as f:
    last10 = deque(f, maxlen=10)          # constant memory over any file size

deque is thread-safe for append/popleft (they are atomic), so it works as a lock-free producer/consumer buffer between threads. It has no sort and no slicing.

namedtuple and the User* Wrappers

A tuple subclass with named fields. Immutable, hashable, and no __dict__, so it is as small as a tuple. For anything with methods or mutation, use a dataclass.

from collections import namedtuple

Point = namedtuple("Point", "x y")                 # or ["x", "y"]
Point = namedtuple("Point", "x y z", defaults=(0,))  # defaults fill from the RIGHT
p = Point(1, 2)
p.x, p[0]                     # attribute or index access
x, y = p                      # unpacks like a tuple
p._replace(y=99)              # a NEW Point, tuples are immutable
p._asdict()                   # {'x': 1, 'y': 2}
Point._fields                 # ('x', 'y')
Point._make([1, 2])           # build from an iterable, e.g. a csv row
p == (1, 2)                   # True, it IS a tuple

UserDict, UserList, and UserString wrap the real data in .data so subclassing works cleanly. Subclassing dict directly means __getitem__ is bypassed by get, update, and the literal constructor.

from collections import UserDict

class CaseInsensitive(UserDict):
    def __setitem__(self, key, value):
        super().__setitem__(key.lower(), value)
    def __getitem__(self, key):
        return super().__getitem__(key.lower())

h = CaseInsensitive()
h["Content-Type"] = "text/html"
h["content-type"]              # 'text/html'

functools

ToolPurpose
@cache 3.9+unbounded memoization, lru_cache(maxsize=None)
@lru_cache(maxsize=128, typed=False)bounded LRU memoization
@cached_propertycompute once per instance, stored in __dict__
partial(fn, *a, **kw)freeze arguments, returns a callable
reduce(fn, it[, initial])fold left to a single value
@wraps(fn)copy name/doc/annotations onto a wrapper
@total_orderingfill in the rest from __eq__ plus one of < <= > >=
@singledispatchoverload a function on its first argument's type
from functools import cache, lru_cache, cached_property, partial, reduce
from functools import wraps, total_ordering, singledispatch
import operator

@cache
def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)
fib(100)                       # instant, 354224848179261915075
fib.cache_info()               # CacheInfo(hits=98, misses=101, ...)
fib.cache_clear()

reduce(operator.mul, [1, 2, 3, 4])        # 24
reduce(operator.or_, [{1}, {2}, {3}])     # {1, 2, 3}
int2 = partial(int, base=2); int2("1011") # 11

class Report:
    @cached_property
    def rows(self): return expensive_query()   # runs once per instance

@singledispatch
def size(x): raise TypeError(type(x))
@size.register
def _(x: str): return len(x)
@size.register
def _(x: int): return x

Cached arguments must be hashable, and the cache keeps strong references, so @cache on a method leaks every self it ever saw. Use @cached_property for per-instance results instead.

operator

Function forms of the operators, faster and clearer than an equivalent lambda, and the standard key= argument for sorting.

from operator import itemgetter, attrgetter, methodcaller

rows = [{"name": "b", "age": 30}, {"name": "a", "age": 30}]
sorted(rows, key=itemgetter("age", "name"))    # multi-key, one pass
sorted(pairs, key=itemgetter(1), reverse=True) # by tuple element
max(objs, key=attrgetter("score"))
sorted(objs, key=attrgetter("dept.name"))     # dotted paths work
list(map(methodcaller("strip"), lines))
list(map(methodcaller("split", ","), lines))
itemgetter(2, 0)("abcd")                       # ('c', 'a')

import operator
operator.add, operator.mul, operator.itruediv  # arithmetic
operator.eq, operator.lt, operator.contains    # comparisons and `in`
operator.getitem, operator.setitem, operator.delitem

Recipes

from collections import Counter, defaultdict, deque
from itertools import chain, groupby, islice, pairwise, product, tee
from operator import itemgetter

# 1. Flatten one level
list(chain.from_iterable([[1, 2], [3], [4, 5]]))        # [1, 2, 3, 4, 5]

# 2. Chunk into fixed sizes
from itertools import batched
list(batched(range(7), 3))                    # 3.12+  [(0,1,2), (3,4,5), (6,)]
def chunks(it, n):                            # any version, works on iterators
    it = iter(it)
    while part := list(islice(it, n)):
        yield part

# 3. Dedupe, preserving first-seen order
list(dict.fromkeys([3, 1, 3, 2, 1]))                    # [3, 1, 2]

# 4. Sliding window of size n
def window(it, n):
    it = iter(it)
    d = deque(islice(it, n), maxlen=n)
    if len(d) == n: yield tuple(d)
    for x in it:
        d.append(x); yield tuple(d)
list(pairwise([1, 2, 3, 4]))                  # n=2  [(1,2), (2,3), (3,4)]

# 5. Group by a key, one pass, no sort
by_dept = defaultdict(list)
for r in rows: by_dept[r["dept"]].append(r)

# 6. Frequency count and the top n
Counter("abracadabra").most_common(3)         # [('a',5), ('b',2), ('r',2)]

# 7. Cartesian grid instead of nested loops
list(product(range(2), "xy"))     # [(0,'x'), (0,'y'), (1,'x'), (1,'y')]

# 8. Transpose rows to columns
list(zip(*[(1, 2), (3, 4), (5, 6)]))          # [(1, 3, 5), (2, 4, 6)]

# 9. Consume an iterator without building a list
deque(gen, maxlen=0)

more-itertools (python -m pip install more-itertools) ships tested versions of chunked, windowed, unique_everseen, first, flatten, and peekable, so reach for it before writing a fourth window helper.