Skip to content

Closures & Scope Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

Python interview questions on closures and free variables, __closure__, the nonlocal keyword, late binding in loops and the default-argument fix, closures vs classes, and common closure uses.

Read the in-depth guidePython Closures Explained — Free Variables, nonlocal, and the Loop Trap(opens in new tab)
15 of 15

A closure is a nested function that remembers variables from its enclosing scope even after that outer function has returned. The remembered names are called free variables — they're neither local parameters nor globals. Python stores them on the function's __closure__ attribute.

def multiplier(factor):
    def multiply(n):
        return n * factor      # 'factor' is a free variable
    return multiply

double = multiplier(2)
double(5)                      # 10
double.__closure__[0].cell_contents   # 2 — captured value

The inner function keeps the binding alive via a cell object, which is why multiplier can return and double still works. Closures are how Python functions carry private state without a class.

By default, assigning to a name inside a function creates a new local. nonlocal tells Python that an assignment should instead rebind a variable in the nearest enclosing function scope — letting a closure mutate, not just read, the captured variable.

def counter():
    count = 0
    def increment():
        nonlocal count        # rebind outer 'count'
        count += 1
        return count
    return increment

c = counter()
c(); c()                       # 1, then 2

Without nonlocal, count += 1 would raise UnboundLocalError (it reads then assigns a local). Use nonlocal for enclosing-function scope and global for module scope.

Closures capture variables, not values — this is late binding. A function created in a loop looks up the loop variable when it's called, not when it's defined, so every closure sees the variable's final value.

funcs = [lambda: i for i in range(3)]
[f() for f in funcs]           # [2, 2, 2]  — all see final i

# fix: bind the current value via a default argument
funcs = [lambda i=i: i for i in range(3)]
[f() for f in funcs]           # [0, 1, 2]

The default-argument trick works because defaults are evaluated at definition time, snapshotting i per iteration. A factory function that takes i as a parameter achieves the same. This is a favorite interview gotcha.

A function with free variables has a non-None __closure__ — a tuple of cell objects, each holding one captured binding accessible via cell_contents. The matching names are listed in __code__.co_freevars. Functions with no closure have __closure__ is None.

def make(x, y):
    def inner():
        return x + y
    return inner

f = make(3, 4)
f.__code__.co_freevars               # ('x', 'y')
[c.cell_contents for c in f.__closure__]   # [3, 4]

This is mostly useful for debugging or teaching how closures actually store state. The cells are shared live, so nonlocal rebinds are visible through cell_contents.

Both bundle behavior with state. A closure is lighter and ideal when you need a single method and a little hidden state. A class wins when you need multiple methods, inheritance, or explicit, inspectable state. Common closure uses include factories, decorators, and callbacks.

# closure: tiny stateful function
def make_adder(n):
    return lambda x: x + n
add10 = make_adder(10)

# class: equivalent but heavier
class Adder:
    def __init__(self, n): self.n = n
    def __call__(self, x): return x + self.n

Rule of thumb: one behavior + private state → closure; many behaviors or shared interface → class. Decorators are the canonical real-world closure.

The closures all reference the same variable, which holds its final value after the loop. Capture the current value with a default argument (bound at definition) or functools.partial.

fns = [lambda: i for i in range(3)]
[f() for f in fns]                   # [2, 2, 2]  -> all see final i

fns = [lambda i=i: i for i in range(3)]
[f() for f in fns]                   # [0, 1, 2]  -> default binds now

Rule of thumb: bind the loop variable as a default argument (x=x) to capture its value at each iteration.

A closure keeps state encapsulated in the enclosing scope — invisible to and unmodifiable by unrelated code — whereas a global is shared mutable state anyone can clobber. Each closure instance also gets its own independent state.

def counter():
    n = 0
    def inc():
        nonlocal n
        n += 1
        return n
    return inc

a, b = counter(), counter()
a(); a(); b()            # a -> 2, b -> 1  (separate state)

Rule of thumb: use closures to encapsulate private, per-instance state instead of leaking it into globals.

A closure stores a reference to the variable, not a snapshot of its value — so it reads the variable's current value when called, not when defined. This late binding is why loop closures surprise people and why mutating an enclosed variable later affects all closures over it.

x = 10
f = lambda: x
x = 20
f()                      # 20  -> reads x at call time

Rule of thumb: closures see live variables; bind a value explicitly (default arg) if you need it frozen.

Enclosed variables become cell objects shared between the outer and inner function. The inner function's __closure__ is a tuple of these cells, and its code lists them in __code__.co_freevars. Reading/writing the free variable goes through the cell, which is how state stays shared and live.

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

f = outer()
f.__closure__[0].cell_contents       # 1
f.__code__.co_freevars               # ('x',)

Rule of thumb: free variables are stored in cells; inspect __closure__ to see what a function captured.

nonlocal rebinds a name in the nearest enclosing function scope; global rebinds a name at module scope. Without either, an assignment inside a function creates a new local, shadowing the outer name.

x = "module"
def outer():
    x = "enclosing"
    def inner():
        nonlocal x; x = "changed"   # affects outer's x
        # global x  -> would affect the module-level x
    inner()
    return x                        # "changed"

Rule of thumb: nonlocal for enclosing-function state, global for module-level state; assignment alone always makes a local.

Capture a cache dict in the enclosing scope; the inner function reads and writes it across calls. This is the manual version of functools.lru_cache and a classic closure use.

def memoize(fn):
    cache = {}
    def wrapper(*args):
        if args not in cache:
            cache[args] = fn(*args)
        return cache[args]
    return wrapper

slow = memoize(slow)

Rule of thumb: a closure over a cache dict gives per-function memoization; reach for lru_cache for the battle-tested version.

A function that returns a customized function, with configuration captured in a closure. It lets you generate specialized functions from parameters without repeating code.

def power_of(exp):
    def raise_(base):
        return base ** exp
    return raise_

square, cube = power_of(2), power_of(3)
square(5), cube(2)       # 25, 8

Rule of thumb: use a factory closure to stamp out related functions parameterized by captured values.

Any assignment to a name inside a function makes it local for the whole function body, so reading it before the assignment — even to do n += 1 — fails with UnboundLocalError. Declare it nonlocal (or global) to rebind the outer one instead.

def counter():
    n = 0
    def inc():
        n += 1           # UnboundLocalError: n treated as local
        return n
    return inc
# fix: add `nonlocal n` at the top of inc

Rule of thumb: to modify (not just read) an enclosing variable, you must declare it nonlocal.

Yes — as long as the closure exists, the cells holding its free variables keep those objects referenced and uncollectable. A long-lived closure capturing a large object (or self) can therefore cause a memory leak if you forget about it.

def make():
    big = load_huge_data()
    return lambda: len(big)      # `big` stays alive via the closure

f = make()                       # huge data retained until f is dropped

Rule of thumb: be mindful that closures pin their captured objects in memory for as long as the closure lives.

A closure reads the variable lazily at call time (late binding); a default argument snapshots the value eagerly at definition time (early binding). That distinction is exactly what fixes the loop-closure bug.

x = 1
late  = lambda: x          # reads x when called
early = lambda x=x: x      # froze x = 1 at definition
x = 99
late(), early()            # (99, 1)

Rule of thumb: closure = live/late, default arg = frozen/early — choose based on whether you want the current or captured value.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel