PYTH-102 · Lesson 2 of 12 · 24 min

Scope and closures

▶ Video coming soon A video version of this lesson is on the way.

Every name in a Python program lives somewhere. When Jordan writes stock = 42 at the top of a script, or price = 18.50 inside a function, Python records that name in a particular namespace, and the rules for finding names decide which value an expression like stock * price actually uses. In small scripts this rarely matters. In a program with dozens of functions, it explains why a function can read a constant but not change a counter, why two functions can both use a variable called total without interfering, and how a function can remember a value between calls without any global variables at all.

This lesson covers namespaces and the LEGB rule that Python uses to look up names, the global and nonlocal statements and why they should be rare, nested functions, closures that capture values from an enclosing function, and function factories that build customized functions on demand. It ends with decorators, a widely used Python feature that is built entirely from closures.

Namespaces

A namespace is a mapping from names to objects, much like a dictionary whose keys are variable names. Python keeps several at once. The built-in namespace holds names such as print, len and ValueError, available everywhere. Each module has a global namespace for the names defined at its top level. And every call of a function creates a fresh local namespace for its parameters and the variables it assigns, which is thrown away when the call returns. The same name can exist in several namespaces at once, referring to different objects.

A local name can hide a global one without changing it
bags = 42                     # global name

def restock(added):
    bags = added + 5          # a new local name, unrelated to the global
    print("inside:", bags)
    return bags

restock(10)
print("outside:", bags)
print("restock" in globals(), "added" in globals())
Output
inside: 15
outside: 42
True False

Assigning bags inside restock did not touch the global bags. Python decided, when it compiled the function, that bags is local to restock because the function assigns to it. The global is shadowed, hidden behind the local name, for the duration of the call. globals() returns the module's namespace as a dictionary; added is not in it because parameters are local.

The LEGB rule

When Python meets a name in an expression, it searches the namespaces in a fixed order and uses the first match. The order is remembered as LEGB: Local (the current function), Enclosing (any functions that contain the current one, from the inside out), Global (the module), and Built-in. If the name is not found in any of them, Python raises a NameError.

Four nested rounded rectangles. The outermost is Built-in, containing print, len and sum. Inside it is Global, the module, containing REORDER_POINT and make_counter. Inside that is Enclosing, the body of make_counter, containing count. The innermost is Local, the body of the inner function, containing step. An arrow starts in Local and moves outward through Enclosing, Global and Built-in, labelled: search order when a name is read.
Python looks a name up in the local, enclosing, global and built-in scopes, in that order
One expression drawing on all four scopes
REORDER_POINT = 10                       # global

def low_report(stock):                   # stock is local to low_report
    def flag(code):                      # code is local to flag
        # stock comes from the enclosing function, REORDER_POINT from
        # the module, and len and print from the built-ins.
        return code + (" LOW" if stock[code] < REORDER_POINT else "")
    for code in sorted(stock):
        print(flag(code), len(code))

low_report({"RC-104": 9, "RC-101": 42})
Output
RC-101 6
RC-104 LOW 6

Scope is decided by where code is written, not by where it is called from. flag can see stock because flag is written inside low_report. This is called lexical scoping, and it makes programs predictable: you can tell which variable a name refers to by reading the code around it.

One consequence surprises beginners. Because Python decides at compile time that a name is local if the function assigns to it anywhere, reading that name before the assignment fails, even if a global with the same name exists.

Assigning a name anywhere in a function makes it local everywhere in that function
sold_today = 0

def record_sale(qty):
    try:
        sold_today = sold_today + qty    # reads the local before it exists
    except UnboundLocalError as err:
        print(type(err).__name__ + ":", err)
    return qty

record_sale(2)
print(sold_today)
Output
UnboundLocalError: cannot access local variable 'sold_today' where it is not associated with a value
0

global and nonlocal

The global statement tells Python that a name inside a function refers to the module-level variable, so assigning it changes the global. It works, but it ties the function to that variable: the function cannot be tested on its own, two calls can interfere with each other, and a reader has to search the whole module to find out who changes what. Code reviewers treat global as a sign that a function should take a parameter and return a value instead.

global works, but passing values in and out is clearer
sold_today = 0

def record_sale_global(qty):
    global sold_today
    sold_today += qty

def record_sale(total, qty):        # the better design
    return total + qty

record_sale_global(2)
record_sale_global(3)
print("global version:", sold_today)

total = 0
for qty in (2, 3):
    total = record_sale(total, qty)
print("parameter version:", total)
Output
global version: 5
parameter version: 5

nonlocal is the equivalent for enclosing functions: it lets an inner function rebind a variable belonging to the function around it. Unlike global, nonlocal has a respectable use, because the variable it changes is private to one function call rather than shared by the whole program. That use is the closure.

Nested functions and closures

A function defined inside another function is a nested function. If the inner function uses names from the outer one, and the outer function returns the inner one, something remarkable happens: the inner function keeps access to those names even after the outer function has finished. The returned function, together with the variables it captured, is called a closure.

Each call of make_tax_adder returns a closure that remembers its own rate
def make_tax_adder(rate):
    def add_tax(amount):
        return round(amount * (1 + rate), 2)   # rate comes from the enclosing call
    return add_tax

gst = make_tax_adder(0.05)
idaho = make_tax_adder(0.06)
print(gst(10.00), idaho(10.00))
print(gst(9.00), idaho(9.00))
print(gst.__closure__[0].cell_contents)
Output
10.5 10.6
9.45 9.54
0.05

make_tax_adder has returned twice, and its local variable rate should have disappeared each time. It did not, because each returned add_tax function holds on to the rate from the call that created it. gst remembers 0.05 and idaho remembers 0.06. The last line shows where Python keeps the captured value, in a cell attached to the function; you never need to touch it, but it shows that closures are not magic.

A function that builds and returns other functions is called a factory. Factories are useful whenever you need several functions that differ only in a setting: tax adders for different regions, price formatters for different currencies, discount functions for different loyalty tiers. Each can be created with one call instead of being written out by hand.

Closures that remember state

With nonlocal, a closure can update the variables it captured, which gives it memory between calls. Kai wants a batch numbering function for the roast log: each call should return the next batch id, starting from a given number. A closure keeps the counter private, so no other code can reset it by accident.

A closure with nonlocal state: each numberer keeps its own count
def make_batch_numberer(start):
    count = start
    def next_batch():
        nonlocal count
        batch_id = f"B-{count:04d}"
        count += 1
        return batch_id
    return next_batch

next_batch = make_batch_numberer(412)
print(next_batch(), next_batch(), next_batch())
test_batches = make_batch_numberer(1)       # an independent counter
print(test_batches(), next_batch())
Output
B-0412 B-0413 B-0414
B-0001 B-0415

Without nonlocal, count += 1 would make count local to next_batch, and the first call would fail with UnboundLocalError. With it, each numberer has its own count, which lives as long as the function does. In lesson 4 you will meet classes, which are the usual way to bundle data with functions when there is more than one piece of state. For a single counter or setting, a closure is lighter and perfectly professional.

Try it yourself

A discount factory

Write a factory make_discount(rate) that returns a function. The returned function takes a price and returns the price after the discount, rounded to 2 decimals.

For example, roast = make_discount(0.10) gives a function where roast(20.00) is 18.0, and reserve = make_discount(0.15) gives reserve(20.00) equal to 17.0.

def make_discount(rate):
    return round(20.00 * (1 - rate), 2)

roast = make_discount(0.10)
print(roast)

A classic closure trap: late binding

Closures capture variables, not values. If an inner function is created inside a loop and refers to the loop variable, every function sees the variable's final value, because they all look it up when they are called, after the loop has finished. This is called late binding.

Late binding in a loop, and the default-argument fix
adders = []
for rate in (0.05, 0.06, 0.13):
    adders.append(lambda amount: round(amount * (1 + rate), 2))
print([f(100) for f in adders])          # all use the last rate

adders = []
for rate in (0.05, 0.06, 0.13):
    adders.append(lambda amount, r=rate: round(amount * (1 + r), 2))
print([f(100) for f in adders])          # each keeps its own rate
Output
[113.0, 113.0, 113.0]
[105.0, 106.0, 113.0]

The fix in the second loop uses a default argument, which is evaluated when the lambda is created and so freezes the current value. A factory function such as make_tax_adder avoids the problem too, because each call has its own rate variable. When a list of functions all give the same answer, late binding is the first thing to check.

Decorators

A decorator is a function that takes a function and returns a new function that adds some behaviour around it. Decorators are built from exactly the ideas in this lesson: functions as values, nested functions and closures. Python gives them a special syntax: writing @name on the line above a def means the function is defined and then immediately replaced by name(function).

A decorator that counts calls, applied with the @ syntax
import functools

def counted(func):
    """Count how many times func is called."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper

@counted
def loyalty_tier(points):
    """Return the loyalty tier for a number of points."""
    return "Reserve" if points >= 300 else "Roast" if points >= 100 else "Bean"

for p in (40, 150, 320):
    print(p, loyalty_tier(p))
print("calls:", loyalty_tier.calls)
print(loyalty_tier.__name__, "-", loyalty_tier.__doc__)
Output
40 Bean
150 Roast
320 Reserve
calls: 3
loyalty_tier - Return the loyalty tier for a number of points.

wrapper accepts *args and **kwargs so it can pass any arguments straight through to the original function, which is captured in the closure as func. functools.wraps copies the original function's name and docstring onto the wrapper, so help and error messages still describe loyalty_tier rather than a mysterious wrapper. Decorators are used throughout Python for logging, timing, checking permissions, caching and registering functions. You will write web routes with them in PYTH-220 and test fixtures with them in PYTH-230.

The standard library includes ready-made decorators. functools.lru_cache remembers the results of previous calls and returns them instantly when the same arguments come back, which can turn a slow function into a fast one at the cost of some memory. PYTH-250 returns to caching when it covers performance.

lru_cache remembers results, so each zone is looked up only once
import functools

@functools.lru_cache(maxsize=None)
def shipping_rate(zone):
    print("  looking up zone", zone)        # pretend this is slow
    return {1: 8.00, 2: 11.50, 3: 15.25}[zone]

for zone in (1, 2, 1, 1, 3, 2):
    print(zone, shipping_rate(zone))
print(shipping_rate.cache_info().hits, "answers came from the cache")
Output
  looking up zone 1
1 8.0
  looking up zone 2
2 11.5
1 8.0
1 8.0
  looking up zone 3
3 15.25
2 11.5
3 answers came from the cache

Worked problem

A decorator that rejects negative quantities

Theo's sell and restock functions both take a quantity as their second argument, and both should refuse a quantity of zero or less. Instead of repeating the check, design a decorator positive_qty that does it once for any such function.

Show solution
  1. The decorator takes func and defines wrapper(code, qty, *args, **kwargs), naming the two parameters it checks.
  2. wrapper raises ValueError("quantity must be positive") if qty <= 0.
  3. Otherwise it returns func(code, qty, *args, **kwargs), passing everything through unchanged.
  4. functools.wraps(func) keeps the original names and docstrings.
  5. Applying @positive_qty to sell and restock removes the duplicated checks from both.

Answer: One decorator enforces the rule for every function it decorates.

One decorator guarding two functions
import functools

def positive_qty(func):
    @functools.wraps(func)
    def wrapper(code, qty, *args, **kwargs):
        if qty <= 0:
            raise ValueError(f"{func.__name__}: quantity must be positive")
        return func(code, qty, *args, **kwargs)
    return wrapper

stock = {"RC-104": 9}

@positive_qty
def sell(code, qty):
    stock[code] -= qty
    return stock[code]

@positive_qty
def restock(code, qty):
    stock[code] += qty
    return stock[code]

print(sell("RC-104", 2), restock("RC-104", 30))
for bad in (0, -3):
    try:
        sell("RC-104", bad)
    except ValueError as err:
        print("ValueError:", err)
Output
7 37
ValueError: sell: quantity must be positive
ValueError: sell: quantity must be positive

Try it yourself

A call logger

Write a decorator logged that appends a line to the global list LOG each time the decorated function is called, then returns the function's result unchanged.

The line is the function name followed by its positional arguments, for example add_points(20, 15) for a call add_points(20, 15). Use functools.wraps so the decorated function keeps its name.

import functools

def logged(func):
    return func

@logged
def add_points(a, b):
    return a + b

print(add_points(20, 15))
print(LOG)

Common mistakes

  • MistakeExpecting an assignment inside a function to change a global variable.

    InsteadReturn the new value and assign it at the call; use global only as a last resort.

  • MistakeReading a variable in a function before assigning it, when a global has the same name.

    InsteadRename the local, or pass the value in as a parameter.

  • MistakeCreating functions in a loop that all capture the same loop variable.

    InsteadFreeze the value with a default argument or build the functions with a factory.

  • MistakeForgetting nonlocal when a closure updates a captured counter.

    InsteadDeclare nonlocal count in the inner function before changing it.

  • MistakeWriting a decorator whose wrapper takes fixed parameters, so it breaks other functions.

    InsteadAccept *args and **kwargs in the wrapper and pass them through.

  • MistakeLeaving out functools.wraps, so decorated functions lose their names and docstrings.

    InsteadDecorate the wrapper with @functools.wraps(func).

Summary and key terms

  • A namespace maps names to objects; Python keeps built-in, global and local namespaces, and a new local one for every call.
  • Names are looked up in Local, Enclosing, Global and Built-in scopes in that order (LEGB).
  • A name assigned anywhere in a function is local throughout it, which can cause UnboundLocalError.
  • global and nonlocal let a function rebind outer names; global is best avoided, nonlocal is used in closures.
  • A closure is an inner function that keeps access to variables of the function that created it.
  • Factories return customized functions; closures with nonlocal can keep private state between calls.
  • A decorator wraps a function to add behaviour; @name applies it, and functools.wraps preserves the original name.
namespace
A mapping from names to objects, such as a module's globals or a call's locals.
scope
The region of code in which a name can be used without qualification.
LEGB rule
The lookup order Local, Enclosing, Global, Built-in.
shadowing
A local name hiding an outer name with the same spelling.
nested function
A function defined inside another function.
closure
A function together with the variables it captured from an enclosing scope.
factory function
A function that creates and returns other functions or objects.
late binding
Closures looking up captured variables when called, not when created.
decorator
A function that takes a function and returns a wrapped replacement.
functools.wraps
A decorator that copies a wrapped function's name and docstring onto its wrapper.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. A module sets x = 1. A function contains print(x) and later x = 2. What happens when it runs, and why?

    Show answer

    UnboundLocalError, because the assignment makes x local throughout the function.

  2. Write a factory make_multiplier(n) that returns a function multiplying its argument by n.

    Show answer

    def make_multiplier(n): return lambda x: x * n

  3. In what order does Python search for the name len inside a nested function?

    Show answer

    Local, enclosing, global, then built-in, where it finds len.

  4. Why might fs = [lambda: i for i in range(3)] give [2, 2, 2] when each f is called?

    Show answer

    Late binding: every lambda looks up i after the loop, when it is 2.

  5. What does @functools.lru_cache do to a function?

    Show answer

    It stores results by argument and returns stored results for repeated calls.

  6. What is the difference between global and nonlocal?

    Show answer

    global refers to a module-level name; nonlocal refers to a name in an enclosing function.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz