Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 14

Custom Decorators: Extending Function Behavior

Recommended resources

In our previous lesson, we explored the powerful concepts of first-class functions, higher-order functions, and closures. You learned that functions in Python are objects that can be passed around and that an inner function can "remember" the environment in which it was created. This foundation is not just theoretical; it's the direct prerequisite for understanding one of Python's most elegant and widely-used features.

This lesson builds directly on that knowledge to tackle decorators. Our goal is to demystify the @ syntax and empower you to create your own custom decorators to extend the behavior of functions. For a backend engineer, decorators are an indispensable tool. Frameworks like Django and Flask use them extensively for critical tasks such as handling user authentication (@login_required), caching expensive query results, logging API requests, and defining API endpoints themselves. Mastering decorators will make your code more modular, readable, and powerful.

1. The Problem Decorators Solve

Before diving into the syntax, let's understand why decorators exist. Imagine you have several functions in your application, and you want to time how long each one takes to execute.

A naive approach would be to add timing logic directly into each function:

import time

def process_data():
    start_time = time.time()



    # ... function's core logic ...
    print("Processing data...")
    time.sleep(1)
    end_time = time.time()
    print(f"process_data took {end_time - start_time:.2f} seconds.")

def fetch_report():
    start_time = time.time()



    # ... function's core logic ...
    print("Fetching report...")
    time.sleep(0.5)
    end_time = time.time()
    print(f"fetch_report took {end_time - start_time:.2f} seconds.")

This approach has two major flaws:

  1. Code Duplication: The timing logic is repeated in every function. If you need to change it (e.g., use a different timer or change the output format), you have to modify every single function. This violates the "Don't Repeat Yourself" (DRY) principle.
  2. Violation of Single Responsibility Principle: Each function is now doing two things: its primary job (processing data or fetching a report) and timing itself. A function should ideally have one single, well-defined responsibility.

Decorators provide an elegant solution to both of these problems by allowing us to wrap a function with additional functionality without modifying its source code. The following video provides an excellent visual introduction to this exact problem and how decorators solve it.

Python Decorators - Visually Explained

This video from the "Visually Explained" channel clearly illustrates the motivation behind decorators and how they work on a conceptual level.

Please watch the first two sections: The introduction explains what a decorator is at a high level. The problem section walks through the code duplication issue we just discussed.


2. The Anatomy of a Decorator

As you saw in the video, a decorator is essentially a function that takes another function as an argument, adds some functionality, and then returns another function. This is a direct application of the higher-order functions and closures we studied in the last lesson.

Let's break down the mechanics. The article "Primer on Python Decorators" from Real Python provides a fantastic step-by-step guide.

Primer on Python Decorators

This first section will walk you through the manual process of decorating a function, which reveals what the @ syntax is actually doing.

Start at the beginning of the article and read the section on simple decorators. Focus on understanding these two key steps: The say_whee = decorator(say_whee) line. This is the explicit, manual way of applying a decorator. You are reassigning the variable say_whee to point to the new wrapper function returned by the decorator. The @decorator syntax, which is just "syntactic sugar" for the manual reassignment.

So, @my_decorator placed above a function my_function is just a shortcut for my_function = my_decorator(my_function).

This sequence diagram illustrates the two distinct phases of a decorator's life. **Step 1 (Decoration Phase)** happens once, when the Python interpreter first loads your code. The decorator function is called with the original function, and it returns the wrapper. The original function's name now refers to this new wrapper. **Step 2 (Execution Phase)** happens every time you call the decorated function. It is the wrapper's logic that executes, calling the original function at the appropriate time.

3. Building a Robust Decorator

The simple decorator from the first example has limitations. What if the function you want to decorate takes arguments? What if it returns a value? A production-ready decorator needs to handle these cases gracefully.

Let's build a general-purpose decorator template step-by-step. We will continue using the Real Python article as our guide.

Primer on Python Decorators

This part of the article is crucial. It builds a robust, reusable decorator from the ground up.

Continue reading from the subsection "Reusing Decorators" until you reach the end of the section "A Few Real World Examples". Pay close attention to the evolution of the do_twice decorator as it solves each of the following problems: Decorating Functions With Arguments: Read about the TypeError and how *args and **kwargs are used in the wrapper to accept any arguments. Returning Values From Decorated Functions: See how the decorator initially "eats" the return value and how this is fixed by returning the value from the wrapper. Finding Yourself: Observe how decorating a function changes its metadata (like __name__ and __doc__). Learn how the @functools.wraps decorator is used to preserve this crucial information. Finally, review the boilerplate template provided at the end. This is the pattern you will use for your own decorators.

To summarize, a robust decorator should almost always follow this template:

import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):



        # 1. Do something BEFORE calling the original function
        print("Executing before the function...")
        



        # 2. Call the original function with its arguments and capture the return value
        value = func(*args, **kwargs)
        



        # 3. Do something AFTER calling the original function
        print("Executing after the function...")
        



        # 4. Return the value that the original function returned
        return value
    return wrapper

4. Practical Decorators for Backend Engineering

Now that you have the boilerplate, you can create decorators for many common backend tasks. The Real Python article and another from freeCodeCamp list several excellent, practical examples.

Common use cases include:

  • @timer: Measures the execution time of a function, crucial for identifying performance bottlenecks in your API.
  • @log_request: Logs the arguments and return value of a function, essential for debugging API calls.
  • @cache: Caches the result of an expensive function call, which can dramatically improve performance for database queries or complex calculations.
  • @rate_limit: Restricts how often a function can be called, protecting your API from abuse.
  • @login_required: Checks if a user is authenticated before allowing them to access an endpoint.

Let's look at a clear implementation of a logging decorator.

The Python Decorator Handbook

This section from the freeCodeCamp handbook provides a clean example of a logging decorator, a very common tool for backend developers.

Read the section titled Log Arguments. Notice how its structure matches the boilerplate template we just established. The log_decorator uses *args and **kwargs and correctly handles the return value.


Hands-On Task: Create a Logging Decorator

It's time to write your own decorator. Your task is to create a decorator named debug_api_call that logs the name, arguments, and return value of any function it decorates. This is an incredibly useful tool for debugging backend services.

  1. Create a new Python file named main.py.

  2. Inside main.py, define a decorator function debug_api_call(func).

    • Use the robust decorator boilerplate, including import functools and @functools.wraps.
    • The wrapper function should accept *args and **kwargs.
    • Before calling the original function, print a message that includes the function's name and its arguments. You can use f-strings and func.__name__ for this.
    • Call the original function, passing along the arguments, and store its return value in a variable.
    • After the function has executed, print a message that shows the function's name and what it returned.
    • Finally, the wrapper must return the captured value.
  3. Define two simple mock API functions to test your decorator:

    @debug_api_call
    def get_user(user_id: int):
        """Retrieves user data from a 'database'."""
        return {"id": user_id, "name": "John Doe"}
    
    @debug_api_call
    def create_user(name: str, email: str):
        """Creates a new user in the 'database'."""
        print(f"--> Creating user '{name}'...")
        return {"id": 123, "name": name, "email": email, "status": "created"}
    
  4. Call your decorated functions and observe the output:

    get_user(42)
    print("-" * 20)
    create_user(name="Jane Doe", email="jane@example.com")
    

Your final output should look something like this:

Calling get_user(args=(42,), kwargs={})
get_user returned: {'id': 42, 'name': 'John Doe'}
--------------------
Calling create_user(args=(), kwargs={'name': 'Jane Doe', 'email': 'jane@example.com'})
--> Creating user 'Jane Doe'...
create_user returned: {'id': 123, 'name': 'Jane Doe', 'email': 'jane@example.com', 'status': 'created'}
Click here for a solution to check your work.
import functools

def debug_api_call(func):
    """Prints the function signature and return value on each call."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):



        # Create a string representation of the arguments
        args_repr = [repr(a) for a in args]
        kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
        signature = ", ".join(args_repr + kwargs_repr)
        
        print(f"Calling {func.__name__}({signature})")
        
        value = func(*args, **kwargs)
        
        print(f"{func.__name__} returned: {value!r}")
        
        return value
    return wrapper




# --- Applying the decorator to mock functions ---
@debug_api_call
def get_user(user_id: int):
    """Retrieves user data from a 'database'."""
    return {"id": user_id, "name": "John Doe"}

@debug_api_call
def create_user(name: str, email: str):
    """Creates a new user in the 'database'."""
    print(f"--> Creating user '{name}'...")
    return {"id": 123, "name": name, "email": email, "status": "created"}




# --- Calling the decorated functions ---
print("--- Testing get_user ---")
get_user(42)

print("\n" + "-" * 20 + "\n")

print("--- Testing create_user ---")
create_user(name="Jane Doe", email="jane@example.com")

Conclusion

In this lesson, you've bridged the gap between the theory of closures and the practical power of decorators. You now have a solid mental model of how decorators work and a reusable template for creating your own.

Key Takeaways:

  • Purpose: Decorators add functionality to existing functions without modifying their code, adhering to the DRY and Single Responsibility principles.
  • Mechanism: The @decorator syntax is a shortcut for my_function = decorator(my_function). It leverages higher-order functions and closures.
  • Boilerplate: A robust decorator accepts *args and **kwargs, handles return values, and uses @functools.wraps to preserve the original function's identity.
  • Application: Decorators are essential in backend development for concerns like logging, timing, caching, and authentication.

We've now covered some advanced ways to work with functions. As our programs grow in complexity, we need more tools for organization. In the next lesson, we will shift our focus to Object-Oriented Programming (OOP), exploring how to structure our backend logic using classes with inheritance and composition. This will be our next major step toward building the large, maintainable systems required by a company like Infilect.