Skip to content

Variables, Scope & the LEGB Rule Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

Python interview questions on the LEGB scope rule, global vs nonlocal, UnboundLocalError, late binding in loop closures, and name shadowing.

Read the in-depth guidePython Scope and the LEGB Rule Explained — global, nonlocal, and Closures(opens in new tab)
15 of 15

LEGB describes the order Python searches for a name: Local (inside the current function), Enclosing (any outer functions), Global (the module's top level), then Built-in (names like len, print). The first match wins, and the search stops there.

x = "global"
def outer():
    x = "enclosing"
    def inner():
        x = "local"
        print(x)     # "local"  — Local found first
    inner()
outer()

Why it matters: nearly every "why is this variable that value?" question reduces to walking L -> E -> G -> B until a name is found.

Both let you rebind a name from an outer scope instead of creating a new local. global targets the module-level name; nonlocal targets the nearest enclosing function scope (and that name must already exist there).

count = 0
def inc():
    global count
    count += 1        # rebinds module-level count

def outer():
    x = 1
    def inner():
        nonlocal x
        x = 2          # rebinds outer's x, not a new local
    inner()
    return x           # 2

Rule of thumb: you only need these keywords to reassign an outer name — you can always mutate an outer mutable object (e.g. list.append) without them.

Python decides a name's scope at compile time by scanning the whole function body. If a name is assigned anywhere in a function, it is treated as local for the entire function — even on lines before the assignment. Reading it before it's bound raises UnboundLocalError.

x = 10
def f():
    print(x)      # UnboundLocalError: x is local because of the line below
    x = 20        # this assignment makes x local everywhere in f

The fix is to declare global x (or nonlocal x) if you meant the outer name, or simply read a different name. Rule of thumb: an assignment anywhere makes the name local everywhere in that function.

Closures capture variables, not values — this is late binding. The inner function looks up the loop variable when it is called, by which time the loop has finished and the variable holds its final value.

funcs = [lambda: i for i in range(3)]
[f() for f in funcs]      # [2, 2, 2]  — all see the 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 captures i's value at definition time. Rule of thumb: if loop-created closures behave strangely, you're hitting late binding — bind the value explicitly.

A local name shadows (hides) an outer name of the same identity for the duration of the scope. Assigning to it inside a function creates a separate local that leaves the module-level name untouched.

value = "module"
def f():
    value = "function"   # new local — shadows the global
    print(value)         # "function"
f()
print(value)             # "module"  — unchanged

list = [1, 2]            # shadows the built-in list() in this scope!

Watch out for shadowing built-ins (list, id, sum, type) — it silently breaks later calls. Rule of thumb: keep names distinct from outer scopes and built-ins to avoid surprising lookups.

Yes. In Python 3 a comprehension runs in its own implicit function scope, so its loop variable does not leak into the surrounding scope. (In Python 2 list comprehensions did leak — a common gotcha when reading old code.)

[i for i in range(3)]
print(i)          # NameError — i never escaped the comprehension

x = 5
[x for x in range(3)]
print(x)          # 5 — outer x untouched

The comprehension can still read enclosing names. Rule of thumb: treat each comprehension like a tiny function — its variables are private to it.

A closure stores captured variables in cell objects, exposed via __closure__ (the cells) and __code__.co_freevars (their names). This is how you prove a function really closed over an outer variable.

def make(n):
    def f():
        return n
    return f

g = make(42)
g.__code__.co_freevars       # ('n',)
g.__closure__[0].cell_contents  # 42

Rule of thumb: if __closure__ is None, the function captured nothing and is effectively a plain function.

Default values are evaluated once, at function-definition time, in the enclosing scope — not each call, and not in the function's local scope. This is why mutable defaults are shared and why they can't reference other parameters.

y = 10
def f(a, b=y):    # b's default is bound to 10 right now
    return a, b
y = 99
f(1)              # (1, 10) — later change to y is irrelevant

# def g(a, b=a): ...  # NameError — a isn't in scope when default is evaluated

Rule of thumb: defaults are snapshots taken at def time in the outer scope, so avoid mutable defaults and don't expect them to see sibling arguments.

globals() returns the module's namespace dict (live — editing it changes real globals). locals() returns a dict snapshot of the current local namespace; writing to it generally does not reliably update real locals inside a function.

x = 1
def f():
    y = 2
    globals()['x'] = 99    # actually changes module x
    locals()['y'] = 100    # usually has NO effect on y
    return y               # still 2
f()
print(x)                   # 99

Rule of thumb: globals() is a real handle you can mutate; locals() inside a function is read-only-in-practice — don't rely on assigning through it.

Yes — reading an enclosing name needs nothing special; LEGB finds it. You only need nonlocal to rebind it. The keyword's sole purpose is assignment.

def outer():
    msg = "hi"
    def inner():
        print(msg)      # fine — reads enclosing msg
    inner()

def outer2():
    n = 0
    def inc():
        n += 1          # UnboundLocalError without nonlocal
    inc()

Rule of thumb: read freely across scopes; reach for global/nonlocal only the moment you need to assign.

The class body is its own scope that exists only while the class is being defined; it is not an enclosing scope for its methods. So a method can't see a class-level name via plain LEGB — it must qualify it with self. or ClassName..

class C:
    factor = 10
    def scale(self, x):
        return x * factor          # NameError — factor isn't enclosing
    def scale_ok(self, x):
        return x * self.factor      # correct

Comprehensions in a class body are also affected — they can't see other class vars. Rule of thumb: class-body names are attributes, reachable only through self/the class, never as free variables in methods.

del name unbinds the name in the current scope — it removes the binding, not necessarily the object. The name still counts as local (assignment/del makes it local), so reading it afterward raises UnboundLocalError/NameError.

def f():
    x = 1
    del x
    print(x)     # UnboundLocalError — x is local but now unbound

y = [1, 2, 3]
del y[0]         # this deletes an element, not the name

Rule of thumb: del on a bare name removes the binding (and the name stays local); del on a subscript/attribute deletes that item or attribute.

You create a global that shadows the built-in for that module. Built-ins are the last place LEGB looks, so a same-named global wins everywhere in the module — often breaking later code that expected the original.

sum = 0
total = sum([1, 2, 3])    # TypeError: 'int' object is not callable

# recover the built-in if needed:
import builtins
sum = builtins.sum

Rule of thumb: never name variables list, dict, str, sum, id, type, input, etc. — shadowing built-ins causes confusing failures far from the cause.

A free variable is a name used in a function but bound in an enclosing function scope (the E in LEGB) — it lives in a closure cell. A global is bound at module level (the G). The compiler classifies each name as local, free, or global at compile time.

g = 1                     # global
def outer():
    e = 2                 # will be a free var for inner
    def inner():
        return g + e      # g is global, e is free
    return inner
outer().__code__.co_freevars   # ('e',)  — only e is free

Rule of thumb: free = captured from an enclosing function (closure); global = module-level. nonlocal targets free variables, global targets globals.

Functions look up global names at call time, by name, in the module dict — so changing the global before the call changes what the function sees. Locals are resolved per call and per scope, so rebinding one elsewhere can't reach in.

RATE = 0.1
def price(x):
    return x * RATE        # reads RATE at call time

RATE = 0.2                  # patch the global
price(100)                  # 20.0 — sees the new value

This is why monkeypatching module-level config or functions works. Rule of thumb: globals are resolved late (by name, each call); locals are fixed within their scope.

More ways to practice

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

Join our WhatsApp Channel