Scope is the area of a program where a variable exists. Knowing which variables a function can see — and what it can change — is the foundation of writing predictable code.
Two main scopes — global and local
A variable defined inside a function is local to that function. It exists only while the function is running, and only the function can see it:
def greet() -> None:
message: str = "Hello"
print(message)
greet() # prints "Hello"
print(message) # NameError — message doesn't exist here
A variable defined outside any function is global. It exists for the whole program and any function can read it:
name: str = "Manikandan"
def greet() -> None:
print(f"Hello, {name}") # reads the global name
greet() # Hello, Manikandan
Local variables shadow globals
If a function defines a local variable with the same name as a global, the local wins inside the function:
name: str = "Manikandan"
def greet() -> None:
name: str = "World" # this is a NEW local variable
print(name)
greet() # World
print(name) # Manikandan — the global is untouched
The global name is unchanged. The function created its own name that lives only while it runs.
You can read globals, but not change them (by default)
This catches everyone at least once:
counter: int = 0
def increment() -> None:
counter += 1 # UnboundLocalError
increment()
UnboundLocalError: local variable 'counter' referenced before assignment
Python sees counter += 1 and decides counter must be a local variable. But there’s no local counter to read from — so it errors.
The right fix in most cases is don’t modify globals. Pass values in, return values out:
def increment(counter: int) -> int:
return counter + 1
counter: int = 0
counter = increment(counter)
print(counter) # 1
This is a more honest design — the function’s effect is visible in its signature.
The global keyword (rarely needed)
If you genuinely must change a global, declare it with global:
counter: int = 0
def increment() -> None:
global counter
counter += 1
increment()
increment()
print(counter) # 2
You’ll see global in some scripts. In production code it’s a smell — it usually means the code should be reorganised. Avoid it when you can.
Mutable objects are different
For lists, dictionaries, and other mutable types, you can change their contents without global:
scores: list[int] = []
def add_score(value: int) -> None:
scores.append(value) # OK — we're calling a method, not reassigning
add_score(80)
add_score(90)
print(scores) # [80, 90]
The difference: scores.append(value) doesn’t reassign scores — it changes the list that scores points to. The global name still points to the same list.
This is one of the trickiest distinctions in Python. The rule of thumb: reassignment makes a local; mutation doesn’t.
Functions inside functions
You can define a function inside another function. The inner function can see the outer function’s variables:
def make_greeter(greeting: str):
def greet(name: str) -> str:
return f"{greeting}, {name}!"
return greet
hello = make_greeter("Hello")
howdy = make_greeter("Howdy")
print(hello("Manikandan")) # Hello, Manikandan!
print(howdy("Friend")) # Howdy, Friend!
The inner greet “remembers” the greeting it was created with. This pattern is called a closure. It’s not common at the beginner level, but worth recognising when you see it.
The LEGB rule
When Python looks up a name, it searches scopes in this order:
- Local — the current function
- Enclosing — any function this one is defined inside
- Global — the module (the file)
- Built-in —
print,len,range, etc.
You don’t have to memorise the rule. Just know that inner scopes win over outer ones, and Python falls back outward only if the name isn’t found.
Practical advice
- Keep functions self-contained. Pass everything they need in as parameters. Return everything they produce.
- Avoid modifying globals. It makes code unpredictable.
- Don’t shadow built-ins. Naming a variable
listordict“hides” the real function. Ruff warns about this.
What’s next
You know where variables live. Next, lambda functions — Python’s syntax for tiny one-line functions.