Python becomes much easier once you understand where variables live and how functions can wrap other functions.
Two concepts sit at the center of that:
Scopes
Decorators
Scopes explain how Python finds variables.
Decorators explain how we can add behaviour to functions without changing their original code.
Python Scope
A scope determines where a variable can be accessed.
Consider:
name = "Dong"
def greet():
message = "Hello"
print(message)
print(name)
greet()
Here:
name
→ global scope
message
→ local scope
message only exists inside greet().
The LEGB Rule
Python searches for variables using the LEGB rule:
Local
↓
Enclosing
↓
Global
↓
Built-in
You can think of it like this:
┌─────────────────────────┐
│ Built-in │
│ │
│ ┌─────────────────┐ │
│ │ Global │ │
│ │ │ │
│ │ ┌───────────┐ │ │
│ │ │ Enclosing │ │ │
│ │ │ │ │ │
│ │ │ ┌───────┐ │ │ │
│ │ │ │ Local │ │ │ │
│ │ │ └───────┘ │ │ │
│ │ └───────────┘ │ │
│ └─────────────────┘ │
└─────────────────────────┘
Python starts from the closest scope and works outward.
Local Scope
Variables created inside a function belong to its local scope.
def greet():
message = "Hello"
print(message)
greet()
Output
Hello
But this fails:
def greet():
message = "Hello"
greet()
print(message)
because message only exists inside the function.
Global Scope
Variables defined outside functions usually belong to the global scope.
language = "Python"
def show_language():
print(language)
show_language()
Output
Python
Python does not find language locally, so it moves to the global scope.
Enclosing Scope
Nested functions introduce an enclosing scope.
def outer():
message = "Hello"
def inner():
print(message)
inner()
outer()
Output
Hello
inner() does not have its own message, so Python finds it in the enclosing outer() function.
Built-in Scope
Python also provides built-in names such as:
print()
len()
range()
sum()
These belong to the built-in scope.
Avoid unnecessarily overwriting them:
list = [1, 2, 3]
because now list no longer refers to Python’s built-in list class in that scope.
global
If you need to modify a global variable inside a function, use global.
count = 0
def increment():
global count
count += 1
increment()
print(count)
Output
1
Use global carefully. Passing values into functions and returning new values is often easier to maintain.
nonlocal
nonlocal lets an inner function modify a variable in its enclosing function.
def counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
Now:
increment = counter()
print(increment())
print(increment())
Output
1
2
This leads directly to another important concept: closures.
Closures
A closure is an inner function that remembers values from its enclosing scope.
def create_multiplier(multiplier):
def multiply(number):
return number * multiplier
return multiply
Now:
double = create_multiplier(2)
print(double(5))
Output
10
Even though create_multiplier() has finished, double() still remembers:
multiplier = 2
This behaviour is one of the foundations of decorators.
Decorators
A decorator takes a function, adds behaviour to it, and returns another function.
Consider:
def decorator(function):
def wrapper():
print("Before")
function()
print("After")
return wrapper
Now:
def greet():
print("Hello")
greet = decorator(greet)
greet()
Output
Before
Hello
After
The flow is:
greet
↓
decorator
↓
wrapper
↓
greet + extra behaviour
The @ Syntax
Python gives us cleaner syntax for decorators.
Instead of:
def greet():
print("Hello")
greet = decorator(greet)
we can write:
@decorator
def greet():
print("Hello")
These two approaches are effectively equivalent.
So whenever you see:
@something
def function():
...
think:
function = something(function)
A Practical Decorator
A common example is logging.
def log_call(function):
def wrapper():
print(f"Calling {function.__name__}")
function()
print("Finished")
return wrapper
Usage:
@log_call
def process_order():
print("Processing order...")
Output
Calling process_order
Processing order...
Finished
The logging logic stays separate from process_order().
Supporting Function Arguments
The previous decorator only works with functions that take no arguments.
For reusable decorators, use:
*args
**kwargs
Example:
def log_call(function):
def wrapper(*args, **kwargs):
print(f"Calling {function.__name__}")
result = function(*args, **kwargs)
print("Finished")
return result
return wrapper
Now it works with:
@log_call
def greet(name):
return f"Hello {name}"
Calling:
print(greet("Dong"))
Output
Calling greet
Finished
Hello Dong
Preserve Metadata with wraps
A decorator replaces the original function with the wrapper.
Without extra handling:
print(greet.__name__)
may return:
wrapper
instead of:
greet
Use functools.wraps:
from functools import wraps
def decorator(function):
@wraps(function)
def wrapper(*args, **kwargs):
return function(*args, **kwargs)
return wrapper
This preserves useful metadata such as:
__name__
__doc__
A good general-purpose decorator usually follows this pattern.
A Useful Decorator Template
from functools import wraps
def my_decorator(function):
@wraps(function)
def wrapper(*args, **kwargs):
# Before
result = function(
*args,
**kwargs
)
# After
return result
return wrapper
Usage:
@my_decorator
def my_function():
...
This is a useful template to remember.
Decorators with Arguments
Sometimes the decorator itself needs configuration.
For example:
@repeat(3)
def greet():
print("Hello")
That requires another function layer:
from functools import wraps
def repeat(times):
def decorator(function):
@wraps(function)
def wrapper(*args, **kwargs):
for _ in range(times):
function(*args, **kwargs)
return wrapper
return decorator
Now:
@repeat(3)
def greet():
print("Hello")
greet()
Output
Hello
Hello
Hello
The structure is:
repeat(3)
↓
decorator(function)
↓
wrapper()
↓
original function
Why Scope Matters for Decorators
Decorators work because inner functions remember values from enclosing scopes.
Consider:
def repeat(times):
def decorator(function):
def wrapper():
for _ in range(times):
function()
return wrapper
return decorator
Inside wrapper():
function
comes from an enclosing scope.
And:
times
comes from another enclosing scope.
Python finds them using the LEGB rule.
So the connection is:
Scopes
↓
Enclosing scopes
↓
Closures
↓
Decorators
Common Mistakes
Forgetting to return the result
Bad:
def wrapper(*args, **kwargs):
function(*args, **kwargs)
If the original function returns something, that value is lost.
Better:
def wrapper(*args, **kwargs):
return function(
*args,
**kwargs
)
Calling the wrapper too early
Usually you want:
return wrapper
not:
return wrapper()
Remember:
wrapper
→ function object
wrapper()
→ execute function
Forgetting *args and **kwargs
This:
def wrapper():
return function()
only works for functions without arguments.
Prefer:
def wrapper(*args, **kwargs):
return function(
*args,
**kwargs
)
for reusable decorators.
Forgetting @wraps
For production-quality decorators, prefer:
from functools import wraps
and:
@wraps(function)
so the original function metadata is preserved.
Where Decorators Are Used
You will often see decorators used for:
logging
authentication
caching
validation
timing
API routes
permissions
For example:
@app.get("/users")
def get_users():
...
or:
@property
def name(self):
...
or:
@staticmethod
def calculate():
...
Final Mental Model
For scope, remember:
L → Local
E → Enclosing
G → Global
B → Built-in
Python searches in that order.
For decorators, remember:
Function
↓
Decorator
↓
Wrapper
↓
Enhanced function
And this:
@decorator
def greet():
...
is roughly:
greet = decorator(greet)
The easiest way to understand decorators is not to memorize the @ syntax.
Understand this progression instead:
Scope
↓
Nested functions
↓
Closures
↓
Functions as objects
↓
Decorators
Once those ideas make sense, decorators stop feeling like Python magic.