Defining and calling functions, arguments and defaults (and the mutable-default trap), *args and **kwargs, how scope actually works with a traced call stack, global and nonlocal, recursion, and lambda — with UnboundLocalError and RecursionError explained.
Define functions with positional, keyword, default and variable arguments
Explain the LEGB scope rule and trace a call through the stack
Avoid the mutable default argument bug — the most famous Python gotcha
Write and reason about a recursive function, and know when it will blow the stack
Use lambda where it belongs: as a short key or callback
01
Defining and calling functions
✓
A function packages a piece of work under a name so you can run it many times with different inputs. def creates it, return sends a value back. A function with no return (or a bare return) returns None — which is why print(list.append(x)) shows None.
pythonEdit it. ⌘/Ctrl + Enter runs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def area(width, height):"""Area of a rectangle. ← a docstring: what the function is for."""return width * height
print(area(3,4))print(area(height=2, width=10))# keyword arguments: order does not matterdef greet(name):print(f"Hi, {name}")# prints, but returns nothing…
result = greet("Ada")print(result)# …so this is None# Functions are values: you can pass them around
ops ={"double":lambda x: x *2,"square":lambda x: x **2}print(ops["square"](7))print(area.__doc__)
You should see
12
20
Hi, Ada
None
49
Area of a rectangle. ← a docstring: what the function is for.
Your turn
Write is_leap(year): divisible by 4, except centuries, except every 400th year. Test it with 1900, 2000, 2024.
Print versus return
A function that prints is a dead end — nothing else can use its result. A function that returns can be printed, stored, tested, and composed. Return values; print at the edges of your program.
02
Arguments and default values
✓
pythonEdit it. ⌘/Ctrl + Enter runs.
1
2
3
4
5
6
7
8
9
10
11
def connect(host, port=5432, timeout=30):return f"{host}:{port} (timeout {timeout}s)"print(connect("db.local"))print(connect("db.local",6543))print(connect("db.local", timeout=5))# skip the middle one by name# Keyword-only (after *) and positional-only (before /) parametersdef pay(amount,/,*, currency="USD"):return f"{amount} {currency}"print(pay(100, currency="EUR"))
['apple']
['apple', 'pear'] ← the second call sees the first call's list
Why the interpreter said that
Default values are evaluated once, when def runs, not on each call. Every call without a basket gets the same list object, so it accumulates. This is the most-asked Python gotcha in interviews.
The fix
Use None as the default and create the list inside.
The function declares two parameters without defaults and was called with one. The error names exactly which parameter is missing. Its sibling, takes 2 positional arguments but 3 were given, is the opposite mistake — and inside a class it usually means you forgot self (Module 08).
The fix
Pass every required argument, or give the parameter a default.
A single star collects any extra positional arguments into a tuple; a double star collects extra keyword arguments into a dict. The names args and kwargs are convention — the stars are the syntax. The same stars in a call do the reverse: unpack a sequence or dict into arguments.
pythonEdit it. ⌘/Ctrl + Enter runs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def log(level,*messages,**context):print(f"[{level}]"," ".join(str(m)for m in messages), context)
log("INFO","user",42,"logged in", ip="10.0.0.1", region="eu")
log("WARN")# Unpacking in a call
nums =[3,1,2]print(max(*nums))# max(3, 1, 2)
settings ={"sep":" | ","end":"!\n"}print("a","b",**settings)# print("a", "b", sep=" | ", end="!\n")# Forwarding everything — the decorator pattern (Module 09)def wrapper(*args,**kwargs):return log(*args,**kwargs)
wrapper("DEBUG","forwarded", via="wrapper")
You should see
[INFO] user 42 logged in {'ip': '10.0.0.1', 'region': 'eu'}
[WARN] {}
3
a | b!
[DEBUG] forwarded {'via': 'wrapper'}
Your turn
Write average(*numbers) that returns the mean of any number of arguments and returns 0 for none.
04
Scope: where a name lives
✓
Every name is looked up in four places, in order — Local (inside the current function), Enclosing (any function this one is nested in), Global (the module), Built-in (print, len). Assignment inside a function creates a local name, always, even if a global with that name exists. That one rule explains every scope surprise.
VisualizeA call, step by step: locals are born and die with the callStep 1 / 7
1rate =0.2
2
3def tax(amount):
4 total = amount * rate
5return total
6
7bill = tax(50)
8print(bill)
Line 1
Module (global) scope: bind rate.
Variables now
rate
0.2
All 7 steps as a table
Step
Line
What happened
Variables now
1
1
Module (global) scope: bind rate.
rate = 0.2
2
3
def creates a function object and binds the global name tax. The body does NOT run yet.
tax = <function>
3
7
Call tax(50). A new local scope is created for this call; amount is bound to 50 inside it.
amount (local) = 50
4
4
Look up amount → found locally. Look up rate → not local, not enclosing, found in global scope: 0.2. Bind local total.
total (local) = 10.0
5
5
Return 10.0. The local scope is destroyed — amount and total no longer exist anywhere.
amount (local) = (gone)total (local) = (gone)
6
7
The returned value is bound to the global bill.
bill = 10.0
7
8
Print it.
Error you will hit
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
Traceback (most recent call last):
File "your code", line 7, in <module>
increment()
File "your code", line 4, in increment
count += 1
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
Why the interpreter said that
Because count is assigned somewhere in the function, Python decides at compile time that count is local to the function. Then count += 1 tries to read that local before anything was assigned to it. Reading a global is fine; assigning to it makes it local.
The fix
Declare global count — or, far better, pass the value in and return the new one. Functions that reach out and modify globals are the hardest code to test.
x ="global"def outer():
x ="enclosing"def inner():nonlocal x # refer to outer's x, not a new local
x ="changed by inner"
inner()return x
print(outer())print(x)# global x untouched
counter =0def bump():global counter # explicitly modify the module-level name
counter +=1
bump(); bump()print(counter)
You should see
changed by inner
global
2
LEGB
Local → Enclosing → Global → Built-in: the order names are searched. The first match wins.
global
Inside a function: "assignments to this name go to the module scope". Use sparingly.
nonlocal
Inside a nested function: "assignments to this name go to the nearest enclosing function's scope". The mechanism behind closures (Module 09).
05
Recursion
✓
A recursive function calls itself on a smaller version of the problem, until it hits a base case it can answer directly. Every recursive function needs both: the base case that stops, and the step that shrinks. Miss the first and you get a RecursionError; miss the second and you get one too.
pythonEdit it. ⌘/Ctrl + Enter runs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def factorial(n):if n <=1:# base casereturn1return n * factorial(n -1)# shrink toward itprint(factorial(5))# Where recursion is the natural tool: nested data
tree ={"name":"root","children":[{"name":"a","children":[{"name":"a1","children":[]}]},{"name":"b","children":[]},]}def names(node, depth=0):print(" "* depth + node["name"])for child in node["children"]:
names(child, depth +1)
names(tree)import sys
print(sys.getrecursionlimit())
You should see
120
root
a
a1
b
1000
Your turn
Write fib(n) recursively, then call fib(30) and notice how slow it is — that is the motivation for the memoisation you meet in Module 12.
Visualizefactorial(3) — the call stack grows, then unwindsStep 1 / 8
1def factorial(n):
2if n <=1:
3return1
4return n * factorial(n -1)
5
6print(factorial(3))
Line 6
Call factorial(3). Frame 1 opens with n = 3.
Variables now
stack
[f(3)]
All 8 steps as a table
Step
Line
What happened
Variables now
1
6
Call factorial(3). Frame 1 opens with n = 3.
stack = [f(3)]
2
2
3 ≤ 1? No.
3
4
Need factorial(2) first — frame 1 pauses at this line. Frame 2 opens with n = 2.
stack = [f(3), f(2)]
4
4
2 ≤ 1? No. Need factorial(1). Frame 3 opens with n = 1.
def countdown(n):print(n)
countdown(n -1)# no base case: never stops
countdown(3)
3
2
1
0
-1
... (about a thousand lines)
Traceback (most recent call last):
File "your code", line 5, in <module>
File "your code", line 3, in countdown
File "your code", line 3, in countdown
[Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded
Why the interpreter said that
Each call adds a frame to the stack and nothing ever returns. Python caps the depth at 1000 to stop the process crashing. Even a correct recursive function will hit this on deep inputs (a 5,000-item linked list, say) — recursion in Python is for tree-shaped problems, not long sequences. Use a loop for those.
The fix
Add the base case.
python
1234567
def countdown(n):if n <0:returnprint(n)
countdown(n -1)
countdown(3)
06
Lambda
✓
A lambda is a one-expression anonymous function. Its whole reason to exist is being passed to something else — a sort key, a map, a callback — where naming a full def would be noise. If a lambda needs a second line, an if statement, or a name, make it a def.
pythonEdit it. ⌘/Ctrl + Enter runs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
square =lambda x: x **2# legal, but PEP 8 says use def for thisprint(square(6))# Where lambda shines: as an argument
words =["banana","fig","apple","kiwi"]print(sorted(words, key=lambda w:len(w)))# by lengthprint(sorted(words, key=lambda w:(len(w), w)))# by length, then alphabeticallyprint(max(words, key=len))# a named function works too
rows =[{"city":"Oslo","temp":4},{"city":"Cairo","temp":31}]print(sorted(rows, key=lambda r: r["temp"], reverse=True)[0]["city"])print(list(map(lambda x: x *10,[1,2,3])))print(list(filter(lambda x: x %2==0,range(10))))
Sort [("Ada", 36), ("Bob", 36), ("Cy", 28)] by age descending, then by name ascending, in one sorted call.
Quick check
Which is the Pythonic choice for a reusable, named, three-line helper?
Lambdas are for the moment they are passed. Anything named, multi-line or reused is a def — it gets a docstring, a real name in tracebacks, and room to grow.