Function decorators allow us  to modify the behavior of a function without having to change the source code of the decorated function. 

They are used to wrap a function, modify its behavior, and return the modified function or a new function with the same or different name.

Decorators are quite common in Python and programming in general, Python offers an easier and more convenient syntax to  work with them. The syntax as shown below:

@decorator_function
def function_to_decorate():
      <block> 

A decorator function is a higher order function that takes a function as an argument and  returns a new function commonly referred to as a wrapper . The wrapper function is used to modify or extend the behavior of the original function, it is also responsible of invoking the original function.

ExampleEdit & Run
def my_decorator(func):
    def wrapper(): 
        print("before calling the function.")  
        func()
        print("after calling the function.")
    return wrapper

@my_decorator
def my_function():
    print("Hello, World!")

my_function()
Output:
before calling the function. Hello, World! after calling the function. [Finished in 0.020695463987067342s]

Without using the short hand syntax, we would need to explicitly pass the function to decorate as an argument to the decorator function, as shown below:

ExampleEdit & Run
def my_decorator(func):
    def wrapper(): 
        print("before calling the function.")  
        func()
        print("after calling the function.")
    wrapper()

def my_function():
    print("Hello, World!")

my_decorator(my_function)
Output:
before calling the function. Hello, World! after calling the function. [Finished in 0.013553750002756715s]

When you call a decorated function with arguments, the arguments are first passed to the wrapper function, which then forwards them to the original function.

ExampleEdit & Run
def addition_decorator(func):
    def wrapper(a, b):
        return "%s + %s = %s"%(a, b, str(func(a, b)))
    return wrapper

@addition_decorator
def add(a, b):
    return a + b

print(add(60, 50))
Output:
60 + 50 = 110 [Finished in 0.01359980902634561s]

If the wrapper function does not define any parameters and arguments are given to the decorated function, an error will be raised .

ExampleEdit & Run
def addition_decorator(func):
    def wrapper():
        return func()
    return wrapper

@addition_decorator
def add(a, b):
    return a + b

print(add(60, 50))
Output:
TypeError: addition_decorator.<locals>.wrapper() takes 0 positional arguments but 2 were given [Finished in 0.024051566957496107s]

It is common to define wrapper functions which take arbitrary arguments as shown below:

ExampleEdit & Run
def addition_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@addition_decorator
def add(a, b):
    return a + b

print(add(60, 50))
Output:
110 [Finished in 0.014768471010029316s]

A timing decorator function:

ExampleEdit & Run
from datetime import datetime
def timer(func):
    def wrapper(*args, **kwargs):
        start = datetime.now()
        print("Execution started at {}".format(start.strftime('%H:%M:%S.%f')))
        func(*args,**kwargs)
        stop = datetime.now()
        print("Execution stopped at", stop.strftime('%H:%M:%S.%f'))
        print("Function '{}' took {}".format(func.__name__, stop - start ))
    return wrapper

@timer
def add(a, b):
    print("%s + %s = %s"%(a, b, a + b))

add(9, 10)

@timer
def delayed_add(a, b):
    import time
    time.sleep(2)
    print(a + b)
    time.sleep(2)

delayed_add(20, 40)
Output:
Execution started at 14:05:30.077265 9 + 10 = 19 Execution stopped at 14:05:30.077315 Function 'add' took 0:00:00.000050 Execution started at 14:05:30.077328 60 Execution stopped at 14:05:34.077636 Function 'delayed_add' took 0:00:04.000308 [Finished in 4.020467195019592s]

Decorator functions with arguments:

Decorator functions can also take additional arguments besides the function being decorated. The syntax of calling such a decorator is as shown below;

@my_decorator(arguments)
def my_function():
    <block>

Defining a decorator function that takes additional arguments requires a slightly different syntax. Instead of defining the decorator function to take a single function as an argument, we define it to take any number of arguments, including the function being decorated. Then we define an inner function that takes the function as its argument and returns a wrapper function that uses the additional arguments passed to the decorator. Example:

ExampleEdit & Run
def repeat(times):
    def decorator(func):
         def wrapper(*args, **kwargs):
             for i in range(times):
                 func()
         return wrapper
    return decorator

@repeat(5)
def func():
    print("Hello, World!")

func()
Output:
Hello, World! Hello, World! Hello, World! Hello, World! Hello, World! [Finished in 0.014884281030390412s]

Class-based Decorators

Decorators are normally implemented as functions, but they can also be written using classes. Class-based decorators provide more flexibility, especially when you need to maintain state between function calls or to implement more complex behavior.

A class-based decorator should implement the __call__ method, which makes an instance of the class callable, just like a function. Inside __call__ is where you modify original function’s behavior.

In the following example, we implement the LogCall decorator for logging function calls.

ExampleEdit & Run
class LogCall:
    def __init__(self, func):
        self.func = func  # Stores the original function

    def __call__(self, *args, **kwargs):
        print(f"Calling {self.func.__name__} with {args}, {kwargs}")
        result = self.func(*args, **kwargs)
        return result

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

print(greet("Alice"))
Output:
Calling greet with ('Alice',), {} Hello, Alice! [Finished in 0.017654942988883704s]

class-based decorators with arguments

The implementation of a class-based decorator with arguments is significantly different to without(as in previous example). Instead of taking the function in __init__, the decorator arguments are passed first, and the function is passed later in __call__.

class Decorator:
    def __init__(arg1, arg2):
        ...
    def __call__(func):
        ...

Let's modify the LogCall decorator to accept arguments

ExampleEdit & Run
class LogCall:
    def __init__(self, level="INFO"):
        self.level = level  # Store the log level

    def __call__(self, func):
        def wrapper(*args, **kwargs):
            print(f"[{self.level}] Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
            result = func(*args, **kwargs)
            print(f"[{self.level}] {func.__name__} returned: {result}")
            return result
        return wrapper

@LogCall(level="DEBUG")
def add(a, b):
    return a + b

@LogCall(level="WARNING")
def risky_divide(x, y):
    return x / y

print(add(2, 3))
print(risky_divide(10, 2))
Output:
[DEBUG] Calling add with args: (2, 3), kwargs: {} [DEBUG] add returned: 5 5 [WARNING] Calling risky_divide with args: (10, 2), kwargs: {} [WARNING] risky_divide returned: 5.0 5.0 [Finished in 0.017505711992271245s]

Compare the above implementation with the previous one,  you will observe that in the above implementation, the arguments to the decorator are passed through __init__ while the function to be decorated is passed through __call__. That is a very important detail to keep in mind.

Stateful Decorators

Class-based decorators are especially useful for maintaining state across multiple function calls. The state is maintained through instance attributes and methods.

In the following example we will implement a call counter decorator which can track how many times a function has been executed.

ExampleEdit & Run
class CountCalls:
    def __init__(self, func):
        self.func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"{self.func.__name__} has been called {self.call_count} times")
        return self.func(*args, **kwargs)

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

say_hello() 
say_hello()
say_hello()
Output:
say_hello has been called 1 times Hello! say_hello has been called 2 times Hello! say_hello has been called 3 times Hello! [Finished in 0.017483972012996674s]

Multiple decorator functions

When multiple decorations are applied to a Python function, they are evaluated and applied in the order in which they appear. The first decoration will wrap the original function, and each successive decoration will wrap the result of the previous decoration. This means that the innermost decorator will be evaluated and applied first, followed by the decorators that wrap it from the outside in. Example:

ExampleEdit & Run
def decorator1(func):
    def wrapper():
        print("Decorator 1 before function call")
        func()
        print("Decorator 1 after function call")
    return wrapper

def decorator2(func):
    def wrapper():
        print("Decorator 2 before function call")
        func()
        print("Decorator 2 after function call")
    return wrapper

@decorator1
@decorator2
def my_function():
    print("Hello, World!")

my_function()
Output:
Decorator 1 before function call Decorator 2 before function call Hello, World! Decorator 2 after function call Decorator 1 after function call [Finished in 0.014498770993668586s]

Decorator for classes

We can also create decorators that operates on entire classes instead of individual functions.  Class decorators make it possible to alter class behavior, add new functionality, or implement design patterns in a clean, reusable way.

A class decorator is simply a function that:

  • Takes a class as input

  • Modifies the class or creates a new one

  • Returns the modified class

The following snippet shows a very simple class decorator.

ExampleEdit & Run
def class_decorator(cls):
    # Modify or extend the class here
    return cls

@class_decorator
class MyClass:
    pass
Output:
[Finished in 0.01357462996384129s]

let us look at a more practical example:

ExampleEdit & Run
def add_utility_methods(cls):
    """Adds common utility methods to any class"""
    def to_dict(self):
        return {k: v for k, v in vars(self).items() if not k.startswith('_')}
    
    cls.to_dict = to_dict
    return cls

@add_utility_methods
class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

p = Product("Laptop", 999)
print(p.to_dict()) 

 

Output:
{'name': 'Laptop', 'price': 999} [Finished in 0.01401548000285402s]

Differences from Function Decorators

  • Operate on class definitions rather than functions

  • Can modify multiple methods at once

  • Have access to the entire class structure

  • Execute when the class is defined (not when instantiated)

Common Built-in Python Decorators

Python comes with several powerful built-in decorators that solve common problems. These decorators are readily available in the standard library.

@classmethod and @staticmethod

These decorators define methods that don't operate on instance data:

  • @classmethod:  for implementing methods that are bound to the class instead of instances.

  • @staticmethod: Utility functions logically related to the class but independent of instances

ExampleEdit & Run
class Date:
    def __init__(self, day, month, year):
        self.day = day
        self.month = month
        self.year = year
    
    @classmethod
    def from_string(cls, date_str):
        day, month, year = map(int, date_str.split('-'))
        return cls(day, month, year)
    
    @staticmethod
    def is_valid(date_str):
        try:
            day, month, year = map(int, date_str.split('-'))
            return 1 <= day <= 31 and 1 <= month <= 12
        except:
            return False
Output:
[Finished in 0.01397622999502346s]

@property

The @property decorator transforms a method into a "getter" for a read-only attribute, while @<property>.setter and @<property>.deleter handle write and delete operations.

ExampleEdit & Run
class Circle:
    def __init__(self, radius):
        self._radius = radius  # Internal storage
    
    @property
    def radius(self):
        """Getter for radius with validation"""
        return self._radius
    
    @radius.setter
    def radius(self, value):
        if value <= 0:
            raise ValueError("Radius must be positive")
        self._radius = value
    
    @property
    def area(self):
        """Read-only computed property"""
        return 3.14 * self._radius ** 2
Output:
[Finished in 0.013965389982331544s]

@functools.wraps

 The @wraps decorator is used in decorators to preserve the original/decorated function's name, docstring, and other attributes.

ExampleEdit & Run
from functools import wraps

def log_call(func):
    @wraps(func)  # Preserves func's metadata
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log_call
def calculate(x, y):
    """Multiplies two numbers"""
    return x * y

print(calculate.__name__)  # Outputs 'calculate' (without wraps: 'wrapper')
print(calculate.__doc__)   # Outputs the docstring
Output:
calculate Multiplies two numbers [Finished in 0.017591772018931806s]

@functools.lru_cache

Used for caching expensive function calls to avoid repeated computation.

ExampleEdit & Run
from functools import lru_cache

@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(50))  # Fast due to memoization
Output:
12586269025 [Finished in 0.0176721730385907s]

@dataclasses.dataclass(Python 3.7+)

Automatically generates special methods for classes can be used to reduce boilerplate code.

ExampleEdit & Run
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float
    z: float = 0.0  # Default value

# Automatically gets __init__, __repr__, __eq__ etc.
p = Point(1.5, 2.5)
print(p)
Output:
Point(x=1.5, y=2.5, z=0.0) [Finished in 0.04876439500367269s]

When to use decorator Functions

Decorators can be used to add extra functionality to an existing function, such functionality can include: 

  1. Logging
  2. Caching
  3. Rate limiting
  4.  Authentication/Authorization
  5. Input/Output Sanitization
  6. Executing functions in parallel.
  7. Adding additional features to functions like argument binding, error handling and retry logic.
  8. Adding context managers to functions.

Python Decorator FAQS

Q.What exactly is a Python decorator?

A decorator is a design pattern in Python that allows you to modify or extend the behavior of functions or classes without permanently changing their source code. It's essentially a function that takes another function/class and returns a modified version.

Q.What's the difference between @ syntax and manual decoration?

Both approaches are equivalent, the @ syntax is just syntactic sugar that makes decorators cleaner and more readable.

# Using @ syntax (most common)
@my_decorator
def my_function():
    pass

# Manual decoration (what actually happens)
def my_function():
    pass
my_function = my_decorator(my_function)

Q.How do decorators with arguments work?

Decorators with arguments require an extra level of nesting:

ExampleEdit & Run
def repeat(num_times):  # Outer function takes decorator arguments
    def decorator(func):  # Middle function takes the function
        def wrapper(*args, **kwargs):  # Inner function does the actual wrapping
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(num_times=3)
def greet(name):
    print(f"Hello {name}")

greet("Alice") 
Output:
Hello Alice Hello Alice Hello Alice [Finished in 0.013568359951023012s]

Q.Can decorators be stacked? How does order matter?

Yes, decorators can be stacked, and order is important - they execute from bottom to top:

@decorator1
@decorator2
@decorator3
def my_function():
    pass

# Equivalent to:
my_function = decorator1(decorator2(decorator3(my_function)))

Q.How do class decorators differ from function

Class decorators operate on entire classes instead of individual functions.

ExampleEdit & Run
def add_method(cls):
    cls.new_method = lambda self: "Added by decorator"
    return cls

@add_method
class MyClass:
    pass

obj = MyClass()
print(obj.new_method())  # "Added by decorator"
Output:
Added by decorator [Finished in 0.014365179988089949s]