Closures and Scope

Updated

September 8, 2026

Closures and Scope

A nested function can remember names from the function that created it. That memory is a closure. The boring default is a small factory: make_taxer(rate) returns a function that always uses that rate. The trap is a loop: every nested function sees the last value of the loop variable unless you bind it as a default argument.

Mental model

Python looks up a name in this order: local, enclosing function, global (module), then built-ins. That is LEGB. A nested def that reads a name from the enclosing function is a closure. The inner function stores a cell pointing at that name, not a snapshot of the value at def time.

Late binding means the value is read when the inner function runs, not when it was defined. In a loop, that name is the last assignment.

The usual fix is a default argument: def inner(n=n):. Defaults are evaluated at def time, so each inner function gets its own copy.

nonlocal lets the inner function assign to a name in the enclosing function. Use it for a tiny counter. Do not use it as a hidden global.

Worked examples

Case 1: A factory that closes over a rate

Save as make_taxer.py. Each returned function remembers its own rate.

# make_taxer.py
def make_taxer(rate):
    def with_tax(cents):
        return round(cents * (1 + rate))

    return with_tax


def main():
    desk = make_taxer(0.1)
    catering = make_taxer(0.0)
    print(desk(400))
    print(catering(400))
    print(desk.__name__)


if __name__ == "__main__":
    main()

Run:

uv run python make_taxer.py

Output:

440
400
with_tax

desk and catering are two functions. They share code, not data.

Case 2: nonlocal for a running count

Save as shift_counter.py. bump assigns to n in make_counter. Without nonlocal, that assignment would create a new local n and the outer n would never change.

# shift_counter.py
def make_counter():
    n = 0

    def bump():
        nonlocal n
        n += 1
        return n

    return bump


def main():
    tickets = make_counter()
    print(tickets())
    print(tickets())
    other = make_counter()
    print(other())
    print(tickets())


if __name__ == "__main__":
    main()

Run:

uv run python shift_counter.py

Output:

1
2
1
3

Two counters do not share n. That is the point of a closure: state that is not a global and not a class — yet.

If you need to inspect the count, name it on an object. A closure with nonlocal is easy to overgrow.

Case 3: Late binding in a loop (the bug, then the default-arg fix)

Save as late_tables.py. make_buggy appends three functions in a loop. Each function reads n. When they run, the loop is finished, so n is 12 for all three.

make_fixed binds n as a default: def open_it(n=n). Defaults freeze the value at def time. Each function keeps the table number from that iteration.

# late_tables.py
def make_buggy(tables):
    fns = []
    for n in tables:
        def open_it():
            return f"open table {n}"

        fns.append(open_it)
    return fns


def make_fixed(tables):
    fns = []
    for n in tables:
        def open_it(n=n):
            return f"open table {n}"

        fns.append(open_it)
    return fns


def main():
    tables = [3, 7, 12]
    print("buggy:")
    for fn in make_buggy(tables):
        print(fn())
    print("fixed:")
    for fn in make_fixed(tables):
        print(fn())


if __name__ == "__main__":
    main()

Run:

uv run python late_tables.py

Output:

buggy:
open table 12
open table 12
open table 12
fixed:
open table 3
open table 7
open table 12

The inner parameter n shadows the loop variable. That is deliberate. Callers still write fn() with no arguments; they never pass n.

A second honest fix is a factory: def make_open(n): def open_it(): return ...; return open_it and append make_open(n). Same idea: bind n as a parameter of a new call. The default-arg form is the one-liner you will see in reviews.

The trap

The trap is Case 3. People write the buggy loop because it looks like the function captured 3, then 7, then 12. It captured the name n. Read late_tables.py from the top. If you only remember one trick from this chapter, remember n=n.

A cousin of the same bug: a list of lambdas, lambda: n, inside a for. Same late binding. Same default-arg fix: lambda n=n: n.

The boring rule

  • Use a closure as a tiny factory (make_taxer(rate)), not as a hidden object model.
  • Lookup is LEGB. Assignment is local unless you write nonlocal or global.
  • Prefer nonlocal only for a small counter or flag. A class is clearer once you have two pieces of state.
  • Never build functions in a loop that close over the loop variable without binding it (n=n or a factory).
  • Do not use global to pass data into a function. Pass an argument.

Try this

  1. In make_taxer.py, add make_discounter(percent) that returns a function subtracting that percent from cents. Print make_discounter(10)(400).
  2. In shift_counter.py, add a nested reset that sets n back to 0 with nonlocal. Call bump, reset, bump.
  3. In late_tables.py, add make_lambda(tables) that uses lambda n=n: f"open table {n}". Confirm it matches make_fixed.
  4. Comment out n=n in make_fixed (empty def open_it():) and run it. You should see the buggy output again.