BASIC Python (Beginner Level) 📂 Basic Python Programming · 13 of 15 20 min read

Python Function Scopes — LEGB, Global vs Local

Master how variables behave inside Python functions: the LEGB scope lookup order, local vs global vs enclosing scope, why reading a global works but assigning creates a local shadow, the famous UnboundLocalError: referenced before assignment trap, the global and nonlocal keywords, calling default arguments by keyword to skip earlier ones, the mutable-default trap, and pass-by-object-reference behaviour with mutable vs immutable objects.

Section 01

Why Scope Exists — The Rooms in Your House

The Whiteboard in Every Room
Imagine a large house where every room has its own whiteboard. You scribble a phone number on the kitchen whiteboard. Then you walk into the bedroom and write a different number on that whiteboard using the same label — "contact". Later, when you're in the kitchen and someone asks for "contact", you read the kitchen whiteboard, not the bedroom one.

Python functions work exactly like these rooms. Each function has its own private whiteboard — a local scope. Names written there don't leak into other rooms. This is what keeps a 50,000-line codebase sane: two functions can both use the variable i without stepping on each other.

A scope is the region of a program where a name (variable, function, class) is directly accessible. Python decides which value belongs to a name by looking at scopes in a fixed order. Understanding that order is the difference between "the code works" and "I have no idea why the code works".

🏠
The Core Insight

Every time a function runs, Python creates a fresh local namespace for that call and destroys it when the function returns. Names created inside a function are born and die with that call — unless you explicitly say otherwise.


Section 02

The LEGB Rule — Python's Scope Lookup Order

Whenever Python sees a name being read, it searches four scopes in this exact order, stopping at the first match. Memorize the acronym LEGB and you have solved 90% of all scope confusion.

L
Local — inside the current function
Names assigned inside the function body, including parameters. Checked first. Fastest.
E
Enclosing — any outer function wrapping this one
Only applies to nested functions. Reaches outward one function at a time.
G
Global — the module (file) level
Names defined at the top of the .py file, outside any function or class.
B
Built-in — Python's baked-in names
Names like print, len, range, str. Always available. Checked last.
# Global scope
x = "global-x"

def outer():
    x = "enclosing-x"              # Enclosing scope

    def inner():
        x = "local-x"              # Local scope
        print("inside inner:", x)   # finds Local first

    inner()
    print("inside outer:", x)       # finds Enclosing

outer()
print("module level: ", x)          # finds Global
print(len(x))                       # len comes from Built-in
OUTPUT
inside inner: local-x inside outer: enclosing-x module level: global-x 8
💡
Read the Name in Your Head as "Local First, Built-in Last"

When you can't figure out which x a piece of code is using, walk outward one ring at a time: current function → any function wrapping it → module top → Python itself. The first place the name exists wins.


Section 03

Local Scope — What Happens Inside a Function, Stays Inside

Any name that Python sees being assigned inside a function body is treated as local to that function — unless you explicitly tag it global or nonlocal. This local-by-default rule is Python's most important scope decision.

def make_greeting():
    message = "Hello"              # local to make_greeting
    count   = 3                    # local to make_greeting
    print(message, count)

make_greeting()
print(message)                        # NameError — message does not exist here
OUTPUT
Hello 3 Traceback (most recent call last): File "demo.py", line 7, in <module> print(message) NameError: name 'message' is not defined

Each Call Gets a Fresh Local Scope

def counter():
    n = 0                       # fresh n on every call
    n += 1
    print(f"n = {n}")

counter()
counter()
counter()
OUTPUT
n = 1 n = 1 n = 1
📈
Why Every Call Prints 1

The name n is born when counter() starts and destroyed when it returns. It has zero memory between calls. If you want state that persists across calls, you need a global, a class attribute, or a closure — not a local variable.


Section 04

Reading Globals from Inside a Function — Works Fine

You can read a global variable from inside a function without any special syntax. Python's LEGB search finds it in the Global scope after failing to find it Locally. No error, no ceremony.

APP_NAME = "PaymentService"       # global
VERSION  = "2.4.1"               # global

def show_banner():
    # Only READING globals — perfectly legal
    print(f"=== {APP_NAME} v{VERSION} ===")

show_banner()
show_banner()
OUTPUT
=== PaymentService v2.4.1 === === PaymentService v2.4.1 ===
This Pattern Is Everywhere

Constants, imported modules, configuration values, and helper functions defined at the top of a file are all globals that get freely read inside functions. This is the normal, healthy use of globals — reading them, not writing them.


Section 05

The Famous Trap — UnboundLocalError

The One Line That Changes Everything
You have a global counter. Your function reads it and prints it — works fine. Then you add one innocent line: counter += 1. Suddenly the whole function explodes with UnboundLocalError: local variable 'counter' referenced before assignment. You didn't touch the print line, so why is it broken?

This is the single most confusing error in Python. Understanding it teaches you how Python's compiler thinks — and once you see it, you can never un-see it.
counter = 0                        # global

def bump():
    print("before:", counter)      # seemed fine before...
    counter += 1                    # ...but this line changes EVERYTHING
    print("after: ", counter)

bump()
OUTPUT
Traceback (most recent call last): File "demo.py", line 7, in <module> bump() File "demo.py", line 4, in bump print("before:", counter) UnboundLocalError: cannot access local variable 'counter' where it is not associated with a value
⚠️
Why the Error Points at the print, Not the +=

When Python compiles the function body, it scans it once and marks every name that gets assigned anywhere in the function as local. It sees counter += 1 further down and decides counter is local for the whole function. Then the earlier print(counter) tries to read a local variable that hasn't been assigned yet — and fails.

What Python Actually Sees

🔎 Compilation-Time Scan of bump()
Step 1
Python scans the whole function body before running any of it.
Step 2
It sees counter += 1 — that's an assignment to counter.
Step 3
It marks counter as a local name for this function.
Step 4
Execution starts. The first print tries to read local counter.
Step 5
Local counter has never been given a value yet. UnboundLocalError.
🔧
The Rule to Etch Into Memory

If a function assigns to a name anywhere in its body, that name is local everywhere in that function — even on lines that come before the assignment. There's no half-local, half-global. It's local for the entire scope.


Section 06

The global Keyword — Reaching Out to Modify Globals

To actually modify a global variable from inside a function, you have to declare it global at the top of the function. This tells Python: "don't create a local counter — reuse the module-level one".

counter = 0                        # global

def bump():
    global counter               # "I mean THE module-level counter"
    print("before:", counter)
    counter += 1                    # now modifies the global
    print("after: ", counter)

bump()
bump()
bump()
print("final: ", counter)
OUTPUT
before: 0 after: 1 before: 1 after: 2 before: 2 after: 3 final: 3

Before vs After — What global Actually Changes

🚫 Without global
BehaviorWhat happens
AssignmentCreates a local shadow variable
Read of same nameUnboundLocalError
Module stateUnchanged after function returns
Bug likelihoodVery high — hidden shadowing
✅ With global
BehaviorWhat happens
AssignmentModifies the module-level variable
Read of same nameReads the same global — no error
Module statePersists across all future calls
Bug likelihoodExplicit — reader knows what's changing
🚩
Use global Sparingly — It's a Code Smell at Scale

Every function that mutates a global becomes an invisible dependency on every other function that reads that global. Testing gets hard, concurrency gets scary, and bugs get hard to trace. Prefer returning values, passing arguments, or using a class attribute. Reserve global for genuine module-wide state — configuration flags, singletons, caches — not general communication between functions.


Section 07

The Mutation Loophole — Why Lists and Dicts Seem to "Just Work"

Here's something that trips up almost everyone. This code has no global declaration and yet it works fine:

cart = []                            # global list

def add_item(item):
    cart.append(item)                # no 'global cart' needed?!

add_item("book")
add_item("pen")
print(cart)
OUTPUT
['book', 'pen']
🎯
The Fine Line: Mutating vs Rebinding

cart.append(x) does not assign to cart — it mutates the object that cart already points to. Python's scope rules only care about assignment. Reading a name and calling a method on it doesn't count as assignment, so no local shadow gets created, and the global object gets modified.

cart = []

def reset_cart():
    cart = []                        # this is REBINDING -> creates a local!
    cart.append("fresh")

reset_cart()
print("global cart:", cart)         # still []
OUTPUT
global cart: []
Operation inside function Is it assignment? Does it touch the global? Needs global?
x = 5YesNo — creates localYes, to modify global
x += 1Yes (rebind)No — creates localYes, to modify global
lst.append(v)No — method callYes — mutates in placeNo
d[k] = vNo — item assignmentYes — mutates in placeNo
lst = [1, 2]Yes (rebind)No — creates localYes, to modify global
print(x)No — read onlyYes — reads globalNo

Section 08

Enclosing Scope and the nonlocal Keyword

When one function is defined inside another, the outer function's variables form an enclosing scope for the inner function. The inner function can read them freely, but assigning creates a local shadow — same trap as globals. To actually modify the enclosing variable, use nonlocal.

def make_counter():
    count = 0                       # enclosing variable

    def increment():
        nonlocal count             # reach outward to enclosing scope
        count += 1
        return count

    return increment

tick = make_counter()
print(tick())
print(tick())
print(tick())
OUTPUT
1 2 3
KeywordTarget scopeTypical use
globalModule (top) levelModify a module-level variable from any function
nonlocalNearest enclosing functionModify an outer function's variable from a nested one (closures, decorators)
🔑
This Is How Closures Work

The tick function above remembers the count from make_counter even after make_counter has returned. That's a closure: an inner function plus the enclosing scope it captured. It's the foundation of decorators, callbacks, and functional patterns in Python.


Section 09

Golden Rules

🌐 Function Scope — Non-Negotiable Rules
1
LEGB — Local, Enclosing, Global, Built-in. Python resolves every name in that order. When you can't tell which variable is which, walk the rings outward one at a time.
2
Assignment inside a function makes the name local for the entire function — including on lines that come before the assignment. This is why UnboundLocalError: referenced before assignment happens even when the read appears above the write.
3
Reading a global is free; writing to one is not. You can freely read constants and configuration from inside any function. But to reassign a global, you must declare it global at the top of the function.
4
Use global only for genuinely module-wide state (config, singletons, caches). For general communication between functions, return values and pass arguments instead. Mutable globals make code hard to test and reason about.
5
Mutating an object (lst.append, d[k] = v) is not assignment — it doesn't create a local shadow. That's why you don't need global to modify a global list or dict in place. But it's also why accidental mutation is one of Python's easiest bugs to create.
6
Use nonlocal to modify an enclosing function's variable from a nested function. This is how closures with mutable state (counters, memoization, simple decorators) are built.
7
When a scope bug confuses you, ask two questions: (1) Am I assigning to the name, or just reading it? (2) Which scope did Python actually pick? Answer those and the bug reveals itself every time.