Skip to content

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)

  1. LEGB — how Python resolves names
  2. Local vs global — the assignment rule
  3. nonlocal — modifying enclosing scope
  4. Closures — functions that remember enclosing state
  5. Parameter passing — call by object reference
  6. Default arguments — intermediate nuances
  7. Keyword-only and positional-only parameters (3.8+)
  8. Function attributes and introspection
  9. Generators — functions that yield
  10. Practical patterns for interviews

LEGB — how Python resolves names#

When Python encounters a name, it searches:

  1. Local — current function
  2. Enclosing — outer functions (nested defs)
  3. Global — module level
  4. 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:

double.__closure__
double.__closure__[0].cell_contents   # 2

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:

def add(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

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

greet.version = 1
greet.calls = 0

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#

sorted(intervals, key=lambda x: x[0])
max(students, key=lambda s: s.score)

Memoization bridge#

from functools import cache

@cache
def dp(i: int, j: int) -> int:
    ...

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#

  1. What triggers UnboundLocalError?
  2. What is the difference between global and nonlocal?
  3. When are default arguments evaluated?
  4. Why do lambdas in a loop often bug out?
  5. 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