Functions — arguments, scope, closures
Functions are the atom of Python. This session covers the four kinds of arguments, the LEGB scope rule, closures, decorators as functions, and the classic tricks that trip up devs who thought they knew Python.
🎯 Write functions that read well, argue clearly, and don't have hidden state — plus read anyone else's function and predict what it will do.
Why this session exists
Functions look simple until they don't. Positional vs keyword vs *args vs **kwargs is a menu most devs pick from wrong. Scope + closures + late binding cause the same design review trick to fool the same people every year. And decorators — the thing that lets @app.route and @lru_cache exist — are literally just functions taking functions. This session installs the whole picture so you never fumble a signature or misread a closure again.
- Design a function signature with the right mix of positional / keyword-only / defaults / *args / **kwargs on purpose.
- Trace the LEGB name-resolution rule for any nested function without running the code.
- Recognise a closure, know when Python captures by reference (surprise!), and fix the late-binding bug.
- Write your own decorator from scratch, including one that takes arguments.
- Predict what `f(*args, **kwargs)` will bind to, given a specific call site.
Prerequisites
- S005 — Python Variables & Types — the memory model is essential.
- S006 — Control Flow — you'll use branches inside functions.
(a) Intuition · 5 min
A restaurant menu item is a contract: ‘give me a burger and $12, get back a burger with fries.’ The kitchen (implementation) can change — same beef, different sauce — but the contract with the customer is stable.
A function is exactly that. The signature is the contract; the body is the kitchen. A good signature (clear names, sensible defaults, no surprise inputs) lets you swap the body a hundred times without breaking anyone.
Formally: a function is a first-class object with a __call__. It has a signature (parameters + return type), a scope (its own namespace + a link to the enclosing one), and a body. You can pass it around, store it in dicts, return it from other functions.
That last property is what makes closures, decorators, callbacks, and higher-order functional programming possible in Python — the same feature.
The four argument shapes
- Positional-only — before `/` in the signature. Callers cannot use the name. Rare, but useful for stable APIs.
- Positional-or-keyword — the default. Can be passed either way.
- Keyword-only — after `*` or `*args` in the signature. Callers MUST use the name. Great for boolean flags.
- Var-args (`*args`, `**kwargs`) — arbitrary numbers of positional/keyword args. Use sparingly; explicit signatures beat implicit ones.
A quick history so you know the shape
- 1991Python 0.9.0 · defFunctions from day one. Guido borrowed def/return from ABC, not C.
- 2001Decorators (PEP 318)Python 2.4 lands `@decorator` syntax — syntactic sugar for `f = decorator(f)`.
- 2006Keyword-only args (PEP 3102)Python 3.0 lets you enforce keyword-only args with `*`. Underused, superb for API design.
- 2015Type hints (PEP 484)Optional annotations arrive. Every serious codebase types their function signatures within 5 years.
- 2020Positional-only params (PEP 570)`/` in signatures. Lets libraries expose stable positional APIs without freezing the parameter names.
(b) Visual walkthrough · 15 min
The full function signature grammar
A full example:
def request(url, /, method="GET", *headers, timeout=30, **params):
...
# url is positional-only
# method is positional-or-keyword with a default
# *headers gathers any extra positional args into a tuple
# timeout is keyword-only (because it comes after *headers)
# **params gathers any extra keyword args into a dictThe LEGB scope resolution rule
Every name lookup walks L → E → G → B. Writes create a Local unless you say nonlocal (to write to an Enclosing) or global (to write to a Global). Memorise the order. Debugging weird scope bugs becomes trivial once you can point at the rung.
The five kinds of ‘function-like’ things
The default
- Named
- Any signature
- Can have docstring
- Debuggable
- Use unless there's a specific reason not to
Anonymous, one-expression
- Unnamed
- One expression only (no statements)
- Great for sort keys and small callbacks
- Bad for anything more complex
- PEP 8: don't assign to a name — use def
A function that transforms a function
- def deco(f): def wrap(*a, **k): return f(*a, **k); return wrap
- Powers @app.route, @lru_cache, @staticmethod
- Use functools.wraps to preserve __name__
- Can be parameterised: @deco(arg)
Freeze some arguments
- greet_bob = partial(greet, 'bob')
- Cleaner than a lambda
- Preserves the underlying function
- Great for callbacks needing a specific signature
A callable OBJECT
- Class with __call__ method
- Instances behave like functions
- Can carry state between calls
- How torch.nn.Module and Django views work
Closures — the ‘function remembers its home’ trick
def make_counter(): count = 0; def inc(): nonlocal count; count += 1; return count; return inc
The outer function returns `inc`. Normally `count` would die when make_counter returns.
The cell object keeps `count` alive as long as any reference to `inc` exists.
Two separate make_counter() calls give two independent counters — each has its own cell.
The mental model to hold
"A default argument is evaluated each time the function is called — so def f(items=[]) gives me a fresh empty list on every call."
Default values are evaluated once, when the def statement executes, and stored on the function object in __defaults__. Every call that omits the argument gets the same object. A mutable default is shared state with a very long lifetime.
Because it is completely invisible for immutable defaults, which is what you write 90% of the time. def f(n=0), def f(s=""), def f(flag=False) all behave identically under both models, because you can never mutate them — you can only rebind, which is local. The moment the default is a list, dict, or a call like datetime.now(), the two models diverge and the function starts remembering things across calls. It looks like a haunting: the second call returns data from the first, and there is nothing in the function body that stores anything.
Watch the default itself change, and see where it lives:
def add(x, bucket=[]):
bucket.append(x)
return bucket
print(add(1)) # [1]
print(add(2)) # [1, 2] <- not fresh
print(add.__defaults__) # ([1, 2],) <- it lives on the function
# the fix, and the reason 'None' is the idiom:
def add(x, bucket=None):
bucket = [] if bucket is None else bucket
bucket.append(x)
return bucketWhy does a closure capture the variable rather than its value — so that every function created in a loop ends up seeing the loop's final value? This is famously surprising; derive why it has to work this way.
- 1A nested function must be able to read names from its enclosing scope, otherwise you could not write a helper that uses its parent's arguments.forced by · lexical scoping is the whole point of nesting a function inside another
- 2The enclosing function's locals can change after the inner function is defined — the outer code keeps running, loops keep incrementing.forced by · definition is just another statement; execution continues past it
- 3So the language must choose: snapshot the value at definition time, or hold a reference to the variable's storage cell. Snapshotting would break the common case where an inner function is defined before the outer function finishes computing what it needs.forced by · a decorator or callback defined early must still see values assigned later
- 4Therefore Python stores a reference to the cell holding the variable, and resolves the name when the closure is called, not when it is created.forced by · late binding is the only rule that keeps mutually-referencing and recursively-defined nested functions working
- 5A loop reuses one cell for the loop variable across all iterations, so all N closures created in the loop share that single cell — and by the time any of them runs, the loop is over.forced by · the loop variable is one name in one scope, not a new name per iteration
Therefore "all my lambdas return 9" is not a bug, it is late binding working exactly as specified. Verify with f.__closure__[0].cell_contents — you can see the shared cell.
And note what this predicts: the fix must be to create a new cell per iteration, which is exactly what a default argument does (lambda x, i=i: ...) because defaults are evaluated at definition time — the same mechanism that caused the mutable-default trap now saves you. It also predicts why functools.partial(f, i) works: it binds the value, not the name.
Stop picturing def as "declaring a subroutine". def is an assignment statement: it builds a function object — carrying code, defaults, closure cells, annotations, a name — and binds it to a name. That object can be stored in a dict, passed as an argument, returned, and given attributes, exactly like a list can.
The signature is the object's public contract: which arguments are positional, which are keyword, which have defaults. Everything painful about Python functions — mutable defaults, late binding, decorators losing metadata, *args/**kwargs — follows from the object being built once and called many times.
- Never use a mutable or time-dependent default. Use
Noneas the sentinel and construct inside the body. - Arguments are passed by assignment: the parameter becomes another name for the caller's object. Rebinding is local; mutating is visible to the caller.
- Use
*in the signature to force keyword-only arguments for anything a reader couldn't decode at the call site — booleans especially.f(data, dropna=True)beatsf(data, True). - A function should either return a value or perform an effect, not both. Returning
Noneafter mutating is Python's own convention (list.sort) precisely to make the choice legible.
Fire this model the moment you see: a function that "remembers" its previous call · every closure in a loop giving the same answer · a decorated function whose __name__ is wrapper · a timestamp default that is always the import time · a caller's list changing unexpectedly.
A function has grown to eight parameters. Do you keep the flat signature, take a config object, or split it into several functions?
help() and IDE completion; type checkers verify each argument; no hidden couplingLook at the parameters before counting them. Flags that fork the body mean you have two functions. Parameters that always move together mean you have a missing type. Only genuinely independent knobs justify a wide signature — and those should be keyword-only so call sites stay readable.
In data and ML work the config-object answer wins more often than in general software, for a reason unrelated to code quality: a serialisable config is a record of the run. If the parameters exist only as positional arguments in a notebook cell, the experiment is not reproducible no matter how clean the function is.
(c) Hands-on · 25 min
Save as s007_functions.py, run, and PREDICT each output before it prints.
"""s007_functions.py — the full menu of Python function features."""
from __future__ import annotations
from functools import wraps, partial, lru_cache
import time
from typing import Callable, Any
# =========================
# Part 1 · Signature grammar
# =========================
print("--- Part 1 ---")
def request(url, /, method="GET", *headers, timeout=30, **params):
return f"{method} {url} timeout={timeout} headers={headers} params={params}"
print(request("https://x", "POST", "H1", "H2", timeout=5, page=2, sort="new"))
# Try to call `request(url="x")` — it fails: url is positional-only.
# =========================
# Part 2 · Default arg trap (revisited from S005)
# =========================
print("\n--- Part 2 ---")
def bad(item, bin=[]):
bin.append(item)
return bin
def good(item, bin=None):
bin = [] if bin is None else bin
bin.append(item)
return bin
print(bad("a"), bad("b")) # shared default — PREDICT
print(good("a"), good("b")) # fresh each call
# =========================
# Part 3 · Scope (LEGB) and nonlocal / global
# =========================
print("\n--- Part 3 ---")
x = "global"
def outer():
x = "enclosing"
def inner_read():
# reads x from Enclosing (LEGB)
return x
def inner_write_local():
# this creates a new LOCAL x — doesn't touch enclosing
x = "local"
return x
def inner_write_nonlocal():
nonlocal x
x = "changed by nonlocal"
return x
print("read:", inner_read())
print("write local:", inner_write_local(), "outer x:", x)
print("write nonlocal:", inner_write_nonlocal(), "outer x:", x)
outer()
print("module x:", x)
# =========================
# Part 4 · The late-binding closure bug
# =========================
print("\n--- Part 4 ---")
# Bug — all three lambdas capture the SAME i
funcs = [lambda: i for i in range(3)]
print("bug:", [f() for f in funcs])
# Fix 1 — default argument snapshot
funcs = [lambda i=i: i for i in range(3)]
print("fix1:", [f() for f in funcs])
# Fix 2 — factory function
def make(i):
def f():
return i
return f
funcs = [make(i) for i in range(3)]
print("fix2:", [f() for f in funcs])
# =========================
# Part 5 · Closures with state
# =========================
print("\n--- Part 5 ---")
def make_counter(start: int = 0) -> Callable[[], int]:
count = start
def inc() -> int:
nonlocal count
count += 1
return count
return inc
c1 = make_counter()
c2 = make_counter(100)
print(c1(), c1(), c1())
print(c2(), c2())
# =========================
# Part 6 · Decorator from scratch
# =========================
print("\n--- Part 6 ---")
def timed(func: Callable) -> Callable:
@wraps(func) # preserves __name__, __doc__
def wrapper(*args: Any, **kwargs: Any) -> Any:
t0 = time.perf_counter()
result = func(*args, **kwargs)
elapsed = (time.perf_counter() - t0) * 1000
print(f" {func.__name__} took {elapsed:.2f} ms")
return result
return wrapper
@timed
def slow_add(a: int, b: int) -> int:
"""Add two numbers slowly, for demo."""
time.sleep(0.05)
return a + b
print(slow_add(2, 3))
print("still named correctly:", slow_add.__name__) # 'slow_add' thanks to @wraps
# Parameterised decorator
def repeat(n: int) -> Callable:
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> list:
return [func(*args, **kwargs) for _ in range(n)]
return wrapper
return decorator
@repeat(3)
def greet(name: str) -> str:
return f"hi {name}"
print(greet("ada"))
# =========================
# Part 7 · Higher-order plumbing
# =========================
print("\n--- Part 7 ---")
# functools.partial
add = lambda a, b: a + b
add5 = partial(add, 5)
print(add5(10))
# functools.lru_cache — a real-world decorator
@lru_cache(maxsize=128)
def fib(n: int) -> int:
return n if n < 2 else fib(n - 1) + fib(n - 2)
print([fib(i) for i in range(10)])
print("cache stats:", fib.cache_info())
# =========================
# Part 8 · Callable classes
# =========================
print("\n--- Part 8 ---")
class Multiplier:
"""A callable that carries state."""
def __init__(self, factor: int) -> None:
self.factor = factor
def __call__(self, x: int) -> int:
return x * self.factor
times3 = Multiplier(3)
print(times3(10), times3(4))
print("is it callable?", callable(times3))What each block teaches
Anatomy of the exercises
Write a decorator @retry(times=3, delay=0.5) that:
- Calls the wrapped function.
- If it raises, sleeps
delayseconds and retries, up totimestotal attempts. - After the last failure, re-raises the exception.
- On success, returns the result.
Test with a function that fails the first 2 calls and succeeds the third. Verify functools.wraps preserves the name.
(d) Production reality · 15 min
A view uses def get_data(request, filters={'active': True}). Under normal traffic, everything's fine. Under some request patterns, a helper inside get_data mutates the filters dict (adding user-specific keys). Second request: dict already has those keys — bug reproduces sometimes, not always.
Same fix as always: filters=None + filters = filters or {}. Bonus: enable ruff's B006 rule to catch mutable defaults at CI time.
Long-term: use types.MappingProxyType({}) for a truly-immutable default, or a frozenset() if the callers agree.
Reviewer writes on the whiteboard:
callbacks = []
for i in range(3):
callbacks.append(lambda: i)
print([cb() for cb in callbacks])‘What does this print, and why?’ Candidates who confidently say [0, 1, 2] fail. Candidates who spot the late-binding and can explain the fix get the offer.
i. Fix with lambda i=i: i (default-arg snapshot) or a factory function.@app.before_request that mutates a module-level list. Under gunicorn with multiple workers, each worker has its own module-level state — which the dev didn't realise. Sessions get ‘forgotten’ intermittently as the load balancer routes to a fresh worker.Any shared state that must be visible across workers goes in an external store (Redis, DB, memcached). Module-level Python state is per-process, and every serious deployment has multiple processes.
Alternative: gunicorn's --preload shares memory across workers, but only for READS — writes still fork on copy-on-write.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- What's a closure? (one sentence + one example)
- What's the difference between
nonlocalandglobal? (one rule each) - Write the shape of a decorator that takes arguments. (three levels of def)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.