Go to Portfolio
Blog III

Python Scopes and Decorators: A Practical Guide


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.