Understanding Python Decorators: A Powerful Tool for Clean and Reusable Code

Python decorators are one of the language’s most powerful and expressive features. They allow you to extend and modify the behaviour of functions or classes without changing their actual code. This article will cover everything you need to know to understand, write, and use decorators effectively.

What is a Decorator?

In Python, decorators are a powerful and flexible way to modify or extend the behaviour of functions or methods, without changing their actual code. A decorator is essentially a function that takes another function as an argument and returns a new function with enhanced functionality.

The Basics: Functions are first-class citizens

Python treats functions as first-class objects, meaning:

  • They can be passed as arguments to other functions.
  • They can be returned from other functions.
  • They can be assigned to variables.

This enables decorators to work.

A Simple Example

def greet(name):
    return f"Hello, {name}!"


say_hello = greet # Assigning function to a variable:
print(say_hello("Alice"))  # Hello, Alice!
Python

Writing Your First Decorator

Here’s a simple decorator that prints something before and after a function runs:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before the function call")
        result = func(*args, **kwargs)
        print("After the function call")
        return result
    return wrapper
Python

Using the decorator manually:

def say_hello():
    print("Hello!")

decorated = my_decorator(say_hello)
decorated()
Python

Using the @ syntax (Pythonic way):

@my_decorator
def say_hello():
    print("Hello!")

say_hello()


'''
Output
Before the function call
Hello!
After the function call
'''
Python

Decorators in the real world:

When I think of decorators in the real world, I think of a gun. What?? A Gun?! Before you think of shooting me, let me clarify. There are a lot of accessories you can use with a gun, like silencers, flashlights, laser scopes, stocks, pistol grips, etc.

decorator

Now think of a gun that is wrapped with a silencer. The silencer modifies the behaviour of the gun and performs some additional action, like suppressing the noise. The silencer extends the functionality of the gun without modifying it. It makes it easy to turn on and turn off the additional features.

decorator

Source

Working of Decorator

  • when you call say_hello(), You’re really calling wrapper().
  • And inside the wrapper, the func (which is the original function say_hello) is called:
result = func(*args, **kwargs)
Python

  • The return value of the original function is stored in result, which is then returned by wrapper.

Flow:

  • You (the caller) call the function → Python calls the wrapper.
  • wrapper calls the original function.
  • The return value goes from the original function → to wrapper → back to you.

How is the wrapper called?

  • wrapper is not called or executed inside my_decorator
  • function wrapper is returned by my_decorator
  • And then you call it outside using ()
decorated = my_decorator(say_hello)

decorated = wrapper #(because my_decorator returns wrapper)

decorated() -> wrapper() # my_decorator(say_hello)()
Python

  • my_decorator returns a wrapper
  • () This is what is actually called a wrapper

What is a Closure?

A closure is a function object that captures and retains access to variables from its enclosing scope, even after that scope has finished execution.

A closure is formed when a nested function captures variables from its enclosing scope and retains access to them even after the outer function has completed execution.

def outer():
    x = 10

    def inner():
        print(x)

    return inner
    
f = outer() # inner
f()   #outer()()
Python

Why two ()()

outer()() --> inner() #outer() -> inner
Python

Flow

  • outer() runs
    • creates x = 10
    • defines inner
    • returns inner
  • outer() is finished
  • Normally, x should be gone…
  • But when you call:f() –> 10

Why does this work?

Because:

  • inner remembers x
  • That memory = closure

A closure = function + its remembered environment

How does a wrapper remember the func?

Python stores func inside the closure (hidden memory) of the wrapper.

  • func is NOT inside wrapper
  • It comes from the outer scope
  • So Python says: I need to capture this variable(closure)

Normally, func should be destroyed, But It is kept alive because the wrapper still needs it

Why do we use *args, **kwargs?

*args and **kwargs are just a way to capture whatever arguments the caller passes and forward them to the original function.

Wrapper should work for any function, i.e.

  • no args
  • positional args
  • keyword args

Example

decorated(1, 2, x=3)

└── wrapper(1, 2, x=3)
      ├── args = (1, 2)
      ├── kwargs = {x: 3}
      └── func(*args, **kwargs)
            → original function call
Python

*args and **kwargs allow decorators to handle arbitrary function signatures by forwarding all positional and keyword arguments to the wrapped function.

Example

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before")
        result = func(*args, **kwargs)
        print("After")
        return result
    return wrapper

def greet(name):
    print(f"Hello, {name}!")

decorated = my_decorator(greet)

decorated("Backendmesh")



'''
Flow
decorated("Backendmesh")
→ wrapper("Backendmesh")

args = ("Backendmesh",)
kwargs = {}

→ func(*args, **kwargs)
→ greet("Backendmesh")
'''
Python

Same example with a keyword argument

decorated(name="Backendmesh")


'''
Flow
wrapper(name="Backendmesh")

args = ()
kwargs = {"name": "Backendmesh"}

→ greet(name="Backendmesh")
'''
Python

Functions with Arguments

Positional argument

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before")

        # modify first 3 positional arguments
        new_args = (
            args[0].upper(),      # name -> uppercase
            args[1] + 1,          # age -> increment
            args[2].lower()       # city -> lowercase
        )

        result = func(*new_args, **kwargs)

        print("After")
        return result
    return wrapper


def greet(name, age, city):
    print(f"Hello, {name}! Age: {age}, City: {city}")


decorated = my_decorator(greet)
decorated("backendmesH", 25, "INDIA")

'''
Before
Hello, BACKENDMESH! Age: 26, City: india
After
'''
Python

Keyword argument

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before")

        kwargs["name"] = kwargs["name"].capitalize()

        result = func(*args, **kwargs)

        print("After")
        return result
    return wrapper


def say_hello(name):
    print(f"Hello, {name}!")


decorated = my_decorator(say_hello)

decorated(name="backendmesh")

'''
Before
Hello, Backendmesh!
After
'''
Python

Safety check

Because the function might be called without the ‘name’ variable

if "name" in kwargs:
    kwargs["name"] = kwargs["name"].capitalize()
Python

Note: Any change to args or kwargs inside the decorator will reflect in the function as well.

Multiple decorators

@decorator_one
@decorator_two
def my_function():
    print("Hello!")
Python

This is equivalent to:

my_function = decorator_one(decorator_two(my_function))
Python

So the decorator closest to the function is applied first, and then wrapped by the next one, and so on — like layers of an onion.

def decorator_one(func):
    def wrapper_one(*args, **kwargs):
        print("Decorator One - Before")
        result = func(*args, **kwargs)
        print("Decorator One - After")
        return result
    return wrapper_one

def decorator_two(func):
    def wrapper_two(*args, **kwargs):
        print("Decorator Two - Before")
        result = func(*args, **kwargs)
        print("Decorator Two - After")
        return result
    return wrapper_two

@decorator_one
@decorator_two
def say_hello():
    print("Hello!")

say_hello()
Python

Output

Decorator One - Before
Decorator Two - Before
Hello!
Decorator Two - After
Decorator One - After
Python

Step by step:

  • decorator_two(say_hello) runs first → returns wrapper_two
  • Then, decorator_one(wrapper_two) → returns wrapper_one
  • So final, say_hello = wrapper_one
say_hello()

wrapper_one()

wrapper_two()

original say_hello()
Python

Execution order

  • wrapper_one() starts
    • prints Decorator One – Before
    • Inside it calls: wrapper_two()
  • wrapper_two() starts
    • prints Decorator Two – Before
    • Calls the original function, prints Hello!
  • Back to wrapper_two()
    • prints Decorator Two – After
  • Back to wrapper_one()
    • prints Decorator One – After

Note:

  • wrapper_one() calls wrapper_two() : func inside wrapper_one refers to wrapper_two
  • wrapper_two() calls original_function() : func inside wrapper_two refers to original_function

Summary

  • say_hello() is first passed to decorator_two, which returns a new function (let’s call it wrapped_two)
  • Then wrapped_two is passed to decorator_one, returning wrapped_one
  • So calling say_hello() actually calls wrapped_one(), which calls wrapped_two(), and finally calls the original function.

Use Cases of Decorators

Decorators are great for many tasks:

  • Logging
  • Authentication / Authorization
  • Memoisation / Caching (@lru_cache from functools)
  • Performance timing
  • Validation and sanitisation
  • routing

Example: Logging

def log(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with {args} {kwargs}")
        return func(*args, **kwargs)
    return wrapper
Python

Built-in Decorators

Python comes with several built-in decorators:

  • @staticmethod
  • @classmethod
  • @property
  • @functools.lru_cache
  • @functools.wraps (used for preserving metadata when writing custom decorators)

Preserving Function Metadata with functools.wraps

When you write a decorator, the metadata (like name and docstring) of the original function gets lost. You can fix this with functools.wraps.

Sample Code

from functools import wraps

def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Calling function...")
        return func(*args, **kwargs)
    return wrapper
Python

Problem without wraps

def decorator(func):
    def wrapper():
        print("Before")
        func()
    return wrapper


@decorator
def say_hello():
    """This is hello function"""
    print("Hello")
Python

Check this

print(say_hello.__name__)   #wrapper
print(say_hello.__doc__) #None
Python

You lost the original info:

  • function name
  • docstring

Fix using functools.wraps

from functools import wraps

def decorator(func):
    @wraps(func)
    def wrapper():
        print("Before")
        func()
    return wrapper


@decorator
def say_hello():
    """This is hello function"""
    print("Hello")
Python

Now output

print(say_hello.__name__) #say_hello
print(say_hello.__doc__) #This is hello function
Python

What wraps actually does

Internally, it copies:

  • __name__
  • __doc__
  • __module__
  • __annotations__
  • __dict__

From original function → wrapper

Why does this matter?

Without wraps:

  • Debugging becomes confusing: Without functools.wraps, all decorated functions appear as wrapper in errors, logs, and debugging, so you lose the original function’s identity, and it becomes hard to trace issues.
  • Logging shows wrong function names
  • frameworks (Flask, FastAPI, pytest) may break

Decorators with Arguments

What if your decorator needs arguments?

def repeat(n):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                func(*args, **kwargs)
        return wrapper
    return decorator

@repeat(3)
def say_hello():
    print("Hello!")

say_hello()
Python

Why need for one more function repeat?

  • When you want your decorator to accept arguments (like @repeat(3)), you need an extra layer of function wrapping — a decorator factory.
  • Python interprets @decorator_name as calling a function that takes just one argument — the function itself, so we need one more function

Class-Based Decorators

Decorators can also be implemented as classes by defining the __call__ method.

class MyDecorator:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        print("Before")
        result = self.func(*args, **kwargs)
        print("After")
        return result

@MyDecorator
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")
Python

Conclusion

Decorators are a powerful feature of Python, allowing you to enhance your functions or methods with minimal and readable syntax. Once you’re comfortable with higher-order functions and closures, decorators become a natural extension of your coding toolkit.

Resource

Leave a Comment