After my previous post (that mentions other related posts with similar details) about Python closure introspection (and a bit of internals) I came across a detail that at first seemed strange to me, but that makes much sense (and made me further dive into the implementation).
Let's say we have these nested functions (with 3 levels of nesting). We return the most nested function (inner_2) that traps variables in the most outer function (becoming a closure):
def outer():
print("outer")
x = "a"
y = "b"
def inner_1():
# it's using x
nonlocal x
x += "b"
print(f"inner_1: {x}")
def inner_2():
# it's using both x and y
nonlocal x
x += "c"
print(f"inner_2, x:{x} y:{y}")
return inner_2
return inner_1
in_1 = outer()
in_2 = in_1()
inner_2 is trapping 2 variables defined in outer: x and y. We can see it by checking its __closure__ and the co_freevars in its __code__ object, and the co_cellvars of the outer function code object:
print(f"in_2.__closure__: {in_2.__closure__}.") # 2 cells, for the x and y values>
print(f"in_2.__code__.co_freevars: {in_2.__code__.co_freevars}.") # in_2.__code__.co_freevars: ('x', 'y')
# in_2.__closure__: (cell at 0x78f58889d570: str object at 0x78f58886f930, cell at 0x78f58889d540: str object at 0x5a0cc7144e08).
# in_2.__code__.co_freevars: ('x', 'y').
print(f"outer.__code__.co_cellvars: {outer.__code__.co_cellvars}") # ('x', 'y')
# in_1.__code__.co_freevars: ('x', 'y')
But checking these attributes for the intermediate inner function comes with some surprise:
print(f"in_1.__closure__: {in_1.__closure__}.") # 2 cells, for the x and y values>
print(f"in_1.__code__.co_freevars: {in_1.__code__.co_freevars}.") # in_1.__code__.co_freevars: ('x', 'y').
print(f"in_1.__code__.co_cellvars: {in_1.__code__.co_cellvars}") # ()
print(f"outer.__code__.co_cellvars: {outer.__code__.co_cellvars}") # ('x', 'y')
#in_1.__closure__: (cell at 0x78f58889d570: str object at 0x78f58886f930, cell at 0x78f58889d540: str object at 0x5a0cc7144e08).
#in_1.__code__.co_freevars: ('x', 'y').
#in_1.__code__.co_cellvars: ()
#outer.__code__.co_cellvars: ('x', 'y')
inner_1 is trapping x in its closure, which is normal as it's using it, but it's also trapping y, that it's not using, why? Well, indeed inner_1 is not using y in a direct, visible way, but it needs it, as when the inner_2 function object is created, it needs both x and y for its closure. The cells for x and y are created in the heap when outer is executed. outer creates inner_1 and returns it, so when inner_1 is executed and creates inner_2, outer is long gone, so we need to have the reference to the x and y cells somewhere, to put them in inner_2.__closure__. That "somewhere" is inner_1 closure. So yes, even if inner_1 only works directly with x, it gets y also in its closure.
Discussing this with a GPT you get a nice explanation:
This is sometimes described as “transitive closure capture” or “cell promotion/relaying”: an intermediate function (inner_1) must carry closure cells that it doesn’t itself use, so that functions nested within it can close over them.
In other words: If a nested function needs a variable from an outer scope, every function layer in between must carry that variable as a closure cell, even if those intermediate layers don’t use it directly.
Only the immediate lexical parent can provide the closure cells to a newly created function.
The approach followed by Python for creating its closures is rather different from that of JavaScript, and explains the limitation that I mentioned in this post. In Python the compiler checks if a function closes over variables of its outer scopes, and if so, it sets the co_freevars and co_cellvars of the corresponding code objects and adds the necessary instructions so that at execution time cell objects get created and when the function object is created, its __closure__ can be correctly set, with exactly the cells that it needs. If some "dynamic code" (code compiled dynamically with exec()) tries to access to a variable of an outer scope that had not been trapped by the __closure__ of the function that invokes exec, it can't, as it's not there. In JavaScript this is quite different. eval() has access to any variable of the outer scopes, because indeed all functions in JavaScript have access to all its outer scopes through the scope chain. When a function is created, it gets its [[scope]] property set to the scope (the activation object I think it's called) of its parent function. So if we have a certain level of nesting when defining functions, we end up with a chain of scopes. And the variable look up mechanism will search in this chain if it does not find a variable in the current scope. This is very powerful, but at the same time has serious performance implications. Outer scopes are kept alive regardless of whether the inner functions access to them or not (cause we allow eval to access to them, and we don't know what eval will be evaluating). This also involves extra longer look ups.
Nicely explained by a GPT:
JavaScript keeps the entire lexical scope chain alive, whereas Python collapses scopes into minimal “cell objects” and releases frames as soon as possible.
In JavaScript, every function carries a scope chain because dynamic features like eval() force engines to preserve the full lexical environment at runtime. Python does not need this because its lexical scope is fixed at compile time and not accessible to exec()/eval().
I was wondering how the most powerful and dynamic language that I can think of, ruby, manages this. I have no practical ruby knowledge, so I just asked a GPT, and as expected it follows a very similar approach to JavaScript, keeping sort of a chain of "scopes" that allows eval access to variables in any of them. From a GPT:
Ruby’s closures sit right between Python and JavaScript, but they lean much closer to JavaScript in philosophy:
- They close over entire lexical scopes, not a minimal set of cell-like variables.
- Ruby scopes are runtime objects (not a purely compile‑time fiction like Python’s).
- Blocks, Procs, and lambdas capture the full environment, not a pruned subset.
- Ruby supports eval within a Binding, which preserves the whole lexical + dynamic scope much like JavaScript’s eval.