Previous | Tutorial index | Next
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.
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.
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.
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.
@ Syntax – Why It MattersThe @ 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.
*args, **kwargs TrickTo 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.
functools.wrapsWhen 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.
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
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.
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.
Python has several built‑in decorators:
@staticmethod – defines a static method (does not receive self or cls).@classmethod – defines a class method (receives cls).@property – makes a method behave like an attribute.@functools.lru_cache – caches function results to avoid recomputation.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)
functools.wraps to preserve metadata.None).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"
What is a decorator in Python?
object.Which syntax is used to apply a decorator log to a function func?
log(func)@log def func(): ...func = log(func)What is the purpose of functools.wraps?
True or False: A decorator must always return a function.
What does the following decorator do?
def toggle(func):
def wrapper(*args, **kwargs):
return not func(*args, **kwargs)
return wrapper
True.How do you pass arguments to a decorator (e.g., @repeat(3))?
*args in the wrapper.__init__.In stacking decorators @A @B def f():, which decorator runs first?
ABWhich of the following is a built‑in decorator?
@staticmethod@classmethod@propertyWhat 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()
What is the potential issue if you forget to return the wrapper from a decorator?
None.Exercise 1: Timing Decorator
Write a decorator timed that prints execution time in milliseconds.
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.
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
</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.
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.
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}")
</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}
4. Decorator with State – Rate Limiting
Implement a decorator rate_limit(limit_per_second) that raises an exception if called too often.
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)
</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)
time.sleep(delay_base * (backoff ** attempt)).current_user; in wrapper, check permission.depth in enclosing scope; modify with nonlocal; print with ' ' * depth.In this tutorial, you have learned:
@ syntax for easy decoration.*args, **kwargs.functools.wraps.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!