Python & AI Tutorials Logo
Python Programming

21. Variable Scope and Name Resolution

When you create a variable in Python, where does it "live"? Can a function see variables created outside it? Can code outside a function access variables created inside it? These questions are about scope - the region of your program where a name is visible and can be used.

Understanding scope is crucial for writing functions that work correctly and predictably. Without this knowledge, you might accidentally create bugs where variables don't have the values you expect, or where changes to variables don't persist as intended.

In this chapter, we'll explore how Python determines which variable a name refers to, how to control where variables are accessible, and what happens when you delete a name. By the end, you'll understand the rules that govern variable visibility in Python programs.

21.1) Local and Global Variables

Every variable in Python exists within a specific scope - a region of code where that variable name is defined and accessible. The two most fundamental scopes are local and global.

Understanding Global Scope

Variables created at the top level of your program - outside any function - exist in the global scope. These are called global variables, and they're accessible from anywhere in your module after they're defined.

python
# Global variable - defined at module level
total_users = 0
 
def show_user_count():
    # This function can READ the global variable
    print(f"Total users: {total_users}")
 
show_user_count()  # Output: Total users: 0
print(total_users)  # Output: 0

In this example, total_users is a global variable. Both the function show_user_count() and the code at module level can access it. Think of global variables as being visible throughout your entire program file.

Understanding Local Scope

Variables created inside a function exist in that function's local scope. These are called local variables, and they're only accessible within the function where they're defined. Once the function finishes executing, local variables disappear.

python
def calculate_discount(price):
    # discount_rate is LOCAL to this function
    discount_rate = 0.15
    discount_amount = price * discount_rate
    return discount_amount
 
result = calculate_discount(100)
print(result)  # Output: 15.0
 
# This would cause an error - discount_rate doesn't exist here
# print(discount_rate)  # NameError: name 'discount_rate' is not defined

The variables discount_rate and discount_amount exist only while calculate_discount() is running. After the function returns, these names no longer exist. This is actually a good thing - it prevents functions from cluttering your program with temporary variables.

Why Local Scope Matters

Local scope provides encapsulation - each function has its own private workspace. This means you can use the same variable names in different functions without conflicts:

python
def calculate_tax(amount):
    rate = 0.08  # Local variable
    return amount * rate
 
def calculate_shipping(weight):
    rate = 5.00  # Different local variable with the same name
    return weight * rate
 
tax = calculate_tax(100)
shipping = calculate_shipping(3)
 
print(f"Tax: ${tax}")         # Output: Tax: $8.0
print(f"Shipping: ${shipping}")  # Output: Shipping: $15.0

Both functions use a variable named rate, but they're completely separate variables in different local scopes. Changes to rate in one function don't affect rate in the other function. This isolation makes functions more reliable and easier to understand.

Reading Global Variables from Functions

Functions can read global variables without any special syntax:

python
# Global configuration
max_login_attempts = 3
 
def check_login(password):
    # Reading global variable
    if password == "secret123":
        return "Login successful"
    else:
        return f"Invalid password. You have {max_login_attempts} attempts."
 
result = check_login("wrong")
print(result)  # Output: Invalid password. You have 3 attempts.

The function check_login() can read max_login_attempts because it's a global variable. However, there's an important limitation we need to understand.

The Assignment Creates Local Variables Rule

Here's where scope gets tricky. If you assign to a variable name inside a function, Python creates a new local variable with that name, even if a global variable with the same name exists:

python
counter = 0  # Global variable
 
def increment_counter():
    # WARNING: This creates a NEW local variable named counter - for demonstration only
    # PROBLEM: Trying to read counter before assigning to it locally
    counter = counter + 1  # UnboundLocalError: local variable 'counter' referenced before assignment
    print(counter)
 
# increment_counter() # This call results in UnboundLocalError

This code fails because Python sees the assignment counter = counter + 1 and decides that counter must be a local variable. But then when it tries to evaluate counter + 1, the local variable counter doesn't have a value yet - we're trying to use it before we've assigned to it.

This is a common source of confusion. The rule is: if a function assigns to a variable name anywhere in its body, that name is treated as local throughout the entire function, even before the assignment.

Let's see this more clearly:

python
message = "Hello"  # Global variable
 
def show_message():
    print(message)  # This works - just reading the global
    
def change_message():
    # WARNING: This demonstrates a common error - for demonstration only
    # PROBLEM: Python sees assignment below, so message is treated as local throughout
    print(message)  # UnboundLocalError!
    message = "Goodbye"  # This makes message local for the ENTIRE function
 
show_message()  # Output: Hello
# change_message()  # This call results in UnboundLocalError

The function show_message() works fine because it only reads message. But change_message() fails because the assignment on the second line makes Python treat message as local throughout the entire function, including the print() statement that comes before the assignment.

Parameters Are Local Variables

Function parameters are local variables that get their initial values from the arguments passed when the function is called:

python
def greet(name):  # 'name' is a local variable
    greeting = f"Hello, {name}!"  # 'greeting' is also local
    return greeting
 
message = greet("Alice")
print(message)  # Output: Hello, Alice!
 
# Neither 'name' nor 'greeting' exist here
# print(name)  # NameError

The parameter name exists only within the greet() function. It's created when the function is called and disappears when the function returns.

Practical Example: Shopping Cart Calculation

Let's see how local and global scope work together in a realistic scenario:

python
# Global configuration
tax_rate = 0.08
free_shipping_threshold = 50
 
def calculate_total(subtotal):
    # Local variables for this calculation
    tax = subtotal * tax_rate  # Reading global tax_rate
    
    # Determine shipping cost
    if subtotal >= free_shipping_threshold:  # Reading global threshold
        shipping = 0
    else:
        shipping = 5.99
    
    total = subtotal + tax + shipping
    return total
 
# Calculate for different cart values
cart1 = calculate_total(30)
cart2 = calculate_total(60)
 
print(f"Cart 1 total: ${cart1:.2f}")  # Output: Cart 1 total: $38.39
print(f"Cart 2 total: ${cart2:.2f}")  # Output: Cart 2 total: $64.80

In this example:

  • tax_rate and free_shipping_threshold are global configuration values
  • subtotal, tax, shipping, and total are local to each call of calculate_total()
  • Each function call gets its own separate set of local variables
  • The function can read the global configuration but doesn't modify it

This separation of concerns makes the code clear: global variables hold configuration that applies everywhere, while local variables hold temporary calculation results specific to each function call.

21.2) The LEGB Rule for Name Resolution

When Python encounters a variable name, how does it know which variable you're referring to? Python follows a specific search order called the LEGB rule. LEGB stands for Local, Enclosing, Global, Built-in - the four scopes Python searches, in that order.

The Four Scopes in LEGB

Let's understand each scope in the LEGB hierarchy:

  1. Local (L): The current function's scope
  2. Enclosing (E): The scope of any enclosing functions (functions that contain the current function)
  3. Global (G): The module-level scope
  4. Built-in (B): Python's built-in names like print, len, int, etc.

When you use a variable name, Python searches these scopes in order: L → E → G → B. It uses the first match it finds and stops searching.

Local Scope: The First Place Python Looks

Python always checks the local scope first:

python
def calculate_price():
    price = 100  # Local variable
    tax = 0.08   # Local variable
    total = price * (1 + tax)
    return total
 
result = calculate_price()
print(result)  # Output: 108.0

When Python sees price, tax, and total inside calculate_price(), it finds them in the local scope and uses those values. The search stops at the local scope - Python doesn't need to look further.

Global Scope: When Local Doesn't Have It

If a name isn't found locally, Python checks the global scope:

python
# Global variables
default_tax_rate = 0.08
default_currency = "USD"
 
def calculate_price(amount):
    # 'amount' is local, found immediately
    # 'default_tax_rate' is not local, found in global scope
    total = amount * (1 + default_tax_rate)
    return total
 
result = calculate_price(100)
print(result)  # Output: 108.0

When Python encounters default_tax_rate inside the function, it doesn't find it locally, so it searches the global scope and finds it there.

Built-in Scope: Python's Predefined Names

If a name isn't found in local or global scope, Python checks the built-in scope - the names that Python provides automatically:

python
def process_data(numbers):
    # 'numbers' is local
    # 'len' is not local or global - it's built-in
    count = len(numbers)
    
    # 'max' is also built-in
    maximum = max(numbers)
    
    return count, maximum
 
data = [10, 25, 15, 30, 20]
result = process_data(data)
print(result)  # Output: (5, 30)

The names len and max aren't defined in your code - they're built-in functions that Python provides. When Python doesn't find these names locally or globally, it checks the built-in scope and finds them there.

Enclosing Scope: Nested Functions

The enclosing scope comes into play when you have nested functions - functions defined inside other functions. This is where the "E" in LEGB becomes important:

python
def outer_function():
    outer_var = "I'm from outer"  # In enclosing scope for inner_function
    
    def inner_function():
        inner_var = "I'm from inner"  # Local to inner_function
        # inner_function can see both inner_var (local) and outer_var (enclosing)
        print(inner_var)   # Output: I'm from inner
        print(outer_var)   # Output: I'm from outer
    
    inner_function()
 
outer_function()

For inner_function(), the scope of outer_function() is an enclosing scope. When inner_function() references outer_var, Python searches:

  1. Local scope of inner_function() - not found
  2. Enclosing scope of outer_function() - found! Uses this value

LEGB in Action: Simple Example

Let's see all four scopes working together in a clear, straightforward example:

python
# Built-in: len (Python provides this)
# Global: multiplier
multiplier = 10
 
def outer(x):
    # Enclosing scope for inner
    y = 5
    
    def inner(z):
        # Local scope
        # z is local (L)
        # y is from enclosing scope (E)
        # multiplier is from global scope (G)
        # len is from built-in scope (B)
        result = len([z, y, multiplier])  # Uses all four scopes!
        return z + y + multiplier
 
    return inner(3)
 
answer = outer(100)
print(answer)  # Output: 18

When Python evaluates z + y + multiplier inside inner():

  1. L (Local): Finds z = 3
  2. E (Enclosing): Finds y = 5 in outer()
  3. G (Global): Finds multiplier = 10
  4. B (Built-in): Finds len function

This example clearly demonstrates how Python searches through all four scopes to resolve names.

Shadowing: When Inner Scopes Hide Outer Names

If the same name exists in multiple scopes, the innermost scope "wins" - this is called shadowing:

python
value = "global"
 
def outer():
    value = "enclosing"
    
    def inner():
        value = "local"
        print(value)  # Which value?
    
    inner()
    print(value)  # Which value?
 
outer()
print(value)  # Which value?

Output:

local
enclosing
global

Each print() statement sees a different value because Python stops at the first match:

  • Inside inner(): finds value locally → prints "local"
  • Inside outer() but outside inner(): finds value in outer()'s scope → prints "enclosing"
  • At module level: finds value globally → prints "global"

Visualizing the LEGB Search Order

Yes

No

Yes

No

Yes

No

Yes

No

Name Reference

Found in Local?

Use Local Value

Found in Enclosing?

Use Enclosing Value

Found in Global?

Use Global Value

Found in Built-in?

Use Built-in Value

NameError

This diagram shows Python's search process. It starts at the innermost scope and works outward. If the name isn't found in any scope, Python raises a NameError.

Why LEGB Matters for Writing Functions

Understanding LEGB helps you:

  1. Predict variable values: You know exactly which variable Python will use
  2. Avoid naming conflicts: You understand when names shadow each other
  3. Design better functions: You can decide which scope is appropriate for each variable
  4. Debug scope issues: When variables don't have expected values, you can trace through LEGB

The LEGB rule is fundamental to how Python resolves names. Every time you use a variable, Python is following this rule behind the scenes.

21.3) Using the global Keyword Carefully

We've seen that functions can read global variables, but what if you need to modify a global variable from inside a function? That's where the global keyword comes in - but it should be used sparingly and carefully.

The Problem: Assignment Creates Local Variables

As we learned earlier, assigning to a variable inside a function creates a local variable:

python
counter = 0  # Global variable
 
def increment():
    # WARNING: This creates a NEW local variable named counter - for demonstration only
    # PROBLEM: Trying to read counter before assigning to it locally
    counter = counter + 1  # UnboundLocalError!
    
# increment()  # This call results in UnboundLocalError

This fails because Python sees the assignment and treats counter as local throughout the entire function. But we're trying to read counter before we've assigned to it locally.

This is one of the most common errors when working with global variables. The error message UnboundLocalError: local variable 'counter' referenced before assignment tells you exactly what happened: Python decided counter was local (because of the assignment), but you tried to use it before giving it a value.

The Solution: Declaring Variables as Global

The global keyword tells Python: "Don't create a new local variable with this name. Use the global variable instead."

python
counter = 0  # Global variable
 
def increment():
    global counter  # Tell Python to use the global counter
    counter = counter + 1  # Now this modifies the global variable
 
print(f"Before: {counter}")  # Output: Before: 0
increment()
print(f"After: {counter}")   # Output: After: 1
increment()
print(f"After again: {counter}")  # Output: After again: 2

The global counter declaration must come before you use the variable. It tells Python that any assignments to counter in this function should modify the global variable, not create a local one.

Multiple Global Variables

You can declare multiple variables as global in one statement:

python
total_sales = 0
total_customers = 0
 
def record_sale(amount):
    global total_sales, total_customers
    total_sales += amount
    total_customers += 1
 
print(f"Sales: ${total_sales}, Customers: {total_customers}")
# Output: Sales: $0, Customers: 0
 
record_sale(25.50)
record_sale(30.00)
 
print(f"Sales: ${total_sales}, Customers: {total_customers}")
# Output: Sales: $55.5, Customers: 2

Both total_sales and total_customers are declared global, so the function can modify both.

When to Use global: Shared State

The global keyword is appropriate when you need to maintain shared state - data that multiple functions need to access and modify:

python
# Game state
player_score = 0
player_lives = 3
game_over = False
 
def award_points(points):
    global player_score
    player_score += points
    print(f"Score: {player_score}")
 
def lose_life():
    global player_lives, game_over
    player_lives -= 1
    print(f"Lives remaining: {player_lives}")
    
    if player_lives <= 0:
        game_over = True
        print("Game Over!")
 
def check_game_status():
    # Just reading globals - no global keyword needed
    if game_over:
        return "Game Over"
    else:
        return f"Playing - Score: {player_score}, Lives: {player_lives}"
 
# Play the game
award_points(100)    # Output: Score: 100
award_points(50)     # Output: Score: 150
lose_life()          # Output: Lives remaining: 2
print(check_game_status())  # Output: Playing - Score: 150, Lives: 2

This example shows appropriate use of global: multiple functions need to modify shared game state. However, notice that check_game_status() doesn't need global because it only reads the variables.

Why global Should Be Used Carefully

While global is sometimes necessary, overusing it can make code harder to understand and maintain. Here's why:

Problem 1: Hidden Dependencies

When functions modify global variables, it's not obvious from the function call what's changing:

python
total = 0
 
def add_to_total(value):
    global total
    total += value
 
# What does this function do? You can't tell without reading its code
add_to_total(10)

Compare this to a function that returns a value:

python
def add_to_total(current_total, value):
    return current_total + value
 
total = 0
total = add_to_total(total, 10)  # Clear: total is being updated

The second version makes it explicit that total is being modified.

Problem 2: Testing Becomes Harder

Functions that modify global state are harder to test because you need to set up and reset global variables:

python
# Hard to test - depends on global state
score = 0
 
def add_score(points):
    global score
    score += points
 
# Each test needs to reset score
# Test 1
score = 0
add_score(10)
assert score == 10
 
# Test 2 - must reset score again
score = 0
add_score(20)
assert score == 20

Problem 3: Functions Aren't Reusable

Functions that depend on specific global variables can't be easily reused in other programs:

python
# This function only works if there's a global variable named 'inventory'
inventory = []
 
def add_item(item):
    global inventory
    inventory.append(item)

Better Alternatives to global

In many cases, you can avoid global by using return values and parameters:

Instead of modifying global state:

python
# Using global (less ideal)
balance = 1000
 
def withdraw(amount):
    global balance
    if amount <= balance:
        balance -= amount
        return True
    return False
 
withdraw(100)
print(balance)  # Output: 900

Use return values:

python
# Using return values (better)
def withdraw(balance, amount):
    if amount <= balance:
        return balance - amount, True
    return balance, False
 
balance = 1000
balance, success = withdraw(balance, 100)
print(balance)  # Output: 900

The second version is more flexible, testable, and reusable.

When global Is Actually Appropriate

There are legitimate uses for global:

  1. Configuration that truly needs to be global:
python
# Application-wide settings
debug_mode = False
log_level = "INFO"
 
def enable_debug():
    global debug_mode, log_level
    debug_mode = True
    log_level = "DEBUG"
  1. Counters for debugging or statistics:
python
# Track function calls for debugging
_function_call_count = 0
 
def tracked_function():
    global _function_call_count
    _function_call_count += 1
    # ... rest of function

Key Takeaways About global

  • Use global only when you truly need to modify module-level state
  • Prefer returning values and using parameters instead
  • When you do use global, document why it's necessary
  • Consider whether your design could be improved to avoid global
  • Remember: reading global variables doesn't require the global keyword - only modifying them does

21.4) Using nonlocal to Modify Variables in Enclosing Functions

When you have nested functions, you might need to modify a variable from an enclosing function's scope. The nonlocal keyword serves this purpose - it's like global, but for enclosing function scopes instead of the global scope.

The Problem: Modifying Enclosing Variables

Just as assignment creates local variables by default, the same problem occurs with enclosing scopes:

python
def outer():
    count = 0  # Variable in outer's scope
    
    def inner():
        # WARNING: This creates a NEW local variable named count - for demonstration only
        # PROBLEM: Trying to read count before assigning to it locally
        count = count + 1  # UnboundLocalError!
        print(count)
    
    inner()
 
# outer()  # This call results in UnboundLocalError

Python sees the assignment to count in inner() and treats it as a local variable. But we're trying to read it before assigning to it locally, causing an error.

The Solution: The nonlocal Keyword

The nonlocal keyword tells Python: "This variable isn't local - look for it in the enclosing function's scope and use that one."

python
def outer():
    count = 0  # Variable in outer's scope
    
    def inner():
        nonlocal count  # Use the count from outer's scope
        count = count + 1
        print(f"Count in inner: {count}")
    
    print(f"Count before: {count}")  # Output: Count before: 0
    inner()                          # Output: Count in inner: 1
    print(f"Count after: {count}")   # Output: Count after: 1
 
outer()

Now inner() can modify the count variable from outer()'s scope. The change persists after inner() returns because we're modifying the actual variable in the enclosing scope.

Why nonlocal Is Useful: Functions That Remember State

The nonlocal keyword enables a powerful pattern where inner functions can maintain and modify state from their enclosing scope. We'll learn about closures and factory functions in detail in Chapter 23, but for now, understand that nonlocal allows inner functions to modify variables from enclosing scopes.

Here's a simple example showing how nonlocal works:

python
def create_counter():
    count = 0  # This variable is in the enclosing scope for increment
    
    def increment():
        nonlocal count  # Modify the count from the enclosing scope
        count += 1
        return count
    
    return increment  # Return the inner function
 
# Create a counter
counter1 = create_counter()
 
print(counter1())  # Output: 1
print(counter1())  # Output: 2
print(counter1())  # Output: 3
 
# Create another independent counter
counter2 = create_counter()
 
print(counter2())  # Output: 1
print(counter2())  # Output: 2

Each call to create_counter() creates a new count variable and a new increment() function that can modify that specific count using nonlocal.

nonlocal vs global

It's important to understand the difference:

python
x = "global"
 
def outer():
    x = "enclosing"
    
    def use_global():
        global x  # Refers to the global x
        print(f"use_global sees: {x}")  # Output: use_global sees: global
    
    def use_nonlocal():
        nonlocal x  # Refers to outer's x
        print(f"use_nonlocal sees: {x}")  # Output: use_nonlocal sees: enclosing
    
    use_global()
    use_nonlocal()
 
outer()
  • global always refers to the module-level scope
  • nonlocal refers to the nearest enclosing function scope

When You Can't Use nonlocal

The nonlocal keyword only works with enclosing function scopes. You can't use it for:

  1. Global scope (use global instead):
python
x = "global"
 
def func():
    nonlocal x  # SyntaxError: no binding for nonlocal 'x' found
    x = "modified"
  1. Variables that don't exist in any enclosing scope:
python
def outer():
    def inner():
        nonlocal count  # SyntaxError: no binding for nonlocal 'count' found

Key Takeaways About nonlocal

  • Use nonlocal to modify variables from enclosing function scopes
  • nonlocal searches enclosing function scopes, not global scope
  • Reading enclosing variables doesn't require nonlocal - only modifying them does
  • nonlocal enables powerful patterns for creating functions with private state
  • We'll learn more about closures and factory functions in Chapter 23

The nonlocal keyword is particularly useful for creating functions that maintain private state, as we saw with the counter, account, and event tracker examples.

21.5) Deleting Names (Not Objects) with del and What It Means

Sometimes you need to remove a variable from your program's namespace - perhaps to free memory in long-running programs, clean up temporary variables, or remove entries from collections. Python's del statement handles these tasks, but it's important to understand exactly what it does and doesn't do.

The del statement in Python is often misunderstood. It doesn't delete objects - it deletes names (variable bindings). Understanding this distinction is crucial for understanding how Python manages memory and references.

What del Actually Does

The del statement removes a name from the current scope:

python
x = 42
print(x)  # Output: 42
 
del x
 
# print(x)  # NameError: name 'x' is not defined

After del x, the name x no longer exists in the current scope. If you try to use it, Python raises a NameError because the name isn't defined anymore.

Deleting Names vs Deleting Objects

This is the key insight: del removes the name, not necessarily the object the name refers to:

python
# Create a list and two names that refer to it
original = [1, 2, 3]
reference = original  # Both names refer to the same list
 
print(original)   # Output: [1, 2, 3]
print(reference)  # Output: [1, 2, 3]
 
# Delete one name
del original
 
# The list still exists because 'reference' still refers to it
print(reference)  # Output: [1, 2, 3]
 
# print(original)  # NameError: name 'original' is not defined

The list [1, 2, 3] continues to exist because reference still refers to it. Deleting original only removed that name - it didn't delete the list object itself.

When Objects Are Actually Deleted

Python automatically deletes objects when they're no longer referenced by any name. This is called garbage collection:

python
data = [1, 2, 3]  # List is created, 'data' refers to it
 
del data  # 'data' name is deleted
 
# Now the list has no references, so Python will eventually delete it
# (This happens automatically - you don't need to do anything)

When we delete data, the list [1, 2, 3] has no remaining references, so Python's garbage collector will eventually reclaim the memory. But this happens automatically - you don't control when.

Deleting Items from Collections

The del statement can also remove items from collections, but this is fundamentally different from deleting names. When you use del with collection indexing or slicing, you're modifying the collection itself, not deleting a name.

This is an important distinction: when you write del numbers[2], you're calling a special method on the list object to remove an element. The name numbers still exists and still refers to the same list object - the list just has fewer elements now.

python
# Deleting list elements by index
numbers = [10, 20, 30, 40, 50]
del numbers[2]  # Remove the element at index 2
print(numbers)  # Output: [10, 20, 40, 50]
 
# Deleting list slices
numbers = [10, 20, 30, 40, 50]
del numbers[1:3]  # Remove elements from index 1 to 3 (exclusive)
print(numbers)  # Output: [10, 40, 50]
 
# Deleting dictionary entries
person = {'name': 'Alice', 'age': 30, 'city': 'Boston'}
del person['age']
print(person)  # Output: {'name': 'Alice', 'city': 'Boston'}
© 2025. Primesoft Co., Ltd.
support@primesoft.ai