Skip to content

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.

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 4
range(2, 8, 2) # 2 4 6
range(5, 0, -1) # 5 4 3 2 1

enumerate 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.

attempts = 0
while attempts < 3:
if try_connect():
break
attempts += 1

break 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)

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)
break
else:
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.

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, False match by identity).
  • Capture patterns — a bare name binds and always matches.
  • Class patternsPoint(x=0, y=0) checks isinstance then 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 patternscase 1 | 2 | 3:.
  • Guardscase x if x > 0:, tested after the pattern matches.
  • as patternscase [x] as whole: binds both.
  • case _: is the wildcard default.
# A concrete example: dispatching on a parsed command
match 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
@dataclass
class Point:
x: int
y: int
match p:
case Point(0, 0):
print("origin")
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 argument
greet(greeting="Hi", name="Ada") # keywords may be reordered

A function with no return returns None. A bare return does the same.

Defaults are evaluated once, when the def executes — not per call.

import time
def stamp(t=time.time()): # WRONG: frozen at import time
return t

The 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 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) # fine
connect("db", 5) # TypeError: takes 1 positional argument but 2 were given

Anything before a / must be passed positionally (Python 3.8+, PEP 570):

def distance(x, y, /, unit="m"):
...
distance(1, 2) # fine
distance(x=1, y=2) # TypeError

Positional-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): ...

An anonymous, single-expression function. No statements, no annotations, no docstring.

square = lambda x: x * x # works, but just use def
sorted(people, key=lambda p: p.age) # the legitimate use

Lambdas 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.

Name lookup walks four scopes in order:

  1. Local — inside the current function
  2. Enclosing — any outer function’s locals (for nested functions)
  3. Global — module level
  4. 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 += 1

global 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 total

Note 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.

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() # => 1
c() # => 2

Each 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 i
funcs = [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]

A decorator is a function that takes a function and returns a replacement. @name above a def is pure syntax sugar:

@log_calls
def add(a, b): ...
# is exactly
add = log_calls(add)
import functools
import 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
@timed
def slow():
time.sleep(0.1)
slow()
# slow took 0.1002s

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.

Decorators apply bottom-up — nearest the def first:

@a
@b
def f(): ...
# f = a(b(f))
import functools
@functools.cache # 3.9+: unbounded memoisation
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
@functools.lru_cache(maxsize=128) # bounded variant
def lookup(key): ...
lookup.cache_info() # hits, misses, size
lookup.cache_clear()

Others you will meet: @property, @staticmethod, @classmethod (see OOP), @dataclass, @contextlib.contextmanager (see context managers), and @functools.singledispatch for type-based overloading.

  • Comparisons chain; := assigns inside an expression.
  • Iterate objects directly; use enumerate, and zip(..., strict=True).
  • The loop else means “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 use functools.wraps.