Module 5.3

Scope and Namespaces

Scope determines where variables live and who can access them. When you reference a variable, Python searches through a specific hierarchy of scopes to find it. Understanding this system helps you avoid bugs and write cleaner code.

45 min
Intermediate
Hands-on
What You'll Learn
  • The LEGB scope rule
  • Local vs global variables
  • The global keyword
  • The nonlocal keyword
  • Closures and encapsulation
Contents
01

The LEGB Rule

When Python encounters a variable name, it searches for it in a specific order: Local, Enclosing, Global, Built-in. This is called the LEGB rule and determines which variable you get when multiple scopes have the same name.

Definition

Scope

A scope is a region of a program where a variable is accessible. Python uses lexical scoping, meaning the scope of a variable is determined by where it is defined in the source code, not where it is called from.

Key insight: Python determines scope at compile time based on where assignments occur, not at runtime.

Definition

Namespace

A namespace is a mapping from names to objects. Each scope has its own namespace dictionary. When you access a variable, Python looks up the name in the appropriate namespace based on the LEGB rule.

Example: Use locals() and globals() to inspect namespace dictionaries.

LEGB Scope Hierarchy
L - Local
Variables inside the current function
E - Enclosing
Variables in outer (enclosing) functions
G - Global
Variables at the module level
B - Built-in
Python's built-in names (print, len, etc.)
Python searches from bottom to top (Local first, Built-in last). First match wins.

Local Scope

Variables created inside a function are local to that function. They exist only while the function runs and cannot be accessed from outside.

def my_function():
    x = 10  # Local variable
    print(f"Inside function: x = {x}")

my_function()  # Output: Inside function: x = 10

# This would cause an error:
# print(x)  # NameError: name 'x' is not defined

def another_function():
    y = 20  # Different local scope
    print(y)

# Each function has its own local scope

Local variables are created when the function starts and destroyed when it returns. They are isolated from other functions and cannot be accessed from outside their defining function. This isolation is a key feature of Python's scope system, preventing accidental modification of variables across different parts of your code. Each function call creates a fresh set of local variables.

Global Scope

Variables defined at the module level (outside any function) are global. They can be read from inside functions, but modifying them requires the global keyword.

count = 0  # Global variable

def show_count():
    print(f"Count is: {count}")  # Reading global is OK

show_count()  # Output: Count is: 0

def try_to_modify():
    # This creates a NEW local variable, not modifying global
    count = 100
    print(f"Inside: {count}")

try_to_modify()  # Output: Inside: 100
print(f"Global still: {count}")  # Output: Global still: 0

Assignment inside a function creates a local variable, even if a global with the same name exists. This behavior is called "shadowing" because the local variable hides the global one within that function's scope. Python decides at compile time whether a variable is local based on whether it appears on the left side of an assignment. Understanding shadowing prevents many confusing bugs in Python programs.

Enclosing Scope

When you have nested functions, inner functions can access variables from the enclosing (outer) function. This is the "E" in LEGB.

def outer():
    message = "Hello from outer"  # Enclosing scope
    
    def inner():
        print(message)  # Accesses enclosing variable
    
    inner()

outer()  # Output: Hello from outer

# The inner function "remembers" the enclosing scope
def make_multiplier(n):
    def multiplier(x):
        return x * n  # n comes from enclosing scope
    return multiplier

double = make_multiplier(2)
print(double(5))  # Output: 10

Enclosing scope sits between local and global in the LEGB hierarchy. It only applies when you have functions defined inside other functions, creating nested scopes. The inner function can read variables from the outer function, but cannot modify them without the nonlocal keyword. This mechanism enables powerful patterns like closures and function factories.

Practice: LEGB Rule

Task: Without running the code, predict what will be printed. Then verify by running it.

x = "global"

def test():
    x = "local"
    print(x)

test()
print(x)
Show Answer
# Output:
# local   (function uses its local x)
# global  (global x is unchanged)

Task: Predict the output of this nested function code.

value = "global"

def outer():
    value = "enclosing"
    
    def inner():
        value = "local"
        print(value)
    
    inner()
    print(value)

outer()
print(value)
Show Answer
# Output:
# local      (inner's local)
# enclosing  (outer's local)
# global     (module level)

Task: This code has a scope-related bug. Find it and explain why it fails.

counter = 0

def increment():
    counter = counter + 1
    return counter

print(increment())
Show Answer
# UnboundLocalError: local variable 'counter' 
# referenced before assignment

# The assignment makes Python treat counter as local,
# but it tries to read it before the assignment completes.
# Fix: use 'global counter' at the start of the function

Task: Explain what happens when you run this code and why it's problematic.

list = [1, 2, 3]
print(list)

# Later in the code...
numbers = list(range(5))  # What happens?
Show Answer
# TypeError: 'list' object is not callable

# The first line shadows the built-in 'list' function
# with a list object. When we try to call list(range(5)),
# we're calling the list [1,2,3] as a function.

# Fix: Never use built-in names as variable names
# Use descriptive names like 'my_list' or 'numbers'
02

Global and Nonlocal Keywords

Sometimes you need to modify variables from outer scopes. The global keyword lets you modify module-level variables. The nonlocal keyword lets you modify enclosing function variables.

Keyword

global

The global keyword declares that a variable name refers to the global (module-level) namespace. Without it, assigning to a variable inside a function creates a new local variable, even if a global with that name exists.

Syntax: global variable_name - Must appear before any use of the variable.

Keyword

nonlocal

The nonlocal keyword declares that a variable refers to a name in the nearest enclosing scope (excluding global). It is used in nested functions when the inner function needs to modify a variable from the outer function.

Requirement: The variable must already exist in an enclosing function scope - nonlocal cannot create new variables.

global

Declares that a variable refers to the global (module-level) scope. Allows modification of global variables from inside functions.

nonlocal

Declares that a variable refers to the nearest enclosing scope. Used in nested functions to modify outer function variables.

Using global

The global keyword tells Python that a variable inside a function refers to the global variable, not a new local one.

counter = 0

def increment():
    global counter  # Declare we're using the global
    counter = counter + 1
    return counter

print(increment())  # Output: 1
print(increment())  # Output: 2
print(increment())  # Output: 3
print(f"Final: {counter}")  # Output: Final: 3

The global declaration must come before any use of the variable in the function. It tells Python that all references to that name within the function refer to the module-level variable, not a local one. Once declared global, both reading and writing affect the same global variable. This is useful for maintaining state across function calls, but should be used sparingly.

Use Sparingly: Global variables make code harder to debug and test. Prefer passing values as arguments and returning results. Use global only when truly necessary.

Using nonlocal

The nonlocal keyword is for nested functions. It lets an inner function modify a variable from the enclosing function.

def make_counter():
    count = 0  # Enclosing variable
    
    def increment():
        nonlocal count  # Modify enclosing, not create local
        count += 1
        return count
    
    return increment

counter = make_counter()
print(counter())  # Output: 1
print(counter())  # Output: 2
print(counter())  # Output: 3

# Each counter is independent
counter2 = make_counter()
print(counter2())  # Output: 1

The nonlocal keyword finds the nearest enclosing scope that contains the specified variable. Unlike global, it does not reach up to module-level scope. If no enclosing function has that variable, Python raises a SyntaxError. This keyword is essential for creating closures that need to modify their captured state, enabling patterns like counters and accumulators.

Practice: Global and Nonlocal

Task: Fix this code so that the function properly increments the global total.

total = 100

def add_to_total(amount):
    total = total + amount
    return total

add_to_total(50)
print(total)  # Should print 150
Show Solution
total = 100

def add_to_total(amount):
    global total
    total = total + amount
    return total

add_to_total(50)
print(total)  # Output: 150

Task: Create a function make_accumulator that returns a function. The returned function takes a number and adds it to a running total, returning the new total. Use nonlocal.

Show Solution
def make_accumulator():
    total = 0
    
    def add(n):
        nonlocal total
        total += n
        return total
    
    return add

acc = make_accumulator()
print(acc(10))  # Output: 10
print(acc(5))   # Output: 15
print(acc(3))   # Output: 18

Task: Create a make_toggle function that returns a function. Each call to the returned function toggles a boolean state and returns it. Start with False, then True, False, True, etc.

Show Solution
def make_toggle():
    state = False
    
    def toggle():
        nonlocal state
        state = not state
        return state
    
    return toggle

switch = make_toggle()
print(switch())  # Output: True
print(switch())  # Output: False
print(switch())  # Output: True

Task: Create two functions that share a global configuration dictionary. One function updates settings, the other retrieves them. Demonstrate with theme and font_size settings.

Show Solution
config = {"theme": "light", "font_size": 12}

def update_config(key, value):
    global config
    config[key] = value
    return f"Updated {key} to {value}"

def get_config(key):
    return config.get(key, "Not found")

print(get_config("theme"))      # Output: light
print(update_config("theme", "dark"))
print(get_config("theme"))      # Output: dark
print(update_config("font_size", 16))
print(get_config("font_size"))  # Output: 16
03

Closures

A closure is a function that remembers values from its enclosing scope even after that scope has finished executing. Closures enable powerful patterns like function factories and data encapsulation.

Key Concept

What is a Closure?

A closure is created when a nested function references variables from its enclosing function. The inner function "closes over" these variables, keeping them alive even after the outer function returns.

Three requirements: 1) Nested function, 2) Inner function references outer variables, 3) Outer function returns the inner function.

Closure Example

When you return an inner function, it carries its enclosing scope with it. This scope persists independently for each call to the outer function.

def make_greeting(greeting):
    def greet(name):
        return f"{greeting}, {name}!"
    return greet

# Create specialized greeting functions
say_hello = make_greeting("Hello")
say_hi = make_greeting("Hi")
say_welcome = make_greeting("Welcome")

print(say_hello("Alice"))    # Output: Hello, Alice!
print(say_hi("Bob"))         # Output: Hi, Bob!
print(say_welcome("Carol"))  # Output: Welcome, Carol!

Each returned function remembers its own greeting value from when it was created. The closure captures the variable itself, not just its value at creation time, which means changes to the variable would be reflected. This pattern creates specialized functions from a general template. Function factories like this are common in Python for creating configurable behavior.

Practical Closure: Caching

Closures can store state, making them perfect for caching expensive computations.

def make_cached_function(func):
    cache = {}
    
    def cached(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    
    return cached

@make_cached_function
def slow_square(n):
    print(f"Computing {n}^2...")
    return n ** 2

print(slow_square(5))  # Computing 5^2... 25
print(slow_square(5))  # 25 (cached, no computation)
print(slow_square(3))  # Computing 3^2... 9

The cache dictionary lives in the enclosing scope and persists between calls to the wrapped function. This pattern is called memoization and dramatically improves performance for expensive computations with repeated inputs. The closure maintains private state that cannot be accessed or modified externally. Python's functools module provides a built-in decorator called lru_cache for this purpose.

Closure: Private State

Closures can create private state that cannot be accessed directly from outside, similar to private variables in object-oriented programming.

def make_bank_account(initial_balance):
    balance = initial_balance  # Private state
    
    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, withdraw, get_balance

dep, wd, bal = make_bank_account(100)
print(bal())    # Output: 100
print(dep(50))  # Output: 150
print(wd(30))   # Output: 120

The balance variable is completely private and cannot be accessed directly from outside the closure. It can only be modified through the returned functions, enforcing controlled access similar to private attributes in object-oriented programming. This encapsulation pattern provides data hiding without using classes. Multiple independent accounts can coexist, each with their own private balance.

Common Closure Pitfall: Loop Variables

A common mistake with closures involves capturing loop variables. All closures end up sharing the same variable, leading to unexpected results.

# WRONG: All functions share the same i
functions = []
for i in range(3):
    functions.append(lambda: i)

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

# FIX: Capture the value with a default parameter
functions = []
for i in range(3):
    functions.append(lambda i=i: i)  # i=i captures current value

for f in functions:
    print(f())  # Output: 0, 1, 2 (correct!)

In the first example, all lambdas share the same variable i, which has value 2 after the loop ends. The fix uses a default parameter i=i to capture the current value at each iteration. This is a classic Python gotcha that trips up even experienced developers. Always be careful when creating closures inside loops.

Use Case Description Example
Function Factories Create specialized functions from a template make_multiplier(n)
Memoization Cache expensive computation results make_cached(func)
Private State Encapsulate data without classes make_counter()
Decorators Wrap functions with additional behavior @timer, @retry
Callbacks Preserve context for async operations Event handlers

Practice: Closures

Task: Create a function make_multiplier that takes a factor and returns a function that multiplies its input by that factor. Create triple (x3) and quadruple (x4) functions.

Show Solution
def make_multiplier(factor):
    def multiply(x):
        return x * factor
    return multiply

triple = make_multiplier(3)
quadruple = make_multiplier(4)

print(triple(10))     # Output: 30
print(quadruple(10))  # Output: 40

Task: Create a function count_calls that wraps any function and counts how many times it has been called. Return both the result and a way to check the count.

Show Solution
def count_calls(func):
    count = 0
    
    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        return func(*args, **kwargs)
    
    def get_count():
        return count
    
    wrapper.get_count = get_count
    return wrapper

@count_calls
def add(a, b):
    return a + b

print(add(1, 2))           # Output: 3
print(add(3, 4))           # Output: 7
print(add.get_count())     # Output: 2

Task: Create a rate_limiter that allows a function to be called at most n times. After the limit, it returns "Rate limit exceeded" instead of calling the function.

Show Solution
def rate_limiter(max_calls):
    def decorator(func):
        calls = 0
        def wrapper(*args, **kwargs):
            nonlocal calls
            if calls >= max_calls:
                return "Rate limit exceeded"
            calls += 1
            return func(*args, **kwargs)
        return wrapper
    return decorator

@rate_limiter(3)
def greet(name):
    return f"Hello, {name}!"

print(greet("Alice"))  # Output: Hello, Alice!
print(greet("Bob"))    # Output: Hello, Bob!
print(greet("Carol"))  # Output: Hello, Carol!
print(greet("Dave"))   # Output: Rate limit exceeded

Task: Create a make_history_tracker that returns functions to add values and retrieve all previous values. The tracker should maintain a list of all values ever added.

Show Solution
def make_history_tracker():
    history = []
    
    def add(value):
        history.append(value)
        return f"Added: {value}"
    
    def get_all():
        return history.copy()
    
    def get_last(n=1):
        return history[-n:]
    
    return add, get_all, get_last

add, get_all, get_last = make_history_tracker()
print(add("first"))     # Output: Added: first
print(add("second"))    # Output: Added: second
print(add("third"))     # Output: Added: third
print(get_all())        # Output: ['first', 'second', 'third']
print(get_last(2))      # Output: ['second', 'third']
04

Namespace Introspection

Python provides built-in functions to examine namespaces at runtime. You can inspect local and global variables, check what names exist in any scope, and even modify namespaces dynamically. This introspection is powerful for debugging and metaprogramming.

Built-in Functions

Namespace Inspection Tools

locals() returns a dictionary of the current local namespace. globals() returns the global namespace dictionary. dir() returns a list of names in the current scope or an object's attributes.

Note: locals() inside a function returns a snapshot - modifying it does not affect actual local variables.

Introspection vs Modification: While you can view namespaces freely, modifying locals() has no effect inside functions. However, modifying globals() does change global variables.

Using locals() and globals()

These functions return dictionaries representing the current namespace, allowing you to see exactly what variables exist and their values.

module_var = "I'm global"

def show_namespaces():
    local_var = "I'm local"
    another_local = 42
    
    print("Local namespace:")
    for name, value in locals().items():
        print(f"  {name} = {value}")
    
    print("\nGlobal namespace (filtered):")
    for name, value in globals().items():
        if not name.startswith('_'):
            print(f"  {name} = {value}")

show_namespaces()
# Output:
# Local namespace:
#   local_var = I'm local
#   another_local = 42
# Global namespace (filtered):
#   module_var = I'm global
#   show_namespaces = 

The locals() function returns a dictionary containing all variables in the current local scope. Similarly, globals() returns the module's namespace as a dictionary. We filter out dunder names (starting with underscore) to show only user-defined items. This is invaluable for debugging when you need to see what variables are available.

Using dir() for Exploration

The dir() function lists names in the current scope or, when given an object, lists that object's attributes and methods.

# dir() with no argument shows current scope
x = 10
y = 20

def my_func():
    pass

print("Current scope names:")
print([n for n in dir() if not n.startswith('_')])
# Output: ['my_func', 'x', 'y']

# dir() with an object shows its attributes
text = "hello"
print("\nString methods (sample):")
print([m for m in dir(text) if not m.startswith('_')][:10])
# Output: ['capitalize', 'casefold', 'center', 'count', 'encode', ...]

Without arguments, dir() returns names in the current local scope. When called with an object, it returns that object's attributes and methods. This is extremely useful for interactive exploration and discovering what operations are available. Combined with help(), it forms the core of Python's introspection capabilities.

Dynamic Variable Access

Using namespace dictionaries, you can access variables by name dynamically, which is useful for configuration and reflection.

settings = {
    "debug_mode": True,
    "max_retries": 3,
    "timeout": 30
}

def get_setting(name):
    return globals()['settings'].get(name, None)

def create_variables_from_dict(data):
    for key, value in data.items():
        globals()[key] = value

# Access setting dynamically
print(get_setting("max_retries"))  # Output: 3

# Create global variables from dictionary
config = {"api_url": "https://api.example.com", "version": "2.0"}
create_variables_from_dict(config)
print(api_url)   # Output: https://api.example.com
print(version)   # Output: 2.0

Accessing globals() as a dictionary allows dynamic variable creation and lookup. This technique is powerful for configuration systems where variable names come from external sources. However, use this sparingly as it can make code harder to understand and debug. Explicit dictionaries are usually clearer than dynamic global variables.

Function Returns Modifiable? Use Case
locals() Local namespace dict No (inside functions) Debugging, logging
globals() Global namespace dict Yes Dynamic variable creation
dir() List of names N/A Exploration, discovery
vars(obj) Object's __dict__ Yes (if object allows) Object introspection

Practice: Namespace Introspection

Task: Create a function that takes any number of keyword arguments and prints each variable name and value using locals().

Show Solution
def print_variables(**kwargs):
    for name, value in locals()['kwargs'].items():
        print(f"{name} = {value}")

print_variables(x=10, name="Alice", active=True)
# Output:
# x = 10
# name = Alice
# active = True

Task: Create a function that returns a list of all callable objects (functions) in the current global namespace, excluding built-ins.

Show Solution
def get_user_functions():
    return [
        name for name, obj in globals().items()
        if callable(obj) and not name.startswith('_')
        and not isinstance(obj, type)  # Exclude classes
    ]

def greet(): pass
def calculate(): pass

print(get_user_functions())
# Output: ['get_user_functions', 'greet', 'calculate']

Task: Create a context manager that prints all new local variables created within its block (comparing before and after).

Show Solution
from contextlib import contextmanager
import sys

@contextmanager
def track_new_variables():
    frame = sys._getframe(1)
    before = set(frame.f_locals.keys())
    yield
    after = set(frame.f_locals.keys())
    new_vars = after - before
    if new_vars:
        print(f"New variables: {new_vars}")
    
# Usage requires careful scope handling
# This is an advanced introspection technique

Task: Create a function that takes an object and a dictionary, then sets attributes on the object from the dictionary keys and values.

Show Solution
def set_attributes(obj, attributes):
    for name, value in attributes.items():
        setattr(obj, name, value)
    return obj

class User:
    pass

user = User()
set_attributes(user, {"name": "Alice", "age": 30, "active": True})
print(user.name)    # Output: Alice
print(user.age)     # Output: 30
print(vars(user))   # Output: {'name': 'Alice', 'age': 30, 'active': True}
05

Scope Best Practices

Understanding scope is one thing; using it effectively is another. Following best practices helps you write code that is easier to understand, test, and maintain. Proper scope management prevents bugs and makes your intentions clear to other developers.

Principle

Principle of Least Privilege

Variables should have the smallest scope necessary to accomplish their task. This reduces the risk of unintended modifications and makes code easier to reason about. Prefer local variables over global ones whenever possible.

Rule of thumb: If a variable is only needed in one function, make it local. If needed across functions, consider passing it as a parameter instead of making it global.

Global Variables Warning: Global variables create hidden dependencies between functions. They make testing difficult, enable action at a distance, and can lead to subtle bugs in concurrent code.

Avoid Mutable Global State

Mutable global variables are particularly dangerous because any part of your program can modify them, leading to unpredictable behavior.

# BAD: Mutable global state
user_list = []

def add_user(name):
    user_list.append(name)  # Modifies global list

def get_users():
    return user_list

This approach uses a global list that any function can modify. The problem is that changes happen invisibly - you cannot tell from looking at add_user() alone that it modifies external state. This makes debugging difficult because bugs can originate anywhere in your codebase. Testing is also hard since each test affects the global state.

# GOOD: Encapsulated state with a class
class UserManager:
    def __init__(self):
        self._users = []
    
    def add_user(self, name):
        self._users.append(name)
    
    def get_users(self):
        return self._users.copy()  # Return copy for safety

The class-based approach encapsulates the user list as an instance attribute. Each UserManager instance has its own independent list. The underscore prefix signals that _users is internal. Returning a copy from get_users() prevents external code from accidentally modifying the internal list. This pattern is testable, maintainable, and thread-safe with proper locking.

# GOOD: Encapsulated state with closures
def create_user_manager():
    users = []
    
    def add(name):
        users.append(name)
    
    def get_all():
        return users.copy()
    
    return add, get_all

add_user, get_users = create_user_manager()

The closure approach provides similar encapsulation without defining a class. The users list is completely private - it cannot be accessed except through the returned functions. This is ideal for simple cases where you need private state but a full class feels like overkill. Each call to create_user_manager() creates an independent set of functions with their own private list.

Parameter Passing vs Global Access

Functions that read global variables have hidden inputs, making them harder to test and understand. Prefer explicit parameters.

# BAD: Hidden dependency on global
config = {"debug": True, "retries": 3}

def process_data(data):
    if config["debug"]:  # Hidden dependency!
        print(f"Processing: {data}")
    for i in range(config["retries"]):
        # process...
        pass

This function has a hidden dependency on the global config variable. Looking at just the function signature process_data(data), you cannot tell it depends on external state. This makes the function impure - its behavior changes based on something outside its control. Testing requires setting up global state first, and bugs can be hard to trace.

# GOOD: Explicit parameters
def process_data(data, debug=False, retries=3):
    if debug:
        print(f"Processing: {data}")
    for i in range(retries):
        # process...
        pass

This version makes all dependencies explicit through parameters with sensible defaults. The function signature tells you exactly what inputs it needs. Testing is simple - just pass different values. The function is pure and self-documenting. Callers can easily customize behavior without modifying global state.

# ALSO GOOD: Configuration object passed explicitly
from dataclasses import dataclass

@dataclass
class Config:
    debug: bool = False
    retries: int = 3

def process_data(data, config: Config):
    if config.debug:
        print(f"Processing: {data}")
    # ...

When you have many configuration options, passing them individually becomes unwieldy. A configuration object groups related settings together while still being passed explicitly. The dataclass decorator provides a clean way to define configuration with defaults. This pattern scales well and supports type hints for better IDE support.

When Global Variables Are Acceptable

Not all global variables are bad. Constants, module-level configurations loaded once, and certain design patterns benefit from global scope.

# OK: Constants (by convention, UPPERCASE)
MAX_CONNECTIONS = 100
DEFAULT_TIMEOUT = 30
API_BASE_URL = "https://api.example.com"

# OK: Module-level logger
import logging
logger = logging.getLogger(__name__)

def process():
    logger.info("Processing started")  # Logger is a reasonable global

# OK: Singleton pattern (sometimes)
_instance = None

def get_database():
    global _instance
    if _instance is None:
        _instance = Database()
    return _instance

Constants are safe as globals because they never change. Module-level loggers are conventional and widely accepted. Singleton patterns use globals carefully to ensure only one instance exists. The key distinction is that these globals are either immutable or their mutation is intentional and controlled. Avoid globals that any function might modify unexpectedly.

Practice Why Example
Prefer local variables Isolation, easier debugging Define vars inside functions
Pass explicit parameters Clear dependencies, testable def process(data, config)
Use UPPERCASE for constants Signal immutability MAX_SIZE = 100
Encapsulate mutable state Controlled access Classes or closures
Return values, don't modify globals Pure functions, no side effects return result

Practice: Best Practices

Task: Identify what's wrong with this code and explain why it's a problem.

total = 0

def add_sale(amount):
    global total
    total += amount

def get_total():
    return total

def reset():
    global total
    total = 0
Show Answer
# Problems:
# 1. Hidden mutable state - total can change unexpectedly
# 2. Hard to test - functions depend on global state
# 3. Not thread-safe - concurrent calls can corrupt total
# 4. Hard to have multiple independent totals

# Better approach: use a class or closure
class SalesTracker:
    def __init__(self):
        self.total = 0
    
    def add_sale(self, amount):
        self.total += amount
    
    def get_total(self):
        return self.total
    
    def reset(self):
        self.total = 0

Task: Refactor this code to eliminate the global variable while preserving functionality.

cache = {}

def expensive_calculation(n):
    if n in cache:
        return cache[n]
    result = n ** 2 + n * 2 + 1  # Pretend this is slow
    cache[n] = result
    return result
Show Solution
# Solution 1: Closure
def create_cached_calculator():
    cache = {}
    
    def calculate(n):
        if n not in cache:
            cache[n] = n ** 2 + n * 2 + 1
        return cache[n]
    
    return calculate

expensive_calculation = create_cached_calculator()

# Solution 2: Use functools.lru_cache
from functools import lru_cache

@lru_cache(maxsize=128)
def expensive_calculation(n):
    return n ** 2 + n * 2 + 1

Task: Design a configuration system that avoids global variables but allows settings to be accessed throughout the application.

Show Solution
from dataclasses import dataclass
from typing import Optional

@dataclass
class AppConfig:
    debug: bool = False
    database_url: str = "sqlite:///app.db"
    max_connections: int = 10

class Application:
    def __init__(self, config: AppConfig):
        self.config = config
        self.db = Database(config.database_url, config.max_connections)
    
    def run(self):
        if self.config.debug:
            print("Running in debug mode")
        # ...

# Usage - config passed explicitly
config = AppConfig(debug=True)
app = Application(config)
app.run()

Task: Create a thread-safe counter class that can be safely used from multiple threads without using global variables.

Show Solution
import threading

class ThreadSafeCounter:
    def __init__(self, initial=0):
        self._value = initial
        self._lock = threading.Lock()
    
    def increment(self, amount=1):
        with self._lock:
            self._value += amount
            return self._value
    
    def decrement(self, amount=1):
        with self._lock:
            self._value -= amount
            return self._value
    
    @property
    def value(self):
        with self._lock:
            return self._value

# Usage
counter = ThreadSafeCounter()
# Pass counter to threads that need it

Interactive Demo: Scope Explorer

Experiment with Python's scope rules. Select different scenarios to see how variable names are resolved through the LEGB hierarchy.

Select a Scenario
Code Example
x = "global"

def show():
    x = "local"
    print(x)

show()
print(x)
Output
local
global
Explanation

The function creates its own local x, which shadows the global. Printing inside the function shows "local", but the global x remains unchanged.

Concept Summary

Concept Keyword/Function Purpose Example
Local Scope (default) Variables inside functions def f(): x = 1
Global Scope global Module-level variable access global counter
Enclosing Scope nonlocal Outer function variable access nonlocal count
Built-in Scope (automatic) Python's built-in names print, len, int
Closure nested function Remember enclosing variables return inner
Shadowing (assignment) Local hides global x = 1 # local
Namespace locals(), globals() Name-to-object mapping globals()['x']
Introspection dir(), vars() Examine namespaces dir(obj)

Key Takeaways

LEGB Order

Python searches Local, Enclosing, Global, then Built-in scopes in that order to resolve variable names

Local Scope

Variables created inside functions are local and cannot be accessed from outside

global Keyword

Use global to modify module-level variables from inside functions (use sparingly)

nonlocal Keyword

Use nonlocal in nested functions to modify variables from the enclosing function scope

Closures

Closures let functions remember enclosing scope variables, enabling factories and private state

Variable Shadowing

Local variables with the same name as globals hide the global without modifying it

Knowledge Check

Test your understanding of Python scope and namespaces:

Question 1 of 6

What does LEGB stand for in Python's scope resolution?

Question 2 of 6

What happens when you assign to a variable inside a function without using global?

Question 3 of 6

What is a closure in Python?

Question 4 of 6

When would you use nonlocal instead of global?

Question 5 of 6

What will this code print?
x = 10; (lambda: print(x))(); x = 20; (lambda: print(x))()

Question 6 of 6

What is the best practice regarding global variables?

Answer all questions to check your score