Skip to main content

Decorators with Arguments: Advanced Patterns

A decorator that works on functions with no arguments is limited. Real-world decorators must handle functions of any signature — functions with positional args, keyword args, or both. This article shows you the universal decorator pattern using *args and **kwargs, plus the critical functools.wraps decorator that preserves function metadata.

The Problem: Decorators Breaking Function Signatures

A simple decorator ignores the wrapped function's parameters:

def logger_decorator(func_to_wrap):
def wrapper():
print("Logging...")
func_to_wrap()
return wrapper

@logger_decorator
def greet(name):
print(f"Hello, {name}!")

# This crashes — wrapper() takes 0 args, but greet() expects 1
# greet("Alice") # TypeError: wrapper() takes 0 positional arguments but 1 was given

The wrapper function doesn't accept any arguments, so decorating a function that takes parameters causes a runtime error. The solution is to make the wrapper accept any combination of arguments.


Using *args and **kwargs for Universal Decorators

The pattern def wrapper(*args, **kwargs): makes your decorator flexible enough to wrap any function, regardless of its parameters. The wrapper collects all positional arguments into a tuple and all keyword arguments into a dictionary, then passes them to the original function.

How do you create a decorator that works with any function signature?

def logger_decorator(func_to_wrap):
# The wrapper now accepts any arguments
def wrapper(*args, **kwargs):
print(f"Calling function '{func_to_wrap.__name__}' with arguments:")
print(f" Positional (args): {args}")
print(f" Keyword (kwargs): {kwargs}")

# Pass the collected arguments to the original function
func_to_wrap(*args, **kwargs)

return wrapper

@logger_decorator
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")

# Now it works!
greet("Alice")
greet("Bob", greeting="Good morning")

Output:

Calling function 'greet' with arguments:
Positional (args): ('Alice',)
Keyword (kwargs): {}
Hello, Alice!
Calling function 'greet' with arguments:
Positional (args): ('Bob',)
Keyword (kwargs): {'greeting': 'Good morning'}
Good morning, Bob!

How it works:

  • def wrapper(*args, **kwargs): — Accepts any positional and keyword arguments.
  • func_to_wrap(*args, **kwargs) — Passes all collected arguments to the original function.
  • This single pattern works for functions with zero arguments, one argument, many arguments, or any mix of positional and keyword arguments.

Handling Return Values from Decorated Functions

A decorator must capture and return the result of the wrapped function. Otherwise, calling a decorated function that returns a value gives you None:

How do you preserve the return value of a decorated function?

import time

def timer_decorator(func):
def wrapper(*args, **kwargs):
start_time = time.time()
# Capture the result of the original function call
result = func(*args, **kwargs)
end_time = time.time()
print(f"Function '{func.__name__}' took {end_time - start_time:.4f} seconds to run.")
# Return the result to the original caller
return result
return wrapper

@timer_decorator
def add(x, y):
"""A simple function that adds two numbers."""
time.sleep(1) # Simulate work
return x + y

# Call the function and get its return value
sum_result = add(10, 20)
print(f"The result of the add function is: {sum_result}")

Output:

Function 'add' took 1.0012 seconds to run.
The result of the add function is: 30

Key point: Without return result, the function returns None even though the original add() function returned 30. Always capture the return value and return it from the wrapper.


Preserving Function Metadata with @functools.wraps

A subtle but critical problem: when you decorate a function, the wrapper function replaces it. This means the original function's name and docstring are lost:

@timer_decorator
def add(x, y):
"""A simple function that adds two numbers."""
return x + y

print(f"Function name: {add.__name__}")
print(f"Docstring: {add.__doc__}")

Output (without functools.wraps):

Function name: wrapper
Docstring: None

This breaks introspection, debugging, and documentation tools. The solution is the @functools.wraps decorator, which copies the original function's metadata to the wrapper:

How do you preserve function metadata with @functools.wraps?

import time
import functools

def timer_decorator(func):
# Apply @functools.wraps to the wrapper function
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"Function '{func.__name__}' took {end_time - start_time:.4f} seconds to run.")
return result
return wrapper

@timer_decorator
def add(x, y):
"""A simple function that adds two numbers."""
time.sleep(1)
return x + y

# Now the metadata is preserved
print(f"Function name: {add.__name__}")
print(f"Docstring: {add.__doc__}")

Output (with functools.wraps):

Function name: add
Docstring: A simple function that adds two numbers.

This is critical: Always use @functools.wraps(func) on your inner wrapper function. It's the standard practice and prevents silent bugs in tools that rely on function metadata.


The Complete, Production-Ready Decorator Pattern

Here's the full pattern you should use for all decorators:

import functools

def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Your decorator logic before the function
print(f"About to call {func.__name__}")

# Call the original function and capture its result
result = func(*args, **kwargs)

# Your decorator logic after the function
print(f"Finished calling {func.__name__}")

# Return the result
return result
return wrapper

@my_decorator
def example_func(x, y):
"""Adds two numbers."""
return x + y

# Use it
result = example_func(3, 5)
print(f"Result: {result}")
print(f"Function name: {example_func.__name__}")

Key Takeaways

  • Use *args and **kwargs: This pattern makes your decorator work with any function signature.
  • Always capture and return the result: Without return result, decorated functions that return values return None instead.
  • Always use @functools.wraps: It preserves the original function's name, docstring, and other metadata.
  • Apply @wraps to the wrapper function: Not the outer decorator function.
  • This is the universal pattern: Once you master it, you can decorate any function confidently.

Frequently Asked Questions

What does @functools.wraps actually do?

It copies metadata attributes like __name__, __doc__, __module__, __qualname__, __annotations__, and __dict__ from the original function to the wrapper function. This makes the wrapper appear to be the original function to introspection tools, help systems, and debuggers.

Do you need functools.wraps if your decorator doesn't need to preserve the function's name?

Technically no, but you should always use it. Even if you don't care about the name today, tools (testing frameworks, documentation generators, debuggers) rely on accurate function metadata. Using @functools.wraps is a best practice with no downside.

Can a decorator modify the arguments before passing them to the wrapped function?

Yes. You can process args and kwargs before passing them, or even prevent the original function from running. For example, a validation decorator could check arguments and raise an exception if they're invalid, preventing the wrapped function from executing.

Why does *args become a tuple and not a list?

Tuples are immutable and represent a fixed sequence of values. Since arguments are passed into the function and should not be modified by the decorator, using a tuple is the correct choice. It signals that these values are fixed input data.

Can you stack multiple decorators on one function?

Yes. When you apply multiple decorators, they are applied from bottom to top:

@decorator1
@decorator2
def my_func():
pass

This is equivalent to decorator1(decorator2(my_func)). Each decorator wraps the result of the previous one.


Further Reading