Functions_Comprehensions_and

1

Scope and Variable Visibility

▶

Understanding how Python finds and resolves variable names is fundamental to writing correct, predictable functions. Python follows a strict four-level hierarchy called the LEGB rule, and two special keywords — global and nonlocal — let functions reach into outer scopes when necessary.

  • The LEGB Scope Rule
    • Local scope contains names assigned within the current function. This is the first place Python looks when a name is referenced inside a function body.
      def greet():
          message = "hello"   # local to greet()
          print(message)      # found in local scope
      
      greet()                 # hello
    • Enclosing scope covers variables in any outer function when functions are nested. Python walks outward through each enclosing function in order, from innermost to outermost.
      def outer():
          city = "Paris"          # enclosing variable
      
          def inner():
              print(city)         # found in enclosing scope
      
          inner()                 # Paris
    • Global scope holds names defined at the top level of the module. These names are visible throughout the entire file unless shadowed by a more local definition.
      language = "Python"         # global variable
      
      def show_language():
          print(language)         # found in global scope
      
      show_language()             # Python
    • Built-in scope contains Python's pre-defined names such as len, range, and print. This is the last place Python checks and is always available without any import.
      numbers = [3, 1, 4, 1, 5]
      print(len(numbers))         # len found in built-in scope
      print(max(numbers))         # max found in built-in scope
    • Name resolution stops at the first scope where the name is found. If a name is discovered in the local scope, Python never inspects the enclosing, global, or built-in scopes for that same lookup.
      len = "oops"                # shadows built-in len in global scope
      
      def check():
          len = 42                # shadows both global and built-in
          print(len)              # 42 — stopped at local scope
      
      check()
      del len                     # restore built-in access
  • Local Variables
    • Assignment inside a function creates a new local variable rather than modifying a global one. Even if a global variable with the same name exists, the assignment targets the local scope exclusively.
      colour = "red"              # global
      
      def paint():
          colour = "blue"         # new local variable, not the global
          print(colour)           # blue
      
      paint()
      print(colour)               # red — global unchanged
    • Local variables are created when the function is called and destroyed when it returns. They have no existence outside of a single function invocation, keeping memory usage efficient and state isolated.
      def calculate():
          result = 100 * 2        # exists only during this call
          return result
      
      print(calculate())          # 200
      # print(result)             # NameError — result does not exist here
    • Reading a local variable before assigning it raises an UnboundLocalError. This happens because Python determines at compile time that any name assigned anywhere in a function is local to that function, even if the assignment appears later in the code.
      total = 10
      
      def broken():
          print(total)            # UnboundLocalError!
          total = total + 1       # assignment makes total local throughout
      
      # broken()                  # uncommenting raises UnboundLocalError
    • Local scope provides encapsulation, preventing accidental modification of outer variables. Functions that rely only on local variables and their parameters are easier to test and reason about in isolation.
      def area(width, height):
          surface = width * height    # fully local, no side effects
          return surface
      
      print(area(5, 3))               # 15
  • The global Keyword
    • Declaring global variable_name inside a function binds that name to the module-level variable. Any subsequent assignment or mutation of that name then affects the global object directly.
      score = 0
      
      def add_points(n):
          global score
          score += n              # modifies the global score
      
      add_points(10)
      add_points(5)
      print(score)                # 15
    • Without global, assigning to a name inside a function always creates a local variable. This is a common source of confusion when a developer intends to update a global counter or flag but inadvertently creates a separate local binding instead.
      counter = 0
      
      def try_increment():
          counter = counter + 1   # UnboundLocalError — counter treated as local
          # fix: add  global counter  above this line
    • The global keyword should be used sparingly to avoid hidden side effects. Functions that silently alter global state are harder to debug, test, and reuse because their behaviour depends on external context.
      # Prefer returning values over mutating globals
      def increment(value):
          return value + 1        # no global state touched
      
      counter = 0
      counter = increment(counter)
      print(counter)              # 1
    • Accessing a global variable for reading does not require the global keyword. Python will naturally find the name in the global scope through normal LEGB lookup as long as no local assignment of the same name exists in the function.
      app_name = "MyApp"
      
      def show_banner():
          print(f"Welcome to {app_name}")   # reads global — no keyword needed
      
      show_banner()               # Welcome to MyApp
  • The nonlocal Keyword
    • nonlocal binds a name to the nearest enclosing scope that contains that variable. Python searches outward through nested function layers until it finds the first enclosing function where the name is defined.
      def outer():
          level = "outer"
      
          def inner():
              nonlocal level          # binds to outer()'s level
              level = "changed"
              print(level)            # changed
      
          inner()
          print(level)                # changed
      
      outer()
    • It enables nested functions to update, not just read, variables from an outer function. Without nonlocal, an assignment in the inner function would create a new local variable, leaving the outer function's variable unchanged.
      def outer():
          value = 10
      
          def without_nonlocal():
              value = 99              # new local — outer's value untouched
              print(value)            # 99
      
          without_nonlocal()
          print(value)                # 10 — unchanged
    • nonlocal does not reach all the way to the global scope; use global for that. The two keywords are distinct tools for distinct situations — nonlocal is strictly for enclosing function scopes.
      x = "global"
      
      def outer():
          x = "enclosing"
      
          def inner():
              nonlocal x              # reaches enclosing, NOT global
              x = "inner"
      
          inner()
          print(x)                    # inner
      
      outer()
      print(x)                        # global — untouched
    • This keyword is essential for implementing closures that maintain mutable state. A classic example is a counter factory where the inner function increments a count variable defined in the outer function, preserving the value across multiple calls to the inner function.
      def make_counter():
          count = 0
      
          def increment():
              nonlocal count
              count += 1
              return count
      
          return increment
      
      counter = make_counter()
      print(counter())            # 1
      print(counter())            # 2
      print(counter())            # 3
# LEGB and keyword demonstration

x = "global"

def outer():
    x = "enclosing"

    def inner():
        nonlocal x          # binds to outer()'s x
        x = "modified by inner"
        print(x)            # modified by inner

    inner()
    print(x)                # modified by inner

outer()
print(x)                    # global  (unchanged)

# global keyword example
count = 0

def increment():
    global count
    count += 1

increment()
increment()
print(count)                # 2
NotesExplains how Python resolves variable names using the LEGB rule and how the global and nonlocal keywords allow functions to modify variables in enclosing scopes. Slides: 1, 2, 3, 4.