Functions and Scope#
Functions in Python are first-class objects with a precise scope model. Misunderstanding scope causes UnboundLocalError, accidental mutation, and closure bugs — all common follow-up questions in interviews and code review.
How to use this page
Builds on Functions and Code Reuse. Advanced closures/decorators: Closures, Decorators.
At a glance
| Track | Python Intermediate |
| Sections | 10 major topics |
| Outline | Use the right-hand TOC to jump |
Topics: LEGB — how Python resolves names · Local vs global — the assignment rule · nonlocal — modifying enclosing scope · Closures — functions that remember enclosing state · Parameter passing — call by object reference · Default arguments — intermediate nuances · Keyword-only and positional-only parameters (3.8+) · Function attributes and introspection · … (+2 more)
- LEGB — how Python resolves names
- Local vs global — the assignment rule
nonlocal— modifying enclosing scope- Closures — functions that remember enclosing state
- Parameter passing — call by object reference
- Default arguments — intermediate nuances
- Keyword-only and positional-only parameters (3.8+)
- Function attributes and introspection
- Generators — functions that
yield - Practical patterns for interviews
LEGB — how Python resolves names#
When Python encounters a name, it searches:
- Local — current function
- Enclosing — outer functions (nested defs)
- Global — module level
- Built-in —
len,print,Exception, …
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x)
inner()
print(x)
outer() # prints: local, then enclosing
print(x) # global
Each function call creates a new local namespace (implemented as a dict, optimized in CPython).
Local vs global — the assignment rule#
If a name is assigned anywhere in a function, Python treats it as local throughout that entire function unless declared global or nonlocal.
count = 0
def broken():
print(count) # UnboundLocalError — count is local due to +=
count += 1
def read_only():
print(count) # OK — no assignment to count
def fixed():
global count
count += 1
| Action | Needs declaration? |
|---|---|
| Read global, no assign | No |
| Assign/rebind global | global name |
| Assign in nested fn to enclosing | nonlocal name |
| Read enclosing, no assign | No |
Interview preference: pass state in and return updates — avoid global unless scripting.
nonlocal — modifying enclosing scope#
def make_counter(start: int = 0):
count = start
def increment(step: int = 1) -> int:
nonlocal count
count += step
return count
def reset() -> None:
nonlocal count
count = start
return increment, reset
inc, reset = make_counter(10)
inc() # 11
inc(5) # 16
reset()
inc() # 11
nonlocal skips the local scope and binds to the nearest enclosing scope that defines the name — not the module global.
Deep dive: Variable Scope and nonlocal Keywords.
Closures — functions that remember enclosing state#
A closure is a function that captures variables from its enclosing scope:
def make_multiplier(n: int):
def multiply(x: int) -> int:
return x * n
return multiply
double = make_multiplier(2)
double(5) # 10
Inspect closure cells:
Late binding trap (must know)#
Closed-over variables are looked up when the inner function runs, not when it is created:
# BUG — all functions return 4
functions = []
for i in range(4):
functions.append(lambda: i)
[f() for f in functions] # [3, 3, 3, 3]
# FIX 1 — default argument binds at definition time
functions = []
for i in range(4):
functions.append(lambda i=i: i)
# FIX 2 — factory
def make_fn(i):
return lambda: i
functions = [make_fn(i) for i in range(4)]
This appears in event handlers, loop-created lambdas, and decorator factories.
Parameter passing — call by object reference#
Python passes references to objects — not copies of objects:
def rebind(lst: list):
lst = [99] # local rebinding only
def mutate(lst: list):
lst.append(99) # caller sees change
nums = [1, 2, 3]
rebind(nums) # nums unchanged
mutate(nums) # nums is [1, 2, 3, 99]
| Type | Reassign param | Mutate param |
|---|---|---|
Immutable (int, str, tuple) |
Caller unaffected | N/A |
Mutable (list, dict, set) |
Caller unaffected | Caller affected |
Return new objects when immutability is part of your contract.
Default arguments — intermediate nuances#
Defaults are evaluated once at function definition:
Never use mutable literals as defaults ([], {}, set()).
Defaults and introspection#
def demo(a, b=[]): pass
demo.__defaults__ # ([],)
demo.__kwdefaults__ # keyword-only defaults
inspect.signature(demo)
Keyword-only and positional-only parameters (3.8+)#
def connect(host, port, /, *, timeout=30, retries=3):
"""
host, port — positional-only (before /)
timeout, retries — keyword-only (after *)
"""
...
connect("localhost", 8080, timeout=60)
# connect(host="localhost", ...) # ERROR for positional-only
Why: API stability — later add parameters without breaking callers; force clarity on optional config.
Full unpacking: Parameter Unpacking.
Function attributes and introspection#
Functions are objects:
def greet(name: str) -> str:
"""Say hello."""
return f"Hello, {name}"
greet.__name__ # 'greet'
greet.__doc__
greet.__annotations__ # {'name': <class 'str'>, 'return': <class 'str'>}
greet.__defaults__
callable(greet) # True
Store metadata on functions (common in decorators):
Generators — functions that yield#
def countdown(n: int):
while n > 0:
yield n
n -= 1
for x in countdown(3):
print(x) # 3, 2, 1
gen = countdown(3)
next(gen) # 3
next(gen) # 2
| Feature | Regular function | Generator |
|---|---|---|
| Returns | Single value | Iterator via yield |
| State | Lost after return | Suspended between yields |
| Memory | Full result materialized | Lazy |
Use generators for large/infinite sequences — full treatment in Advanced iterators section.
Practical patterns for interviews#
Pure helper extraction#
def is_valid(board: list[list[str]], row: int, col: int, ch: str) -> bool:
...
def solve(board: list[list[str]]) -> bool:
for r, c in candidates:
if is_valid(board, r, c, num):
board[r][c] = num
if solve(board):
return True
board[r][c] = "."
return False
Callback / key functions#
Memoization bridge#
Interview traps (quick reference)#
| Trap | What goes wrong | Safe approach |
|---|---|---|
Assign without nonlocal |
UnboundLocalError in nested fn | nonlocal or default arg bind |
| Lambda in loop | All see final loop variable | Default arg or factory |
| Mutating caller's list | Hidden side effect | Copy or document |
| Mutable default | Shared across calls | None sentinel |
global overuse |
Untestable coupling | Pass/return state |
| Assuming pass-by-value | Surprise mutations | Know object reference model |
Mental model checklist#
- What triggers
UnboundLocalError? - What is the difference between
globalandnonlocal? - When are default arguments evaluated?
- Why do lambdas in a loop often bug out?
- What does a closure store — values or names?
What's next#
| Topic | Page |
|---|---|
| Closures and decorators | Closures |
| Higher-order functions | Higher Order Functions |
| Modules | Modules and Packages |
| Intermediate syntax | Intermediate Syntax and Structures |