Introduction
Have you ever wondered why a variable defined inside a function isn’t accessible outside of it? Or why modifying a list inside a function affects the original list, but reassigning a variable doesn’t? These questions touch on one of Python’s most important concepts: scope.
Scope determines where a variable can be accessed in your code. Understanding scope is crucial for writing bug-free code and avoiding unexpected behavior. Even more powerful is understanding closuresโfunctions that “remember” variables from their enclosing scope. Closures enable elegant patterns like decorators, callbacks, and function factories.
In this guide, we’ll explore how Python manages variable scope, master the LEGB rule that governs variable resolution, and unlock the power of closures.
What Is Scope?
The Concept
Scope is the region of code where a variable is accessible. A variable defined in one scope may not be accessible in another. This prevents naming conflicts and helps organize code.
# Global scope
x = "global"
def my_function():
# Local scope
y = "local"
print(x) # Can access global x
print(y) # Can access local y
my_function()
print(x) # Can access global x
# print(y) # Error! y is not accessible here
Why Scope Matters
Without scope, every variable would be global, leading to:
- Naming conflicts - Variables with the same name overwrite each other
- Unexpected behavior - Functions accidentally modify global state
- Difficult debugging - Hard to track where variables are modified
- Poor code organization - No clear separation of concerns
The LEGB Rule: How Python Resolves Variables
When Python encounters a variable name, it searches for it in a specific order: LEGB. Understanding this rule is fundamental to mastering scope.
LEGB Explained
| Level | Scope | Description |
|---|---|---|
| L | Local | Inside the current function |
| E | Enclosing | In an outer function (for nested functions) |
| G | Global | At the module level |
| B | Built-in | Python’s built-in namespace |
Python searches in this order and stops at the first match.
Example: LEGB in Action
# Built-in scope
print # Built-in function
# Global scope
x = "global"
def outer():
# Enclosing scope
x = "enclosing"
def inner():
# Local scope
x = "local"
print(x) # Prints "local" (L)
inner()
print(x) # Prints "enclosing" (E)
outer()
print(x) # Prints "global" (G)
Local Scope
What Is Local Scope?
Local scope refers to variables defined inside a function. These variables are only accessible within that function.
def greet(name):
"""Local scope example."""
greeting = f"Hello, {name}!" # Local variable
print(greeting)
greet("Alice")
# print(greeting) # Error! greeting is not defined outside the function
Local Variables Are Created and Destroyed
Local variables are created when the function is called and destroyed when it returns:
def create_list():
my_list = [1, 2, 3] # Created when function is called
return my_list
# my_list is destroyed here
result = create_list()
print(result) # [1, 2, 3] - we have the list, but my_list no longer exists
Parameters Are Local Variables
Function parameters are local variables:
def add(a, b):
"""Parameters a and b are local variables."""
result = a + b
return result
add(5, 3)
# print(a) # Error! a is not accessible outside the function
Global Scope
What Is Global Scope?
Global scope refers to variables defined at the module level (outside any function). These variables are accessible throughout the module.
# Global scope
counter = 0
def increment():
global counter # Declare we're using the global variable
counter += 1
print(f"Counter: {counter}")
increment() # Output: Counter: 1
increment() # Output: Counter: 2
print(counter) # Output: 2
Reading Global Variables
You can read global variables without declaring them as global:
# Global variable
MAX_ATTEMPTS = 3
def check_attempts(attempts):
"""Read global variable without declaring it."""
if attempts > MAX_ATTEMPTS:
print("Too many attempts!")
else:
print("Attempts OK")
check_attempts(2) # Output: Attempts OK
check_attempts(5) # Output: Too many attempts!
Modifying Global Variables
To modify a global variable, you must declare it with the global keyword:
# Bad: Creates a local variable instead of modifying global
counter = 0
def increment_bad():
counter = counter + 1 # Error! UnboundLocalError
return counter
# Good: Explicitly declare global
counter = 0
def increment_good():
global counter
counter = counter + 1
return counter
print(increment_good()) # Output: 1
print(increment_good()) # Output: 2
When to Use Global Variables
Use global variables sparingly. They make code harder to test and understand:
# Bad: Relying on global state
config = {"debug": True}
def log_message(message):
if config["debug"]:
print(f"DEBUG: {message}")
# Good: Pass configuration as parameter
def log_message(message, debug=False):
if debug:
print(f"DEBUG: {message}")
log_message("Test", debug=True)
Enclosing Scope and Nested Functions
What Is Enclosing Scope?
Enclosing scope refers to variables in an outer function when you have nested functions. This is where closures come into play.
def outer():
x = "outer" # Enclosing scope
def inner():
print(x) # Can access x from enclosing scope
inner()
outer() # Output: outer
Nested Functions
Nested functions are functions defined inside other functions:
def outer(x):
"""Outer function."""
def inner(y):
"""Inner function - nested inside outer."""
return x + y
return inner(10)
result = outer(5)
print(result) # Output: 15
Accessing Enclosing Variables
Inner functions can read variables from enclosing scopes:
def make_multiplier(factor):
"""Create a function that multiplies by factor."""
def multiplier(x):
return x * factor # Accesses factor from enclosing scope
return multiplier
times_three = make_multiplier(3)
print(times_three(5)) # Output: 15
print(times_three(10)) # Output: 30
The nonlocal Keyword
To modify variables in enclosing scope, use nonlocal:
def outer():
x = 0
def increment():
nonlocal x # Declare we're modifying x from enclosing scope
x += 1
return x
print(increment()) # Output: 1
print(increment()) # Output: 2
print(x) # Output: 2
outer()
nonlocal vs global
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # Output: local
inner()
print(x) # Output: enclosing
outer()
print(x) # Output: global
# With nonlocal
x = "global"
def outer():
x = "enclosing"
def inner():
nonlocal x
x = "modified"
print(x) # Output: modified
inner()
print(x) # Output: modified
outer()
print(x) # Output: global (global x is unchanged)
Understanding Closures
What Is a Closure?
A closure is a function that “remembers” variables from its enclosing scope, even after the outer function has returned. The inner function “closes over” these variables.
def make_adder(x):
"""Create a function that adds x to its argument."""
def adder(y):
return x + y # Remembers x even after make_adder returns
return adder
add_five = make_adder(5)
print(add_five(3)) # Output: 8
print(add_five(10)) # Output: 15
add_ten = make_adder(10)
print(add_ten(3)) # Output: 13
How Closures Work
When you return an inner function, Python captures the variables it needs:
def outer():
x = "I'm captured!"
def inner():
print(x)
return inner
func = outer()
func() # Output: I'm captured!
# Even though outer() has finished, inner() still has access to x
Closures Capture Variables, Not Values
This is a common source of confusion:
# Bad: All functions capture the same variable
functions = []
for i in range(3):
def func():
return i
functions.append(func)
print(functions[0]()) # Output: 2 (not 0!)
print(functions[1]()) # Output: 2 (not 1!)
print(functions[2]()) # Output: 2
# Good: Capture the value using a default argument
functions = []
for i in range(3):
def func(x=i): # Capture current value of i
return x
functions.append(func)
print(functions[0]()) # Output: 0
print(functions[1]()) # Output: 1
print(functions[2]()) # Output: 2
Inspecting Closures
You can inspect what a closure captures:
def make_multiplier(factor):
def multiplier(x):
return x * factor
return multiplier
times_three = make_multiplier(3)
# Inspect the closure
print(times_three.__closure__) # Shows the closure cells
print(times_three.__closure__[0].cell_contents) # Output: 3
Practical Closure Patterns
Pattern 1: Function Factories
Create specialized functions:
def create_power_function(exponent):
"""Create a function that raises numbers to a power."""
def power(base):
return base ** exponent
return power
square = create_power_function(2)
cube = create_power_function(3)
print(square(5)) # Output: 25
print(cube(5)) # Output: 125
Pattern 2: Decorators
Decorators use closures to wrap functions:
def repeat(times):
"""Decorator that repeats function execution."""
def decorator(func):
def wrapper(*args, **kwargs):
results = []
for _ in range(times):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator
@repeat(3)
def greet(name):
return f"Hello, {name}!"
print(greet("Alice"))
# Output: ['Hello, Alice!', 'Hello, Alice!', 'Hello, Alice!']
Pattern 3: Callbacks and Event Handlers
def create_event_handler(event_name):
"""Create an event handler that remembers the event name."""
def handle_event(data):
print(f"Event '{event_name}' triggered with data: {data}")
return handle_event
click_handler = create_event_handler("click")
hover_handler = create_event_handler("hover")
click_handler({"x": 100, "y": 200}) # Output: Event 'click' triggered with data: {'x': 100, 'y': 200}
hover_handler({"element": "button"}) # Output: Event 'hover' triggered with data: {'element': 'button'}
Pattern 4: Stateful Functions
Use closures to maintain state:
def create_counter(start=0):
"""Create a counter function that maintains state."""
count = start
def increment():
nonlocal count
count += 1
return count
def decrement():
nonlocal count
count -= 1
return count
def get_count():
return count
return {
"increment": increment,
"decrement": decrement,
"get": get_count
}
counter = create_counter(10)
print(counter["increment"]()) # Output: 11
print(counter["increment"]()) # Output: 12
print(counter["decrement"]()) # Output: 11
print(counter["get"]()) # Output: 11
Pattern 5: Memoization
Cache function results using closures:
def memoize(func):
"""Decorator that caches function results."""
cache = {}
def wrapper(n):
if n not in cache:
cache[n] = func(n)
return cache[n]
return wrapper
@memoize
def fibonacci(n):
"""Calculate fibonacci number (with caching)."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(10)) # Output: 55 (computed efficiently with caching)
Common Pitfalls and Misconceptions
Pitfall 1: Confusing Local and Global
# Bad: Trying to modify global without declaring it
count = 0
def increment():
count = count + 1 # Error! UnboundLocalError
return count
# Good: Declare global
count = 0
def increment():
global count
count = count + 1
return count
print(increment()) # Output: 1
Pitfall 2: Closure Variable Capture
# Bad: All closures capture the same variable
functions = []
for i in range(3):
functions.append(lambda: i)
print([f() for f in functions]) # Output: [2, 2, 2] - all return 2!
# Good: Capture the value
functions = []
for i in range(3):
functions.append(lambda x=i: x)
print([f() for f in functions]) # Output: [0, 1, 2]
Pitfall 3: Modifying Mutable Objects
# This works because we're modifying the object, not reassigning
my_list = [1, 2, 3]
def modify_list():
my_list.append(4) # Modifies the list (no global needed)
modify_list()
print(my_list) # Output: [1, 2, 3, 4]
# But reassignment requires global
my_list = [1, 2, 3]
def reassign_list():
global my_list
my_list = [5, 6, 7] # Reassigns the variable (global needed)
reassign_list()
print(my_list) # Output: [5, 6, 7]
Pitfall 4: Scope Shadowing
# Bad: Shadowing a global variable
x = "global"
def func():
x = "local" # Shadows global x
print(x) # Output: local
func()
print(x) # Output: global (global x is unchanged)
# This can be confusing if you intended to use the global x
Pitfall 5: Forgetting nonlocal
# Bad: Trying to modify enclosing variable without nonlocal
def outer():
x = 0
def inner():
x = x + 1 # Error! UnboundLocalError
return x
return inner()
# Good: Use nonlocal
def outer():
x = 0
def inner():
nonlocal x
x = x + 1
return x
return inner()
print(outer()) # Output: 1
Best Practices
Practice 1: Minimize Global Variables
# Bad: Relying on global state
debug_mode = False
def log(message):
if debug_mode:
print(f"DEBUG: {message}")
# Good: Pass configuration as parameter
def log(message, debug=False):
if debug:
print(f"DEBUG: {message}")
log("Test", debug=True)
Practice 2: Use Closures for Encapsulation
# Good: Closures provide data privacy
def create_bank_account(initial_balance):
balance = initial_balance
def deposit(amount):
nonlocal balance
balance += amount
return balance
def withdraw(amount):
nonlocal balance
if amount > balance:
return "Insufficient funds"
balance -= amount
return balance
def get_balance():
return balance
return {
"deposit": deposit,
"withdraw": withdraw,
"balance": get_balance
}
account = create_bank_account(1000)
print(account["deposit"](/programming/500)) # Output: 1500
print(account["withdraw"](/programming/200)) # Output: 1300
print(account["balance"]()) # Output: 1300
Practice 3: Be Explicit About Scope
# Good: Clear variable scope
def process_data(data):
"""Process data with clear scope."""
# Local variables
result = []
for item in data:
processed = item * 2
result.append(processed)
return result
# Avoid: Unclear scope
def process_data(data):
result = []
for item in data:
result.append(item * 2)
return result
Practice 4: Document Closures
def create_logger(prefix):
"""Create a logger function with a prefix.
Args:
prefix: String to prepend to log messages
Returns:
A function that logs messages with the prefix
Example:
>>> error_log = create_logger("ERROR")
>>> error_log("Something went wrong")
ERROR: Something went wrong
"""
def log(message):
print(f"{prefix}: {message}")
return log
Practice 5: Test Scope Behavior
def test_closure_state():
"""Test that closures maintain state correctly."""
counter = create_counter(0)
assert counter["get"]() == 0
assert counter["increment"]() == 1
assert counter["increment"]() == 2
assert counter["decrement"]() == 1
assert counter["get"]() == 1
print("All tests passed!")
test_closure_state()
Real-World Example: Configuration Manager
def create_config_manager(defaults):
"""Create a configuration manager with closures."""
config = defaults.copy()
def get(key, default=None):
"""Get a configuration value."""
return config.get(key, default)
def set(key, value):
"""Set a configuration value."""
config[key] = value
def update(new_config):
"""Update multiple configuration values."""
config.update(new_config)
def reset():
"""Reset to default configuration."""
nonlocal config
config = defaults.copy()
return {
"get": get,
"set": set,
"update": update,
"reset": reset
}
# Usage
config = create_config_manager({
"debug": False,
"timeout": 30,
"max_retries": 3
})
print(config["get"]("debug")) # Output: False
config["set"]("debug", True)
print(config["get"]("debug")) # Output: True
config["update"]({"timeout": 60})
print(config["get"]("timeout")) # Output: 60
config["reset"]()
print(config["get"]("debug")) # Output: False
Conclusion
Understanding scope and closures is essential for writing effective Python code. These concepts enable you to:
Key takeaways:
- LEGB rule - Python searches for variables in Local, Enclosing, Global, Built-in order
- Local scope - Variables inside functions are local and destroyed when the function returns
- Global scope - Module-level variables accessible everywhere (use sparingly)
- Enclosing scope - Variables in outer functions accessible to nested functions
globalkeyword - Modify global variables from within functionsnonlocalkeyword - Modify enclosing variables from nested functions- Closures - Functions that remember variables from their enclosing scope
- Closure patterns - Function factories, decorators, callbacks, stateful functions, memoization
- Common pitfalls - Variable capture, shadowing, forgetting keywords
- Best practices - Minimize globals, use closures for encapsulation, be explicit about scope
Master these concepts, and you’ll write cleaner, more maintainable Python code. Closures are particularly powerfulโthey enable elegant patterns like decorators and provide a way to create private data in Python.
Happy coding! ๐
Quick Reference
# Local scope
def func():
x = "local" # Only accessible inside func
# Global scope
x = "global" # Accessible everywhere
def func():
global x
x = "modified" # Modify global variable
# Enclosing scope (nested functions)
def outer():
x = "enclosing"
def inner():
print(x) # Access enclosing variable
inner()
# Modify enclosing variable
def outer():
x = 0
def inner():
nonlocal x
x += 1
inner()
# Closure
def make_adder(x):
def adder(y):
return x + y # Remembers x
return adder
add_five = make_adder(5)
print(add_five(3)) # Output: 8
# LEGB Rule
# L - Local (inside current function)
# E - Enclosing (in outer function)
# G - Global (module level)
# B - Built-in (Python's built-in namespace)
Comments