🎯 Learning Objectives
- Understand what scope and namespaces are in Python
- Apply the LEGB rule (Local, Enclosing, Global, Built-in)
- Use the
globalandnonlocalkeywords correctly - Understand closures and how they capture variables
- Avoid common scoping pitfalls and write clean, predictable code
What is Scope?
Scope determines where in your code a variable is accessible. A variable's scope is defined by where it is created — Python doesn't use explicit declarations, so the location of assignment determines visibility.
x = "global" # module-level → global scope
def my_func():
y = "local" # inside a function → local scope
print(x) # can READ global x
print(y) # can access local y
my_func()
print(x) # "global" — still accessible
# print(y) # NameError — y doesn't exist here
scope_basics.py
The LEGB Rule
When Python encounters a name, it searches for it in this order:
| Level | Name | Where |
|---|---|---|
| L | Local | Inside the current function |
| E | Enclosing | Inside any enclosing (outer) functions |
| G | Global | Module-level (top of the file) |
| B | Built-in | Python's built-in names (print, len, etc.) |
Python checks L → E → G → B in order and uses the first match.
# B — Built-in scope
# print, len, int, etc. live here
# G — Global scope
x = "global"
def outer():
# E — Enclosing scope (for inner())
x = "enclosing"
def inner():
# L — Local scope
x = "local"
print(x) # "local" — found in L first
inner()
print(x) # "enclosing" — inner's x didn't affect this
outer()
print(x) # "global" — neither function changed it
legb.py
global or nonlocal.
Local Scope
Variables created inside a function are local — they exist only during that function call and are inaccessible outside:
def calculate():
result = 42 # local
temp = result * 2 # local
return temp
value = calculate()
print(value) # 84
# print(result) # NameError: name 'result' is not defined
# Each call gets its own local namespace
def counter():
count = 0
count += 1
return count
print(counter()) # 1
print(counter()) # 1 — local count is re-created each time
local_scope.py
Global Scope & the global Keyword
Variables defined at the module level (outside any function) live in the global scope.
Functions can read globals freely, but to modify them you need
the global keyword:
counter = 0 # global
def increment():
global counter # declare intent to modify the global
counter += 1
increment()
increment()
print(counter) # 2
# Without 'global', assignment creates a LOCAL variable:
score = 100
def reset_score():
score = 0 # This creates a NEW local 'score' — doesn't touch global!
print(f"Inside: {score}") # 0
reset_score()
print(f"Outside: {score}") # 100 — global unchanged!
global_keyword.py
global whenever possible. Global mutable state
makes code harder to test, debug, and reason about. Prefer passing values as
parameters and returning results. Use global only as a last resort.
The UnboundLocalError trap
x = 10
def broken():
print(x) # UnboundLocalError!
x = 20 # This assignment makes x LOCAL for the entire function
# Python sees the assignment x = 20 and marks x as local
# for the WHOLE function — so print(x) on the line above
# tries to read a local that hasn't been assigned yet.
def fixed():
global x
print(x) # 10 — reads the global
x = 20 # modifies the global
unbound_local.py
The nonlocal Keyword
nonlocal lets a nested function modify a variable in its
enclosing (not global) scope:
def make_counter():
count = 0
def increment():
nonlocal count # modify enclosing scope's count
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3
# Without 'nonlocal', assignment would create a new local
def make_counter_broken():
count = 0
def increment():
count += 1 # UnboundLocalError!
return count
return increment
nonlocal_keyword.py
global targets the module-level variable.
nonlocal targets the nearest enclosing function's variable.
You cannot use nonlocal for module-level variables.
Closures
A closure is a function that remembers variables from its enclosing scope, even after that outer function has finished executing:
def make_multiplier(factor):
# 'factor' lives in make_multiplier's local scope
def multiply(n):
return n * factor # captures 'factor' from enclosing scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
# The closure remembers 'factor' even though make_multiplier has returned
print(double.__closure__[0].cell_contents) # 2
closures.py
Common closure pattern: configuration
def make_logger(prefix):
def log(message):
print(f"[{prefix}] {message}")
return log
info = make_logger("INFO")
error = make_logger("ERROR")
info("Server started") # [INFO] Server started
error("Disk full") # [ERROR] Disk full
closure_config.py
Inspecting Namespaces
# globals() — returns the global namespace dict
x = 42
print("x" in globals()) # True
# locals() — returns the current local namespace dict
def show_locals(a, b):
c = a + b
print(locals()) # {'a': 1, 'b': 2, 'c': 3}
show_locals(1, 2)
# dir() — list names in current scope (or an object's attributes)
import math
print(dir(math)) # ['acos', 'asin', 'atan', ...]
# vars() — same as locals() in a function, or __dict__ of an object
print(vars(math)["pi"]) # 3.141592653589793
namespaces.py
Scope in Loops & Comprehensions
# Loop variables LEAK into the enclosing scope!
for i in range(5):
pass
print(i) # 4 — i still exists after the loop
# This is different from languages like C or Java
# where loop variables are scoped to the loop.
# Comprehension variables do NOT leak (Python 3+)
squares = [x ** 2 for x in range(5)]
# print(x) # NameError — x is scoped to the comprehension
# Common gotcha with closures in loops
funcs = []
for i in range(3):
funcs.append(lambda: i) # all capture the SAME i
print([f() for f in funcs]) # [2, 2, 2] — not [0, 1, 2]!
# Fix: use default argument to capture current value
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # each gets its own snapshot
print([f() for f in funcs]) # [0, 1, 2] ✓
scope_loops.py
i is whatever it was at the end of the loop.
Best Practices
- Minimize global state. Pass data via parameters, return results. Pure functions are easier to test and debug.
- Avoid
globalexcept in small scripts. Use classes or module-level constants instead. - Keep functions small. If you need
nonlocal, consider whether a class or a different structure would be clearer. - Name shadowing: Avoid naming local variables the same as globals or built-ins (
list,dict,type,id). - Constants at module level are fine — they're "global" but immutable and clearly named in UPPER_SNAKE_CASE.
# ❌ Shadowing a built-in
list = [1, 2, 3] # now list() is broken!
# print(list("abc")) # TypeError!
# ✓ Use a descriptive name
items = [1, 2, 3]
# ❌ Relying on global mutation
total = 0
def add(n):
global total
total += n
# ✓ Pure function — no side effects
def add_to(current, n):
return current + n
best_practices.py
Primary sources: Python Docs — Scopes and Namespaces · Python Docs — Naming and Binding
Ask your AI tutor! Confused about why a variable gives
UnboundLocalError? Not sure when to use nonlocal vs
global? Scope issues cause subtle bugs — ask to get them cleared up.
💻 Exercises
Without running the code, predict what each print() outputs.
Then verify by running it:
x = "global"
def outer():
x = "outer"
def inner():
x = "inner"
print("A:", x)
inner()
print("B:", x)
outer()
print("C:", x)
Show solution
# A: inner — inner()'s local x
# B: outer — outer()'s local x (inner didn't change it)
# C: global — module-level x (neither function changed it)
Write a function make_counter(start=0) that returns a dictionary
with two functions: "increment" and "get".
increment() adds 1 to an internal count.
get() returns the current count.
Use nonlocal.
Show solution
def make_counter(start=0):
count = start
def increment():
nonlocal count
count += 1
def get():
return count
return {"increment": increment, "get": get}
c = make_counter(10)
c["increment"]()
c["increment"]()
c["increment"]()
print(c["get"]()) # 13
The following code is supposed to create a list of functions that return 0, 1, 2, 3, 4 respectively. But it's broken — all return 4. Fix it.
funcs = []
for i in range(5):
funcs.append(lambda: i)
# Expected: [0, 1, 2, 3, 4]
# Actual: [4, 4, 4, 4, 4]
Show solution
# Fix 1: default argument captures current value
funcs = []
for i in range(5):
funcs.append(lambda i=i: i)
print([f() for f in funcs]) # [0, 1, 2, 3, 4]
# Fix 2: use a factory function
def make_func(n):
return lambda: n
funcs = [make_func(i) for i in range(5)]
print([f() for f in funcs]) # [0, 1, 2, 3, 4]