Control Flow and Functions
Python’s control flow is small — a handful of statements with no surprises — while its function signatures are unusually expressive. Both reward knowing the details.
if / elif / else
Section titled “if / elif / else”No parentheses, no braces; a colon opens the block.
if score >= 90: grade = "A"elif score >= 80: grade = "B"else: grade = "F"Comparisons chain the way maths does, and each operand is evaluated once:
if 0 <= x < 10: # not (0 <= x) < 10 ...if a == b == c: ...The conditional expression (Python’s ternary) reads value-first:
label = "even" if n % 2 == 0 else "odd"Since 3.8 the walrus operator := assigns inside an expression, avoiding a duplicate call:
if (m := pattern.search(line)) is not None: print(m.group(1))
while (chunk := f.read(8192)): process(chunk)for iterates over anything iterable; there is no C-style three-part loop.
for item in [10, 20, 30]: print(item)
for ch in "abc": ...for key, value in d.items(): ...range(stop), range(start, stop), range(start, stop, step) generate integers lazily — stop is exclusive.
range(5) # 0 1 2 3 4range(2, 8, 2) # 2 4 6range(5, 0, -1) # 5 4 3 2 1enumerate gives index and value; zip walks several iterables together.
for i, name in enumerate(names, start=1): print(f"{i}. {name}")
for name, score in zip(names, scores): ...Anti-pattern to unlearn: for i in range(len(xs)). Iterate the thing directly, or use enumerate when you truly need the index.
while, break, continue
Section titled “while, break, continue”attempts = 0while attempts < 3: if try_connect(): break attempts += 1break exits the innermost loop; continue skips to the next iteration. Python has no goto and no labelled break — to leave nested loops, refactor into a function and return, or use a flag.
while True with a break is the idiomatic “loop until” construct:
while True: line = input("> ") if line == "quit": break handle(line)The loop else
Section titled “The loop else”Both for and while accept an else clause. It runs when the loop finishes without hitting break. Read it as “no break”:
for item in haystack: if matches(item): print("found", item) breakelse: print("no match found")Without it you would need a sentinel flag. It is rare, but when the shape fits it is the clearest option.
match — structural pattern matching
Section titled “match — structural pattern matching”Added in Python 3.10 (PEP 634). It is not a C switch: it matches the shape of data and binds names from it.
def describe(value): match value: case 0: return "zero" case int() | float(): return "a number" case [x]: return f"one-element list holding {x}" case [first, *rest]: return f"list starting with {first}, {len(rest)} more" case {"type": "user", "name": str(name)}: return f"user {name}" case Point(x=0, y=0): return "origin" case str() as s if len(s) > 10: return "a long string" case _: return "something else"The pieces:
- Literal patterns match by
==(None,True,Falsematch by identity). - Capture patterns — a bare name binds and always matches.
- Class patterns —
Point(x=0, y=0)checksisinstancethen the attributes.int()is a class pattern testing the type, not a call. - Sequence patterns —
[a, b, *rest]matches lists and tuples (not strings or dicts). - Mapping patterns —
{"key": value}matches if those keys exist; extra keys are allowed. - Or patterns —
case 1 | 2 | 3:. - Guards —
case x if x > 0:, tested after the pattern matches. aspatterns —case [x] as whole:binds both.case _:is the wildcard default.
# A concrete example: dispatching on a parsed commandmatch command.split(): case ["go", direction]: move(direction) case ["take", *items]: for item in items: pick_up(item) case ["quit" | "exit"]: raise SystemExit case _: print("unknown command")Classes can declare __match_args__ to enable positional patterns; dataclasses and NamedTuples set it for you.
from dataclasses import dataclass
@dataclassclass Point: x: int y: int
match p: case Point(0, 0): print("origin")Functions
Section titled “Functions”def greet(name, greeting="Hello"): """Return a greeting. The first line is the docstring.""" return f"{greeting}, {name}!"
greet("Ada") # => 'Hello, Ada!'greet("Ada", greeting="Hi") # keyword argumentgreet(greeting="Hi", name="Ada") # keywords may be reorderedA function with no return returns None. A bare return does the same.
Default arguments
Section titled “Default arguments”Defaults are evaluated once, when the def executes — not per call.
import time
def stamp(t=time.time()): # WRONG: frozen at import time return tThe consequential version of this bug involves mutable defaults:
def add(item, target=[]): # the SAME list on every call target.append(item) return target
add(1) # => [1]add(2) # => [1, 2] — surprise*args and **kwargs
Section titled “*args and **kwargs”*args collects extra positional arguments into a tuple; **kwargs collects extra keyword arguments into a dict. The names are convention only.
def log(level, *args, **kwargs): print(level, args, kwargs)
log("INFO", 1, 2, user="ada")# INFO (1, 2) {'user': 'ada'}The same symbols unpack at the call site:
args = [1, 2]opts = {"user": "ada"}log("INFO", *args, **opts)This is how wrappers forward arguments unchanged:
def wrapper(*args, **kwargs): return original(*args, **kwargs)Keyword-only and positional-only parameters
Section titled “Keyword-only and positional-only parameters”Anything after a bare * must be passed by keyword:
def connect(host, *, timeout=30, retries=3): ...
connect("db", timeout=5) # fineconnect("db", 5) # TypeError: takes 1 positional argument but 2 were givenAnything before a / must be passed positionally (Python 3.8+, PEP 570):
def distance(x, y, /, unit="m"): ...
distance(1, 2) # finedistance(x=1, y=2) # TypeErrorPositional-only parameters let you rename them later without breaking callers, and let a parameter name like list not collide with **kwargs. Many built-ins are positional-only for exactly this reason.
Full parameter order in a signature:
def f(pos_only, /, standard, *args, kw_only, **kwargs): ...Lambdas
Section titled “Lambdas”An anonymous, single-expression function. No statements, no annotations, no docstring.
square = lambda x: x * x # works, but just use defsorted(people, key=lambda p: p.age) # the legitimate useLambdas are for passing a tiny function inline — as a key, a callback, or a default. PEP 8 explicitly says not to assign a lambda to a name; write def instead, so tracebacks show a useful name.
Scope: LEGB
Section titled “Scope: LEGB”Name lookup walks four scopes in order:
- Local — inside the current function
- Enclosing — any outer function’s locals (for nested functions)
- Global — module level
- Built-in —
len,print,Exception, …
x = "global"
def outer(): x = "enclosing" def inner(): print(x) # finds the enclosing x inner()Assigning to a name anywhere in a function makes it local for the whole function, even before the assignment line:
count = 0
def bump(): print(count) # UnboundLocalError count += 1global and nonlocal change the binding target:
count = 0
def bump(): global count # rebind the module-level name count += 1
def outer(): total = 0 def add(n): nonlocal total # rebind the ENCLOSING function's name total += n add(5) return totalNote that if, for, while, and with do not create scopes — only functions, classes, modules, and comprehensions do. A variable assigned inside a for body is visible after the loop.
Closures
Section titled “Closures”A nested function that references a name from an enclosing scope closes over it — the variable stays alive after the outer function returns.
def make_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment
c = make_counter()c() # => 1c() # => 2Each call to make_counter produces an independent closure with its own count.
Closures capture the variable, not its value at definition time. This bites in loops:
funcs = [lambda: i for i in range(3)][f() for f in funcs] # => [2, 2, 2] — all see the final ifuncs = [lambda i=i: i for i in range(3)] # bind now via a default[f() for f in funcs] # => [0, 1, 2]When you are pre-filling arguments to a real function, functools.partial says it more directly — it evaluates its arguments immediately, so there is nothing left to capture:
from functools import partial
def power(base, exponent): return base ** exponent
funcs = [partial(power, exponent=e) for e in range(3)][f(2) for f in funcs] # => [1, 2, 4]Decorators
Section titled “Decorators”A decorator is a function that takes a function and returns a replacement. @name above a def is pure syntax sugar:
@log_callsdef add(a, b): ...
# is exactlyadd = log_calls(add)A working decorator
Section titled “A working decorator”import functoolsimport time
def timed(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() try: return func(*args, **kwargs) finally: elapsed = time.perf_counter() - start print(f"{func.__name__} took {elapsed:.4f}s") return wrapper
@timeddef slow(): time.sleep(0.1)
slow()# slow took 0.1002sA decorator with arguments
Section titled “A decorator with arguments”That needs one more layer: a function that returns a decorator.
import functools
def retry(times=3, exceptions=(Exception,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): last = None for attempt in range(1, times + 1): try: return func(*args, **kwargs) except exceptions as exc: last = exc print(f"attempt {attempt} failed: {exc}") raise last return wrapper return decorator
@retry(times=5, exceptions=(ConnectionError,))def fetch(url): ...Read @retry(times=5) as: call retry(times=5) to get a decorator, then apply it.
Stacking
Section titled “Stacking”Decorators apply bottom-up — nearest the def first:
@a@bdef f(): ...# f = a(b(f))Standard-library decorators worth knowing
Section titled “Standard-library decorators worth knowing”import functools
@functools.cache # 3.9+: unbounded memoisationdef fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)
@functools.lru_cache(maxsize=128) # bounded variantdef lookup(key): ...
lookup.cache_info() # hits, misses, sizelookup.cache_clear()Others you will meet: @property, @staticmethod, @classmethod (see OOP), @dataclass, @contextlib.contextmanager (see context managers), and @functools.singledispatch for type-based overloading.
Key points
Section titled “Key points”- Comparisons chain;
:=assigns inside an expression. - Iterate objects directly; use
enumerate, andzip(..., strict=True). - The loop
elsemeans “the loop was not broken out of”. match(3.10+) matches structure and binds names — a bare name always captures.- Defaults evaluate once at definition; never make one mutable.
*//in a signature force keyword-only and positional-only arguments.- Scope resolves L→E→G→B; assignment makes a name local for the whole function.
- Closures capture variables, not values — bind with a default argument in loops.
- Decorators are
f = deco(f); always usefunctools.wraps.