You assign x = 10 inside a function. Outside, x is undefined. Python follows the LEGB rule to find variables: Local, Enclosing, Global, Built-in. Understanding scope prevents confusing bugs.

Local vs global variables

Understand where variables are visible.

local_global.py
def main():
    print("=== Local vs Global Variables ===\n")
    
    global_demo()
    
    print("\n=== Local Variables ===")
    local_demo()
    
    print("\n=== Checking Global After Function ===")
    print(f"message is still: '{message}'")
    
    print("\n=== Shadowing ===")
    shadow_demo()
    print(f"Global message unchanged: '{message}'")

# Global variable - defined at module level
message = "Hello from global scope"

def global_demo():
    # Can READ global without keyword
    print("Inside function, reading global:")
    print(f"  message = '{message}'")

def local_demo():
    # This creates a LOCAL variable, doesn't affect global
    greeting = "Hello from local scope"
    count = 42
    print(f"  greeting = '{greeting}'")
    print(f"  count = {count}")
    # greeting and count die when function returns

def shadow_demo():
    # This 'message' shadows the global one
    message = "I'm a local variable!"
    print(f"  Local message: '{message}'")
    # Global 'message' is unchanged

if __name__ == "__main__":
    main()

Variables assigned inside a function are local by default.

local variable Variable created inside function. Only visible within that function.

The LEGB rule

Python's variable lookup order.

legb_rule.py
def main():
    print("=== LEGB Rule ===\n")
    print("Python searches names in order:")
    print("L → Local (current function)")
    print("E → Enclosing (outer function)")
    print("G → Global (module level)")
    print("B → Built-in (Python's names)")
    
    print("\n" + "="*40)
    demonstrate_legb()
    
    print("\n=== Built-in Scope ===")
    demonstrate_builtin()

x = "global x"  # Global scope

def demonstrate_legb():
    print("\n--- LEGB Demonstration ---\n")
    
    x = "enclosing x"  # Enclosing scope
    
    def inner():
        x = "local x"  # Local scope
        print(f"In inner(): x = '{x}'")  # Finds local first
    
    def inner_no_local():
        # No local x here
        print(f"In inner_no_local(): x = '{x}'")  # Finds enclosing
    
    inner()
    inner_no_local()
    print(f"In outer(): x = '{x}'")  # Uses enclosing

def demonstrate_builtin():
    # len is a built-in function
    numbers = [1, 2, 3, 4, 5]
    print(f"len({numbers}) = {len(numbers)}")
    
    # We could shadow it (don't do this!)
    # len = 10  # This would break len()!

if __name__ == "__main__":
    main()

Local → Enclosing → Global → Built-in. First match wins.

LEGB Name resolution order: Local, Enclosing, Global, Built-in.

The global keyword

Modify a global variable from inside a function.

global_keyword.py
def main():
    print("=== The 'global' Keyword ===\n")
    
    print(f"Initial counter: {counter}")
    
    # This works - reading global
    show_counter()
    
    # This modifies global
    increment_counter()
    increment_counter()
    increment_counter()
    
    print(f"\nFinal counter: {counter}")
    
    print("\n=== Without vs With 'global' ===")
    compare_approaches()
    
    print("\n=== Why Global is Often Bad ===")
    demonstrate_problem()

counter = 0  # Global variable

def show_counter():
    # Reading global - no keyword needed
    print(f"Counter is: {counter}")

def increment_counter():
    global counter  # Required to MODIFY global
    old = counter
    counter += 1
    print(f"Incremented: {old} → {counter}")

def compare_approaches():
    # Without global - creates local!
    def broken_increment():
        # counter = counter + 1  # Error! Or creates local
        local_counter = 0
        local_counter += 1
        print(f"Local only: {local_counter}")
    
    broken_increment()
    print(f"Global unchanged: {counter}")

total = 0

def add_to_total(value):
    global total
    total += value

def demonstrate_problem():
    global total
    total = 0
    
    add_to_total(10)
    add_to_total(20)
    print(f"Total: {total}")
    
    print("\nProblem: Hard to track who modifies 'total'")
    print("Better: Return values instead of modifying globals")

if __name__ == "__main__":
    main()

Without global, assignment creates a local variable instead.

global Declare `global x` to modify module-level variable from inside a function.

The nonlocal keyword

Modify an enclosing function's variable.

nonlocal_keyword.py
def main():
    print("=== The 'nonlocal' Keyword ===\n")
    
    counter_example()
    
    print("\n=== Accumulator Example ===")
    accumulator_example()
    
    print("\n=== Without nonlocal ===")
    without_nonlocal()

def counter_example():
    print("--- Counter with Closure ---")
    
    count = 0  # Enclosing scope variable
    
    def increment():
        nonlocal count  # Modify enclosing, not create local
        count += 1
        print(f"Count: {count}")
    
    def decrement():
        nonlocal count
        count -= 1
        print(f"Count: {count}")
    
    increment()
    increment()
    increment()
    decrement()
    
    print(f"Final count: {count}")

def accumulator_example():
    print("--- Running Total ---")
    
    total = 0
    
    def add(value):
        nonlocal total
        total += value
        return total
    
    print(f"Add 10: {add(10)}")
    print(f"Add 5: {add(5)}")
    print(f"Add 3: {add(3)}")
    print(f"Add 7: {add(7)}")

def without_nonlocal():
    print("--- Without nonlocal (fails) ---")
    
    message = "original"
    
    def try_modify():
        # Without nonlocal, this creates a LOCAL variable
        message = "modified"  # New local, doesn't touch outer!
        print(f"Inside: {message}")
    
    try_modify()
    print(f"Outside: {message}")  # Still "original"!

if __name__ == "__main__":
    main()

nonlocal reaches into the enclosing function's scope.

nonlocal Declare `nonlocal x` to modify variable from enclosing (not global) scope.

Closures remember scope

Functions can capture variables from their enclosing scope.

closure.py
def main():
    print("=== Closures ===\n")
    print("A closure 'remembers' variables from enclosing scope")
    print("even after the outer function has returned.\n")
    
    # Create closures
    counter_a = make_counter()
    counter_b = make_counter()
    
    print("Two independent counters:")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_b(): {counter_b()}")  # Independent!
    print(f"counter_b(): {counter_b()}")
    
    print("\n=== Multiplier Factory ===")
    double_factor = 
    triple_factor = 
    double = make_multiplier(double_factor)
    triple = make_multiplier(triple_factor)
    
    print(f"times {double_factor}(5) = {double(5)}")
    print(f"times {triple_factor}(5) = {triple(5)}")
    print(f"times {double_factor}(10) = {double(10)}")
    
    print("\n=== Practical: Logger ===")
    info_log = make_logger("INFO")
    error_log = make_logger("ERROR")
    
    info_log("Application started")
    info_log("Loading config")
    error_log("Connection failed!")

def make_counter():
    count = 0  # Captured by inner function
    
    def counter():
        nonlocal count
        count += 1
        return count
    
    return counter  # Return the inner function

def make_multiplier(factor):
    # 'factor' is captured by inner function
    def multiply(x):
        return x * factor  # Uses captured factor
    
    return multiply

def make_logger(level):
    """Factory for creating loggers with preset level."""
    def log(message):
        print(f"[{level}] {message}")
    return log

if __name__ == "__main__":
    main()
def main():
    print("=== Closures ===\n")
    print("A closure 'remembers' variables from enclosing scope")
    print("even after the outer function has returned.\n")
    
    # Create closures
    counter_a = make_counter()
    counter_b = make_counter()
    
    print("Two independent counters:")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_b(): {counter_b()}")  # Independent!
    print(f"counter_b(): {counter_b()}")
    
    print("\n=== Multiplier Factory ===")
    double_factor = 
    triple_factor = 
    double = make_multiplier(double_factor)
    triple = make_multiplier(triple_factor)
    
    print(f"times {double_factor}(5) = {double(5)}")
    print(f"times {triple_factor}(5) = {triple(5)}")
    print(f"times {double_factor}(10) = {double(10)}")
    
    print("\n=== Practical: Logger ===")
    info_log = make_logger("INFO")
    error_log = make_logger("ERROR")
    
    info_log("Application started")
    info_log("Loading config")
    error_log("Connection failed!")

def make_counter():
    count = 0  # Captured by inner function
    
    def counter():
        nonlocal count
        count += 1
        return count
    
    return counter  # Return the inner function

def make_multiplier(factor):
    # 'factor' is captured by inner function
    def multiply(x):
        return x * factor  # Uses captured factor
    
    return multiply

def make_logger(level):
    """Factory for creating loggers with preset level."""
    def log(message):
        print(f"[{level}] {message}")
    return log

if __name__ == "__main__":
    main()
def main():
    print("=== Closures ===\n")
    print("A closure 'remembers' variables from enclosing scope")
    print("even after the outer function has returned.\n")
    
    # Create closures
    counter_a = make_counter()
    counter_b = make_counter()
    
    print("Two independent counters:")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_b(): {counter_b()}")  # Independent!
    print(f"counter_b(): {counter_b()}")
    
    print("\n=== Multiplier Factory ===")
    double_factor = 
    triple_factor = 
    double = make_multiplier(double_factor)
    triple = make_multiplier(triple_factor)
    
    print(f"times {double_factor}(5) = {double(5)}")
    print(f"times {triple_factor}(5) = {triple(5)}")
    print(f"times {double_factor}(10) = {double(10)}")
    
    print("\n=== Practical: Logger ===")
    info_log = make_logger("INFO")
    error_log = make_logger("ERROR")
    
    info_log("Application started")
    info_log("Loading config")
    error_log("Connection failed!")

def make_counter():
    count = 0  # Captured by inner function
    
    def counter():
        nonlocal count
        count += 1
        return count
    
    return counter  # Return the inner function

def make_multiplier(factor):
    # 'factor' is captured by inner function
    def multiply(x):
        return x * factor  # Uses captured factor
    
    return multiply

def make_logger(level):
    """Factory for creating loggers with preset level."""
    def log(message):
        print(f"[{level}] {message}")
    return log

if __name__ == "__main__":
    main()
def main():
    print("=== Closures ===\n")
    print("A closure 'remembers' variables from enclosing scope")
    print("even after the outer function has returned.\n")
    
    # Create closures
    counter_a = make_counter()
    counter_b = make_counter()
    
    print("Two independent counters:")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_a(): {counter_a()}")
    print(f"counter_b(): {counter_b()}")  # Independent!
    print(f"counter_b(): {counter_b()}")
    
    print("\n=== Multiplier Factory ===")
    double_factor = 
    triple_factor = 
    double = make_multiplier(double_factor)
    triple = make_multiplier(triple_factor)
    
    print(f"times {double_factor}(5) = {double(5)}")
    print(f"times {triple_factor}(5) = {triple(5)}")
    print(f"times {double_factor}(10) = {double(10)}")
    
    print("\n=== Practical: Logger ===")
    info_log = make_logger("INFO")
    error_log = make_logger("ERROR")
    
    info_log("Application started")
    info_log("Loading config")
    error_log("Connection failed!")

def make_counter():
    count = 0  # Captured by inner function
    
    def counter():
        nonlocal count
        count += 1
        return count
    
    return counter  # Return the inner function

def make_multiplier(factor):
    # 'factor' is captured by inner function
    def multiply(x):
        return x * factor  # Uses captured factor
    
    return multiply

def make_logger(level):
    """Factory for creating loggers with preset level."""
    def log(message):
        print(f"[{level}] {message}")
    return log

if __name__ == "__main__":
    main()

A closure "remembers" variables from where it was defined, not where it's called.

closure Function that captures variables from enclosing scope. Remembers them for later.

Exercise: scope_gotchas.py

Explore common scope-related mistakes and how to avoid them