July 25, 2026 · All About CS

Python Decorators Explained: The Complete Guide to @wrapper Functions

Master Python decorators from first principles, including how functions work as first-class objects, the @ syntactic sugar, and *args/**kwargs. Finish by building a real-world @timer decorator to profile a data-processing pipeline.

pythondecoratorspython-decoratorswrapper-functionstimer-decoratorhigher-order-functionspython-tutorialclean-codesoftware-engineeringperformance-profiling

Python Decorators Explained: The Complete Guide to @wrapper Functions

Decorators are one of Python's most powerful — and most misunderstood — features. If you've ever seen the @ symbol above a function in frameworks like Django or Flask and wondered what's actually happening under the hood, this guide will take you from the underlying theory to a production-style implementation.

Decorators let you modify or enhance the behavior of a function without permanently changing its underlying source code. Whether you're logging data, checking authentication, or measuring performance, decorators make your code more efficient, readable, and idiomatically Pythonic. By the end of this tutorial, you'll have built a complete performance-logging system using a custom @timer decorator.

The Prerequisite: Functions as Objects

Before diving into decorators, it's essential to understand a core concept: in Python, functions are first-class objects. That means functions can be:

  • Passed as arguments to other functions
  • Returned from other functions
  • Assigned to variables

Here's what that looks like in practice:

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

def call_function(func, argument):
    return func(argument)

result = call_function(greet, "Alice")
print(result)
# -> Hello, Alice!

Here, greet is passed directly into call_function without parentheses — meaning we're passing the function object itself, not calling it. call_function then invokes it internally via func(argument).

This ability to treat functions just like any other piece of data is the exact foundation that makes decorators possible.

Creating Your First Decorator

So what exactly is a decorator? Simply put:

A decorator is a function that takes another function as its argument, wraps it in some new functionality, and returns a new, modified function.

Let's write a simple decorator that announces when a function is starting and finishing:

Python
def my_decorator(func):
    def wrapper():
        print("Something is happening before the function is called.")
        func()
        print("Something is happening after the function is called.")
    return wrapper

Breaking this down:

  1. my_decorator accepts func — the function we want to decorate.
  2. Inside it, we define a new function called wrapper.
  3. wrapper adds custom behavior around the call to func().
  4. Finally, my_decorator returns the wrapper function itself — without calling it.

Now let's apply it manually to a basic function:

Python
def say_hello():
    print("Hello from the original function!")

say_hello = my_decorator(say_hello)
say_hello()
# -> Something is happening before the function is called.
# -> Hello from the original function!
# -> Something is happening after the function is called.

We passed say_hello into our decorator, and reassigned the returned wrapper function back to the say_hello name. When executed, it prints the "before" message, runs the original function, then prints the "after" message — all without modifying say_hello's original source code.

The Syntactic Sugar: The @ Symbol

Writing say_hello = my_decorator(say_hello) every time is repetitive and clunky. Python gives us a cleaner shortcut — often called syntactic sugar — using the @ symbol placed directly above the function definition:

Python
@my_decorator
def say_hello_again():
    print("Hello from the newly decorated function!")

say_hello_again()
# -> Something is happening before the function is called.
# -> Hello from the newly decorated function!
# -> Something is happening after the function is called.

By placing @my_decorator above say_hello_again, Python automatically passes say_hello_again into my_decorator behind the scenes. The output is identical to the manual reassignment version, but the code is far cleaner and more idiomatic.

@my_decorator is purely syntactic sugar for func = my_decorator(func). Understanding this equivalence is the key to demystifying decorators — there's no special magic beyond function composition and reassignment.

Decorating Functions with Arguments

There's one problem with our current decorator: it breaks if the decorated function accepts arguments.

Python
@my_decorator
def greet_person(name):
    print(f"Hello, {name}!")

greet_person("Alice")
# -> TypeError: wrapper() takes 0 positional arguments but 1 was given

This fails because our wrapper() function doesn't accept any parameters. To make decorators flexible enough to handle any function signature, we need *args and **kwargs:

Python
def smart_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Executing: {func.__name__}")
        result = func(*args, **kwargs)
        print("Execution complete!")
        return result
    return wrapper

Key changes:

  • wrapper now accepts *args (positional arguments) and **kwargs (keyword arguments).
  • Those same arguments are unpacked into func(*args, **kwargs).
  • The result of the original function call is captured and returned.

This is the general-purpose, bulletproof template for writing decorators that work with any function signature:

Python
@smart_decorator
def add_numbers(a, b):
    return a + b

print(add_numbers(10, 20))
# -> Executing: add_numbers
# -> Execution complete!
# -> 30

Always use *args, **kwargs in your wrapper signature unless you have a specific reason not to. This ensures your decorator is reusable across any function, regardless of its parameter list.

Real-World Project: Building a Performance Logger

Now let's apply everything we've covered to a practical scenario. Imagine you're building a data analysis pipeline and want to identify which processing functions are taking the longest to run. Rather than pasting timing code inside every single function, we'll build a reusable @timer decorator.

Step 1: Create the Timer Decorator

Python
import time

def timer(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()

        # Execute the actual function
        result = func(*args, **kwargs)

        end_time = time.time()
        execution_time = round(end_time - start_time, 4)
        print(f"[LOG] {func.__name__} executed in {execution_time} seconds")

        return result
    return wrapper

The wrapper function here:

  1. Records the current time before execution.
  2. Runs the wrapped function and captures its return value.
  3. Records the time again after execution.
  4. Calculates and logs the elapsed time.

Step 2: Apply the Decorator to Pipeline Functions

Python
@timer
def process_customer_data(records):
    print("Processing customer data...")
    time.sleep(1.2)  # Simulating a heavy task
    return f"{records} records processed."

@timer
def generate_sales_report():
    print("Generating monthly report...")
    time.sleep(0.8)
    return "Report Generation Complete."

Notice how clean these functions remain — there's zero timing logic cluttering up the actual business logic. All the heavy lifting is handled entirely by the @timer decorator.

Step 3: Execute the Pipeline

Python
print("=== PIPELINE START ===\n")
process_customer_data(5000)
print("-" * 20)
generate_sales_report()

# -> === PIPELINE START ===
# ->
# -> Processing customer data...
# -> [LOG] process_customer_data executed in 1.2003 seconds
# -> --------------------
# -> Generating monthly report...
# -> [LOG] generate_sales_report executed in 0.8002 seconds

The pipeline executes exactly as expected — each original function prints its own message, and the @timer decorator seamlessly injects execution-time logs into the console. This is exactly how production-grade Python code separates cross-cutting concerns (like logging and performance monitoring) from core business logic.

This same pattern — wrap, measure, log — is the basis for far more advanced use cases, including caching (functools.lru_cache), retry logic, authentication checks, and rate limiting.

Quick Reference

ConceptSyntax / ExamplePurpose
First-class functionscall_function(greet, "Alice")Pass functions as arguments/data
Basic decoratordef my_decorator(func): ...Wraps a function with new behavior
Manual applicationsay_hello = my_decorator(say_hello)Explicitly reassigns the wrapped function
Syntactic sugar@my_decoratorShorthand for manual reassignment
Flexible argumentsdef wrapper(*args, **kwargs):Supports functions with any signature
Preserving return valuesresult = func(*args, **kwargs); return resultEnsures the wrapped function's output isn't lost
Practical example@timerMeasures and logs function execution time

What's Next?

You now have a solid, practical foundation in Python decorators:

  • How functions behave as first-class objects
  • How to construct a decorator manually using a wrapper function
  • How the @ syntax simplifies decorator application
  • How to use *args and **kwargs for universal compatibility
  • How to build a real-world @timer decorator for performance profiling

From here, natural next steps include exploring decorators with arguments (e.g., @retry(times=3)), preserving function metadata with functools.wraps, and chaining multiple decorators together on a single function. Mastering these patterns will make your Python code significantly cleaner, more modular, and more maintainable.