Module 7 · Functional & Decorative Python ⏱ 19 min

Closures & State Encapsulation

By the end of this lesson you will be able to:
  • Explain what a closure is and how it captures outer variables
  • Use closures to hide state without classes
  • Recognise the late-binding trap in loop-generated closures

A function defined inside another function can read variables from the outer scope. What is more surprising is that it can remember them after the outer function has finished and returned. That remembered bundle — a function plus the variables it captured — is called a closure.

Closures give you a way to attach private state to a function without building a class. They are the mechanism that makes factory functions and decorators work, and they appear in almost every Python codebase once you know what to look for. Understanding closures is the bridge between simple functions and the advanced patterns you will use in real libraries.

flowchart TD
  A["make_counter() runs"] --> B["count = 0"]
  B --> C["returns increment()"]
  C --> D["increment remembers count"]
  D --> E["each call: count += 1"]
  style C fill:#3776ab,color:#fff
A closure captures variables from its enclosing scope and carries them along.

How a closure works

When Python compiles a nested function, it notices which names come from outer scopes. Those names are stored in a cell object attached to the inner function. The cell is a reference, not a copy, so if the outer variable changes later, the inner function sees the new value.

In the example below, make_counter creates a local count, defines increment to read and update it, and returns increment. After make_counter finishes, its local scope would normally be thrown away — but because increment still holds a reference to count through its closure cell, the variable survives. Each factory call creates a fresh count in a fresh scope, so separate counters never interfere.

A closure that keeps private state across calls.
def make_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

c = make_counter()
print(c())
print(c())
print(c())

d = make_counter()
print('fresh:', d())

nonlocal versus global

Without nonlocal, assigning count inside increment would create a brand-new local variable shadowing the outer one. The outer count would stay at zero forever, and every call would return 1.

nonlocal says this name lives in an enclosing function scope, not the global module scope. It is the right tool for closures because it keeps the state private to the factory that created it. global would expose the variable at module level, where any code could touch it, defeating the purpose of encapsulation. Resist the temptation to use global as a quick fix; it makes code harder to test and reason about.

flowchart TD
  G["global x"] --> F["outer()"]
  F --> L["local x"]
  F --> I["inner() with nonlocal x"]
  I --> L
  style I fill:#3776ab,color:#fff
nonlocal binds to the nearest enclosing scope; global jumps straight to the module.
A closure that captures a configuration value and reuses it.
def make_prefixer(prefix):
    def prefixer(text):
        return prefix + ': ' + text
    return prefixer

error = make_prefixer('ERROR')
info = make_prefixer('INFO')

print(error('disk full'))
print(info('started'))
print(error('network down'))

Closures versus classes

For very small pieces of state, a closure is often clearer than a class. A class needs __init__, self, and a method definition; a closure needs a nested function and a local variable. When the behaviour is just one method and the state is one or two variables, the closure wins on brevity and directness.

As the state grows richer — several variables, several methods, inheritance — a class becomes the better tool. The two are not competitors; they are points on a spectrum, and knowing when to switch is part of writing Pythonic code. A good rule of thumb is: if you find yourself writing more than one nested function or mutating more than two variables, consider graduating to a class.

flowchart LR
  A["for n in range(3)"] --> B["lambda: n"]
  B --> C["all see n=2"]
  D["lambda n=n: n"] --> E["each sees its own value"]
  style D fill:#3776ab,color:#fff
Loop closures share the same variable; default arguments freeze the value at creation.

Reading closure state safely

Closures hide state by default, which is usually what you want. Occasionally you need to inspect that state for debugging or testing. You can expose it by returning the value from the inner function, or by attaching the value as an attribute on the function object itself. The latter is advanced and relies on the fact that function objects are mutable; for now, prefer designing your closure so the information you need is part of its return value or accessible through a separate accessor closure.

Exercise

Write make_counter() that returns a function with no arguments. Each time the returned function is called, it increments a private count and returns the new value. The first call should return 1.

def make_counter():
    # your code here
    pass
A closure that captures both a scale factor and an offset.
def make_linear(scale, offset):
    def linear(x):
        return x * scale + offset
    return linear

ten_plus = make_linear(10, 5)
print(ten_plus(2))
print(ten_plus(3))
Exercise

What does this print? Pay attention to which variable each closure captures.

def make_adder(n):
    def adder(x):
        return x + n
    return adder

a = make_adder(5)
b = make_adder(10)
print(a(1))
print(b(1))
Exercise

Write make_multiplier(n) that returns a function. The returned function takes one argument x and returns x * n. Do not use classes.

def make_multiplier(n):
    # your code here
    pass
Exercise

This code creates three multipliers in a loop, but they all return the same result. Fix the late-binding trap by capturing the current loop value in each closure.

def make_multipliers():
    return [lambda x: x * i for i in range(1, 4)]
Exercise

Write make_averager() that returns a function. The returned function accepts one number each call and returns the running average of every number it has ever received. Use a closure to track the total and count privately.

def make_averager():
    # track total and count in the closure
    pass

Recap

  • A closure is a nested function plus the variables it captured from outer scopes.
  • The inner function keeps those variables alive after the outer function returns.
  • nonlocal lets the inner function assign to an enclosing scope variable without exposing it globally.
  • For simple state with one or two variables, closures are often clearer than classes.
  • Loop-generated closures share the loop variable; freeze values with default arguments.
  • Each factory call creates an independent scope, so separate closures never interfere.

Next you will use closures to build decorators — functions that wrap other functions to add behaviour transparently.

Checkpoint quiz

What keyword lets a nested function assign to a variable in its enclosing scope?

Why do loop-generated closures often all produce the same result?

Go deeper — technical resources