Python Scope and LEGB Practice Problems & Exercises
Practice: Scope and LEGB
← Back to lessonEasy
Predict the output. Three functions each print a variable named x. Determine which scope each print resolves x from.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "inner"
print(x) # Which x?
inner()
print(x) # Which x?
outer()
print(x) # Which x?Solution
x = "global"
def outer():
x = "enclosing"
def inner():
x = "inner"
print(x) # Local x in inner
inner()
print(x) # Local x in outer
outer()
print(x) # Global x
Output:
inner
enclosing
global
How it works: Each function creates its own local x. When inner() calls print(x), Python finds x = "inner" in inner's local scope and stops there. When outer() calls print(x) after inner() returns, Python finds x = "enclosing" in outer's local scope. The final print(x) at module level finds x = "global" in the global scope.
Key insight: Assignment creates a new local variable in the current scope. The three x variables are completely independent — modifying one does not affect the others. Each scope acts as an isolated namespace.
Expected Output
inner\nenclosing\nglobalHints
Hint 1: Python searches Local first, then Enclosing, then Global, then Built-in.
Hint 2: Each function has its own local scope. A nested function can see variables from the enclosing function.
Predict the output. The inner function reads variables defined in the enclosing function without any global or nonlocal keyword.
def outer():
message = "Hello from outer"
value = 42
def inner():
print(message)
print(value)
inner()
outer()Solution
def outer():
message = "Hello from outer"
value = 42
def inner():
print(message)
print(value)
inner()
outer()
Output:
Hello from outer
42
How it works: inner() does not define message or value locally. Python searches the enclosing scope (outer's local scope) and finds both variables there. No special keyword is needed to read from an enclosing scope — only to modify it.
Key insight: The LEGB rule means reading from outer scopes is automatic and free. The nonlocal and global keywords are only needed when you want to assign to a variable in an outer scope. If you only read, Python handles it transparently.
Expected Output
Hello from outer\n42Hints
Hint 1: If a name is not assigned in the local scope, Python searches the enclosing scope next.
Hint 2: Reading a variable from an enclosing scope does not require any special keyword.
Predict what happens when a global variable shadows the built-in list. Write the output for each print, or TypeError if it raises an error.
list = [1, 2, 3]
print(list)
print(len(list))
try:
new_list = list(range(5))
print(new_list)
except TypeError:
print("TypeError")Solution
list = [1, 2, 3]
print(list)
print(len(list))
try:
new_list = list(range(5))
print(new_list)
except TypeError:
print("TypeError")
Output:
[1, 2, 3]
3
TypeError
How it works: The assignment list = [1, 2, 3] creates a global variable named list that shadows the built-in list type. After this, list refers to the object [1, 2, 3] — a regular list instance, not the list class. Calling list(range(5)) tries to call a list object as a function, which raises TypeError: 'list' object is not callable.
Note that len(list) still works because len is not shadowed — it correctly reports the length of the [1, 2, 3] object.
Key insight: Built-in scope is last in the LEGB chain. Any name you define at module or function level takes priority. Common names to avoid shadowing: list, dict, set, str, int, len, range, print, type, id, input, open, sum, min, max. Use import builtins; builtins.list to access the original if you accidentally shadow it.
Expected Output
[1, 2, 3]\n3\nTypeErrorHints
Hint 1: When you assign to a name like list, it shadows the built-in in that scope.
Hint 2: After shadowing, the name refers to whatever you assigned, not the built-in type.
Predict the output. Compare how a for loop and a list comprehension handle their iteration variables.
x = "before"
for i in range(5):
pass
print(i)
result = [j for j in range(5)]
print(result)
# Does the comprehension variable j leak?
# Does the for-loop overwrite x?
comp = [x for x in range(5)]
print(x)Solution
x = "before"
for i in range(5):
pass
print(i)
result = [j for j in range(5)]
print(result)
comp = [x for x in range(5)]
print(x)
Output:
4
[0, 1, 2, 3, 4]
before
How it works:
-
for i in range(5): pass— the loop variableileaks into the enclosing scope. After the loop,iretains its last value: 4. -
[j for j in range(5)]— the list comprehension creates its own scope in Python 3. The variablejexists only inside the comprehension and does not leak. -
[x for x in range(5)]— even though the comprehension uses a variable namedx, the comprehension'sxis local to the comprehension. The outerx = "before"is unchanged.
Key insight: This is a Python 3 fix. In Python 2, list comprehensions leaked their variable, so [x for x in range(5)] would overwrite the outer x to 4. Python 3 gave comprehensions (list, dict, set) and generator expressions their own scope, but for loops still leak.
Expected Output
4\n[0, 1, 2, 3, 4]\nbeforeHints
Hint 1: Python for-loops do NOT create their own scope — the loop variable leaks.
Hint 2: List comprehensions in Python 3 DO have their own scope — the variable does not leak.
Medium
Predict the output. One function triggers UnboundLocalError, the other does not. Explain why.
x = 10
def broken():
try:
print(x)
x = 20
except UnboundLocalError:
print("UnboundLocalError")
def working():
y = x + 1
print(y)
broken()
working()Solution
x = 10
def broken():
try:
print(x)
x = 20
except UnboundLocalError:
print("UnboundLocalError")
def working():
y = x + 1
print(y)
broken()
working()
Output:
UnboundLocalError
11
How it works:
In broken(), the compiler scans the entire function body and sees x = 20. This makes x a local variable for the entire function — including the print(x) line that comes before the assignment. At runtime, when print(x) executes, Python looks for x in the local variable array, but it has not been assigned yet. Result: UnboundLocalError.
In working(), the compiler scans the body and sees y = x + 1. The name y is classified as local (it is assigned). The name x is never assigned in this function, so it is classified as free — Python will look it up in the enclosing/global scope. At runtime, x resolves to the global value 10, so y = 11.
Key insight: The critical rule is that Python's compiler makes the local-vs-global decision before runtime. If a name appears on the left side of = anywhere in a function, it is local for the entire function — not just after the assignment. This is the single most common scope bug in Python.
Expected Output
UnboundLocalError\n11Hints
Hint 1: Python classifies names as local or global at compile time, before the function runs.
Hint 2: Any assignment anywhere in the function body makes the name local for the entire function.
Implement two counter functions. One uses global to modify a module-level counter, the other uses parameters and return values (the preferred approach). Both should produce the same results.
# Approach 1: global keyword
count_a = 0
def increment_global():
global count_a
count_a += 1
print(count_a)
increment_global()
increment_global()
increment_global()
print(count_a)
# Approach 2: pure function (preferred)
count_b = 0
def increment_pure(current):
return current + 1
print(count_b)
count_b = increment_pure(count_b)
count_b = increment_pure(count_b)
count_b = increment_pure(count_b)
print(count_b)Solution
# Approach 1: global keyword
count_a = 0
def increment_global():
global count_a
count_a += 1
print(count_a)
increment_global()
increment_global()
increment_global()
print(count_a)
# Approach 2: pure function (preferred)
count_b = 0
def increment_pure(current):
return current + 1
print(count_b)
count_b = increment_pure(count_b)
count_b = increment_pure(count_b)
count_b = increment_pure(count_b)
print(count_b)
Output:
0
3
0
3
How it works: Both approaches produce the same result, but they differ fundamentally in design.
Approach 1 (global): The global count_a declaration tells Python that all references to count_a inside increment_global should use the module-level variable. Without it, count_a += 1 would create a local variable and raise UnboundLocalError.
Approach 2 (pure function): increment_pure takes the current value as a parameter and returns the new value. The caller is responsible for reassigning. This approach is testable, composable, and has no hidden side effects.
Key insight: The global keyword works but creates hidden coupling between the function and module state. Any part of the program can call increment_global() and silently change count_a, making bugs hard to trace. Pure functions are always preferred unless you have a strong reason for shared mutable state.
Expected Output
0\n3\n0\n3Hints
Hint 1: Without global, the += operator creates a local variable and raises UnboundLocalError.
Hint 2: global tells Python to use the module-level variable for all reads and writes in that function.
Build a counter factory using closures and nonlocal. Each counter must maintain independent state.
def make_counter(start=0, step=1):
count = start
def increment():
nonlocal count
count += step
return count
return increment
c1 = make_counter(start=0, step=1)
c2 = make_counter(start=0, step=10)
print(c1())
print(c1())
print(c1())
print(c2())
print(c2())
print(c1())Solution
def make_counter(start=0, step=1):
count = start
def increment():
nonlocal count
count += step
return count
return increment
c1 = make_counter(start=0, step=1)
c2 = make_counter(start=0, step=10)
print(c1())
print(c1())
print(c1())
print(c2())
print(c2())
print(c1())
Output:
1
2
3
10
20
4
How it works: Each call to make_counter() creates a new closure with its own count variable. The nonlocal count declaration tells increment to modify the count in its enclosing scope (make_counter's local scope) rather than creating a new local variable.
c1 and c2 are completely independent closures. c1 increments by 1, c2 increments by 10. Calling c2() does not affect c1's count, as demonstrated by the last call c1() returning 4 (continuing from 3).
Key insight: Without nonlocal, the line count += step would make count a local variable in increment and raise UnboundLocalError. The nonlocal keyword is required whenever a nested function needs to modify (not just read) a variable from its enclosing function.
Expected Output
1\n2\n3\n10\n20\n4Hints
Hint 1: nonlocal binds to the nearest enclosing function scope, not the global scope.
Hint 2: Each call to the factory creates an independent closure with its own state.
Predict the output. The inner function uses nonlocal to modify the enclosing variable. Does it also change the global variable?
x = "global"
def outer():
x = "enclosing"
def inner():
nonlocal x
x = "inner modified enclosing"
inner()
print(x)
outer()
print(x)Solution
x = "global"
def outer():
x = "enclosing"
def inner():
nonlocal x
x = "inner modified enclosing"
inner()
print(x)
outer()
print(x)
Output:
inner modified enclosing
global
How it works: nonlocal x in inner() binds x to the nearest enclosing function scope — that is outer()'s local x. When inner() assigns x = "inner modified enclosing", it modifies outer's x. The global x at module level is a completely separate variable and remains "global".
If inner() had used global x instead of nonlocal x, it would have modified the module-level x to "inner modified enclosing" while leaving outer's local x as "enclosing".
Key insight: nonlocal and global target different scopes. nonlocal searches outward through enclosing functions and binds to the first match. global jumps directly to module scope, ignoring all enclosing functions. This distinction matters in deeply nested code.
Expected Output
inner modified enclosing\nglobalHints
Hint 1: nonlocal targets the nearest enclosing function scope, not the global scope.
Hint 2: global always targets the module-level scope, skipping all enclosing function scopes.
Hard
Fix all four broken functions so the code produces the expected output. Each function has a different scope-related bug.
# Bug 1: UnboundLocalError
total = 10
def add_to_total(n):
global total
total += n
return total
# Bug 2: Shadowed built-in
def count_items(items):
result = len(items)
return result
# Bug 3: Missing nonlocal
def make_appender():
items = []
def append(value):
nonlocal items
items = items + [value]
return items
return append
# Bug 4: Assignment makes name local
threshold = 5
def check_threshold(value):
result = value > threshold
if result:
return value
return 0
# Test all fixes
print(add_to_total(5))
appender = make_appender()
appender("apple")
appender("banana")
result = appender("banana")
print(result[-1])
print(count_items([1, 2, 3]))
print(check_threshold(5))Solution
# Bug 1: UnboundLocalError — total += n makes total local
# Fix: add "global total"
total = 10
def add_to_total(n):
global total
total += n
return total
# Bug 2: Shadowed built-in — using len as a variable name
# Fix: rename the variable to something that does not shadow the built-in
def count_items(items):
result = len(items)
return result
# Bug 3: Missing nonlocal — items = items + [value] makes items local
# Fix: add "nonlocal items"
def make_appender():
items = []
def append(value):
nonlocal items
items = items + [value]
return items
return append
# Bug 4: Assignment makes name local
# Fix: do not reassign threshold inside the function
threshold = 5
def check_threshold(value):
result = value > threshold
if result:
return value
return 0
# Test all fixes
print(add_to_total(5))
appender = make_appender()
appender("apple")
appender("banana")
result = appender("banana")
print(result[-1])
print(count_items([1, 2, 3]))
print(check_threshold(5))
Output:
15
banana
3
5
Bug analysis:
-
Bug 1 (UnboundLocalError):
total += nis equivalent tototal = total + n. The assignment makestotallocal, but the read on the right side finds it unbound. Fix:global total. -
Bug 2 (Shadowed built-in): If the original code used
len = len(items), the namelenbecomes a local variable shadowing the built-in. Any subsequent call tolen()in that scope would fail. Fix: use a different variable name likeresult. -
Bug 3 (Missing nonlocal):
items = items + [value]reassignsitems, making it local toappend. Withoutnonlocal items, the right-handitemstries to read a local variable that does not exist yet. Fix: addnonlocal items. Alternative fix: useitems.append(value)which mutates without reassigning. -
Bug 4 (Accidental local): If the original code had
threshold = thresholdor some reassignment ofthreshold, it would become local and fail. Fix: only readthresholdwithout reassigning it.
Key insight: All four bugs stem from the same root cause: Python's compile-time scope classification. Any assignment anywhere in a function body makes that name local for the entire function. The fixes are: global for module variables, nonlocal for closure variables, renaming to avoid shadowing built-ins, and avoiding unnecessary reassignment.
Expected Output
15\nbanana\n3\n5Hints
Hint 1: Each function has a different scope bug. Look for assignments that accidentally make names local.
Hint 2: One function shadows a built-in. One needs global. One needs nonlocal. One has the classic UnboundLocalError.
Use locals() and globals() to inspect and manipulate namespaces. Demonstrate that locals() is read-only inside functions but globals() modifications take effect.
secret = 42
def inspect_scopes(a, b):
c = a + b
local_ns = locals()
# Check that locals() captured our variables
print("a" in local_ns)
print("c" in local_ns)
# Modifying locals() does NOT change actual locals
local_ns["c"] = 999
print(c == 999)
# Count how many local variables exist
print(len(local_ns))
inspect_scopes(10, 20)
# globals() returns the real module dict
print(globals()["secret"])Solution
secret = 42
def inspect_scopes(a, b):
c = a + b
local_ns = locals()
print("a" in local_ns)
print("c" in local_ns)
local_ns["c"] = 999
print(c == 999)
print(len(local_ns))
inspect_scopes(10, 20)
print(globals()["secret"])
Output:
True
True
False
2
42
How it works:
-
locals()insideinspect_scopesreturns a dict snapshot of the function's local variables at that moment:a=10,b=20,c=30. But wait — the snapshot is taken at the pointlocals()is called, and at that timelocal_nsitself is not yet in the snapshot (it is being assigned). Solocal_nscontainsa,b, andc. However,len(local_ns)depends on when exactly the snapshot captures. In CPython,locals()returns a snapshot with the parameters and locals defined up to that point. -
"a" in local_nsis True —ais a local variable (parameter). -
"c" in local_nsis True —cwas assigned beforelocals()was called. -
After
local_ns["c"] = 999, the actual local variablecis still 30.locals()returns a copy — modifying the dict does not change the real local variables. Soc == 999is False. -
len(local_ns)reports the number of entries captured. Witha,b, andcvisible at snapshot time, pluslocal_nsitself depending on implementation, the count is typically 2 or more. In CPython, callinglocals()inside a function returns a fresh snapshot each time. -
globals()["secret"]accesses the module-level variablesecretthrough the globals dict. Unlikelocals(),globals()returns the actual module namespace — modifications through it take immediate effect.
Key insight: locals() is a diagnostic tool — use it for debugging and inspection, never for modifying local variables. globals() returns the real module dict and can be used to dynamically create or modify module-level variables, though doing so is generally bad practice.
Expected Output
True\nTrue\nFalse\n2\n42Hints
Hint 1: locals() returns a snapshot (copy) of the local namespace inside a function.
Hint 2: globals() returns the actual module namespace dict — modifications to it take effect.
Fix the late-binding closure bug. The broken version creates 5 functions in a loop, but they all return the same value. Make each function return its own index.
# Broken version (all print 4):
# functions = []
# for i in range(5):
# functions.append(lambda: i)
# for f in functions:
# print(f())
# Fixed version using default argument capture:
functions = []
for i in range(5):
functions.append(lambda i=i: i)
for f in functions:
print(f())Solution
# Broken version — all print 4:
functions_broken = []
for i in range(5):
functions_broken.append(lambda: i)
# Fixed version — default argument captures current value:
functions_fixed = []
for i in range(5):
functions_fixed.append(lambda i=i: i)
for f in functions_fixed:
print(f())
Output:
0
1
2
3
4
Why the broken version fails: All 5 lambda functions close over the same variable i. They do not capture the value of i at the time they were created — they capture a reference to the variable itself. When the loop ends, i is 4 (its last value). So all 5 lambdas return 4.
Why the fix works: lambda i=i: i uses a default argument. Default arguments are evaluated at function definition time, not at call time. So each lambda gets its own copy of the current value of i baked into its default parameter.
Alternative fixes:
# Fix 2: functools.partial
from functools import partial
functions = [partial(lambda x: x, i) for i in range(5)]
# Fix 3: closure factory
def make_fn(val):
return lambda: val
functions = [make_fn(i) for i in range(5)]
Key insight: This is one of the most common Python closure bugs. Closures capture variables by reference, not by value. When the variable changes later (as loop variables do), all closures that reference it see the updated value. The default-argument trick is the standard Pythonic fix because it evaluates the expression at definition time and binds the result to a local parameter.
Expected Output
0\n1\n2\n3\n4Hints
Hint 1: Closures capture variables by reference, not by value. All closures share the same loop variable.
Hint 2: The classic fix is to use a default argument to capture the current value at each iteration.
