Deep Engineering
Intermediate·Published·3.12 · 3.13 · 3.14·35 MIN

Closures capture the variable, not the value — and the scope chain walks past the class body

A closure holds a cell, not a copy: the value is read when the function is called, and one cell is shared by every inner function at once. The familiar “local → enclosing → global → builtins” chain leaves out the two things people actually trip on: a class body is not part of it, and an assignment anywhere in a body makes the name local for the whole body.

Full technical treatment

TL;DR

A function created inside another one goes on using that function's variables after the outer function has finished. That is what a closure is. What matters is precisely what it uses: not a copy of the value taken at creation time, but the variable itself — so the value is read when the inner function is called, not when it was created.

Hence the main consequence: a variable is captured, not a value, and that diverges from expectation in three places. A change made to the variable after the function was created reaches it: v becomes "second" after def show, and show() returns "second". The access is also shared: two inner functions reading the same name point at the same object, which an is comparison confirms. And the familiar "local → enclosing → global → builtins" chain leaves out two things: a class body is not part of it (a method reading x takes x from the enclosing function even when the class right next to it defines its own), and an assignment anywhere in a body makes the name local for the whole body — so reading it earlier in the code raises UnboundLocalError without ever reaching a global of the same name.

Beyond that is what separates knowing from having read. The object that shared access goes through is called a cell, and a name a function uses but does not define is a free variable; the reference states the rule for them outright: “Name resolution of free variables occurs at runtime, not at compile time”. The cost of all this is small and resolvable at the same time: reading a cell takes 9.1 ns against 6.9 ns for a local — a third more, two nanoseconds (3.13.7). A global costs the same as a cell down to the hundredths of the ratio, which is not what “globals are slow” would lead you to expect.

Where to start
Before this lesson it is enough to understand
  • that a function can be defined inside another function and returned from it like any other value;
  • that a function has variables of its own — parameters and locals — that live while the function is running;
  • that the function you get back can be called any time later, not only right away.
You do not need to know in advance
  • what a cell, a free variable and late binding are — those words turn up along the way and are explained there;
  • nonlocal, global, UnboundLocalError, co_freevars, LOAD_DEREF, MAKE_CELL.

Base: an inner function remembers the outer one's variables

The place to start is the most ordinary picture there is, with no special vocabulary in it yet: one function creates another and hands it back.

PYTHON
def make_adder(step):          # step is the outer function's parameter
    def add(value):
        return value + step    # ...and the inner function uses it
    return add
 
add1 = make_adder(1)           # make_adder has run and finished
add1(10)                       # 11 — and step is still available

Look at the last two lines. make_adder is over: its call has completed, there is nothing left to return. Normally a function's locals and parameters disappear along with the call — they exist only for the duration of it. And yet step works.

Here is the question this lesson is about: why does the outer function's parameter still exist after the outer function has finished?

The answer that is enough at the basic level: because the inner function keeps access to the outer function's variable. It was created inside make_adder, it used that step — and on its way out it took the access with it. As long as add1 is alive, so is its step. A function that carried away access to the variables of the place it was created in is what we call a closure.

One thing in that answer should be read literally: what is kept is access to the variable, not a copy of its value. The difference is invisible while the variable never changes, and becomes the whole story the moment it does.

That is already enough to answer the basic interview question. Everything below is about how that access is actually built, what happens when there is more than one inner function, and the two places where the name-lookup rule does not work the way it is usually retold.

Mechanism 1: a cell, not a copy

language contractLanguage guarantee: a closure captures a cell, not a value. Everything else in the lesson follows from that.

An inner function that reads a name from the outer one does not receive its value. It receives a cell — a separate object both sides reach the variable through.

PYTHON
def outer():
    x = 1
    def inner():
        return x
    return inner

The compiler decides this, and the decision shows up in two fields of the code object:

PYTHON
outer.__code__.co_cellvars      # ('x',)  — the cell is created here
outer().__code__.co_freevars    # ('x',)  — and read here

Both fields are filled in before the first call: the code for inner sits inside outer's code object from compile time. The second line calls outer() only for brevity — to reach that code through a ready-made function.

co_freevars holds exactly what the reference calls free variables: “If a variable is used in a code block but not defined there, it is a free variable”. A function with no such names has no closure at all, and __closure__ is None — not an empty tuple, but None.

The value itself lives inside the cell and is taken from there:

PYTHON
inner = outer()
inner.__closure__[0].cell_contents   # 1

Mechanism 2: the value is read on the call, not on creation

Since a cell is held rather than a copy, changing the variable after the function is created reaches it:

PYTHON
def make():
    v = "first"
    def show():
        return v
    v = "second"          # changed after show already exists
    return show
 
make()()                  # 'second'

This is not a quirk of nested functions. The reference states the rule for all free names — “Name resolution of free variables occurs at runtime, not at compile time” — and gives an example that behaves the same at module level:

PYTHON
i = 10
def f():
    print(i)
i = 42
f()                       # 42

The famous loop bug, where three functions all return the last value, grows from the same root. It is worked through in the lambda lesson — along with the fact that it has nothing to do with lambda, since a plain def in a loop breaks in exactly the same way.

Mechanism 3: one cell for all of them

If one function returns two inner functions reading the same name, their cell is shared — not one copy each:

PYTHON
def two():
    x = 1
    def a(): return x
    def b(): return x
    return a, b
 
a, b = two()
a.__closure__[0] is b.__closure__[0]    # True

That is the same object, not two equal ones: the check is is, that is, identity. This is where the whole family of “why do they all return the same thing” bugs comes from: there is nothing to differ — one cell, one value.

Mechanism 4: a cell keeps its object alive

A cell is a reference, and everything known about references applies to it. While the function lives, the cell lives; while the cell lives, the object inside it lives. Hence a class of leak that looks mysterious in a profiler and harmless in the code (bench/closure-lifetime/retention.py, identical output on 3.12, 3.13 and 3.14).

First, the boundary worth knowing so as not to be frightened for nothing. A closure holds not the whole frame but only the names in co_freevars (here and below the script's output is abridged to the relevant lines):

2) after make_callback returned:
   data, mentioned in the body : alive
   unused, not mentioned       : dead
   the closure's __closure__   : 1 cell(s)
   co_freevars                 : ('data',)

(Labels translated from the script's output.) The compiler puts into cells exactly the names actually read in the body. A local the nested function never mentions dies with the call, closure or no closure. The documentation describes the contents directly:

A tuple containing the names of free (closure) variables that a nested scope references in an outer scope. […] Note: references to global and builtin names are not included.

The data model — codeobject.co_freevars

The practical case is a handler registered somewhere:

5) the connection was released by the caller, the handler is in the registry: alive
   what holds it: ('self',)
   after clearing the registry: dead

del conn does nothing here: the object is held by the self in the handler's co_freevars, and the handler is held by the registry. This is the commonest shape — a method taken as a callback drags the whole object behind it.

The second case is quieter: a cycle through a cell. The node holds the handler, and the handler holds the node through a cell.

6) cycle "node -> handler -> cell -> node", collector off: alive
   after gc.collect(): dead | objects collected: True

Reference counting does not break such a cycle — the cycle collector is required. Until it runs, the memory is occupied, and with collection disabled or delayed that shows up as growth.

Releasing the object without deleting the function is possible: a cell may be rewritten from inside through nonlocal.

7) before release: alive
   after release(): dead | cb2() = released

The cell is still there — it now holds None. That is the supported way to build a "closeable" callback, and it also explains why weakref solves this class of problem outright: a weak reference is not enough to keep an object alive.

Mechanism 5: where things actually live — the cell, the snapshot and partial

The lambda: i trap above was explained by a cell. There are in fact three ways, and telling them apart is useful not for choosing between them — two of them give the same result in the loop — but for knowing what you are looking at in someone else's function (bench/introspection/closure_vars.py, identical output on 3.12, 3.13 and 3.14):

8) three ways in a loop:
   closure over i     : [2, 2, 2]
   default argument   : [0, 1, 2]
   functools.partial  : [0, 1, 2]

6) storage:
   closure: __closure__ = (<cell ...>,) -> cell_contents = ['second']
   default: __defaults__ = ('first',) | __closure__ = None
   partial: .args = ('first',) | .keywords = {}

(Labels translated from the script's output.) Three different places. And the difference is not cosmetic: only the first is live.

5) before rebinding: first | first | first
   after rebind('second'): second | first | first
   the closure sees the new value, the snapshots the old

The gap between a default argument and partial is smaller than people assume: both take a snapshot at creation, both miss the rebinding. They differ in where the value is kept, and in that __defaults__ can be rewritten from outside while partial is immutable — you need a new object.

The tool that shows the first storage and not the other two is inspect.getclosurevars:

Get the mapping of external name references in a Python function or method func to their current values. A named tuple ClosureVars(nonlocals, globals, builtins, unbound) is returned. nonlocals maps referenced names to lexical closure variables, globals to the function's module globals and builtins to the builtins visible from the function body. unbound is the set of names referenced in the function that could not be resolved at all given the current module globals and builtins.

inspect — getclosurevars

It sorts a function's external names into four buckets at once:

1) getclosurevars(by_closure):
   nonlocals: {'n': 2}
   globals  : {'GLOBAL_RATE': 10}
   builtins : {'len': <built-in function len>}
   unbound  : set()

The unbound bucket is separately useful: it collects names that do not resolve in the current module at all. A typo in a global's name shows up there before the call rather than in production.

But the tool's boundary has to be known. The snapshot in __defaults__ does not exist for it, and partial it refuses outright:

2) getclosurevars(by_default) — n is NOT in nonlocals, it is a parameter:
   nonlocals: {}
   and the snapshot's value is here: (2,)

3) getclosurevars(partial) -> TypeError: functools.partial(...) is not a Python function
   the snapshot's value on partial: (2,) | func: body

So "the closure is empty" in its output means "there are no cells", not "the function remembered nothing". Where to look for what was remembered: __defaults__ and .args.

Mechanism 6: the scope chain, and the two places it is not what you think

The four buttons above the diagram are four situations for one and the same name x: in three of them it is found in a different scope, and in the fourth the search stops at the very first step. The class body sits in the list as its own struck-through row.

The search order is usually taught as “local → enclosing → global → builtins”. That is correct as far as it goes — and it leaves out two things.

The class body is skipped

The language has three kinds of block: “a module, a function body, and a class definition”. A class body is a block, but it is not part of the name-search chain for methods:

PYTHON
def make():
    x = "from the function"
    class C:
        x = "from the class"
        def get(self):
            return x        # ← which x?
    return C
 
make()().get()              # 'from the function'

The method takes x from the enclosing function, even though the class defines its own x and it sits closer. The reference is explicit: “The scope of names defined in a class block is limited to the class block; it does not extend to the code blocks of methods”. PEP 227, which introduced nested scopes, puts it even more bluntly: “If a class definition occurs in a chain of nested scopes, the resolution process skips class definitions”.

With no enclosing function, the name is simply not found:

PYTHON
class C:
    y = "class"
    def get(self):
        return y            # NameError: name 'y' is not defined

A class attribute has to be reached through self or the class name — as an attribute, not as a free name.

An assignment makes the name local for the whole body

The second place is not about the order of scopes but about which scope the name belongs to in the first place:

PYTHON
x = "global"
 
def trap():
    print(x)                # UnboundLocalError
    x = "local"

The error happens on the line above the assignment. The decision was made by the compiler for the entire body at once, and it is visible in the code object: the name lands in co_varnames, the list of locals, rather than in co_names, where names looked up from outside go.

PYTHON
trap.__code__.co_varnames   # ('x',)  — local, for the whole body

The message is precise about it: cannot access local variable 'x' where it is not associated with a value. Not “name not found”, but “the local variable exists and has no value yet”.

Mechanism 7: nonlocal and global — two different operations

Reading a free name works on its own. To assign to an outer variable, you have to say which one:

PYTHON
def counter():
    n = 0
    def inc():
        nonlocal n
        n += 1
        return n
    return inc
 
c = counter()
c(), c(), c()               # (1, 2, 3)

nonlocal points the name, in the reference's words, “to previously bound variables in the nearest enclosing function scope”, and when several qualify, “the nearest binding is used”. The word “function” matters for the same reason it did in the section on classes: a class body does not qualify.

One more difference matters more than it first appears: nonlocal requires an existing variable, and its absence is not a runtime error but a compile-time one:

PYTHON
def outer():
    def inner():
        nonlocal zz         # SyntaxError: no binding for nonlocal 'zz' found
        zz = 1

The reference states it directly: “If a name is not bound in any nonlocal scope, or if there is no nonlocal scope, a SyntaxError is raised”. global works the other way round: it does not require the name to exist, and it points it at the module. Lookup there goes through the global namespace first and builtins second.

nonlocalglobal
points atthe nearest enclosing functionthe module namespace
requires an existing variableyes, otherwise SyntaxErrorno
works at the top levelnoyes

Deeper: what the bytecode shows

implementation detail · CPython 3.13The bytecode and the instruction names belong to a specific version: they have changed before and will change again.

The cell is not a documentation abstraction; it has instructions of its own. The outer function creates the cell and writes into it, the inner one picks it up and reads it (here and below, python3.13):

outer:                            inner:
  MAKE_CELL          x              COPY_FREE_VARS
  LOAD_CONST         1              RESUME
  STORE_DEREF        x              LOAD_DEREF       x
  LOAD_FAST          x              RETURN_VALUE
  BUILD_TUPLE
  MAKE_FUNCTION
  SET_FUNCTION_ATTRIBUTE  closure

These are two independent listings: each reads top to bottom, and the columns do not correspond line by line.

Note STORE_DEREF where STORE_FAST would normally be: even inside its own function, a variable that has a cell is written through the cell. Otherwise the inner function would not read what the outer one wrote.

The opcodes around building a closure have moved:

3.12.33.13.73.14.7
cell goes into the closureLOAD_CLOSURELOAD_FASTLOAD_FAST_BORROW
closure is attached to the functionMAKE_FUNCTION closureSET_FUNCTION_ATTRIBUTE closureSET_FUNCTION_ATTRIBUTE closure

The read itself — LOAD_DEREF — has not changed once. This is exactly the thing worth remembering when reading any bytecode listing: instruction names are tied to a version, the mechanism behind them is not.

Deeper: what it costs

measured observationbench/closures/scopes.py, CPython 3.13.7. Measured on one machine; the difference between rows carries the meaning, not the absolute figures.

Reading a cell costs more than reading a local, but so little that restructuring code for it makes no sense. Two hundred reads of one name in a row, minimum of nine repeats, 3.13.7:

accessopcodeper readvs local
local variableLOAD_FAST6.9 ns×1.00
closure cellLOAD_DEREF9.1 ns×1.32
globalLOAD_GLOBAL9.1 ns×1.32

Two nanoseconds per read — a third of what a local costs. Whether that is much or little follows from the fact that a single read is under ten nanoseconds to begin with: for a third of that to show up as a win, the name has to be read inside a loop that is itself the bottleneck.

The third row deserves its own moment: it landed on top of the second, and that is not a rounding in favour of a convenient conclusion. The run repeats the whole measurement five times and compares the distance between rows against its own wobble (labels translated from the script's output):

    SPREAD OVER 5 REPEATS OF THE WHOLE MEASUREMENT, as ratios to the local
    LOAD_FAST  (local)         x1.00 .. x1.01   spread 0.01
    LOAD_DEREF (cell)          x1.32 .. x1.33   spread 0.01
    LOAD_GLOBAL (global)       x1.32 .. x1.33   spread 0.01

    distance between cell and global        0.00
    widest spread of those same two rows    0.01
    can the instrument resolve the gap      no

“Global variables are slow” is advice that has outlived its reasons: the instrument sees no difference between a global and a cell at all. The interpreter specialises a global lookup while the module dictionary is unchanged; that machinery is taken apart in the article on the interpreter loop.

That clause is also the boundary of the claim, and the rule is worth writing from the boundary rather than from the number. "A global costs the same" holds where the specialisation holds: CPython 3.13.7, with the module dictionary not being rewritten as the code runs. The rule that survives being carried elsewhere reads differently: at these magnitudes you pick the kind of access by meaning, not by price — and if a decision really does come down to nanoseconds, you measure them on your own code and your own build.

Timings are never compared across versions here, and the reason is in the builds. 3.12 is built with GCC 13.3.0; 3.13 and 3.14 use Clang 20.1.4, and 3.14 additionally has --with-tail-call-interp, which 3.13 does not. More than the compiler differs, and the gap between builds swamps the gap between versions. Only rows of one table are comparable: they come from a single run on 3.13.7.

Version history

Python 2.1 — PEP 227 introduces nested scopes: a name from an enclosing function becomes visible. The same PEP fixes the exception for class bodies that still holds today.

Python 3nonlocal arrives, making it possible not only to read an outer variable but to assign to it; the reference names PEP 3104 as its specification.

Python 3.11 — an explicit MAKE_CELL appears in the bytecode: creating the cell becomes its own instruction at the start of the function's code. It carries “Added in version 3.11” in the dis documentation, as does COPY_FREE_VARS.

Python 3.13LOAD_CLOSURE leaves the listing: the closure is attached to the function by a separate SET_FUNCTION_ATTRIBUTE, documented as “Added in version 3.13”.

Python 3.14 — loading the cell into the closure tuple goes through LOAD_FAST_BORROW, like every other local load. Observable behaviour is unchanged.

How to answer in an interview

Short answer: a closure is a function that kept access to the variables of the place it was created in, and goes on using them after that place has finished running. What it kept is access to the variable, not a copy of its value: the value is read at call time. That is why a change made to the variable after the function was created reaches it.

That is enough for a correct answer. What follows is what you add when the interviewer digs.

If the interviewer digs deeper

The object that access goes through is called a cell, and the reference states the rule outright: name resolution of free variables occurs at runtime, not at compile time. The cell is shared, too: two inner functions reading one name point at the same object, which an is comparison settles.

What separates a good answer: naming what the familiar chain "local → enclosing → global → builtins" leaves out. A class body is not part of the chain: a method reading x takes it from the enclosing function even when the class right next to it defines its own. And an assignment anywhere in a body makes the name local for the whole body — reading it earlier in the code raises UnboundLocalError without ever reaching a global of the same name. The cost of all this is small: 9.1 ns against 6.9 for a local — two nanoseconds — and a global costs exactly the same, which is not what "globals are slow" would lead you to expect.

Next they ask

Next they ask

Then how do you make each function from the loop remember its own value?

Short answer

Give it as an argument with a default, or through functools.partial — that is, store a snapshot rather than a cell. A closure holds the variable, and the value is read at call time: a change made to the variable after the function was created reaches it.

Next they ask

A closure holds a reference. What does that cost in memory?

Short answer

A class of leak that looks mysterious in a profiler and harmless in the code: while the function lives the cell lives, and while the cell lives so does the object in it. The boundary is worth knowing so as not to fear the wrong thing: what is held is not the whole frame, only the names in co_freevars.

Common misconceptions

Claim

“A closure saves the variable's value as of the moment the function was created.”

Actually

It saves the cell. A variable changed after the function was created reaches it: v becomes “second” after def show, and show() returns “second”. The reference states this as a rule: “Name resolution of free variables occurs at runtime, not at compile time”.

Claim

“Each inner function gets its own copy of the outer variable.”

Actually

The cell is shared. Two functions reading one name give a.__closure__[0] is b.__closure__[0]True: one object, not two equal ones. This is also where the “why do they all return the same thing” family of bugs comes from.

Claim

“A method sees its own class's attributes as ordinary names.”

Actually

A class body is not part of the search chain. A method reading x takes it from the enclosing function even when the class defines its own, and with no enclosing function it gets a NameError. The reference: “The scope of names defined in a class block is limited to the class block; it does not extend to the code blocks of methods”. An attribute is reached through self or the class name.

Claim

“UnboundLocalError means you forgot to create the variable.”

Actually

It means the name is local — because of an assignment somewhere below in the body — and holds no value yet. A global of the same name may well exist, but it is never reached. The decision was made by the compiler for the whole body at once, and it shows up in co_varnames.

Claim

“Reading global variables is noticeably slower.”

Actually

On 3.13.7 a global costs 9.1 ns per read — exactly the same as a closure cell: over five repeats of the measurement the distance between them is 0.00 against a spread of 0.01 in each, so the instrument does not separate them at all. Both cost more than a local, by two nanoseconds — a third, but a third of nine nanoseconds.

Practice

Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.

Practice · predict the output

The name x is declared three times: in the module, in the enclosing function and in the class body. The method reads x. And a second function reads x before assigning to it. What does this code print?
x = "module"


def outer():
  x = "enclosing"

  class C:
      x = "class"

      def read(self):
          return x

  return C().read()


def shadow():
  try:
      got = x
  except UnboundLocalError:
      return "UnboundLocalError"
  x = "local"
  return got


print(outer())
print(shadow())

Practice · estimate

Reading an ordinary local variable against reading a closure cell. How many times more expensive is the cell?
times

Knowledge check

Question 1 of 4

make binds x = 'from the function'; inside it, class C binds x = 'from the class', and the method get returns a free x. What does make()().get() return?

What measured this

The numbers in this article come from these scripts. Each one opens from here, together with the record of the run: what it was measured on, what came out, and with what spread.

Sources & further reading

5 SOURCES

  1. Language reference — execution model, scopes and name bindingOfficial documentation. The primary source for every rule in this lesson. On blocks: “A block is a piece of Python program text that is executed as a unit. The following are blocks: a module, a function body, and a class definition”. On free variables: “If a variable is used in a code block but not defined there, it is a free variable”. And the one that matters most here: “Name resolution of free variables occurs at runtime, not at compile time” — the reference gives the i = 10 / i = 42 example reproduced in the text.https://docs.python.org/3.14/reference/executionmodel.html
  2. Language reference — a class body does not continue the chainOfficial documentation. Verbatim: “The scope of names defined in a class block is limited to the class block; it does not extend to the code blocks of methods”. Hence the consequence for methods: a class attribute is reachable through self or the class name, but not as a free name. This is one of the two places where the familiar scope chain misleads (the other is not about the order of scopes but about which scope a name belongs to). Checked by running it on 3.12, 3.13 and 3.14.7.https://docs.python.org/3.14/reference/executionmodel.html
  3. Language reference — the nonlocal statementOfficial documentation. The full specification of nonlocal, the source of both claims made here. On the nearest scope: “If a name is bound in more than one nonlocal scope, the nearest binding is used”. On failing before the program ever runs, verbatim: “If a name is not bound in any nonlocal scope, or if there is no nonlocal scope, a SyntaxError is raised”. The reference names PEP 3104 as the specification for the statement. Checked by compiling on 3.12, 3.13 and 3.14.7.https://docs.python.org/3.14/reference/simple_stmts.html#the-nonlocal-statement
  4. PEP 227 — Statically Nested ScopesPEP. Jeremy Hylton; Final, Python 2.1. The PEP that introduced nested scopes at all: “The proposal changes the rules so that names bound in B are visible in A”. It also states the exception this lesson devotes a section to: “Names in class scope are not accessible. Names are resolved in the innermost enclosing function scope. If a class definition occurs in a chain of nested scopes, the resolution process skips class definitions”.https://peps.python.org/pep-0227/
  5. dis — MAKE_CELL, STORE_DEREF, LOAD_DEREF, COPY_FREE_VARSOfficial documentation. Where the opcodes in the bytecode section come from, and how they moved between versions: on 3.12 the cell goes into the closure via LOAD_CLOSURE and MAKE_FUNCTION with a closure flag, on 3.13 and 3.14 via an ordinary load plus a separate SET_FUNCTION_ATTRIBUTE. It also dates the instructions: MAKE_CELL and COPY_FREE_VARS carry “Added in version 3.11”, SET_FUNCTION_ATTRIBUTE “Added in version 3.13”. Disassembled on all three versions.https://docs.python.org/3.14/library/dis.html