Study guide

7.3 Variable Scopes, the LEGB Rule, and Closures

OpenEDG PCAP — Certified Associate in Python Programming Exam

Track your progress as you read

Finished sections get a check mark.

1 Chapter 1: The PCAP-31-03 Exam Blueprint and Test-Day Logistics

1.1 The PCAP-31-03 Blueprint, Scoring, and Exam-Day Logistics

2 Chapter 2: Modules, Packages, and the Standard Library

2.1 Module Imports and Namespace Mechanics 2.2 Module Construction and Execution 2.3 Package Hierarchy and Navigation 2.4 Standard Library Modules and Pip

3 Chapter 3: Exceptions and Error Handling

3.1 Exception Control Flow and Syntax 3.2 The Built-in Exception Hierarchy 3.3 Raising Exceptions and Assertions 3.4 Custom Exceptions and Exception Chaining

4 Chapter 4: Strings, Encodings, and Text Processing

4.1 Character Encodings and String Immutability 4.2 String Indexing, Slicing, and Comparison 4.3 String Inspection and Classification Methods 4.4 String Transformation and Formatting Methods 4.5 Iterating, Sorting, and Comparing Strings Against Other Types

5 Chapter 5: Object-Oriented Programming Fundamentals

5.1 Classes, Instances, and Attributes 5.2 Encapsulation, Privacy, and Name Mangling 5.3 Dunder Methods and Object Lifecycle 5.4 Discovering Class Structure: __name__, __module__, __bases__, and Identity

6 Chapter 6: Advanced OOP, Inheritance, and Metaprogramming

6.1 Single Inheritance, Method Overriding, and Polymorphism 6.2 Multiple Inheritance and Method Resolution Order (MRO) 6.3 Class Methods, Static Methods, and Properties

7 Chapter 7: Functional Python, Comprehensions, and Closures

7.1 List, Dictionary, and Set Comprehensions 7.2 Lambda Expressions and Higher-Order Functions 7.3 Variable Scopes, the LEGB Rule, and Closures

8 Chapter 8: Iterators, Generators, and File I/O

8.1 Iterators and the Iteration Protocol 8.2 Generator Functions and Generator Expressions 8.3 File Input/Output and Context Managers 8.4 Predefined Streams, Handles vs. Streams, and errno Diagnostics

7.3 Variable Scopes, the LEGB Rule, and Closures

Key Takeaways Explain This Topic AI

Python resolves identifier references sequentially using the LEGB rule: Local -> Enclosing -> Global -> Built-in.

Any assignment to an identifier within a function binds it to the local scope during compilation, raising an UnboundLocalError if referenced prior to assignment.

The global keyword rebinds module-level variables from local scope, while nonlocal rebinds identifiers in the nearest enclosing non-global function scope.

A closure occurs when an inner function retains access to free variables from its enclosing lexical scope even after the outer function has completed execution.

Closures capture variable names rather than value snapshots (late binding); freezing loop variables requires default parameter arguments (def f(x=i):).

Last updated: August 2026

Variable Scopes, the LEGB Rule, and Closures

In Python, the accessibility and lifetime of identifiers (variables, functions, classes) are governed by scoping rules and namespaces . When Python code references or modifies a variable, the interpreter uses a deterministic lookup hierarchy to locate the corresponding binding. Combining lexical scoping with Python's first-class functions gives rise to closures —a fundamental mechanism for data encapsulation and functional programming.

1. Namespace Hierarchy and the LEGB Resolution Rule

A scope is a textual region of a Python program where a namespace is directly accessible. When an identifier is referenced, Python searches up to four nested scopes in strict sequential order, known as the LEGB Rule : Local (L) ──▶ Enclosing (E) ──▶ Global (G) ──▶ Built-in (B)

The Four Scopes Defined

Local (L) : Identifiers defined inside the currently executing function, method, or lambda (including formal parameters and locally assigned variables). Local scopes are created upon function invocation and destroyed upon function exit.

Enclosing (E) : Identifiers in the local scope of any enclosing (outer) functions from inner to outer. This exists in nested function definitions.

Global (G) : Identifiers defined at the top level of the current module file, or identifiers explicitly declared with the global keyword inside a function.

Built-in (B) : Identifiers provided in Python's standard builtins module, loaded automatically upon interpreter startup (e.g., print , len , range , ValueError , int , open ). x = "GLOBAL" # Global Scope (G)

def outer():

x = "ENCLOSING" # Enclosing Scope (E) relative to inner()

def inner():

  • x = "LOCAL" # Local Scope (L)
  • print(x) # Resolves to LOCAL
  • inner()
  • print(x) # Resolves to ENCLOSING
  • outer()
  • print(x) # Resolves to GLOBAL

If an identifier is not found in any of the four LEGB scopes, Python raises a NameError .

2. Scope Mutation: The global and nonlocal Keywords

Reading a variable searches the LEGB hierarchy. However, assigning to a variable follows a completely different rule: by default, any assignment inside a function creates or rebinds a local variable in that function's namespace.

The Local Shadowing Trap and UnboundLocalError

When Python compiles a function, it scans the function body. If an identifier appears on the left-hand side of an assignment ( = , += , -= , etc.) anywhere in the function, Python marks that identifier as strictly local for the entire duration of the function. counter = 0 # Global variable

def increment():

# Attempting to read counter, then assign to it

counter = counter + 1 # Raises UnboundLocalError!

# Calling increment() fails:

# UnboundLocalError: local variable 'counter' referenced before assignment

Because counter = ... is present, Python designates counter as local. When the right-hand side counter + 1 is evaluated, the local counter has not yet been assigned a value, triggering an UnboundLocalError .

The global Keyword

To rebind a module-level global variable from within a local function scope, declare the identifier using global : counter = 0

def increment():

  • global counter # Instructs Python: bind 'counter' to the module global scope
  • counter += 1
  • increment()
  • increment()
  • print(counter) # 2 (Global variable successfully modified)
  • The nonlocal Keyword (Python 3)

The nonlocal statement causes the listed identifiers to refer to previously bound variables in the nearest enclosing function scope (excluding the global module scope): def outer():

count = 10

def inner():

  • nonlocal count # Rebinds 'count' in outer()'s scope
  • count += 5
  • print("Inner count:", count)
  • inner() # Inner count: 15
  • print("Outer count:", count) # Outer count: 15
  • outer()
  • Critical nonlocal Rules for the PCAP Exam

Enclosing Scope Requirement : The identifier specified in nonlocal x must exist in an outer function scope. If no matching identifier is found in any enclosing function scope, Python raises a compile-time SyntaxError: no binding for nonlocal 'x' found .

No Global Binding : nonlocal cannot bind to module-level global variables. If a variable exists only in the global scope, nonlocal will fail with a SyntaxError . global_val = 100

def bad_scope():

  • # SyntaxError: no binding for nonlocal 'global_val' found
  • nonlocal global_val
  • global_val += 1
  • Scope Modification Comparison
  • Keyword Target Scope Behavior on Missing Target Applicable In
  • (none) Local Creates a new local variable Functions, Lambdas
  • global Global (Module-level) Creates a new global variable if not present Functions, Lambdas
  • nonlocal Nearest Enclosing Function Raises SyntaxError if not found Nested Functions
  • 3. First-Class Functions and Higher-Order Patterns

In Python, functions are instances of types.FunctionType . They can be:

  • Assigned to variables: f = math.sqrt; print(f(25)) → \rightarrow → 5.0
  • Passed as arguments: map(func, seq)
  • Stored in data structures: ops = {'+': add, '-': subtract}

Returned from other functions: Function factories and closures def get_multiplier(factor):

def multiply(number):

  • return number * factor
  • return multiply # Returning the function object itself
  • 4. Lexical Closures and Cell Objects

A closure is a function object that remembers and retains access to values in enclosing lexical scopes even after the enclosing scope has finished execution and returned.

Anatomical Requirements for a Closure

For a closure to exist, three conditions must be met:

There must be a nested function (an inner function defined inside an outer function).

The inner function must reference at least one free variable (a variable defined in the outer enclosing function).

The outer function must return the inner function object. def make_greeter(greeting):

# 'greeting' is in the enclosing scope of 'greet'

def greet(name):

  • return f"{greeting}, {name}!"
  • return greet
  • # outer function completes execution and returns
  • say_hello = make_greeter("Hello")
  • say_hola = make_greeter("Hola")

print(say_hello("Alice")) # Hello, Alice!

print(say_hola("Bob")) # Hola, Bob!

Internal Closure Mechanics: __closure__ and Cell Objects

How does say_hello remember "Hello" after make_greeter() has finished execution and its stack frame has been destroyed?

Python allocates a special cell object on the heap for any variable referenced by an inner function. The returned function object stores a tuple of these cell objects in its __closure__ attribute: print(type(say_hello.__closure__)) # <class 'tuple'>

  • print(len(say_hello.__closure__)) # 1
  • cell = say_hello.__closure__[0]
  • print(cell.cell_contents) # 'Hello'

If a nested function does not reference any free variables from an enclosing scope, func.__closure__ is None .

5. The Late-Binding Loop Closure Trap and Fixes

One of the most frequently tested advanced scoping questions on the PCAP exam is the late-binding loop closure trap .

The Problem: Late Binding

In Python, closures bind to variable names , not the snapshot value of the variable at the moment the closure is created. The variable is looked up at the time the inner function is called (invoked) , not when it is defined . # TRAP: Creating closures in a loop

multipliers = []

for i in range(4):

multipliers.append(lambda x: x * i)

  • # When the loop finishes, i is 3
  • results = [m(2) for m in multipliers]
  • print(results) # [6, 6, 6, 6] (NOT [0, 2, 4, 6]!)

When m(2) executes, it looks up i in the enclosing scope. By the time any lambda is called, the loop has completed and i holds its final value: 3 . Every lambda multiplies by 3 ( 2 × 3 = 6 2 \times 3 = 6 2 × 3 = 6 ).

The Fix: Freezing Values with Default Parameter Arguments

Default parameter values in Python are evaluated at function definition time , not invocation time. By binding the loop variable to a default argument, each lambda freezes the current loop value in its own local parameter namespace: # SOLUTION: Freeze loop variable via default argument

fixed_multipliers = []

for i in range(4):

fixed_multipliers.append(lambda x, step=i: x * step)

  • results = [m(2) for m in fixed_multipliers]
  • print(results) # [0, 2, 4, 6] (Correct!)

During each loop iteration, step=i captures a snapshot of the current value of i ( 0 , 1 , 2 , 3 0, 1, 2, 3 0 , 1 , 2 , 3 ) as the default argument for that specific function instance.

Loading diagram... Python LEGB Scope Hierarchy and Closure Cell Architecture