Navigation

Python

Python Function Scope and the LEGB Rule: Mastering Variable Resolution

Master Python's LEGB rule & function scope. Learn variable resolution, closures, global/nonlocal keywords with examples & best practices.

Understanding how Python resolves variable names is crucial for writing clean, predictable code and avoiding common scoping pitfalls. Python follows the LEGB rule for variable resolution - a systematic approach that determines where Python looks for variables. This comprehensive guide will explore function scope, the LEGB rule, and practical techniques for managing variable scope effectively.

Table Of Contents

What is the LEGB Rule?

LEGB stands for the order in which Python searches for variables:

  • Local - Inside the current function
  • Enclosing - In any outer function
  • Global - At the module level
  • Built-in - In the built-in namespace

Python searches these scopes in order and uses the first match it finds.

# Global scope
global_var = "I'm global"

def outer_function():
    # Enclosing scope
    enclosing_var = "I'm enclosing"
    
    def inner_function():
        # Local scope
        local_var = "I'm local"
        
        # Python searches: Local -> Enclosing -> Global -> Built-in
        print(local_var)      # Found in Local
        print(enclosing_var)  # Found in Enclosing
        print(global_var)     # Found in Global
        print(len([1, 2, 3])) # Found in Built-in
    
    inner_function()

outer_function()

Local Scope: Function Variables

Variables defined inside a function exist in the local scope and are only accessible within that function.

def calculate_tax(income, rate):
    # Local variables
    tax_amount = income * rate
    after_tax = income - tax_amount
    
    # Helper function - also local to calculate_tax
    def format_currency(amount):
        return f"${amount:,.2f}"
    
    return {
        'tax': format_currency(tax_amount),
        'after_tax': format_currency(after_tax)
    }

# This would raise NameError - tax_amount is not accessible outside
# print(tax_amount)

result = calculate_tax(50000, 0.2)
print(result)  # {'tax': '$10,000.00', 'after_tax': '$40,000.00'}

Function Parameters and Local Scope

Function parameters are automatically local variables:

def process_data(data, multiplier=2):
    # 'data' and 'multiplier' are local variables
    result = []
    
    for item in data:  # 'item' is also local
        processed = item * multiplier
        result.append(processed)
    
    return result

# Parameters don't exist outside the function
numbers = [1, 2, 3, 4, 5]
processed = process_data(numbers, 3)
print(processed)  # [3, 6, 9, 12, 15]

# This would raise NameError
# print(multiplier)

Enclosing Scope: Nested Functions and Closures

The enclosing scope refers to variables in outer functions that are accessible to inner functions.

def create_multiplier(factor):
    # Enclosing scope variable
    multiplier = factor
    
    def multiply(number):
        # Accesses 'multiplier' from enclosing scope
        return number * multiplier
    
    return multiply

# Create specialized functions
double = create_multiplier(2)
triple = create_multiplier(3)

print(double(5))  # 10
print(triple(5))  # 15

# Each function retains its own copy of the enclosing variable
print(double(10))  # 20
print(triple(10))  # 30

Modifying Enclosing Variables with nonlocal

Use nonlocal to modify variables in the enclosing scope:

def create_counter(start=0):
    count = start
    
    def increment(step=1):
        nonlocal count  # Allows modification of enclosing variable
        count += step
        return count
    
    def decrement(step=1):
        nonlocal count
        count -= step
        return count
    
    def get_count():
        return count  # Read-only access doesn't need nonlocal
    
    return increment, decrement, get_count

# Create a counter
inc, dec, get = create_counter(10)

print(inc())     # 11
print(inc(5))    # 16
print(dec(3))    # 13
print(get())     # 13

Multiple Levels of Enclosing Scope

Python can access variables through multiple levels of enclosing functions:

def level_one():
    var_1 = "Level 1"
    
    def level_two():
        var_2 = "Level 2"
        
        def level_three():
            var_3 = "Level 3"
            
            def level_four():
                # Can access all enclosing scopes
                print(f"Local: {var_3}")      # Level 3 (most recent enclosing)
                print(f"Enclosing 1: {var_2}") # Level 2
                print(f"Enclosing 2: {var_1}") # Level 1
            
            level_four()
        
        level_three()
    
    level_two()

level_one()

Global Scope: Module-Level Variables

Global variables are defined at the module level and are accessible throughout the module.

# Global variables
APPLICATION_NAME = "Data Processor"
VERSION = "1.0.0"
DEBUG_MODE = True

def get_app_info():
    # Accessing global variables (read-only)
    return f"{APPLICATION_NAME} v{VERSION}"

def toggle_debug():
    global DEBUG_MODE  # Required to modify global variable
    DEBUG_MODE = not DEBUG_MODE
    return DEBUG_MODE

print(get_app_info())  # Data Processor v1.0.0
print(toggle_debug())  # False
print(DEBUG_MODE)      # False

Global vs Local Variable Confusion

Be careful when using the same name for global and local variables:

counter = 0  # Global variable

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

def increment_local():
    counter = 1  # Creates new local variable, doesn't affect global
    counter += 1
    return counter

def read_global():
    return counter  # Reads global variable

print(f"Initial global: {counter}")        # 0
print(f"After increment_global: {increment_global()}")  # 1
print(f"After increment_local: {increment_local()}")    # 2
print(f"Global is still: {read_global()}")              # 1 (unchanged by increment_local)

Built-in Scope: Python's Built-in Functions

Built-in scope contains Python's built-in functions and constants.

# These are all in built-in scope
print(len([1, 2, 3]))      # len function
print(max([1, 5, 3]))      # max function
print(abs(-10))            # abs function
print(type("hello"))       # type function

# Built-in constants
print(True, False, None)   # Boolean and None constants

# You can override built-ins (but shouldn't!)
def len(obj):
    return "I broke len!"

print(len([1, 2, 3]))      # "I broke len!"

# To restore built-in len, you'd need to delete the override
del len
print(len([1, 2, 3]))      # 3 (built-in len restored)

Checking Built-in Names

You can inspect built-in names:

import builtins

# See all built-in names
builtin_names = dir(builtins)
print(f"Number of built-ins: {len(builtin_names)}")
print("Some built-ins:", builtin_names[:10])

# Check if a name is a built-in
def is_builtin(name):
    return hasattr(builtins, name)

print(is_builtin('len'))    # True
print(is_builtin('max'))    # True
print(is_builtin('my_func'))  # False

Practical Examples and Common Patterns

Configuration Management with Scopes

# Global configuration
CONFIG = {
    'database_url': 'localhost:5432',
    'debug': False,
    'max_connections': 100
}

def create_database_manager():
    # Enclosing scope for connection management
    connections = []
    max_conn = CONFIG['max_connections']
    
    def connect():
        nonlocal connections
        if len(connections) >= max_conn:
            raise Exception("Max connections reached")
        
        # Local scope for connection details
        connection_id = len(connections) + 1
        connection_info = {
            'id': connection_id,
            'url': CONFIG['database_url'],
            'created_at': __import__('datetime').datetime.now()
        }
        
        connections.append(connection_info)
        return connection_info
    
    def disconnect(connection_id):
        nonlocal connections
        connections = [c for c in connections if c['id'] != connection_id]
    
    def get_stats():
        return {
            'active_connections': len(connections),
            'max_connections': max_conn,
            'database_url': CONFIG['database_url']
        }
    
    return connect, disconnect, get_stats

# Usage
connect, disconnect, stats = create_database_manager()
conn1 = connect()
conn2 = connect()
print(stats())  # {'active_connections': 2, 'max_connections': 100, ...}

Event Handler System

# Global event registry
event_handlers = {}

def create_event_system():
    # Enclosing scope for event management
    active_listeners = {}
    
    def register_handler(event_type):
        # Returns a decorator for registering event handlers
        def decorator(func):
            if event_type not in event_handlers:
                event_handlers[event_type] = []
            event_handlers[event_type].append(func)
            return func
        return decorator
    
    def emit_event(event_type, data=None):
        # Local scope for event processing
        handlers = event_handlers.get(event_type, [])
        results = []
        
        for handler in handlers:
            try:
                result = handler(data) if data else handler()
                results.append(result)
            except Exception as e:
                print(f"Handler error: {e}")
        
        return results
    
    def list_events():
        return list(event_handlers.keys())
    
    return register_handler, emit_event, list_events

# Create event system
register, emit, list_events = create_event_system()

# Register event handlers
@register('user_login')
def log_user_login(user_data):
    print(f"User {user_data['username']} logged in")
    return f"Logged: {user_data['username']}"

@register('user_login')
def update_last_seen(user_data):
    print(f"Updated last seen for {user_data['username']}")
    return f"Updated: {user_data['username']}"

# Emit events
results = emit('user_login', {'username': 'alice', 'timestamp': '2025-01-01'})
print("Handler results:", results)

Advanced Scope Concepts

Scope and Comprehensions

List comprehensions have their own local scope:

# Variables in comprehensions are local to the comprehension
x = "global x"
numbers = [1, 2, 3, 4, 5]

# 'x' in the comprehension doesn't affect global 'x'
squared = [x**2 for x in numbers]
print(f"Global x is still: {x}")  # "global x"
print(f"Squared: {squared}")       # [1, 4, 9, 16, 25]

# Same applies to other comprehensions
even_dict = {x: x**2 for x in numbers if x % 2 == 0}
print(f"Global x is still: {x}")  # "global x"

Class Scope and Method Resolution

Classes create their own scope for class variables:

class ScopeExample:
    class_var = "I'm a class variable"
    
    def __init__(self, instance_var):
        self.instance_var = instance_var
    
    def show_scopes(self):
        # Local variable
        local_var = "I'm local to this method"
        
        # Access different scopes
        print(f"Local: {local_var}")
        print(f"Instance: {self.instance_var}")
        print(f"Class: {self.class_var}")
        print(f"Global: {global_var if 'global_var' in globals() else 'No global_var'}")
    
    @classmethod
    def class_method(cls):
        # Can access class variables but not instance variables
        print(f"Class method accessing: {cls.class_var}")
    
    @staticmethod
    def static_method():
        # No access to class or instance variables
        local_var = "Static method local"
        print(f"Static method: {local_var}")

# Usage
obj = ScopeExample("I'm an instance variable")
obj.show_scopes()
ScopeExample.class_method()
ScopeExample.static_method()

Scope in Exception Handling

Exception variables have limited scope:

def handle_exceptions():
    try:
        result = 10 / 0
    except ZeroDivisionError as e:
        error_message = str(e)
        print(f"Caught error: {error_message}")
    
    # 'e' is no longer available here (Python 3+)
    print(f"Error message: {error_message}")  # But this is still available
    
    # This would raise NameError in Python 3:
    # print(e)

handle_exceptions()

Common Scope Pitfalls and Solutions

Late Binding in Loops

A common pitfall with closures and loops:

# Problem: All functions reference the same variable
functions = []
for i in range(3):
    functions.append(lambda: i)  # i is captured by reference

# All functions return 2 (the final value of i)
print([f() for f in functions])  # [2, 2, 2]

# Solution 1: Default argument captures current value
functions_fixed1 = []
for i in range(3):
    functions_fixed1.append(lambda x=i: x)

print([f() for f in functions_fixed1])  # [0, 1, 2]

# Solution 2: Use a closure to capture the value
def make_func(x):
    return lambda: x

functions_fixed2 = []
for i in range(3):
    functions_fixed2.append(make_func(i))

print([f() for f in functions_fixed2])  # [0, 1, 2]

Mutable Default Arguments and Scope

Be careful with mutable default arguments:

# Problem: Mutable default argument retains state
def add_item(item, container=[]):  # Don't do this!
    container.append(item)
    return container

print(add_item(1))  # [1]
print(add_item(2))  # [1, 2] - Previous state retained!

# Solution: Use None and create new list
def add_item_fixed(item, container=None):
    if container is None:
        container = []
    container.append(item)
    return container

print(add_item_fixed(1))  # [1]
print(add_item_fixed(2))  # [2] - Fresh list each time

Global Statement Placement

The global statement affects the entire function:

counter = 0

def confusing_function():
    print(counter)  # This will raise UnboundLocalError!
    global counter  # global statement applies to entire function
    counter = 1

# This would fail:
# confusing_function()

# Correct approach:
def correct_function():
    global counter
    print(counter)  # Now this works
    counter = 1

correct_function()

Best Practices for Scope Management

Minimize Global Variables

# Instead of global variables
# total = 0
# count = 0

# Use a configuration object or class
class AppState:
    def __init__(self):
        self.total = 0
        self.count = 0
    
    def add_value(self, value):
        self.total += value
        self.count += 1
    
    def get_average(self):
        return self.total / self.count if self.count > 0 else 0

state = AppState()

Use Clear Variable Names

# Good: Clear distinction between scopes
global_config = {'debug': True}

def process_user_data(user_input):
    local_result = []
    
    for local_item in user_input:
        processed_item = transform_data(local_item)
        local_result.append(processed_item)
    
    return local_result

def transform_data(data):
    if global_config['debug']:
        print(f"Processing: {data}")
    return data.upper()

Prefer Function Arguments Over Global Access

# Instead of accessing globals directly
# def calculate_tax():
#     return income * TAX_RATE

# Pass values as arguments
def calculate_tax(income, tax_rate):
    return income * tax_rate

# This makes functions more testable and predictable

Conclusion

Understanding Python's LEGB rule and function scope is essential for writing maintainable code. Key takeaways:

  • LEGB Order: Python searches Local → Enclosing → Global → Built-in
  • Local Scope: Variables and parameters inside functions
  • Enclosing Scope: Variables in outer functions, accessible via closures
  • Global Scope: Module-level variables, modified with global keyword
  • Built-in Scope: Python's built-in functions and constants

Best practices:

  • Minimize global variable usage
  • Use nonlocal to modify enclosing scope variables
  • Be aware of late binding in loops
  • Avoid mutable default arguments
  • Use clear, descriptive variable names that indicate scope

By mastering these concepts, you'll write more predictable Python code and avoid common scoping pitfalls that can lead to subtle bugs and maintenance headaches.

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Python