Previous | Tutorial index | Next

Tutorial 10: Decorators – Extending Functions Without Modifying Them

Learning Objectives

Overview

Decorators are one of Python’s most powerful and elegant features. They allow you to modify or enhance functions without changing their source code—a concept known as metaprogramming. A decorator is a function that takes another function as an argument, wraps it with additional behavior, and returns a new function. Decorators are widely used for cross‑cutting concerns such as logging, performance measurement, access control, caching, and validation. In this tutorial, you will learn how to create your own decorators, understand the underlying mechanics (closures), and discover advanced patterns like parameterized decorators and preserving metadata.

1. What Is a Decorator?

A decorator is a callable that takes a callable (function or class) as input and returns a new callable with extended functionality. The syntax using @decorator_name is just syntactic sugar for:

@decorator def func(): pass # Is equivalent to: func = decorator(func)

This means a decorator is simply a function that returns a function (or a callable). The decorated function is replaced by the wrapper.

1.1 A Simple Decorator

Let's start with the logging example from the introduction:

def log_call(func): def wrapper(*args, **kwargs): print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}") result = func(*args, **kwargs) print(f"Returned: {result}") return result return wrapper @log_call def add(a, b): return a + b add(3, 5) # Output: # Calling add with args=(3, 5), kwargs={} # Returned: 8

Here, log_call is the decorator. It takes add as an argument, defines an inner wrapper that adds logging, and returns the wrapper. When we call add(3, 5), we are actually calling the wrapper, which then calls the original add.

1.2 The Closure Connection

Decorators are built on closures. The wrapper function captures the func parameter from the outer scope, creating a closure. The func variable is preserved even after the decorator returns.

2. The @ Syntax – Why It Matters

The @ symbol makes decoration explicit and readable. Without it, you would have to assign the result manually:

def add(a, b): return a + b add = log_call(add) # manual decoration

While this works, using @ at the definition site is clearer and more maintainable, especially when stacking multiple decorators.

3. Writing a Generic Decorator – The *args, **kwargs Trick

To make a decorator work with any function, regardless of its signature, the wrapper should accept arbitrary arguments and pass them along.

def my_decorator(func): def wrapper(*args, **kwargs): # Do something before result = func(*args, **kwargs) # Do something after return result return wrapper

This pattern is used in almost every decorator. It ensures compatibility with functions that have any number of positional or keyword arguments.

4. Preserving Metadata – functools.wraps

When you decorate a function, the wrapper replaces the original. This obscures the original function’s name, docstring, and other metadata. To fix this, use functools.wraps in the wrapper.

from functools import wraps def log_call(func): @wraps(func) def wrapper(*args, **kwargs): print(f"Calling {func.__name__}") return func(*args, **kwargs) return wrapper @log_call def add(a, b): """Add two numbers.""" return a + b print(add.__name__) # 'add' (not 'wrapper') print(add.__doc__) # 'Add two numbers.'

@wraps updates the wrapper’s metadata to match func. It is considered best practice to always use functools.wraps in your decorators.

5. Parameterized Decorators (Decorators with Arguments)

Sometimes you need to pass arguments to the decorator itself, e.g., to specify a log level or a repeat count. This requires an extra level of nesting.

Example: Repeat a function multiple times

def repeat(times): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for _ in range(times): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(3) def say_hello(): print("Hello!") say_hello() # Prints "Hello!" three times.

Here, repeat(times) returns the actual decorator (decorator), which then decorates say_hello. This is sometimes called a decorator factory.

Another example: Logging with a level

def log(level="INFO"): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): print(f"[{level}] Calling {func.__name__}") return func(*args, **kwargs) return wrapper return decorator @log("DEBUG") def process(): pass

6. Stacking Decorators

You can apply multiple decorators to one function. They are applied from the bottom up (the one nearest the function runs first, then the next, etc.). In practice, the order is:

@decorator1 @decorator2 def func(): ... # Equivalent to: func = decorator1(decorator2(func))

So decorator2 runs first (wraps func), then decorator1 wraps the result.

Example: Timing and logging together

def timer(func): @wraps(func) def wrapper(*args, **kwargs): import time start = time.time() result = func(*args, **kwargs) print(f"{func.__name__} took {time.time()-start:.4f}s") return result return wrapper def log_call(func): @wraps(func) def wrapper(*args, **kwargs): print(f"Calling {func.__name__}") return func(*args, **kwargs) return wrapper @timer @log_call def slow_function(): import time time.sleep(1) slow_function() # Calling slow_function # slow_function took 1.0001s

Here, log_call wraps slow_function, then timer wraps the result. So the order is: timer wrapper calls log_call wrapper, which calls the original.

7. Class‑Based Decorators

Decorators can also be implemented as classes by making them callable (implementing __call__). This is useful when you need to maintain state.

class CountCalls: def __init__(self, func): self.func = func self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 print(f"Call {self.count} of {self.func.__name__}") return self.func(*args, **kwargs) @CountCalls def say_hello(): print("Hello") say_hello() # Call 1 of say_hello say_hello() # Call 2 of say_hello

You can also create parameterized class‑based decorators by using __init__ to accept arguments.

8. Common Built‑in Decorators

Python has several built‑in decorators:

Example:

from functools import lru_cache @lru_cache(maxsize=128) def fib(n): if n < 2: return n return fib(n-1) + fib(n-2)

9. Practical Use Cases

10. Decorator Pitfalls and Best Practices

11. Decorator with Arguments – Example: Retry

def retry(max_attempts=3, delay=1): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_attempts): try: return func(*args, **kwargs) except Exception as e: print(f"Attempt {attempt+1} failed: {e}") if attempt < max_attempts - 1: time.sleep(delay) raise Exception(f"All {max_attempts} attempts failed") return wrapper return decorator @retry(5, delay=0.5) def unstable_connection(): import random if random.random() < 0.8: raise ConnectionError("Failed") return "Success"

📝 Quiz – Check Your Understanding

  1. What is a decorator in Python?

    Answer(A) A function that modifies another function's behavior.
  2. Which syntax is used to apply a decorator log to a function func?

    Answer(D) Both B and C.
  3. What is the purpose of functools.wraps?

    Answer(B) To preserve the original function's metadata.
  4. True or False: A decorator must always return a function.

    AnswerTrue
  5. What does the following decorator do?

    def toggle(func): def wrapper(*args, **kwargs): return not func(*args, **kwargs) return wrapper
    Answer(B) It negates the boolean result.
  6. How do you pass arguments to a decorator (e.g., @repeat(3))?

    Answer(D) Both A and C are valid.
  7. In stacking decorators @A @B def f():, which decorator runs first?

    Answer(B) `B` runs first, then `A` wraps the result.
  8. Which of the following is a built‑in decorator?

    Answer(D) All of the above.
  9. What is the output of the following code?

    def foo(func): def wrapper(): print("A") func() return wrapper def bar(func): def wrapper(): print("B") func() return wrapper @foo @bar def hello(): print("Hello") hello()
    Answer(A) A B Hello
  10. What is the potential issue if you forget to return the wrapper from a decorator?

    Answer(A) The decorated function becomes `None`.

💻 Exercises – Practice Makes Perfect

Exercise 1: Timing Decorator
Write a decorator timed that prints execution time in milliseconds.

Sample Solution ```python import time from functools import wraps

def timed(func): @wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args,**kwargs) elapsed = (time.perf_counter() - start) * 1000 print(f"{func.name} took {elapsed:.2f}ms") return result return wrapper

@timed def sleepy(): time.sleep(0.5)

sleepy()

</details> **Exercise 2: Count Calls** Write a decorator `count_calls` that prints the number of times a function is called. <details><summary>Sample Solution</summary> ```python from functools import wraps def count_calls(func): count = 0 @wraps(func) def wrapper(*args, **kwargs): nonlocal count count += 1 print(f"{func.__name__} called {count} times") return func(*args, **kwargs) return wrapper @count_calls def say_hello(): print("Hello") say_hello() say_hello()

Exercise 3: Validate Positive
Write a decorator check_positive that raises ValueError if any positional argument is not a positive integer.

Sample Solution ```python from functools import wraps

def check_positive(func): @wraps(func) def wrapper(*args, **kwargs): for arg in args: if not (isinstance(arg, int) and arg > 0): raise ValueError("All arguments must be positive integers") return func(*args,**kwargs) return wrapper

@check_positive def add(a, b): return a + b

print(add(3, 5)) # 8

add(3, -1) # ValueError

</details> **Exercise 4: Memoize (Cache) with Decorator** Implement a decorator `memoize` that caches results based on arguments. <details><summary>Sample Solution</summary> ```python from functools import wraps def memoize(func): cache = {} @wraps(func) def wrapper(*args, **kwargs): key = (args, tuple(kwargs.items())) if key not in cache: cache[key] = func(*args, **kwargs) return cache[key] return wrapper @memoize def fib(n): if n < 2: return n return fib(n-1) + fib(n-2) print(fib(40)) # fast!

Exercise 5: Parameterized Logging
Write a decorator factory log(level) that prints messages with a given log level.

Sample Solution ```python from functools import wraps

def log(level="INFO"): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): print(f"[{level}] Calling {func.name}") return func(*args,**kwargs) return wrapper return decorator

@log("DEBUG") def process(): print("Processing...")

process() # [DEBUG] Calling process

</details> --- ### 🏠 Homework – Deeper Thinking #### Short Answer Questions **1. Retry Decorator with Exponential Backoff** Write a decorator `retry(max_attempts, delay_base=1, backoff=2)` that retries on exception with exponential delay. <details><summary>Sample Answer</summary> ```python import time from functools import wraps def retry(max_attempts, delay_base=1, backoff=2): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_attempts): try: return func(*args, **kwargs) except Exception as e: if attempt == max_attempts - 1: raise time.sleep(delay_base * (backoff ** attempt)) return wrapper return decorator @retry(3, delay_base=0.1) def unstable(): import random if random.random() < 0.7: raise ValueError("Failed") return "Success"

2. Permission Checker
Write a parameterized decorator requires_permission(permission) that checks a global current_user.

Sample Answer ```python current_user = {"name": "Alice", "permissions": ["read", "write"]}

def requires_permission(permission): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): if permission not in current_user.get("permissions", []): raise PermissionError(f"Missing permission: {permission}") return func(*args,**kwargs) return wrapper return decorator

@requires_permission("delete") def delete_user(user_id): print(f"Deleted user {user_id}")

delete_user(1) # PermissionError

</details> **3. Class Decorator – Counting Method Calls** Write a class decorator `count_methods(cls)` that adds a `__call_counts` attribute counting method calls. <details><summary>Sample Answer</summary> ```python def count_methods(cls): call_counts = {} for name, method in cls.__dict__.items(): if callable(method) and not name.startswith("__"): def wrapper(method): @wraps(method) def wrapped(self, *args, **kwargs): call_counts[name] = call_counts.get(name, 0) + 1 return method(self, *args, **kwargs) return wrapped setattr(cls, name, wrapper(method)) cls.__call_counts__ = call_counts return cls @count_methods class MyClass: def method1(self): pass def method2(self): pass obj = MyClass() obj.method1() obj.method2() obj.method1() print(obj.__call_counts__) # {'method1': 2, 'method2': 1}

Essay Questions

4. Decorator with State – Rate Limiting
Implement a decorator rate_limit(limit_per_second) that raises an exception if called too often.

Sample Answer ```python import time from functools import wraps

class RateLimitExceeded(Exception): pass

def rate_limit(limit_per_second): def decorator(func): timestamps = [] @wraps(func) def wrapper(*args, **kwargs): now = time.time() # Remove timestamps older than 1 second timestamps[:] = [t for t in timestamps if t > now - 1] if len(timestamps) >= limit_per_second: raise RateLimitExceeded("Too many calls") timestamps.append(now) return func(*args,**kwargs) return wrapper return decorator

@rate_limit(2) def fast_call(): print("Called")

for _in range(3): fast_call() time.sleep(0.1)

Third call raises RateLimitExceeded

</details> **5. Tracing with Indentation** Write a decorator `trace()` that prints function entry/exit with indentation reflecting call depth. <details><summary>Sample Answer</summary> ```python from functools import wraps depth = 0 def trace(func): @wraps(func) def wrapper(*args, **kwargs): global depth indent = " " * depth print(f"{indent}-> {func.__name__}({args}, {kwargs})") depth += 1 result = func(*args, **kwargs) depth -= 1 print(f"{indent}<- {func.__name__} -> {result}") return result return wrapper @trace def factorial(n): if n <= 1: return 1 return n * factorial(n-1) factorial(4)

Homework Hints

Summary

In this tutorial, you have learned:

Decorators are a hallmark of Python’s expressiveness. They allow you to separate cross‑cutting concerns and keep your code DRY (Don’t Repeat Yourself). With the knowledge gained here, you can now create your own decorators to simplify and enhance your projects.

Next Steps: In future tutorials, you could explore more advanced topics like context managers, metaclasses, or concurrency.

Happy decorating!

Previous | Tutorial index | Next