Loading episodes…
0:00 0:00

A Visual Guide to Python Decorators: Enhance Your Code with @Syntax

00:00
BACK TO HOME

A Visual Guide to Python Decorators: Enhance Your Code with @Syntax

10xTeam December 28, 2025 8 min read

Ever encountered a Python function with an @ symbol perched on top? That’s the decorator syntax, a powerful feature for enhancing functions without altering their core logic.

A decorator is a function that “wraps” another function, adding new capabilities before or after the original function runs.

graph TD;
    A[Decorator Function] -- Receives --> B(Base Function);
    A -- Defines & Returns --> C{Enhanced Wrapper Function};
    C -- Executes --> D[Setup Code e.g., Start Timer];
    D -- Calls --> B;
    B -- Executes --> E[Teardown Code e.g., Stop Timer];

In essence, when you apply @my_decorator to my_function, you’re telling Python to replace my_function with an enhanced version created by my_decorator.

The Problem: Code That Does Too Much

Why not just add the extra logic directly into the function? Let’s consider a simple brew_beverage function.

import time

def brew_beverage():
    print("Brewing beverage...")
    time.sleep(1)
    print("Beverage is ready!")

brew_beverage()

This function works, but what if we want to time its exact execution? The naive approach is to modify it directly.

 import time
+import datetime

 def brew_beverage():
+    start_time = datetime.datetime.now()
     print("Brewing beverage...")
     time.sleep(1)
     print("Beverage is ready!")
+    end_time = datetime.datetime.now()
+    print(f"Execution time: {end_time - start_time}")

This “solution” introduces two significant problems:

  1. Violation of the Single Responsibility Principle: The function is now doing two things: brewing a beverage and timing itself. Functions should be focused and do one thing well.
  2. Poor Reusability: If we create another function, say prepare_coffee, and want to time it, we’d have to duplicate the timing logic. This makes the codebase repetitive and hard to maintain.

Decorators solve these issues elegantly.

Building Your First Decorator

Let’s create a timing_decorator to handle the performance measurement. Since decorators are just functions, we define one that accepts a function (base_func) as its argument.

Inside the decorator, we define a wrapper function. This wrapper will contain the timing logic and, crucially, a call to the base_func it’s wrapping. The decorator then returns this wrapper function.

import time
import datetime

# This is our decorator
def timing_decorator(base_func):
    # This is the function that will be returned
    def wrapper():
        start_time = datetime.datetime.now()
        base_func() # Call the original function
        end_time = datetime.datetime.now()
        print(f"[{base_func.__name__}] Execution time: {end_time - start_time}")
    return wrapper

# A simple function to decorate
def brew_beverage():
    """Simulates brewing a beverage."""
    print("Brewing beverage...")
    time.sleep(1)
    print("Beverage is ready!")

# Manual application of the decorator
timed_brew = timing_decorator(brew_beverage)
timed_brew()

When we run this, timed_brew is the wrapper function returned by our decorator. Calling it executes the timing logic around the original brew_beverage function.

The Magic of @ Syntax

While manually applying decorators works, Python provides a much cleaner, more expressive syntax: the @ symbol.

Applying @timing_decorator above a function definition is syntactic sugar for brew_beverage = timing_decorator(brew_beverage).

@timing_decorator
def brew_beverage():
    """Simulates brewing a beverage."""
    print("Brewing beverage...")
    time.sleep(1)
    print("Beverage is ready!")

@timing_decorator
def prepare_coffee():
    """Simulates preparing coffee."""
    print("Grinding coffee beans...")
    time.sleep(1.5)
    print("Coffee is ready!")

# Now, calling the functions directly executes the decorated behavior
brew_beverage()
prepare_coffee()

This approach is superior because it makes the enhancement explicit and keeps the decoration coupled with the function definition. We’ve also effortlessly reused our timing logic for prepare_coffee!

Level Up: Handling Arguments and Keywords

What happens when our decorated functions have parameters?

Let’s modify brew_beverage to accept arguments.

 @timing_decorator
-def brew_beverage():
+def brew_beverage(beverage_type="tea", steep_time=1):
     """Simulates brewing a beverage."""
-    print("Brewing beverage...")
-    time.sleep(1)
+    print(f"Brewing {beverage_type}...")
+    time.sleep(steep_time)
     print("Beverage is ready!")

-brew_beverage()
+brew_beverage("green tea", steep_time=2)

Running this code will raise a TypeError! The wrapper function inside our decorator is defined to accept zero arguments, but we’re trying to pass two.

The solution is to make our wrapper flexible enough to accept any combination of arguments using *args and **kwargs.

  • *args: Packs all positional arguments into a tuple.
  • **kwargs: Packs all keyword arguments into a dictionary.

We then use the unpacking operators (* and **) to forward these arguments to the base_func.

graph TD
    subgraph "Function Call"
        A("brew_beverage('green tea', steep_time=2)")
    end
    subgraph "Decorator Wrapper"
        B["wrapper(*args, **kwargs)"]
    end
    subgraph "Base Function Call"
        C["base_func(*args, **kwargs)"]
    end

    A -- "('green tea',) -> args<br>{'steep_time': 2} -> kwargs" --> B;
    B -- "Unpacks args and kwargs" --> C;

Here is the improved, robust decorator:

def timing_decorator(base_func):
    def wrapper(*args, **kwargs): # Accept any arguments
        start_time = datetime.datetime.now()
        base_func(*args, **kwargs) # Forward them to the base function
        end_time = datetime.datetime.now()
        print(f"[{base_func.__name__}] Execution time: {end_time - start_time}")
    return wrapper

Now our decorator works flawlessly with functions that take any number of arguments.

Final Polish: Handling Return Values

There’s one last piece to the puzzle: what if the decorated function returns a value?

from datetime import datetime, timedelta

@timing_decorator
def prepare_coffee():
    """Simulates preparing coffee and returns an optimal consumption time."""
    print("Grinding coffee beans...")
    time.sleep(1.5)
    drink_by = datetime.now() + timedelta(minutes=20)
    return f"Coffee is ready! Drink by {drink_by.strftime('%H:%M:%S')}."

drink_time = prepare_coffee()
print(drink_time)
# Output: None

The output is None because our wrapper function doesn’t return anything. We need to capture the result of the base_func call and return it from the wrapper.

 def timing_decorator(base_func):
     def wrapper(*args, **kwargs):
         start_time = datetime.datetime.now()
-        base_func(*args, **kwargs)
+        result = base_func(*args, **kwargs) # Capture the return value
         end_time = datetime.datetime.now()
         print(f"[{base_func.__name__}] Execution time: {end_time - start_time}")
+        return result # Return it
     return wrapper

With this change, our decorator is now fully flexible and transparently handles arguments and return values.

[!TIP] Best Practice: Preserve Function Metadata Decorators have a side effect: they obscure the original function’s metadata (like its name __name__ and docstring __doc__). The decorated function will incorrectly report itself as wrapper.

To fix this, always use @functools.wraps from Python’s standard library. This is a decorator for your decorator! It copies the metadata from the original function to the wrapper.

Here is the final, production-ready decorator pattern:

import time
import datetime
import functools # Import functools

def timing_decorator(base_func):
    @functools.wraps(base_func) # Apply the wraps decorator
    def wrapper(*args, **kwargs):
        start_time = datetime.datetime.now()
        result = base_func(*args, **kwargs)
        end_time = datetime.datetime.now()
        print(f"[{base_func.__name__}] Execution time: {end_time - start_time}")
        return result
    return wrapper

@timing_decorator
def brew_beverage(beverage_type="tea", steep_time=1):
    """Simulates brewing a beverage."""
    print(f"Brewing {beverage_type}...")
    time.sleep(steep_time)
    print("Beverage is ready!")

print(brew_beverage.__name__)
# Output: brew_beverage (Correct!)

By mastering this pattern, you can create powerful, reusable components for logging, caching, authentication, and much more, leading to cleaner and more professional Python code.


Join the 10xdev Community

Subscribe and get 8+ free PDFs that contain detailed roadmaps with recommended learning periods for each programming language or field, along with links to free resources such as books, YouTube tutorials, and courses with certificates.

Audio Interrupted

We lost the audio stream. Retry with shorter sentences?