Functions & Scoping
1 · The lesson
readA function is a named, reusable chunk of behaviour. You give it inputs, it gives you back a value (or it doesn't). Most non-trivial Python files are mostly functions — the script-level code at the bottom is just the conductor calling them in order.
This lesson covers the shape of a function, every way to pass arguments, type hints, docstrings, the four scopes Python searches when it sees a name, and the handful of mistakes that trip everyone up the first time.
1. The Shape of a Function
def greet(name): message = f"Hi, {name}." return message print(greet("Grace")) # Hi, Grace.
Four parts: the def keyword, the name, the parameter list in parentheses, and an indented body. return hands a value back to the caller and ends the function immediately — any code after a hit return doesn't run.
A function with no return still returns something — None.
def log(msg): print(f"[log] {msg}") # no return statement result = log("starting") # [log] starting print(result) # None
That None is the most common cause of "why is my variable suddenly None?" — you assigned the result of a function that doesn't return anything.
2. Positional vs Keyword Arguments
Pass arguments by position, by name, or both.
def greet(name, greeting): return f"{greeting}, {name}." print(greet("Grace", "Hi")) # positional — order matters print(greet(name="Grace", greeting="Hi")) # keyword — order doesn't print(greet("Grace", greeting="Hi")) # mixed — positional first
Keyword arguments are self-documenting at the call site. For any function with more than two parameters — or any boolean flag — use keywords. connect("db", True, False, 30) is unreadable; connect("db", ssl=True, retry=False, timeout=30) reads itself.
3. Default Parameter Values
Give a parameter a default and the caller can omit it.
def power(base, exponent=2): return base ** exponent print(power(5)) # 25 — exponent defaults to 2 print(power(5, 3)) # 125 print(power(base=5, exponent=4))# 625
Parameters with defaults must come after parameters without them. def f(x=1, y): is a SyntaxError.
4. *args and **kwargs
When you don't know in advance how many arguments you'll receive, use these two.
def total(*numbers): # *args — collects extra positionals into a tuple return sum(numbers) print(total(1, 2, 3)) # 6 print(total(1, 2, 3, 4, 5)) # 15 def configure(**options): # **kwargs — collects keywords into a dict for key, value in options.items(): print(f"{key} = {value}") configure(host="localhost", port=5432, ssl=True)
setup added so this can run · defines numbers, options
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) numbers = _AutoMock('numbers') options = _AutoMock('options')
The names args and kwargs are convention — the * and ** are what matter. You can combine all four kinds:
def call(method, *args, timeout=30, **kwargs): ...
Order is fixed: positional → *args → keyword-with-default → **kwargs.
5. Type Hints
Optional annotations describing what types a function expects and returns. The interpreter doesn't enforce them — they're documentation for humans and tools like mypy, pyright, and your editor.
def add(a: int, b: int) -> int: return a + b def first_name(full: str) -> str: return full.split()[0] def divide(x: float, y: float) -> float | None: if y == 0: return None return x / y
Skip them in throwaway scripts. Add them to any function others — including future-you — will call. The signature becomes a contract you can read without opening the body.
6. Docstrings
The first statement of a function body can be a triple-quoted string. That's the function's docstring — what help(fn) prints and what IDEs surface on hover.
def slugify(text: str) -> str: """Convert a string into a URL-safe slug. Lowercases, replaces whitespace with hyphens, and strips everything that isn't alphanumeric or a hyphen. """ import re text = text.lower().strip() text = re.sub(r"\s+", "-", text) return re.sub(r"[^a-z0-9-]", "", text) help(slugify)
One-line docstrings are fine for short functions. Reach for the multi-line form once a function has interesting parameters, edge cases, or return shapes worth documenting.
7. LEGB Scope
When Python sees a name, it searches four scopes in this order — Local, Enclosing, Global, Built-in.
- Local: names defined inside the current function.
- Enclosing: names in the function that wraps this one (only matters for nested functions).
- Global: names defined at the top of the module — the
.pyfile. - Built-in: names like
print,len,rangethat Python provides without imports.
x = "global-x" # G def outer(): x = "enclosing-x" # E (relative to inner) def inner(): x = "local-x" # L print(x) # local-x inner() print(x) # enclosing-x outer() print(x) # global-x print(len("abc")) # B — len is built-in
Python finds the closest binding. Remove the x = "local-x" line inside inner and it falls back to "enclosing-x". Remove the enclosing one too and it falls back to "global-x".
8. global and nonlocal
By default, assigning to a name inside a function creates a new local binding — it does not modify the outer one.
count = 0 def bump(): count = count + 1 # UnboundLocalError — Python sees the assignment # and decides count is local, then reads it before write
To genuinely mutate the outer name, declare it:
count = 0 def bump(): global count count += 1 bump(); bump() print(count) # 2
nonlocal is the same idea for the enclosing function (one step out, not all the way to module level):
def make_counter(): n = 0 def step(): nonlocal n n += 1 return n return step
Both are usually a smell. A function that quietly mutates module-level state is hard to test and reason about. Prefer returning a new value and letting the caller decide what to do with it. Know they exist; reach for them rarely.
9. Pure vs Impure Functions
A pure function depends only on its inputs and produces only its return value. Same inputs → same output, every time. No file writes, no network calls, no mutation of arguments, no reliance on globals.
# Pure — no side effects, no hidden inputs def tax(amount: float, rate: float) -> float: return amount * rate # Impure — touches I/O and the clock import datetime def log_tax(amount, rate): result = amount * rate with open("audit.log", "a") as f: f.write(f"{datetime.datetime.now()}: {result}\n") return result
Pure functions are trivial to test, safe to cache, and easy to reuse. Impure functions are necessary — programs that don't touch the outside world are just space heaters — but you want the impure parts concentrated at the edges and the bulk of your logic pure.
Default to pure. Push side effects to the boundary.
10. Lambda Expressions
A lambda is an anonymous, single-expression function. Useful as a one-off key= or filter predicate.
data = [("apple", 3), ("banana", 1), ("cherry", 2)] # Sort by the second element of each tuple data.sort(key=lambda pair: pair[1]) print(data) # [('banana', 1), ('cherry', 2), ('apple', 3)]
The body is a single expression — no statements, no return keyword (the expression's value is returned). The moment you want two lines, a conditional, or a docstring, switch to a real def. Naming the function also names the intent:
# Reaches the limit of lambda's usefulness: sorted(data, key=lambda x: (-x[1], x[0].lower())) # Better: def by_count_desc_then_name(pair): return (-pair[1], pair[0].lower()) sorted(data, key=by_count_desc_then_name)
setup added so this can run · defines data
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) data = _AutoMock('data')
Common Mistakes
1. Mutable default arguments — the classic bug
def append_item(item, lst=[]): # BAD — list is created once at def-time lst.append(item) return lst print(append_item("a")) # ['a'] print(append_item("b")) # ['a', 'b'] — the same list! print(append_item("c")) # ['a', 'b', 'c']
The default [] is evaluated once when the function is defined, and every call that omits lst shares that same list object. The fix is to use None as a sentinel and create a fresh list inside:
def append_item(item, lst=None): if lst is None: lst = [] lst.append(item) return lst
Same trap applies to {} dicts, set(), or any other mutable default. Memorise this — it bites every Python developer exactly once.
2. Forgetting return
def double(x): x * 2 # computed and thrown away result = double(5) print(result + 1) # TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
The function ran, did the multiplication, returned None, and your downstream code blew up somewhere unrelated. If a function's job is to produce a value, the body must return it.
3. Shadowing built-ins as parameter names
def find(list, type, id): # shadows list, type, id inside this function ...
Inside find, list no longer refers to the built-in — you can't write list("abc"). Use items, kind, item_id instead. Common offenders: list, dict, type, id, str, input, sum, min, max, filter, map.
4. Mutating a parameter and surprising the caller
def add_admin(users): users.append("admin") # mutates the caller's list return users team = ["alice", "bob"] add_admin(team) print(team) # ['alice', 'bob', 'admin'] — caller's list changed
Lists, dicts, and sets are passed by reference. If a function shouldn't change its inputs, work on a copy: users = list(users) at the top of the body. This connects to the aliasing trap from the Lists lesson.
5. Calling vs referencing
def get_user(): return {"name": "Grace"} print(get_user) # <function get_user at 0x...> — the function object print(get_user()) # {'name': 'Grace'} — the return value
fn is the function. fn() runs it. Forgetting the parentheses on the call is a common source of "why is my variable a function object?" confusion.
🎯 Your Turn — Function Composition
Write compose(f, g) that returns a new function. The returned function, when called with x, computes f(g(x)) — i.e. g runs first, then f runs on the result.
def add_one(x): return x + 1 def double(x): return x * 2 add_one_then_double = compose(double, add_one) print(add_one_then_double(3)) # (3 + 1) * 2 = 8
setup added so this can run · defines compose
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def compose(*_a, **_kw): print('-> compose() called') return _AutoMock('compose()')
Skeleton:
def compose(f, g): # TODO 1: define an inner function that takes x # TODO 2: have it return f(g(x)) # TODO 3: return the inner function (don't call it!) ...
Hint 1 — Functions returning functions
You define a function insidecompose, then return it — without parentheses. The parentheses would call it; you want to hand back the function itself for the caller to invoke later.
Hint 2 — Apply g first
Inside the inner function, computeg(x) first, then pass that result into f. One line: return f(g(x)).
Show full solution
def compose(f, g): def composed(x): return f(g(x)) return composed def add_one(x): return x + 1 def double(x): return x * 2 add_one_then_double = compose(double, add_one) print(add_one_then_double(3)) # 8 print(add_one_then_double(10)) # 22 # Compose three? Just nest. def square(x): return x * x pipeline = compose(square, compose(double, add_one)) print(pipeline(3)) # ((3+1)*2)^2 = 64
You've just written a higher-order function — a function that takes functions and returns a function. This is the cornerstone of functional programming and a pattern you'll see all over Python: sorted(key=...), map, filter, decorators, every event-driven framework.
What You Learned
- A function is
def name(params): body. Noreturnmeans it returnsNone. - Arguments pass by position or by keyword. Use keywords for clarity on calls with flags or many params.
- Defaults go after non-defaults. Never use a mutable object as a default — use
Noneand build inside. *argscollects extra positionals into a tuple;**kwargscollects extra keywords into a dict.- Type hints are optional but make signatures self-documenting. Docstrings live on the first line of the body.
- LEGB: Python resolves names by looking Local → Enclosing → Global → Built-in.
globalandnonlocalexist; use them sparingly. - Pure functions are easier to test and reason about. Push side effects to the edges.
- Lambdas are fine for one-expression callables. Promote to
defthe moment they grow.
Next: Dictionaries — the workhorse data structure for everything keyed.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.