Scope: where a variable lives
A variable created inside a function does not exist outside it. That single
rule is what makes functions safe to write — you can name something total
without wondering whether some other part of the program is already using that
name.
def calculate():
subtotal = 450
return subtotal
print(calculate())
print(subtotal)
NameError: name 'subtotal' is not defined
subtotal was born when calculate started and destroyed when it returned.
The print on the last line is asking about something that no longer exists.
That error is a feature. Without it, every function in a large program would be quietly interfering with every other.
Local and global
A variable defined at the top level of a file is global — visible everywhere in that file. One defined inside a function is local to that call.
TAX_RATE = 1.15
def with_tax(price):
result = price * TAX_RATE
return round(result)
print(with_tax(250))
print(TAX_RATE)
288 1.15
The function read TAX_RATE without being handed it. Reading a global from
inside a function works. That is how constants and imported modules are used
everywhere without being passed around.
Reading works, assigning does not
Here is the line that surprises people.
count = 0
def increment():
count = count + 1
return count
print(increment())
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
The function reads count, adds one, and assigns the result to count. It
looks like it should work. It does not, and the reason is worth understanding
because it explains the whole model.
Python decides whether a name is local or global by scanning the function body before running it. If a name is assigned anywhere in the function, it is local throughout the function — including on lines before the assignment.
So count is local. And on the right-hand side of count = count + 1, that
local count has no value yet. Hence the error, and hence its odd wording:
"where it is not associated with a value".
Note the asymmetry. Remove the assignment and it reads the global perfectly:
count = 5
def show():
return count
print(show())
5
One line changed the meaning of the name for the entire function.
global, and why to avoid it
The global keyword says "this name refers to the module-level variable, even
though I assign to it".
count = 0
def increment():
global count
count = count + 1
return count
print(increment())
print(increment())
print(count)
1 2 2
It works, and it is almost always the wrong answer. A function that modifies a global is a function whose result depends on when it was called and what else has run — you cannot read it in isolation, cannot test it without setting up the world first, and cannot call it twice with the same input and expect the same output.
Pass the value in and return the new one instead:
def increment(count):
return count + 1
count = 0
count = increment(count)
count = increment(count)
print(count)
2
The function now says everything it does in its own two lines. That is the version you can trust.
Mutable objects are a different matter
Assignment rebinds a name. Calling a method that changes an object in place
does not, so no global is needed — and this is where accidental sharing gets
in.
basket = []
def add(item):
basket.append(item)
add("chai")
add("paratha")
print(basket)
['chai', 'paratha']
No error and no global, because basket.append(...) never assigns to
basket. It looks the name up, finds the global list, and mutates it.
That is a real distinction: basket = [...] inside the function would create
a local and leave the global untouched, while basket.append(...) changes
the shared one. The same applies to any argument you pass in:
def add_all(target, items):
for item in items:
target.append(item)
my_list = ["chai"]
add_all(my_list, ["paratha", "samosa"])
print(my_list)
['chai', 'paratha', 'samosa']
The caller's list changed. Sometimes that is exactly the intent; when it is not, build and return a new list rather than modifying the one you were given.
LEGB: the lookup order
When Python meets a name it searches four places, in order:
- Local — this function
- Enclosing — any function wrapping this one
- Global — the module
- Built-in —
print,len,sumand the rest
The first match wins.
value = "global"
def outer():
value = "enclosing"
def inner():
value = "local"
return value
return inner(), value
print(outer())
print(value)
('local', 'enclosing')
globalThree variables named value, three separate boxes, no interference. Each
scope shadows the one outside it for as long as it lasts.
Shadowing a built-in
Because built-ins are searched last, a variable of the same name hides them.
list = [1, 2, 3]
print(list)
[1, 2, 3]
That works, and list("abc") anywhere after it now fails, because list is a
list rather than the type. The usual casualties are list, dict, str,
sum, id, type and input — all short, obvious names for exactly the
thing you were about to store in them.
The convention is a trailing underscore when you really want the name:
list_, input_, type_.
Loop variables leak
Unlike many languages, a for variable outlives its loop.
for i in range(3):
pass
print(i)
2
A loop body is not a new scope; only functions, classes and comprehensions
create one. This is occasionally handy and mostly a trap — a stale i from an
earlier loop is a genuinely confusing bug.
Comprehensions do have their own scope, so they do not leak:
squares = [n * n for n in range(3)]
print(squares)
[0, 1, 4]
n does not exist afterwards.
The habit
A function should take everything it needs as arguments and hand back everything it produces as a return value. Read globals for genuine constants; never assign to them. A function written that way can be read on its own, tested on its own, and moved to another file without a thought.
Next: designing a good function.
Prefer learning with a teacher?
WasiLearn Academy runs small-group classes covering this material, online and in Karachi.
See the classes