Python Cheatsheet
The idioms that separate “code that works” from “Pythonic code that reads like a sentence” — comprehensions, generators, decorators, and the standard-library shortcuts most tutorials skip. For the full explanations, see the Python guides.
Comprehensions — the readability default
squares = [x**2 for x in range(10)]evens = [x for x in range(20) if x % 2 == 0]pairs = {(x, y) for x in range(3) for y in range(3) if x != y} # set comprehensionlookup = {name: len(name) for name in ["alice", "bob", "carol"]} # dict comprehensionflat = [item for row in matrix for item in row] # flatten a 2D listThe rule that keeps comprehensions readable: one for and at most one if fits on a line naturally. Two nested for clauses (like the flatten example) is the practical ceiling — beyond that, a plain loop reads better than a comprehension nobody wants to parse.
Generators — lazy by design
def read_large_file(path): with open(path) as f: for line in f: yield line.strip()
# Generator expression — same laziness, no function neededsquares_gen = (x**2 for x in range(1_000_000))A list comprehension builds the entire list in memory immediately; a generator produces one value at a time, on demand. For a 10-item list this distinction doesn’t matter. For iterating a multi-GB log file line by line, it’s the difference between the program running and an OutOfMemoryError. The tell: swap [...] for (...) and nothing about the logic changes, only when the work happens.
# yield from — delegate to a sub-generator without a manual loopdef chain(*iterables): for it in iterables: yield from it
list(chain([1, 2], (3, 4), "ab")) # [1, 2, 3, 4, 'a', 'b']Decorators
import functoolsimport time
def timer(func): @functools.wraps(func) # preserves func.__name__ and __doc__ — skip this and debugging gets confusing def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) print(f"{func.__name__} took {time.perf_counter() - start:.4f}s") return result return wrapper
@timerdef slow_add(a, b): time.sleep(0.1) return a + b# A decorator that takes arguments needs one extra level of nestingdef retry(times=3): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(times): try: return func(*args, **kwargs) except Exception: if attempt == times - 1: raise return wrapper return decorator
@retry(times=5)def flaky_api_call(): ...@functools.wraps(func) is easy to skip and easy to regret — without it, slow_add.__name__ becomes 'wrapper' and help(slow_add) shows the wrapper’s (empty) docstring, which silently breaks anything relying on introspection: debuggers, API doc generators, functools.lru_cache diagnostics.
Context managers
class DatabaseConnection: def __enter__(self): self.conn = connect_to_db() return self.conn def __exit__(self, exc_type, exc_val, exc_tb): self.conn.close() return False # False = don't suppress exceptions
with DatabaseConnection() as conn: conn.execute("SELECT 1")# contextlib — the same thing with a generator, usually less boilerplatefrom contextlib import contextmanager
@contextmanagerdef db_connection(): conn = connect_to_db() try: yield conn finally: conn.close()__exit__ returning True swallows any exception raised inside the with block — a subtle footgun if you write return True unconditionally to “clean up” and accidentally hide real errors. Return False (or nothing) unless you specifically intend to suppress an exception type.
*args, **kwargs, and unpacking
def flexible(*args, **kwargs): print(args) # tuple of positional args print(kwargs) # dict of keyword args
flexible(1, 2, name="alice") # (1, 2) {'name': 'alice'}
# Unpacking on the call sidedef add(a, b, c): return a + b + cnums = [1, 2, 3]add(*nums) # unpack a list into positional argsconfig = {"a": 1, "b": 2, "c": 3}add(**config) # unpack a dict into keyword args
# Merging dicts (3.9+)merged = {**defaults, **overrides} # overrides wins on key collisionsMutable default arguments — the classic trap
# WRONG — the list is created once, at function definition, and reused across every calldef append_bad(item, target=[]): target.append(item) return target
# RIGHTdef append_good(item, target=None): if target is None: target = [] target.append(item) return targetdef f(x, target=[]) evaluates the default [] exactly once, when the function is defined, not each time it’s called — every call that doesn’t pass target shares the same list object. This is one of the most common real Python bugs, and it’s invisible until a second call reveals state leaking from the first.
Typing — enough to make your IDE useful
from typing import Optional, Unionfrom collections.abc import Iterable
def greet(name: str, greeting: str = "Hello") -> str: return f"{greeting}, {name}!"
def find_user(user_id: int) -> Optional[dict]: # may return None ...
def process(value: Union[int, str]) -> None: ... # or: int | str (3.10+)
def total(values: Iterable[float]) -> float: return sum(values)from dataclasses import dataclass
@dataclassclass Order: order_id: str amount: float status: str = "pending" # default value
order = Order(order_id="A1", amount=99.5)Type hints don’t change runtime behavior at all — Python never enforces them without a separate tool. Their entire value is static: mypy/pyright catch mismatches before you run the code, and editors use them for autocomplete and inline errors. Skipping them costs nothing at runtime and loses all of that.
The collections module
from collections import Counter, defaultdict, namedtuple, deque
Counter("mississippi") # Counter({'i': 4, 's': 4, 'p': 2, 'm': 1})Counter(["a", "b", "a"]).most_common(1) # [('a', 2)]
groups = defaultdict(list)for word in ["apple", "banana", "avocado"]: groups[word[0]].append(word)# no KeyError on first insert — defaultdict supplies the empty list automatically
Point = namedtuple("Point", ["x", "y"])p = Point(1, 2)p.x, p.y # 1, 2 — readable, immutable, no class boilerplate
dq = deque(maxlen=3) # a fixed-size ring buffer — great for "last N events"dq.extend([1, 2, 3, 4]) # [2, 3, 4] — oldest item auto-evicteddefaultdict exists specifically to remove the if key not in d: d[key] = [] boilerplate before every append — reach for it any time you’re building a dict of lists (or counts, or sets) from scratch. Counter is the same idea specialized for counting, and its .most_common() method is usually faster and clearer than sorting a manually-built frequency dict.
Sorting with key functions
people = [{"name": "Bob", "age": 35}, {"name": "Alice", "age": 30}]sorted(people, key=lambda p: p["age"]) # ascending by agesorted(people, key=lambda p: p["name"], reverse=True) # descending by namesorted(people, key=lambda p: (p["age"], p["name"])) # multi-key: age first, name breaks ties
from operator import itemgetter, attrgettersorted(people, key=itemgetter("age")) # same as the lambda, marginally fasterkey= computes a sort value once per element and compares those — this is why it’s faster and more flexible than a custom __lt__ or a comparison function: the key function runs n times, not n log n times.
enumerate, zip, and the walrus operator
for i, name in enumerate(["a", "b", "c"], start=1): print(i, name) # 1 a / 2 b / 3 c
names = ["alice", "bob"]ages = [30, 25]list(zip(names, ages)) # [('alice', 30), ('bob', 25)]dict(zip(names, ages)) # {'alice': 30, 'bob': 25}
# Walrus operator (3.8+) — assign and test in one expressiondata = [1, 2, 3, 4, 5]if (n := len(data)) > 3: print(f"{n} items, that's a lot")
while (chunk := file.read(8192)): process(chunk)enumerate and zip both exist to kill the range(len(...)) anti-pattern — indexing into a list manually to get position or to walk two lists together is a strong signal one of these two belongs there instead. The walrus operator’s most common legitimate use is exactly the while (chunk := ...) pattern above: avoiding either an infinite loop with a manual break, or reading once before the loop and again at the end of it.
Slicing
seq = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]seq[2:5] # [2, 3, 4]seq[::2] # [0, 2, 4, 6, 8] — every other elementseq[::-1] # reversed copy, no .reverse() mutation neededseq[:3] = [10, 20, 30] # slice assignment replaces in placeSlicing always returns a new list (or string, or tuple) — it never mutates the original and never raises IndexError for an out-of-range slice, unlike single-index access. seq[100:200] on a 10-item list just returns [] instead of crashing, which is convenient until it silently hides a bug where you expected an error.
Iterators and itertools
from itertools import chain, groupby, islice, product, combinations
list(chain([1, 2], [3, 4])) # [1, 2, 3, 4]list(islice(range(100), 5, 10)) # [5, 6, 7, 8, 9] — slice a generator lazilylist(product([1, 2], ["a", "b"])) # [(1,'a'),(1,'b'),(2,'a'),(2,'b')]list(combinations([1, 2, 3], 2)) # [(1,2),(1,3),(2,3)]
data = sorted([("a", 1), ("a", 2), ("b", 3)], key=lambda x: x[0])for key, group in groupby(data, key=lambda x: x[0]): print(key, list(group))groupby only groups consecutive matching items — it is not a general “group by key across the whole iterable” operation like SQL’s GROUP BY. Sorting first (as above) is required whenever the input isn’t already grouped, which trips up almost everyone the first time.
String formatting
name, score = "Alice", 92.567f"{name} scored {score:.1f}%" # 'Alice scored 92.6%'f"{score:>10.2f}" # right-align, width 10, 2 decimalsf"{name!r}" # repr — quotes shown, useful for debuggingf"{score=}" # 3.8+ debug specifier → 'score=92.567'Exception handling
try: result = risky_operation()except (ValueError, TypeError) as e: print(f"Bad input: {e}")except Exception: raise # re-raise unknown errors instead of silently swallowing themelse: print("ran only if no exception was raised")finally: cleanup()
# Custom exceptions carry meaning a generic Exception can'tclass InsufficientFundsError(Exception): def __init__(self, balance, requested): self.balance = balance self.requested = requested super().__init__(f"Balance {balance} insufficient for {requested}")Catching bare except: (or except Exception without re-raising unhandled cases) is the single most common way real bugs go silent — a typo’d variable name raises NameError, gets swallowed by an overly broad except, and the program limps on with wrong output instead of failing loudly where the mistake happened.
Common patterns
Memoization with lru_cache
from functools import lru_cache
@lru_cache(maxsize=None)def fibonacci(n): if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2)Without caching, naive recursive Fibonacci is exponential (O(2^n)) because it recomputes the same subproblems repeatedly. lru_cache turns it linear by remembering every input it’s already seen — a one-line fix for a whole category of “why is my recursive function so slow” problems.
Chunking a list
def chunk(seq, size): for i in range(0, len(seq), size): yield seq[i:i + size]
list(chunk(list(range(10)), 3)) # [[0,1,2],[3,4,5],[6,7,8],[9]]Flattening a nested dict (for logging/CSV export)
def flatten(d, parent_key="", sep="."): items = {} for k, v in d.items(): new_key = f"{parent_key}{sep}{k}" if parent_key else k if isinstance(v, dict): items.update(flatten(v, new_key, sep)) else: items[new_key] = v return items
flatten({"user": {"name": "Alice", "address": {"city": "Boston"}}})# {'user.name': 'Alice', 'user.address.city': 'Boston'}Threading vs multiprocessing — which one actually helps
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
with ThreadPoolExecutor(max_workers=10) as pool: results = list(pool.map(fetch_url, urls)) # I/O-bound: threads help
with ProcessPoolExecutor(max_workers=4) as pool: results = list(pool.map(cpu_heavy_task, data)) # CPU-bound: needs real processesThe GIL (Global Interpreter Lock) means only one thread executes Python bytecode at a time, so threads don’t speed up CPU-bound work — they only help when a thread is waiting on I/O (a network call, a disk read) and can hand control to another thread during the wait. CPU-bound work needs separate processes, each with its own interpreter and its own GIL, to actually run in parallel.
Path handling that doesn’t break on Windows
from pathlib import Path
data_dir = Path("data") / "raw" / "2024"for file in data_dir.glob("*.csv"): print(file.stem, file.suffix)
data_dir.mkdir(parents=True, exist_ok=True)Path objects handle the / vs \ separator difference between OSes automatically — string-concatenating paths with + or manual slashes is the recurring source of “works on my machine” path bugs.
Not covered: Async/await and asyncio internals get their own dedicated treatment rather than a cheatsheet blurb — concurrency models deserve more than a code snippet. For deeper coverage of any section above, see the Python guides.