Dataslope logoDataslope

Scope

How Python looks up names (LEGB) and the global / nonlocal keywords

When you write print(x), Python has to find the binding for x. It does so by walking through four nested namespaces in order, known by the acronym LEGB:

LetterScope
LLocal (inside the current function)
EEnclosing (any enclosing function for nested defs)
GGlobal (the module level)
BBuilt-in (len, print, etc.)

The first name found wins. If nothing matches, Python raises NameError.

Why scope matters: real-world impact

Understanding scope is critical because:

  • Globals are dangerous — They make testing hard (every test must reset them), concurrency unsafe (shared mutable state), and debugging a nightmare (who changed this value?).
  • Closures are everywhere — Every decorator, callback, or factory function uses closures. Understanding them is non-negotiable for intermediate Python.
  • Shadowing builtins breaks things — Assigning list = [1, 2, 3] in one function can silently break code that calls list() in the same scope.

The LEGB rule is simple, but its implications are profound.

Local, then global

Code Block
Python 3.13.2

The local x shadows the global one. Assigning to x inside the function created a new local binding; the global x was never touched.

Shadowing

Shadowing means a local name hides a name in an outer scope. It is not an error; it is how scope works. But it can be confusing. Use distinct names when possible.

Reading vs writing

This is the key gotcha: reading a name searches LEGB, but assigning a name creates a local by default.

Code Block
Python 3.13.2

Why? Python sees counter += and decides counter is a local variable. Then it tries to read the current value of counter (for the += 1 part), but there is no local counter yet. Hence UnboundLocalError.

To rebind the global from inside a function, use the global keyword:

Code Block
Python 3.13.2

Globals are a code smell

In practice, prefer returning new values over mutating globals. It keeps functions pure and testable. A function that touches a global variable is hard to test in isolation (you have to reset the global between tests) and impossible to run in parallel.

Enclosing scope and nonlocal

When you nest functions, the inner one can read names from the outer one (closure). To rebind one, use nonlocal:

Code Block
Python 3.13.2

Each call to make_counter() returns a fresh step with its own private count. This is the basis of decorators, factory functions, and many Pythonic patterns.

Closures vs classes

A closure is a lightweight alternative to a class when you need "a function with private state." For simple cases (like a counter), a closure is cleaner. For complex state with multiple methods, use a class.

Built-ins

The built-in scope contains everything in the builtins module. It is why print, len, range, and friends just work everywhere.

Code Block
Python 3.13.2

You can shadow a built-in by accident:

Code Block
Python 3.13.2

A linter will warn about this; pay attention. Shadowing list, dict, id, type, input, or any other built-in is a common mistake that causes confusing errors.

Never shadow builtins

Assigning to list, dict, str, max, sum, etc. will break any code in that scope that expects the built-in. Always use a different name: items, data, values, etc.

Closures: functions that remember

A closure is a function that remembers variables from its enclosing scope, even after that scope has finished executing.

Code Block
Python 3.13.2

When multiplier(2) finishes, factor=2 doesn't disappear. It is captured by inner, which continues to reference it. Each call to multiplier creates a new factor binding, so double and triple are independent.

Why closures matter

Decorators are closures:

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)   # 'func' is captured
        print(f"{func.__name__} took {time.time() - start:.2f}s")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(1)

Callbacks are closures:

def button_click_handler(user_id):
    def on_click():
        print(f"Button clicked by user {user_id}")  # 'user_id' is captured
    return on_click

If you don't understand closures, you can't understand decorators. And decorators are everywhere in modern Python (Flask routes, pytest fixtures, dataclass, property, etc.).

The late-binding closure gotcha

This is a classic interview question:

Code Block
Python 3.13.2

Why? The lambda captures i by reference, not by value. When the loop finishes, i=2, so all three lambdas add 2.

Late-binding closure gotcha

Closures capture variables by reference, not by value. If the variable changes after the closure is created, the closure sees the new value. This is especially tricky in loops.

Fix: capture the current value explicitly:

Code Block
Python 3.13.2

Real-world: why avoid globals

Imagine a web server with a global request counter:

request_count = 0

def handle_request():
    global request_count
    request_count += 1
    # ... process request

Problems:

  1. Testing is hard — Every test must reset request_count, or tests interfere with each other.
  2. Concurrency is broken — Two threads calling handle_request() at the same time can corrupt request_count (race condition).
  3. Debugging is a nightmare — Any function anywhere can modify request_count. Who changed it?

Better:

class RequestHandler:
    def __init__(self):
        self.count = 0

    def handle(self):
        self.count += 1
        # ... process request

Now each RequestHandler instance has its own count, tests are isolated, and concurrency is easier to reason about.

Challenges

Challenge
Python 3.13.2
Build a counter factory

Define a function make_counter(start=0, step=1) that returns a function next_value which, each time it is called, returns the current count and then advances by step. The first call returns start.

Use a closure with nonlocal; do not use globals.

Challenge
Python 3.13.2
Fix the late-binding closure bug

The function below has a late-binding closure bug: all returned functions add the same value (the final loop value). Fix it by capturing the loop variable's current value in a default argument.

Challenge
Python 3.13.2
Build a simple closure counter

Define a function counter() that returns two functions: increment() which increases an internal count by 1, and get() which returns the current count. Both should close over a shared count variable initialized to 0.

Return them as a tuple: (increment, get).

Multiple choice questions

QuestionSelect one

Which order does Python use to resolve a name?

Global, Local, Enclosing, Built-in

Built-in, Enclosing, Local, Global

Local, Enclosing, Global, Built-in (LEGB)

Local, Global only

QuestionSelect one

What does this print?

x = 1
def outer():
  x = 2
  def inner():
      nonlocal x
      x = 3
  inner()
  print("outer:", x)
outer()
print("module:", x)

outer: 2\nmodule: 3

outer: 3\nmodule: 1

outer: 3\nmodule: 3

A SyntaxError

QuestionSelect one

Why does this raise UnboundLocalError?

count = 0
def increment():
  count += 1
increment()

Because count is a reserved keyword.

Because count += 1 creates a local variable, which is read before it's assigned.

Because global variables are read-only.

Because += is not allowed inside functions.

QuestionSelect one

What is a closure?

A function that is defined inside a class.

A function that captures variables from its enclosing scope.

A function that takes another function as an argument.

A function that has no return value.

QuestionSelect one

How do you rebind a variable from an enclosing function scope (not global)?

Use the global keyword.

Use the nonlocal keyword.

Use the enclosing keyword.

You cannot; enclosing variables are read-only.

Now that scope is clear, we can split code across files: modules.

On this page