Scope, Shadowing, global, and Local Errors
Key Takeaways
- A name assigned anywhere inside a function is local to that function unless a global declaration says otherwise.
- Local variables vanish when the call returns, but values can survive by being returned to and stored by the caller.
- A local name can shadow a global name of the same spelling without modifying the global object.
- Reading an undefined name raises NameError; reading a local name before it is assigned raises UnboundLocalError.
- The global statement lets a function rebind a module-level name, and PCEP uses it mainly to test tracing accuracy.
Names live in scopes
A scope is the region of a program where a name can be found. PCEP tests two scopes: the global (module-level) scope and the local scope created by each function call. Python resolves names using the LEGB order: Local, Enclosing, Global, Built-in. For entry level, focus on Local then Global then Built-in.
x = 10
def show():
x = 3
print(x)
show()
print(x)
This prints 3 then 10. The assignment x = 3 inside show creates a brand-new local x; it never touches the global x. Each call to a function gets a fresh local namespace, so locals from one call cannot leak into another.
Shadowing and read-only global access
Shadowing is when an inner scope reuses the spelling of an outer name. It is legal but makes snippets harder to read because the same word points to different objects on different lines.
| Where the name is assigned | Category | Visible where |
|---|---|---|
| Outside any function | Global | Module level; readable inside functions |
Inside a function (no global) | Local | That single call only |
Inside a function with global x | Global rebinding | Affects module-level x |
A function may read a global name as long as it never assigns that same name locally:
rate = 2
def cost(n):
return n * rate
print(cost(5))
cost only reads rate, so Python looks outward and finds the global value 2; the result is 10. The moment you add rate = ... anywhere in the body, the rules change, as the next block shows.
The local-assignment trap: UnboundLocalError
If a function assigns to a name anywhere in its body, Python treats that name as local for the entire function, even on lines before the assignment, unless a global declaration overrides it.
count = 1
def bump():
print(count)
count = count + 1
bump()
This does not print 1. Because count is assigned later in bump, Python marks count local throughout. The first line tries to read the local count before it has a value, raising UnboundLocalError (a specialized subclass of NameError). This is one of the most heavily tested scope traps on PCEP.
Contrast with a plain NameError, which occurs when a name exists in no accessible scope at all:
def report():
print(total)
report()
If no global total exists, this raises NameError. UnboundLocalError means "the name is local but not yet assigned"; NameError means "the name does not exist anywhere I can look."
global, and how values escape a function
To rebind a module-level name from inside a function, declare it global first:
count = 1
def bump():
global count
count = count + 1
return count
print(bump()) # 2
print(count) # 2
Both prints show 2 because the assignment now targets the module-level count.
Without global, a local variable disappears when the call ends. The only entry-level way to keep its value is to return it:
def make_score():
score = 95
return score
result = make_score()
print(result) # 95
# print(score) here would raise NameError
The integer 95 survives in result, but the name score was purely local and never created at module level.
Exam approach. Annotate every assignment: mark globals before the first def, mark locals inside each function, route global name assignments to the module level, and check whether any name is read before it exists. Most scope errors on PCEP come from reading code visually instead of following Python's name-resolution rules.
Built-ins, the LEGB chain, and mutation versus rebinding
The final letter of LEGB is Built-in: names like len, print, int, and list live in a built-in scope Python always searches last. Because it is searched last, you can accidentally shadow a built-in by assigning to its name:
list = [1, 2, 3]
print(list) # works, prints [1, 2, 3]
# print(list(range(3))) # now raises TypeError: 'list' object is not callable
Once list names your data, the built-in constructor is hidden in that scope, and calling list(...) fails. PCEP uses this to test whether you understand that built-ins are just names, not protected keywords.
Mutation does not require global. A function may freely change the contents of a mutable object it received, because that is reading the name then mutating the object, not rebinding the name:
def add_item(bag):
bag.append('x') # mutates, no global needed
stuff = []
add_item(stuff)
print(stuff) # ['x']
The global stuff changed because both names point to the same list object. Contrast with bag = [1] inside the function, which would rebind the local name and leave stuff untouched. The distinction is mutation versus assignment: mutation affects the shared object; assignment creates or rebinds a name.
| Operation inside function | Needs global? | Affects caller's object? |
|---|---|---|
bag.append(1) (mutate) | No | Yes |
bag = [1] (rebind) | Yes, to affect a global | No, otherwise local |
Read rate (no assign) | No | n/a (read-only) |
This table resolves the most common scope confusion on the exam.
What does shadowing mean in an entry-level Python function question?
A function does print(count) and then later count = count + 1, while a global count exists. What happens when it is called?
What is the effect of writing global count at the top of a function body?