Think of a decorator like gift wrap.

  1. The Decorator is the shiny paper and the card on top.
  2. The Function is the gift inside.

You can look at the card, check the weight, or add a “Happy Birthday” sticker before the gift is even opened. In code, decorators allow you to execute logic before and after a function runs, without touching the function’s internal code.

Basic Template

We use *args and **kwargs to make sure the decorator works with any function, regardless of how many inputs it has.

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("1. Logic before the function")
        
        result = func(*args, **kwargs) # The gift is opened!
        
        print("2. Logic after the function")
        return result
    return wrapper

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

What is the @ symbol actually doing?

The @decorator syntax is what developers call “Syntactic Sugar.” This is just a fancy way of saying it’s a “sweeter,” shorter way to write something that would otherwise be a bit ugly.

Without the @ symbol, you would have to manually reassign your function.

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

# This is what the @ symbol does behind the scenes:
say_hello = my_decorator(say_hello)

Using @wraps

When you wrap a function with a decorator, you are essentially replacing your function with the wrapper. By default, Python “forgets” the original function’s name, docstrings, etc. and starts using the wrapper’s info instead.

Without @wraps:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def say_hello():
    """This function says hello."""
    print("Hello!")

print(say_hello.__name__)  # Output: wrapper
print(say_hello.__doc__)   # Output: None

@wraps ensures the function keeps its original identity (name, docstrings, etc.). Always use it.

from functools import wraps

def my_decorator(func):
    # copies the metadata (the name, the docstrings, and the arguments)
    # from the original function onto the wrapper.
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def say_hello():
    """This function says hello."""
    print("Hello!")

print(say_hello.__name__)  # Output: say_hello
print(say_hello.__doc__)   # Output: This function says hello.

Practical Example

Decorators are perfect for security. Instead of checking if a user is logged in inside 50 different functions, you write one “Bouncer” decorator and reuse it.

user_is_logged_in = False

def login_required(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        if not user_is_logged_in:
            print("Access Denied: Please log in.")
            return None
        return func(*args, **kwargs)
    return wrapper

@login_required
def view_account_balance():
    print("Your balance is $5,000.")

view_account_balance() # Output: Access Denied

Decorators with Arguments

Sometimes your decorator needs its own settings (e.g., a specific multiplier or a custom log prefix). This requires a 3-Layer structure:

  • Layer 1: The configuration layer (takes your settings).
  • Layer 2: The actual decorator (takes the function).
  • Layer 3: The wrapper (takes the arguments).
def multiplier(factor):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs) * factor
        return wrapper
    return decorator

@multiplier(3) # Triple the points!
def get_score():
    return 10

print(get_score()) # Output: 30

Stacking

You can stack decorators! They apply from the bottom up (the one closest to the function runs first).

@logging_decorator
@timer_decorator # This one runs first
def my_function():
    pass