Good to see you again. In the previous lesson, you designed function signatures that make a call contract explicit. This lesson moves one level beneath the call: when a function body refers to a bare name such as timeout_s, logger, or len, how does Python decide which object that name means?
This is especially useful when troubleshooting automation code that unexpectedly uses an outer configuration value, masks a built-in, or raises UnboundLocalError after what appears to be an innocent assignment. You will learn to trace those cases using Python’s LEGB rule: local, enclosing, global, and built-in scope.
Names are bindings, and scopes make bindings visible
Python variables are names bound to objects. This is consistent with the reference behavior you examined earlier in the module: assigning a name usually does not copy an object; it creates or changes a binding.
timeout_s = 3.0
backup_timeout_s = timeout_s
Both names currently refer to the same float object. Scope answers a different question from object identity:
From this line of code, which namespaces may Python search for an unqualified name?
A namespace is a mapping of names to objects. A module’s top-level names, a particular function call’s local names, and Python’s built-in names are all namespaces. A scope is the region of source code in which a namespace can be searched directly.
For example:
DEFAULT_RETRIES = 3
def probe(target):
attempts = 0
return DEFAULT_RETRIES - attempts
Inside probe(), Python can resolve:
targetandattemptsfrom the function call’s local namespace;DEFAULT_RETRIESfrom the module namespace;- built-ins such as
print,range, andlenif no nearer binding exists.
A function call creates a new local namespace each time. Therefore, concurrent or recursive calls do not share local variable bindings merely because they execute the same function.
def make_label(site):
label = f"site={site}"
return label
print(make_label("chennai-edge"))
print(make_label("blr-edge"))
The first call has one local binding for site and label; the second has another. When each call returns, its local namespace is no longer directly accessible.
9. Classes — Python 3.14.2 documentation
Read “Python Scopes and Namespaces” in the official Python documentation. It establishes the precise distinction between namespaces, scopes, name binding, and the lifetime of local and module-level names.
In Section 9.2, “Python Scopes and Namespaces,” read the core discussion. Focus on the four searchable scope levels, the fact that a function’s local namespace is created per call, and the important distinction between textual scope determination and runtime lookup.
The LEGB lookup rule
When Python evaluates a bare name inside a function, it searches applicable scopes in this order:
- Local: the current function call’s parameters and bindings.
- Enclosing: bindings in lexically surrounding functions, if this is a nested function.
- Global: the namespace of the module where the function was defined.
- Built-in: Python’s preloaded names, such as
str,ValueError,sum, andprint.
Python stops at the first scope that contains a binding. It does not compare values across scopes or look for a “best” match.

Consider this deliberately shadowed example:
environment = "global"
def configure_probe():
environment = "enclosing"
def render_status():
environment = "local"
return environment
return render_status()
print(configure_probe())
print(environment)
The output is:
local
global
Inside render_status(), Python finds environment in the local scope immediately. The enclosing and global bindings are irrelevant for that lookup. Once configure_probe() returns, its local binding is gone; the final print(environment) is at module level, so it finds the global value.
Now remove the innermost binding:
environment = "global"
def configure_probe():
environment = "enclosing"
def render_status():
return environment
return render_status()
print(configure_probe())
This prints:
enclosing
render_status() has no local environment, so it checks its nearest enclosing function, configure_probe(), and finds one there.
Finally, remove the outer function binding too:
environment = "global"
def configure_probe():
def render_status():
return environment
return render_status()
print(configure_probe())
Now it prints:
global
The search reaches the module-level binding. The progression is always the same: Python searches only as far outward as necessary.
A compact trace table makes this explicit:
| Code location | Local binding for environment? | Nearest successful scope | Value |
|---|---|---|---|
Inside render_status() with all three bindings | Yes | Local | "local" |
Inside render_status() without its own binding | No | Enclosing | "enclosing" |
Inside render_status() without either function binding | No | Global | "global" |
| At module level | Not applicable | Global | "global" |
The word enclosing is important: it refers to a function surrounding another function in the source code. It is not simply “the function that called me.” Scope is lexical, not based on the runtime caller.
For example, if a function is defined in inventory.py, its global scope is inventory’s module namespace even when another module imports and calls it.
Python Tutorial: Variable Scope - Understanding the LEGB rule and global/nonlocal statements
Watch Corey Schafer’s “Python Tutorial: Variable Scope.” It provides a concise visual walkthrough of LEGB and makes the nested-function case easy to follow line by line.
Watch the LEGB overview for the vocabulary and search order. Then watch enclosing lookup, stopping just before the discussion of modifying an enclosing binding. Track which x binding exists in each function as the local definitions are removed.
Global and built-in fallbacks
A module-level binding is called global within that module:
DEFAULT_TIMEOUT_S = 5.0
def connect(target):
return f"Connecting to {target} with timeout {DEFAULT_TIMEOUT_S}s"
connect() can read DEFAULT_TIMEOUT_S because local lookup fails and its module global namespace contains the name.
However, “global” does not mean “available automatically in every module.” Each imported module has its own global namespace. In a multi-file project, prefer explicit module imports and explicit function arguments over relying on another module’s mutable global state.
If Python cannot find a name locally, in an enclosing function, or in the module, it checks the built-in namespace:
def summarize_latencies(samples):
return min(samples), max(samples), len(samples)
No imports are needed for min, max, or len; they are built-ins. But built-ins have the lowest lookup priority, so they are easy to shadow accidentally:
len = 4
def summarize_latencies(samples):
return len(samples)
This fails with:
TypeError: 'int' object is not callable
The function does not reach Python’s built-in len. It finds the global integer named len first and attempts to call it.
Avoid using built-in names for variables, functions, or parameters. Common problematic choices include:
listdictstridtypeinputformatmaxsum
Linters generally flag these names because the resulting failure may occur far from the accidental reassignment. In an interactive session, deleting the shadowing name restores the built-in fallback:
del len
A missing name in all four levels produces NameError:
def report():
return unknown_status
report()
Python searches local, enclosing if any, global, and built-ins. Because none has unknown_status, it raises an error rather than inventing a new binding.
The assignment rule: why a read can fail before an assignment
The most important practical complication is that Python determines whether a name is local by examining the function body’s source code.
If a function assigns to a name anywhere in its body, Python normally treats that name as local throughout that function, unless a scope declaration says otherwise. This includes augmented assignment such as +=.
retries = 3
def increase_retries():
print(retries)
retries += 1
Calling increase_retries() raises:
UnboundLocalError: cannot access local variable 'retries' where it is not associated with a value
At first glance, it may look as though print(retries) should find the global binding. It does not. Python has already classified retries as a local name because of the later retries += 1.
The runtime behavior is effectively:
- Enter
increase_retries()with a local slot namedretries. - Evaluate
print(retries). - Find that the local slot has not yet been bound to an object.
- Raise
UnboundLocalError.
Python does not continue to enclosing or global scope once it has identified retries as a local name for this function.
This is a binding issue, not a mutation issue. Compare these two functions:
retry_policy = {"max_attempts": 3}
def mutate_policy():
retry_policy["max_attempts"] += 1
def replace_policy():
retry_policy = {"max_attempts": 4}
mutate_policy() works. It reads the global binding retry_policy, then mutates the dictionary object that it refers to.
replace_policy() creates a new local binding called retry_policy. It does not replace the global binding. If it tried to read retry_policy before that assignment, it would encounter the same UnboundLocalError pattern.
This distinction matters in configuration-heavy automation:
- Mutating an object changes the object through an existing binding.
- Rebinding a name makes that name point to a different object in the scope selected for assignment.
Python provides global and nonlocal declarations for the cases where an assignment must deliberately target an outer scope. Treat those as explicit design choices rather than routine fixes. The next lesson focuses on nonlocal and controlled state in closures.
A reliable method for tracing a name
When a name behaves unexpectedly, avoid guessing. Trace it systematically.
1. Identify the exact name use
LEGB concerns unqualified names:
timeout_s
It does not directly govern attribute lookup:
config.timeout_s
For config.timeout_s, Python first resolves config using LEGB. The lookup of timeout_s on the resulting object follows attribute-access rules, which you will study later with classes and descriptors.
2. Check whether the current function binds the name
Look for:
- parameters;
- ordinary assignment, including
+=; - loop targets;
with ... as name;except ... as name;- imports;
- nested
deforclassdeclarations using that name.
If the function binds it, the name is local unless declared otherwise.
3. Search outward only when no local binding exists
For a nested function, inspect each surrounding function from the nearest outward. Then check the definition module’s globals, followed by built-ins.
4. Separate lookup from object behavior
Once lookup finds an object, ordinary Python rules apply to that object. For example, this code resolves devices globally and then mutates the list:
devices = []
def register(device):
devices.append(device)
The list mutation is visible outside the function because both uses resolve to the same list object. That does not mean devices is a local variable or that a new global binding was created.
5. Inspect scope state while debugging
For temporary diagnosis, locals() and globals() can make bindings visible:
DEFAULT_VRF = "production"
def build_request(device):
timeout_s = 2.0
print("local:", locals())
print("global vrf:", globals()["DEFAULT_VRF"])
return {"device": device, "timeout_s": timeout_s}
Use these for observation rather than as a normal way to modify program state. Designing clear parameters and explicit configuration objects is more maintainable than dynamically editing namespace dictionaries.
Key takeaways and next step
Python resolves a bare name by searching local, enclosing, global, and then built-in scope, stopping at the first matching binding.
Keep these principles in mind:
- Each function call has its own local namespace.
- Enclosing scope exists only for nested functions and is searched from the nearest surrounding function outward.
- A function’s global scope is the module where it was defined.
- Built-ins are a final fallback and can be accidentally shadowed by local or global names.
- A normal assignment inside a function makes that name local throughout the function, which explains many
UnboundLocalErrorcases. - Name lookup and object mutation are separate concerns: mutating a globally found object is not the same as rebinding its global name.
Next, you will use nonlocal to intentionally modify a binding in an enclosing function, a key technique for maintaining controlled state in closures without introducing module-level global state.
Can't find a good explanation? Sign up and we'll make it for you
Sign up