🌱 Beginner

Scope & Namespaces

📖 Lesson 12 ⏱ 25 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Understand what scope and namespaces are in Python
  • Apply the LEGB rule (Local, Enclosing, Global, Built-in)
  • Use the global and nonlocal keywords 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
A namespace is a dictionary that maps names to objects. Scope is the textual region where a namespace is directly accessible. Every function call creates a new local namespace that is destroyed when the function returns.

The LEGB Rule

When Python encounters a name, it searches for it in this order:

LevelNameWhere
LLocalInside the current function
EEnclosingInside any enclosing (outer) functions
GGlobalModule-level (top of the file)
BBuilt-inPython'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
LEGB only applies to reading a variable. Assignment always creates or updates a variable in the local scope by default — unless you explicitly use 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
Function parameters are also local variables — they exist in the function's local namespace for the duration of the call.

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
Avoid 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
Closures are the mechanism behind decorators (Lesson 18), function factories, and callback patterns. Understanding them is key to intermediate/advanced Python.

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
The closure-in-a-loop gotcha is one of Python's most common traps. Closures capture variables (references), not values (snapshots). By the time the lambda runs, 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 global except 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
🤖

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

01 LEGB Prediction

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)
02 Counter Factory

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
03 Fix the Closure Bug

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]