Python Mastery: Complete Beginner to Professional
HomeInsightsCoursesPythonScope & The LEGB Rule

Python Scope & The LEGB Rule

Understanding how Python finds, modifies, and hides variables.

Introduction: Where does a variable live?

In many programming languages (like C++ or Java), variables are strictly scoped to the block {} they are defined in. Python is different. It uses a hierarchy of four scopes to determine what a name means.

Crucial Concept: Scope in Python is determined at compile time (Lexical Scoping). When Python compiles your function to bytecode, it decides then and there if a variable is local or global.

1. The LEGB Hierarchy

L: Local Scope

This is the innermost scope. It contains names defined inside the current function. This scope is created when the function is called and destroyed when it returns.

PYTHON
def my_func():
    x = 10 # Local variable
    print(x)

my_func()
# print(x) # ❌ NameError: name 'x' is not defined

E: Enclosing Scope

This applies to nested functions. If the inner function asks for `x`, and it's not local, Python looks at the outer (enclosing) function.

PYTHON
def outer():
    x = "I am outside"
    
    def inner():
        # Python looks in Local (no x) -> Enclosing (found x!)
        print(x) 
    
    inner()

outer() # Output: "I am outside"

G: Global Scope

This is the top-level scope of the script/module. Variables defined here are visible everywhere (unless shadowed).

PYTHON
x = "I am global"

def show_global():
    print(x)

show_global() # Output: "I am global"

B: Built-in Scope

This is the special system scope that contains keywords like `len`, `print`, `range`, and `str`. It is the last place Python looks.

PYTHON
# We never defined 'len', but Python finds it in Built-ins
print(len([1, 2, 3])) # Output: 3

2. The "UnboundLocalError" Mystery

The Trap: This is the most common scope error beginners face. "I can read the global variable, but why does my code crash when I try to change it?"

PYTHON
x = 10

def crash_me():
    print(x) # We expect this to print 10...
    x = x + 1 # But because we assign to 'x' here...
    
    # 💥 UnboundLocalError: local variable 'x' referenced before assignment

Why does this happen?

Remember: Scope is decided at compile time.When Python sees `x = ...` anywhere inside the function, it tags `x` as a Local Variable for the entire function.

So line 1 (`print(x)`) tries to print the local `x`, but the local `x` hasn't been assigned a value yet! It does not fall back to the global `x` because the compiler has already decided "`x` is local here".

2. Modifying Scope: global and nonlocal

Reading variables from outer scopes is easy. Writing to them requires specific keywords. If you try to assign a value to a global variable inside a function without permission, Python will just create a new local variable instead.

The `global` Keyword

PYTHON
score = 0

def update_score():
    # We want to modify the global 'score'
    global score
    score += 10 # ✅ This works now

update_score()
print(score) # 10

The `nonlocal` Keyword

Used in nested functions (Closures) to modify a variable in the Enclosing scope.

PYTHON
def counter_factory():
    count = 0 # Enclosing variable
    
    def increment():
        nonlocal count
        count += 1
        return count
    
    return increment

my_counter = counter_factory()
print(my_counter()) # 1
print(my_counter()) # 2

3. The Danger of Shadowing

Shadowing occurs when you create a variable with the same name as a variable in an outer scope. The most dangerous version is shadowing Built-in functions.

PYTHON
# ❌ BAD PRACTICE
list = [1, 2, 3] # You just obliterated the 'list()' constructor!

# Later in the code...
my_data = (10, 20)
# clean_data = list(my_data)
# 💥 TypeError: 'list' object is not callable

# You have to restart your kernel to fix this!

Common victims: `list`, `dict`, `str`, `sum`, `id`, `min`, `max`. Always use distinct names like `my_list` or `total_sum`.

4. The Loop Scope "gotcha"

In languages like C++ or Java (and modern JS with `let`), variables declared inside a loop block {} stay inside that block.In Python, loops do NOT create a new scope.

PYTHON
for i in range(5):
    pass

print(i) # Output: 4 (Wait, what?)

# The variable 'i' LEAKS out into the surrounding function!
# This is a feature, not a bug, but it catches beginners off guard.

5. List Comprehensions: The Exception

In Python 2, list comprehensions leaked variables just like for loops. In Python 3, they were fixed. They now have their own private scope.

PYTHON
x = 100

# Using a variable 'x' inside the comprehension
squares = [x*x for x in range(5)]

print(x) 
# Output: 100 (Unchanged!)
# The 'x' inside the loop was temporary and isolated.

6. Mutability in Scope

A confusing concept is how scope handles mutable objects (lists) vs immutable ones (integers).

PYTHON
def confusing(my_list, my_int):
    # Assignment (=) changes the LOCAL reference
    my_int = 999 
    
    # Method calls (.) modify the OBJECT itself (Heap)
    my_list.append(999)

numbers = [1, 2]
n = 10

confusing(numbers, n)

print(n)       # 10 (Protected)
print(numbers) # [1, 2, 999] (Modified!)

7. Performance: Inside the Bytecode

Why did we say Local variables are faster? Let's use the `dis` (disassembler) module to see what Python is actually doing.

PYTHON
import dis

global_var = 10

def pure_local():
    local_var = 10
    return local_var

def pure_global():
    return global_var

print("--- Local ---")
dis.dis(pure_local)

print("--- Global ---")
dis.dis(pure_global)

If you ran this, you would see:

  • Local: Uses `LOAD_FAST`. This is an array lookup (O(1) and instant).
  • Global: Uses `LOAD_GLOBAL`. This is a dictionary hash lookup (slower).

Optimization Tip: In tight loops (like image processing), assign global functions to local pointers (e.g., `local_len = len`) to get that `LOAD_FAST` speed boost.

8. Introspection Tools: Seeing the Invisible

Python provides tools to "see" the current scope dictionaries.

globals() vs locals()

`globals()` returns the actual dictionary of the module. If you change it, the variable changes. `locals()` in a function, however, returns a copy of the local namespace.

PYTHON
x = 10

def inspect_me():
    y = 20
    # Trying to hack local variables via dictionary
    locals()['y'] = 999 
    print(y) # Output: 20 (Unchanged!)
    
    # Trying to hack global variables
    globals()['x'] = 999
    print(x) # Output: 999 (Changed!)

Warning: Never modify `locals()` directly. It usually doesn't work and is largely undefined behavior.

9. Deep Dive: The Class Body Scope Trap

Here is a question that fails 90% of senior Python interviews. Scope behaves normally inside methods, but inside the class body itself, it behaves strangely.

PYTHON
class WeirdScope:
    base = 10
    
    # 1. This works (Accessing 'base' in assignment)
    doubled = base * 2 
    
    # 2. This FAILS in Python 3 (List Comprehension)
    # The comprehension has its own scope and can't see 'base'!
    # items = [base + i for i in range(5)] 
    # 💥 NameError: name 'base' is not defined

    # 3. But ANY normal function (def) also can't see 'base' directly?
    def show(self):
        # print(base) # ❌ NameError
        print(self.base) # ✅ Must use 'self' or 'WeirdScope'

10. Advanced: Sandboxing with eval()

The dangerous `eval()` function takes a string of code and runs it. But did you know you can control its scope? You can pass custom dictionaries for `globals` and `locals` to create a "Sandbox".

PYTHON
code = "x + y"

# A completely empty scope
safe_globals = {"__builtins__": None}
safe_locals = {"x": 5, "y": 10}

result = eval(code, safe_globals, safe_locals)
print(result) # 15

# If the user tries to hack...
# eval("__import__('os').system('rm -rf /')", safe_globals, safe_locals)
# 💥 TypeError: 'NoneType' object is not subscriptable (Import blocked!)

11. Closures: State without Classes

A Closure is a function that remembers values from its enclosing scope even after the outer function has finished executing. This allows you to hide state in a function's "backpack".

Under the Hood: The `__closure__` Attribute

How does Python remember `exponent`? It stores it in a special tuple called `__closure__`. Each item in this tuple is a "Cell" object that points to the value.

PYTHON
def power_factory(exponent):
    def power(base):
        return base ** exponent
    return power

square = power_factory(2)

# Inspecting the backpack
print(square.__closure__) 
# (<cell at 0x...: int object at 0x...>,)

print(square.__closure__[0].cell_contents) 
# 2 (There it is!)

This is the functional alternative to Object-Oriented Programming (Classes).

12. Language Face-off: Python vs JavaScript

If you are coming from JavaScript, Python's scope rules can be jarring.

  • JavaScript: `let` and `const` have Block Scope. If you define a variable inside an `if` block, it dies at the closing }.
  • Python: Has Function Scope. Variables defined inside an `if` or `for` block LEAK out to the entire function.
PYTHON
# Python
if True:
    x = 10
print(x) # 10 (Alive!)
JAVASCRIPT
// JavaScript
if (true) {
    let x = 10;
}
console.log(x); // Error! (Dead)

13. Theory: Lexical vs Dynamic Scope

You might hear that Python is a Lexically Scoped (or Statically Scoped) language. This means the scope consists of the physical text structure of your program.

Thought Experiment: Imagine Python was Dynamically Scoped.

PYTHON
x = 10

def print_x():
    print(x)

def runner():
    # In Dynamic Scope, print_x would look HERE for 'x' because runner called it.
    x = 999 
    print_x()

runner()

# Python (Lexical): Prints 10 (Looks at GLOBAL definition)
# Bash/Lisp (Dynamic): Would print 999 (Looks at CALLER definition)

Lexical scope is preferred in modern CS because it makes code predictable. You don't need to know who called your function to know what variables it sees.

14. Pruning Scope: The `del` Keyword

You can remove a name from the current scope using `del`. This doesn't necessarily delete the object from memory (garbage collector handles that), but it removes the label.

PYTHON
x = "secret"
del x

# print(x) 
# 💥 NameError: name 'x' is not defined

This is useful in Jupyter Notebooks to free up RAM if you are holding onto a massive Pandas DataFrame you no longer need.

15. Scope Across Files: Modules and Namespaces

So far, we've discussed scope inside a single file. But how does Python handle large projects? Every file in Python is a Module, and every module has its own Global Scope.

The `__init__.py` File

To turn a folder into a "Package" (a collection of modules), you traditionally put an `__init__.py` file in it. Variables defined in `__init__.py` become available at the package level scope.

PYTHON
# my_package/__init__.py
version = "1.0.0"

# main.py
import my_package
print(my_package.version) # "1.0.0"

16. The `__main__` Scope

You have seen `if __name__ == "__main__":` a thousand times. But what does it mean for scope?

When you run a file directly (`python script.py`), its internal name is set to `"__main__"`. Variables defined here are in the Global Scope of the script.

Best Practice: Don't leave loose code in the global scope. Wrap your main logic in a `main()` function. Why? Because if another file imports your script, those "loose" global variables will run via side-effects!

17. Advanced: Thread Safety and Globals

Critical Warning: Global variables are not just bad style; they are dangerous in multi-threaded programs.

Even though Python has a Global Interpreter Lock (GIL), operations on global variables are often not atomic. Consider `x += 1`. This is actually three steps: Read `x`, Add 1, Write `x`. If two threads do this at the same time, they can overwrite each other, leading to "Race Conditions".

PYTHON
import threading

counter = 0

def increment():
    global counter
    for _ in range(100000):
        # This looks safe, but it isn't atomically safe!
        counter += 1 

# If you run this with 10 threads, 'counter' will mostly likely NOT be 1,000,000.

Solution: Always use `threading.Lock()` when modifying globals, or better yet, don't use globals at all.

18. The "Star Import" Pollution

You often see `from math import *`. This is called a "Wildcard Import". It is dangerous because it dumps every variable from that module into your current Global Scope.

PYTHON
from math import *
# ... 500 lines of code ...
tan = "My suntan level" 
# Oops! You just overwrote the tangent function from math!

# Later...
print(tan(45)) 
# 💥 TypeError: 'str' object is not callable

19. Scope vs Memory: Garbage Collection

Scope isn't just about visibility; it's about life and death. When a variable goes "out of scope" (e.g., a function returns), Python decreases its Reference Count.

When the Reference Count hits zero, the Garbage Collector (GC) deletes the object to free RAM.

PYTHON
class Life:
    def __del__(self):
        print("I am dying!")

def short_life():
    x = Life()
    print("Alive inside function")
    # Function ends here. Scope dies. 'x' dies.

short_life()
# Output:
# Alive inside function
# I am dying!

Summary: Best Practices Checklist

  • Minimize Globals: Global state makes code hard to test and debug. Pass arguments instead.
  • Use `nonlocal` sparingly: If you need deep state, write a Class instead of a Closure.
  • Avoid Shadowing: Never name variables `list`, `str`, or `id`.
  • Trust the Function: Functions should be self-contained units.

What's Next?

We will explore the dangers of "Shadowing" and scope leaks in loops.