Variable Scope

Which name a piece of code can see, the four places Python looks, and why assigning inside a function does not change the outside.

Overview

The four places

LEGB is the order:

  • Local — names assigned in the current function
  • Enclosing — names in a function that wraps this one
  • Global — names at module level
  • Builtin — len, print, range and friends

The first match wins, which is why naming a variable list or sum shadows the builtin for the rest of that scope. It is legal and it will confuse you later.

scope.py

scope.py Python 3
Output

                    

legb.py

legb.py Python 3
Output

                    

Worth knowing

Python looks in four places, in order: Local, Enclosing, Global, Builtin.
Reading an outer name works. Assigning creates a local one instead.
One assignment anywhere makes the name local for the whole function - which is why reading it earlier raises UnboundLocalError.
global rebinds a module name; nonlocal rebinds the enclosing function's.

Variable Scope: A Practical Guide

When Python meets a name, it looks in four places in a fixed order — local, enclosing, global, builtin — and stops at the first one that has it. Almost every scope surprise comes from one rule about assignment.

Reading is easy, assigning is the trap

A function can read an outer name without ceremony:

x = "global"
def show():
    print(x)  # fine

But assigning to that name inside the function does not change the outer one. It creates a new local name that happens to be spelled the same, and it disappears when the function returns.

The rule that catches everyone

If a name is assigned anywhere in a function, it is local for the entire function — including lines that run before the assignment.

def broken():
    print(x)      # UnboundLocalError
    x = "too late"

That print looks like it should read the global, and it would have, if the line below it did not exist. Python decides local-or-not when it compiles the function, not while running it. The error message — "local variable referenced before assignment" — is precise once you know this, and baffling before.

global and nonlocal

To rebind rather than shadow, say so:

def bump():
    global count
    count += 1

global reaches module level. nonlocal reaches the nearest enclosing function, which is what makes closures able to keep state:

def counter():
    n = 0
    def step():
        nonlocal n
        n += 1

Both are worth knowing and neither is worth reaching for often. A function that rebinds globals is a function whose behaviour depends on when you call it, which is exactly the kind of thing that makes bugs hard to find. Returning a value is nearly always better.

Mutating is not assigning

One clarification that resolves a lot of confusion: items.append(1) is not an assignment. It mutates the object the name already points at, so it affects the outer list without needing global. items = [1] is an assignment, and creates a local. The distinction is the object versus the name.

Why the rule exists

Deciding local-or-not at compile time looks like an odd choice until you consider the alternative. If Python worked out scope while running, then whether a name was local could depend on which branch had executed, and the same line could mean two different things on two different runs. Instead the compiler scans the whole function body once: if a name is assigned anywhere in it, that name is local everywhere in it.

That single rule explains the UnboundLocalError that catches everyone. The read on line one is a read of a local variable, because line five assigns to it, and the local has no value yet. The error message is precise; it just describes a decision made before the function ran.

Closures, and why they are useful

A function defined inside another function can see the enclosing function's variables, and it keeps seeing them after the outer function has returned. That combination - an inner function plus the variables it captured - is a closure, and it is how a function can carry state without a class or a global.

The counter on this page is the small version. The pattern scales to configuration: a function that builds and returns another function with some settings already baked in. make_formatter(currency="GBP") returns something you can call many times without repeating the currency.

nonlocal exists because assigning inside the inner function would otherwise create a new local there, exactly as it would at module level. It says "this name belongs to the function that wraps me", and it is the only way to update captured state rather than merely read it.

Globals, and when they stop being harmless

Reading a module-level constant from inside a function is ordinary and fine - that is what constants are for. Writing to a module-level name from inside a function is where trouble starts, because the function's behaviour now depends on what ran before it, and testing it in isolation stops being possible.

The practical test is whether calling the function twice with the same arguments gives the same answer. If a global statement means it does not, the state probably wants to be a parameter, a return value, or an attribute on an object - all three of which make the dependency visible in the signature rather than buried in the body.

Shadowing builtins

list, dict, set, id, type, sum, input and max are all ordinary names that happen to be defined in the builtin scope, and assigning to any of them inside your code hides the original for the rest of that scope. It is legal and it produces confusing failures later, usually when something calls list(x) and gets a TypeError about your list not being callable.

Watching the four scopes resolve

LEGB is easier to trust once you have seen it choose:

x = "global"

def outer():
    x = "enclosing"

    def inner():
        print("inner sees:", x)

    inner()

outer()
print("module sees:", x)
inner sees: enclosing
module sees: global

inner never assigns x, so it looks outward: not local, so enclosing — and it stops there, at outer's x, without ever reaching the module-level one. The module's x is untouched, because outer's assignment created a name in outer rather than changing the global.

Now the version that fails:

def broken():
    print(x)
    x = "too late"

broken()
UnboundLocalError: cannot access local variable 'x' where it is not associated
with a value

There is a perfectly good global x, and the print does not reach it. The assignment on the line *below* made x local for the whole function, so the print is reading a local that has not been given a value yet.

The wording of that error changed in Python 3.11; older versions say "local variable 'x' referenced before assignment". Both describe the same thing, and the newer one is clearer about it.

Comprehensions have their own scope, loops do not

Two things that look similar and differ, which explains a family of small surprises.

A for loop does not create a scope. The loop variable is an ordinary local, it survives after the loop ends, and it overwrites anything of the same name. This is why for i in range(3) leaves i set to 2 afterwards, and why reusing a name in nested loops silently shadows.

A comprehension does create one. Its loop variable lives only inside it, does not leak, and does not clobber a name of the same spelling outside. This changed in Python 3 specifically because the leaking was a common source of bugs.

The consequence worth remembering is that a comprehension can read enclosing names but cannot see a class body's names. A comprehension written directly in a class body that refers to another class attribute raises NameError, because the class body is not a function scope and the comprehension's scope skips straight past it to the module. It is the one genuinely strange corner of Python's scoping, and the fix is to compute the value outside the class or pass it in through the iterable.

Where scope shows up in practice

The rules are short; the situations where they bite are worth naming.

A function that "does not update" a counter. Assigning to a module-level name inside a function creates a local instead. The function appears to run and the value never changes. global fixes it and is usually the wrong fix — returning the new value and assigning at the call site keeps the dependency visible.

A callback that captured the wrong value. Functions built in a loop capture the *variable*, not its value, so they all see the final one. Binding it as a default argument — lambda i=i: ... — captures at definition time. This is the same late-binding behaviour that makes closures useful, seen from the wrong side.

A shadowed builtin. Naming a variable list, dict, sum, id or input hides the builtin for the rest of that scope. The failure comes later, usually as TypeError: 'list' object is not callable, at a line that looks correct.

A name that only exists sometimes. Assigning inside an if and reading after it works when the branch ran and raises UnboundLocalError when it did not. Initialise before the branch.

All four are the same rule seen from different angles: assignment decides where a name lives, and it decides it for the whole function.

Why there is no block scope

Coming from C, Java or JavaScript, the most surprising rule is that an if or a for does not create a scope. A name assigned inside one is visible after it, and the loop variable outlives the loop.

This is deliberate, and the reasoning is the same as everywhere else in the language: functions are the unit of encapsulation, and adding a second, finer kind of scope would mean two sets of rules for readers to hold. A function body is one namespace, and where in that body a name was first assigned does not change what it refers to.

It has practical consequences worth using rather than fighting. A value computed inside an if is available afterwards, so the common pattern of declaring a variable before a branch purely so it exists later is unnecessary — assign it in both branches instead. A loop can leave its last value behind on purpose, which is occasionally the neat way to say "the last item that matched".

It also has one real hazard: a name assigned only inside a branch that did not run does not exist, and reading it raises UnboundLocalError. The compiler knows the name is local, so it does not fall back to a global of the same spelling. Initialising before the branch, or assigning on every path, is the fix — and the error is at least loud rather than silently reading something from an outer scope.

The one exception, comprehensions, exists precisely because the leaking there was a genuine problem rather than a convenience.

Questions people ask

Does an if or a for block create a scope? No. Only functions, classes, modules and comprehensions do.

Can I read a global without declaring it? Yes. global is only needed to *assign* to one.

What is the difference between global and nonlocal? global reaches module level; nonlocal reaches the nearest enclosing function. nonlocal fails if there is no such name.

Why does items.append(1) work without global? Because it mutates the object rather than rebinding the name. Only assignment is affected.

Does a class body count as an enclosing scope? No, and this is the exception that surprises people — methods do not see class-level names without self or the class name.

Can I list what is in scope? locals() and globals() return dictionaries of the current names, which is occasionally useful for debugging.

Is shadowing a builtin ever fine? In a two-line function where it is obvious, it does no harm. As a habit it costs more than the shorter name saves.

Does del x remove a name from scope? It unbinds it, so a later read raises NameError or UnboundLocalError. It does not reach into an outer scope.

Why can a method not see class attributes directly? Because a class body is not an enclosing scope for its methods. Reach them through self. or the class name.

What scope does a lambda have? The same as any function: its own local scope, with the enclosing one visible. That is why the loop-capture trap applies to lambdas exactly as it does to def.

Recap in one screen

  • Names resolve local, enclosing, global, builtin — first match wins.
  • If a name is assigned anywhere in a function it is local everywhere in that function, including lines above the assignment.
  • global and nonlocal rebind rather than shadow, and both are usually a sign that a return value would be better.
  • Mutating is not assigning: items.append(x) needs no declaration, items = [x] creates a local.
  • A for loop shares the enclosing scope; a comprehension has its own.

Scope says who can see a name, not who can see it at the same time

Everything above assumes one thread of execution. Add a second and every rule still holds — a call still gets its own local namespace, global still rebinds at module level — but a question appears that scope cannot answer. Two threads running the same function have separate locals, and *share* every global, along with every mutable object those locals happen to point at.

That sharing is where the hard bugs live. counter += 1 on a module-level integer is a read, an add and a write, and the other thread can be scheduled in either gap.

Check yourself

0 of 3

Answer without scrolling back up.

  1. What order does Python search for a name?

  2. Why does reading `x` before `x = 1` inside a function raise?

  3. `items.append(1)` inside a function affects the outer list. Why no `global`?

Cheat sheet

Variable Scope

When Python meets a name, it looks in four places in a fixed order — local, enclosing, global, builtin — and stops at the first one that has it. Almost every scope surprise comes from one rule about assignment.

PYTHON · vizlearn.in/python/variable_scope.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.