Popular Searches
Popular Course Categories
Popular Courses

Applying Decorators to Functions and Methods

Applying Decorators to Functions and Methods

Functional Programming and Advanced Python Features

Applying Decorators to Functions and Methods in Python

Course: Python Training

Topic: Applying Decorators to Functions and Methods

Course Link: Python Training Course

Demo Registration: Register for a Course Demo

JustAcademy Homepage: Visit JustAcademy


1. Introduction to Decorators

Decorators are an important Python feature that allows developers to extend or modify the behavior of functions and methods without changing their original source code. They are widely used for logging, authentication, authorization, timing, caching, input validation, and access control.

A decorator takes a function or another callable as input and returns a callable. In many common cases, it creates a wrapper function that executes additional logic before or after calling the original function.

Decorators rely on Python's support for first-class functions, higher-order functions, and closures. Understanding these concepts makes it easier to write reusable and maintainable decorators.

2. Learning Objectives

  • Understand the purpose and syntax of Python decorators.
  • Learn how to create and apply custom decorators.
  • Understand wrapper functions and the role of closures.
  • Apply decorators to ordinary functions and class methods.
  • Use decorators with arguments and return values.
  • Understand the built-in @staticmethod, @classmethod, and @property decorators.
  • Preserve function metadata with functools.wraps.
  • Recognize common decorator mistakes and follow best practices.

3. What Is a Decorator?

A decorator is a callable that receives another callable and returns a callable, often with additional behavior. The original function can then be used through the decorated version.

Python provides a convenient decorator syntax using the @ symbol followed by the decorator name.

Basic Syntax

def my_decorator(function):
    def wrapper():
        # Additional behavior
        return function()

    return wrapper

@my_decorator
def greet():
    print("Hello, Python!")

greet()

Output:

Hello, Python!

The expression @my_decorator is effectively equivalent to assigning the decorated function back to its original name:

def greet():
    print("Hello, Python!")

greet = my_decorator(greet)

The decorator receives the original function and returns the wrapper. Calling greet() then invokes the decorated callable.

4. Why Are Decorators Useful?

Many applications need common behavior to run around several functions. For example, multiple functions may need logging, execution-time measurement, or permission checks. Repeating the same code in every function creates duplication and makes maintenance harder.

Decorators allow this shared behavior to be implemented once and reused across many functions.

Use Case Purpose
Logging Record when functions are called and what they return.
Authentication Check whether a user is signed in.
Authorization Check whether a user has permission to perform an operation.
Performance monitoring Measure function execution time.
Caching Reuse results for repeated calls with the same arguments.
Validation Check input values before executing the original function.
Retries Retry certain operations when a recoverable failure occurs.
Transactions Apply shared transaction-handling logic around operations.

5. Understanding Wrapper Functions

A wrapper is an inner function that surrounds a call to the original function. It can execute additional logic before or after the original function and can return the original result to the caller.

Example: Adding Messages Before and After a Function

def my_decorator(function):
    def wrapper():
        print("Before function execution")
        result = function()
        print("After function execution")
        return result

    return wrapper

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

say_hello()

Output:

Before function execution
Hello!
After function execution

Here, the wrapper adds two messages around the original function call. Returning result is important because it allows the decorated function to preserve the original function's return value.

6. Applying a Decorator to a Function with Arguments

Functions may accept positional and keyword arguments. A reusable decorator should normally support both. The conventional solution is to define the wrapper using *args and **kwargs.

Example

def log_call(function):
    def wrapper(*args, **kwargs):
        print("Calling:", function.__name__)
        result = function(*args, **kwargs)
        print("Function completed")
        return result

    return wrapper

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

print(add(10, 20))

Output:

Calling: add
Function completed
30

*args collects positional arguments into a tuple, while **kwargs collects keyword arguments into a dictionary. Passing both to the original function allows the decorator to work with a wide variety of function signatures.

7. Preserving Function Metadata with functools.wraps

When a decorator returns a wrapper, the decorated function's name and documentation may otherwise appear to belong to the wrapper. Python provides functools.wraps to preserve important metadata from the original function.

Example Without wraps

def simple_decorator(function):
    def wrapper(*args, **kwargs):
        return function(*args, **kwargs)

    return wrapper

@simple_decorator
def greet():
    """Display a greeting."""
    print("Hello!")

print(greet.__name__)
print(greet.__doc__)

Without metadata preservation, the name will usually be wrapper, and the documentation string will usually be None.

Example With wraps

from functools import wraps

def simple_decorator(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        return function(*args, **kwargs)

    return wrapper

@simple_decorator
def greet():
    """Display a greeting."""
    print("Hello!")

print(greet.__name__)
print(greet.__doc__)

Output:

greet
Display a greeting.

Using @wraps(function) is a recommended practice when creating decorators. It helps documentation tools, debugging, testing, and introspection work more naturally.

```html

8. Creating a Decorator with Arguments

Sometimes a decorator needs configuration values, such as a retry limit, a permission name, or a logging level. In this situation, a decorator factory is used. The outer function receives the configuration and returns the actual decorator.

Example: Configurable Prefix Decorator

from functools import wraps

def add_prefix(prefix):
    def decorator(function):
        @wraps(function)
        def wrapper(*args, **kwargs):
            result = function(*args, **kwargs)
            return f"{prefix}{result}"

        return wrapper
    return decorator

@add_prefix("[INFO] ")
def get_message():
    return "Application started"

print(get_message())

Output:

[INFO] Application started

The decorator works in three stages:

  1. add_prefix() receives the configuration value.
  2. decorator() receives the original function.
  3. wrapper() adds the prefix to the function's result.

9. Applying Multiple Decorators

Python allows multiple decorators to be applied to one function. They are applied from the bottom upward, so the decorator closest to the function is applied first.

Example

from functools import wraps

def uppercase(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        return function(*args, **kwargs).upper()
    return wrapper

def add_exclamation(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        return function(*args, **kwargs) + "!"
    return wrapper

@uppercase
@add_exclamation
def greet():
    return "hello"

print(greet())

Output:

HELLO!

The decoration is equivalent to greet = uppercase(add_exclamation(greet)). Understanding this order helps when combining logging, validation, caching, and other decorators.

10. Applying Decorators to Instance Methods

An instance method is a function defined inside a class that normally receives the object instance as its first argument, conventionally named self. A decorator applied to an instance method must allow that instance argument to pass through to the original method.

Example: Logging an Instance Method

from functools import wraps

def log_method(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        print("Executing method:", function.__name__)
        return function(*args, **kwargs)
    return wrapper

class Calculator:
    @log_method
    def add(self, a, b):
        return a + b

calculator = Calculator()
print(calculator.add(10, 20))

Output:

Executing method: add
30

When calculator.add(10, 20) is called, Python supplies the instance as the first argument. The wrapper accepts that argument through *args and passes it to the original method.

11. Applying Decorators to Class Methods

A class method receives the class itself as its first argument, conventionally named cls, rather than a particular object instance. The built-in @classmethod decorator makes this behavior possible.

Example: Creating an Alternative Constructor

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    @classmethod
    def from_string(cls, employee_data):
        name, salary = employee_data.split(",")
        return cls(name, float(salary))

employee = Employee.from_string("Asha,55000")

print(employee.name)
print(employee.salary)

Output:

Asha
55000.0

The method uses cls to create an instance of the class. This is useful for alternative constructors that create objects from strings, dictionaries, configuration files, or API data.

Combining a Custom Decorator with @classmethod

from functools import wraps

def log_call(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        print("Class method called")
        return function(*args, **kwargs)
    return wrapper

class Product:
    @classmethod
    @log_call
    def category(cls):
        return cls.__name__

print(Product.category())

Output:

Class method called
Product

Here, the custom decorator wraps the underlying function, and @classmethod binds it to the class. Decorator order matters, particularly when combining custom decorators with built-in method decorators.

12. Applying Decorators to Static Methods

A static method does not automatically receive either the instance or the class as its first argument. It is useful for a function logically associated with a class but independent of instance-specific and class-specific state.

Example

class MathOperations:
    @staticmethod
    def multiply(a, b):
        return a * b

print(MathOperations.multiply(5, 4))

math = MathOperations()
print(math.multiply(3, 6))

Output:

20
18

Both the class and its instances can call the static method. It does not receive an implicit self or cls argument.

Combining a Custom Decorator with @staticmethod

from functools import wraps

def log_call(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        print("Running calculation")
        return function(*args, **kwargs)
    return wrapper

class MathOperations:
    @staticmethod
    @log_call
    def multiply(a, b):
        return a * b

print(MathOperations.multiply(4, 5))

Output:

Running calculation
20

Using @staticmethod outside the custom decorator is a common pattern because it first wraps the function and then exposes it as a static method.

13. Understanding the @property Decorator

The built-in @property decorator allows a method to be accessed like an attribute. It is useful for computed values and for controlling access to an object's internal data.

Example: Calculating an Area

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @property
    def area(self):
        return self.width * self.height

rectangle = Rectangle(10, 5)
print(rectangle.area)

Output:

50

The property is accessed as rectangle.area, without parentheses. This makes a computed value behave like a normal attribute.

Using a Property Setter

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature cannot be below absolute zero")
        self._celsius = value

temperature = Temperature(25)
print(temperature.celsius)

temperature.celsius = 30
print(temperature.celsius)

Output:

25
30

The setter validates new values before storing them. A property is a built-in descriptor, so it is related to Python's attribute-access mechanism as well as decorators. Read the Python Descriptor Guide.

14. Common Built-in Decorators

Decorator Purpose Typical Use
@staticmethod Defines a method without implicit instance or class binding. Utility functions associated with a class.
@classmethod Receives the class as the first argument. Alternative constructors and class-level operations.
@property Exposes a method as an attribute. Computed attributes and controlled data access.
@functools.wraps Preserves metadata from a wrapped function. Custom decorator implementations.
@functools.lru_cache Caches results for eligible repeated calls. Expensive calculations with reusable results.
@functools.cache Provides an unbounded cache for function results. Memoization when unbounded caching is appropriate.

15. Caching Results with a Decorator

Caching stores results so that repeated calls with the same eligible arguments can reuse a previous result instead of recalculating it. Python provides caching decorators in the functools module.

Example: Using lru_cache

from functools import lru_cache

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

print(fibonacci(10))
print(fibonacci.cache_info())

Output begins with:

55
CacheInfo(...)

The exact cache statistics depend on the calls made. The cache can reduce repeated work in recursive calculations. Cached functions should generally behave predictably for their arguments; consider whether their results depend on mutable external state before applying caching.

16. Measuring Execution Time with a Decorator

A timing decorator measures how long a function takes to execute. It can help identify slow operations during development and performance testing.

Example

import time
from functools import wraps

def measure_time(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return function(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(
                f"{function.__name__} took {elapsed:.6f} seconds"
            )
    return wrapper

@measure_time
def calculate_total(limit):
    return sum(range(limit))

print(calculate_total(100000))

The elapsed time varies by computer and system load. The finally block ensures the timing message is produced even if the function raises an exception. The original exception still propagates to the caller.

17. Authentication and Permission Decorators

In web applications and other protected systems, decorators can enforce access rules before a function runs. The following is a simplified educational example, not a complete security system.

Example

from functools import wraps

def require_admin(function):
    @wraps(function)
    def wrapper(user, *args, **kwargs):
        if not user.get("is_admin", False):
            raise PermissionError("Administrator access required")
        return function(user, *args, **kwargs)
    return wrapper

@require_admin
def delete_record(user, record_id):
    return f"Record {record_id} deleted"

admin = {"name": "Asha", "is_admin": True}

print(delete_record(admin, 101))

Output:

Record 101 deleted

The decorator checks the user's role before executing the protected function. In real applications, access checks should use trusted authentication and authorization mechanisms; user-supplied dictionaries alone are not a security boundary.

18. Decorator Order and Execution Flow

Multiple decorators are applied from the bottom upward. When the decorated function is called, the outermost wrapper runs first, and each wrapper may invoke the next decorated callable.

@decorator_b
@decorator_a
def example():
    pass

This is equivalent to:

example = decorator_b(decorator_a(example))

When combining decorators, check whether each one expects a plain function, a bound method, a class method, or another callable type. This is especially important when using decorators that register functions, enforce method-specific behavior, or alter function signatures.

19. Common Mistakes and Troubleshooting

Mistake Cause Solution
Wrapper does not accept arguments The wrapper was defined without parameters. Use *args and **kwargs for a general-purpose wrapper.
Return value is lost The wrapper calls the original function but does not return its result. Return the result of the original function.
Function name changes to wrapper Metadata was not preserved. Use @wraps(function).
Unexpected extra argument A method decorator mishandles the implicit self or cls argument. Ensure the wrapper passes all arguments to the original method.
Decorator order causes errors Decorators are applied in an unexpected order. Remember that application proceeds from bottom to top.
Exception is accidentally suppressed The wrapper catches an exception without re-raising or handling it intentionally. Allow exceptions to propagate unless recovery is explicitly required.
Cached result becomes stale The function depends on state that changes between calls. Review cache suitability and provide a deliberate invalidation strategy.
Decorated method behaves incorrectly The decorator was designed for a different callable type. Test it with ordinary functions, instance methods, and class or static methods as appropriate.

20. Best Practices for Writing Decorators

  • Use descriptive names that explain what the decorator does.
  • Use functools.wraps to preserve metadata.
  • Support positional and keyword arguments when the decorator is intended to be reusable.
  • Return the original function's result unless changing it is the decorator's purpose.
  • Keep wrappers focused on one responsibility.
  • Document configuration arguments and any behavior the decorator changes.
  • Be deliberate about exception handling and avoid silently hiding failures.
  • Test decorated functions with normal inputs, edge cases, and exceptions.
  • Consider how decorators interact with descriptors and method binding.
  • Use caching only when its memory usage and result validity are appropriate.
  • Avoid adding decorators when a simple helper function would be clearer.

21. Practical Project: Employee Management with Decorators

Create an employee management example that uses decorators to log method calls and validates salary values through a property.

Project Code

from functools import wraps

def log_action(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        print(f"Executing {function.__name__}")
        return function(*args, **kwargs)
    return wrapper

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    @property
    def salary(self):
        return self._salary

    @salary.setter
    def salary(self, value):
        if value < 0:
            raise ValueError("Salary cannot be negative")
        self._salary = value

    @log_action
    def increase_salary(self, amount):
        if amount < 0:
            raise ValueError("Increase cannot be negative")
        self.salary += amount
        return self.salary

    @classmethod
    def from_string(cls, data):
        name, salary = data.split(",")
        return cls(name, float(salary))

employee = Employee.from_string("Asha,50000")
print(employee.name)
print(employee.salary)
print(employee.increase_salary(5000))
print(employee.salary)

Expected Output

Asha
50000.0
Executing increase_salary
55000.0
55000.0

Project Explanation

  • @property allows salary to be accessed like an attribute.
  • @salary.setter validates salary changes before saving them.
  • @log_action adds logging behavior to the instance method.
  • @classmethod provides an alternative constructor that builds an employee from a string.
  • @wraps preserves the method's metadata.

22. Practice Exercises

  1. Create a decorator that prints a message before and after a function call.
  2. Write a decorator that records the name of the function being executed.
  3. Create a decorator that validates that a numeric argument is positive.
  4. Write a configurable decorator that adds a prefix to a returned string.
  5. Apply two decorators to one function and observe their order.
  6. Create a timing decorator using time.perf_counter().
  7. Use functools.wraps and compare the decorated function's metadata with and without it.
  8. Decorate an instance method that accepts positional and keyword arguments.
  9. Create a class method that constructs an object from a comma-separated string.
  10. Build a class with a property and setter that validate a value.
  11. Apply @lru_cache to a recursive calculation and inspect its cache statistics.
  12. Extend the Employee Management project with a decorator that checks a user's permission before a protected operation.

23. Interview Questions and Answers

Q1. What is a decorator in Python?

A decorator takes a callable and returns a callable, commonly adding behavior without modifying the original function's source code.

Q2. What does the @ symbol mean in decorator syntax?

It applies a decorator to the function or method defined immediately below it.

Q3. What is a wrapper function?

A wrapper is a function that calls or surrounds another function and may add behavior before or after that call.

Q4. Why are *args and **kwargs used in decorators?

They allow a wrapper to accept arbitrary positional and keyword arguments and pass them to the original callable.

Q5. Why is functools.wraps important?

It preserves metadata such as the original function's name and documentation, making debugging and introspection easier.

Q6. Can decorators accept arguments?

Yes. A decorator factory can accept configuration values and return the decorator that wraps the target function.

Q7. In what order are multiple decorators applied?

They are applied from the bottom upward. The decorator closest to the function is applied first.

Q8. Can a decorator be used with a class method?

Yes. The decorator must handle the callable and method-binding behavior correctly, including the class argument supplied by @classmethod.

Q9. What is the difference between @staticmethod and @classmethod?

A static method receives no implicit instance or class argument. A class method receives the class as its first argument.

Q10. What is the purpose of @property?

It exposes a method as an attribute and can be used to compute values or manage controlled attribute access.

Q11. What is caching in Python?

Caching stores previously computed results so that suitable repeated calls can reuse those results.

Q12. How can a decorator measure execution time?

It can record a start time, call the original function, and calculate the elapsed time afterward, using a finally block if timing must also be recorded when an exception occurs.

Q13. What happens if a wrapper does not return the original result?

The decorated function may return None instead of the original result.

Q14. Are decorators only for functions?

No. Decorator syntax can also be applied to methods, classes, and other supported definitions, depending on what the decorator accepts and returns.

Q15. What are common applications of decorators?

Logging, timing, caching, input validation, registration, authentication, authorization, and other reusable cross-cutting behaviors.

24. Quick Revision Checklist

  • A decorator receives a callable and returns a callable.
  • The @decorator syntax applies the decorator to a definition.
  • Wrappers can add behavior before and after the original function call.
  • *args and **kwargs support flexible arguments.
  • functools.wraps preserves function metadata.
  • Decorator factories support configurable decorators.
  • Multiple decorators are applied from bottom to top.
  • Instance methods normally receive self.
  • Class methods receive cls.
  • Static methods receive no implicit instance or class argument.
  • @property exposes a method through attribute access.
  • @lru_cache can reuse results for repeated eligible calls.
  • Decorators should be tested with return values, exceptions, and different argument types.

25. Conclusion

Decorators are a powerful Python feature for adding reusable behavior to functions and methods. By using wrapper functions, closures, and tools such as functools.wraps, developers can implement logging, timing, caching, validation, and access checks without repeating the same code throughout an application.

Understanding decorators alongside @classmethod, @staticmethod, and @property also helps clarify how Python methods and managed attributes work. Practise creating simple decorators first, then progress to configurable decorators and class-based examples.

26. Additional Learning Resources

whatsapp