Python - Global and Local Variables (Scope)

Python resolves variable names using the LEGB rule: Local, Enclosing, Global, and Built-in scopes. When you reference a variable, Python searches these scopes in order until it finds the name.

Key Insights

  • Python’s LEGB rule (Local, Enclosing, Global, Built-in) determines variable resolution order, with local scope taking precedence over outer scopes
  • The global and nonlocal keywords allow modification of variables outside the current scope, but overuse creates hard-to-maintain code
  • Function parameters, loop variables, and comprehensions create their own local scopes, which can lead to unexpected behavior if scope rules aren’t understood

Understanding Python’s Scope Hierarchy

Python resolves variable names using the LEGB rule: Local, Enclosing, Global, and Built-in scopes. When you reference a variable, Python searches these scopes in order until it finds the name.

x = "global"  # Global scope

def outer():
    x = "enclosing"  # Enclosing scope
    
    def inner():
        x = "local"  # Local scope
        print(x)
    
    inner()
    print(x)

outer()
print(x)

# Output:
# local
# enclosing
# global

Built-in scope contains Python’s built-in functions like len(), print(), and range(). You can access these from anywhere without importing them.

def demonstrate_builtin():
    # 'len' is in built-in scope
    result = len([1, 2, 3])
    return result

print(demonstrate_builtin())  # 3

Local Scope Behavior

Variables defined inside a function exist only within that function’s local scope. They’re created when the function executes and destroyed when it returns.

def calculate_total(items):
    total = 0  # Local variable
    for item in items:
        total += item
    return total

result = calculate_total([10, 20, 30])
print(result)  # 60

# This raises NameError: name 'total' is not defined
# print(total)

Function parameters are also local variables:

def greet(name, greeting="Hello"):
    message = f"{greeting}, {name}!"  # Both name and greeting are local
    return message

print(greet("Alice"))  # Hello, Alice!
# print(name)  # NameError

The Global Keyword

To modify a global variable from within a function, use the global keyword. Without it, Python creates a new local variable instead.

counter = 0  # Global variable

def increment_wrong():
    counter = counter + 1  # UnboundLocalError!
    return counter

def increment_correct():
    global counter
    counter = counter + 1
    return counter

# increment_wrong()  # Raises UnboundLocalError
increment_correct()
print(counter)  # 1

The UnboundLocalError occurs because Python sees the assignment and treats counter as local, but then tries to read it before assignment.

Here’s a practical example with multiple global variables:

request_count = 0
error_count = 0

def process_request(success):
    global request_count, error_count
    request_count += 1
    if not success:
        error_count += 1

process_request(True)
process_request(False)
process_request(True)

print(f"Requests: {request_count}, Errors: {error_count}")
# Requests: 3, Errors: 1

The Nonlocal Keyword

The nonlocal keyword modifies variables in the nearest enclosing scope, useful for nested functions and closures.

def outer():
    count = 0
    
    def increment():
        nonlocal count
        count += 1
        return count
    
    print(increment())  # 1
    print(increment())  # 2
    print(increment())  # 3

outer()

Without nonlocal, you’d get an UnboundLocalError:

def outer():
    count = 0
    
    def increment_wrong():
        count = count + 1  # UnboundLocalError
        return count
    
    increment_wrong()

Practical use case - creating a closure with state:

def create_multiplier(factor):
    def multiply(number):
        nonlocal factor
        result = number * factor
        factor += 1  # Modify enclosing scope variable
        return result
    
    return multiply

times_three = create_multiplier(3)
print(times_three(10))  # 30 (factor becomes 4)
print(times_three(10))  # 40 (factor becomes 5)
print(times_three(10))  # 50 (factor becomes 6)

Scope in Comprehensions and Loops

List comprehensions, dict comprehensions, and generator expressions create their own scope in Python 3, but regular loops don’t.

# Comprehension variables are scoped
numbers = [1, 2, 3, 4, 5]
squares = [x**2 for x in numbers]
# print(x)  # NameError in Python 3

# Loop variables leak into enclosing scope
for i in range(3):
    pass
print(i)  # 2 (variable still exists)

This difference matters when nesting comprehensions:

matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

# Each comprehension has its own scope for iteration variables
flattened = [num for row in matrix for num in row]
print(flattened)  # [1, 2, 3, 4, 5, 6, 7, 8, 9]

Common Pitfalls and Solutions

Pitfall 1: Late binding in closures

# Problem: all functions reference the same 'i'
functions = []
for i in range(3):
    functions.append(lambda: i)

for f in functions:
    print(f())  # Prints: 2, 2, 2 (not 0, 1, 2)

# Solution: use default argument to capture current value
functions = []
for i in range(3):
    functions.append(lambda x=i: x)

for f in functions:
    print(f())  # Prints: 0, 1, 2

Pitfall 2: Shadowing built-ins

# Avoid shadowing built-in names
list = [1, 2, 3]  # Bad: shadows built-in list()
print(list([4, 5]))  # TypeError: 'list' object is not callable

# Use different names
items = [1, 2, 3]  # Good
print(list([4, 5]))  # Works fine

Pitfall 3: Mutable default arguments

# Problem: default list is shared across calls
def add_item(item, items=[]):
    items.append(item)
    return items

print(add_item(1))  # [1]
print(add_item(2))  # [1, 2] - unexpected!

# Solution: use None as default
def add_item_correct(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print(add_item_correct(1))  # [1]
print(add_item_correct(2))  # [2] - correct!

Inspecting Scope with Built-in Functions

Python provides functions to examine variable scopes:

global_var = "global"

def inspect_scope():
    local_var = "local"
    
    print("Local variables:", locals())
    print("Global variables:", list(globals().keys())[:5])  # First 5
    
inspect_scope()

# Output shows dictionaries of local and global namespaces

Use vars() to get the namespace dictionary:

class Config:
    debug = True
    timeout = 30

print(vars(Config))
# {'__module__': '__main__', 'debug': True, 'timeout': 30, ...}

Best Practices

Minimize global variable usage. Instead, use function parameters and return values:

# Avoid this
total = 0
def add_to_total(value):
    global total
    total += value

# Prefer this
def add_to_total(current_total, value):
    return current_total + value

total = 0
total = add_to_total(total, 10)

Use classes to manage state instead of global variables:

class RequestTracker:
    def __init__(self):
        self.request_count = 0
        self.error_count = 0
    
    def process_request(self, success):
        self.request_count += 1
        if not success:
            self.error_count += 1
    
    def get_stats(self):
        return {
            'requests': self.request_count,
            'errors': self.error_count
        }

tracker = RequestTracker()
tracker.process_request(True)
tracker.process_request(False)
print(tracker.get_stats())  # {'requests': 2, 'errors': 1}

Understanding Python’s scoping rules prevents bugs and makes code more maintainable. Use global and nonlocal sparingly, prefer explicit parameter passing, and leverage classes for state management.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.