Python · Syntax · Advanced

Decorators

10 tasks

Functions that wrap other functions to modify or extend their behaviour. Covers `@` syntax, decorators with arguments, stacking decorators, and `functools.wraps`.

Why decorators exist, closures, anatomy, and functools.wraps

#
**Why decorators exist** Imagine you want to log every time any function is called, or measure how long each function takes. Without decorators you'd copy-paste timing code into every function. Decorators let you write that logic once and attach it anywhere: ```python # Without decorators — you'd repeat this everywhere: def compute(n): start = time.perf_counter() result = sum(range(n)) print(f'took {time.perf_counter() - start:.4f}s') return result # With a decorator — timing logic lives in one place: @timer def compute(n): return sum(range(n)) ``` This is called a *cross-cutting concern* — behaviour that belongs to many functions but isn't part of any single one's job. Decorators are Python's answer to that. **What `@decorator` actually does** The `@` syntax is pure shorthand. These two are identical: ```python @timer def greet(name): return f'Hello, {name}!' # Same as: def greet(name): return f'Hello, {name}!' greet = timer(greet) # greet is now the wrapper timer returned ``` Python evaluates the decorator first, passes the function to it, and rebinds the original name to whatever the decorator returns. **Why closures make this work** The `wrapper` function inside a decorator *closes over* the `func` parameter — it remembers a reference to the original function even after `my_decorator` has returned. This is the fundamental mechanism: ```python def my_decorator(func): # func is 'greet' at decoration time def wrapper(*args, **kwargs): print('before') # wrapper closes over func result = func(*args, **kwargs) # still has access to 'func' print('after') return result return wrapper # return the closure, not the result ``` The `*args, **kwargs` in the wrapper signature means the decorator works with *any* function, regardless of how many parameters it takes. **Always use `@functools.wraps`** Without it, the wrapper replaces the original function's identity. Debugging becomes confusing, and frameworks that inspect `__name__` or `__doc__` (like FastAPI route discovery or pytest) will behave unexpectedly: ```python import functools def my_decorator(func): @functools.wraps(func) # copies __name__, __doc__, __annotations__ def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @my_decorator def greet(name: str) -> str: 'Say hello to name.' return f'Hello, {name}!' greet.__name__ # 'greet' (not 'wrapper') greet.__doc__ # 'Say hello to name.' greet.__annotations__ # {'name': str, 'return': str} # Without @wraps: # greet.__name__ would be 'wrapper' — hard to debug ``` `functools.update_wrapper(wrapper, func)` is the explicit version of the same thing — `@wraps(func)` is just shorthand for calling it.

Decorator factories, stacking, class-based decorators

#
**Decorator with arguments — the factory pattern** A plain decorator takes a function and returns a function. If you need to pass *parameters* to the decorator itself, you add one more layer — a factory that accepts the parameters and returns the actual decorator: ```python import functools def repeat(n): # layer 1: factory — accepts the argument def decorator(func): # layer 2: actual decorator @functools.wraps(func) def wrapper(*args, **kwargs): # layer 3: wrapper for _ in range(n): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(3) # repeat(3) returns the decorator; that decorator wraps say def say(msg): print(msg) say('hello') # prints 'hello' three times ``` The pattern: each `@decorator(arg)` call returns a decorator, and that decorator is what wraps the function. **Stacking decorators** You can apply multiple decorators to one function. They're applied bottom-up at decoration time, but called top-down at runtime: ```python @timer # applied second → outermost layer @log_calls # applied first → innermost layer def compute(n): return sum(range(n)) # Equivalent to: compute = timer(log_calls(compute)) # Call order at runtime: # timer's wrapper runs → log_calls' wrapper runs → compute runs ``` A common use of stacking: `@functools.lru_cache` combined with a custom decorator that clears the cache on certain conditions. **Class-based decorator** Any object with a `__call__` method can act as a decorator. This is useful when the decorator needs to maintain state across calls: ```python import functools class CountCalls: def __init__(self, func): functools.update_wrapper(self, func) # same as @wraps but for classes self.func = func self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 return self.func(*args, **kwargs) @CountCalls def say_hi(): print('hi') say_hi() say_hi() print(say_hi.count) # 2 — state lives on the CountCalls instance ``` Class-based decorators are the right choice when you need to expose extra methods or attributes (like `.count` above) alongside the callable. > **Note on class-method decorators:** Python also ships `@property`, `@classmethod`, and `@staticmethod` — built-in decorators that change how a method binds to its class. These are a distinct topic covered in depth in the next section.

Real patterns, common mistakes, when not to use, method decorators preview

#
**Common real-world decorator patterns** ```python import functools, time # 1. Timer — measure execution time def timer(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f'{func.__name__} took {elapsed:.4f}s') return result return wrapper # 2. Retry on failure def retry(n=3, exceptions=(Exception,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, n + 1): try: return func(*args, **kwargs) except exceptions: if attempt == n: raise return wrapper return decorator # 3. Singleton — ensure only one instance is created def singleton(cls): instances = {} @functools.wraps(cls) def get_instance(*args, **kwargs): if cls not in instances: instances[cls] = cls(*args, **kwargs) return instances[cls] return get_instance @singleton class Config: def __init__(self): self.debug = False Config() is Config() # True — same instance every time ``` **Common mistakes** **1. Forgetting to return the result:** ```python def bad_decorator(func): def wrapper(*args, **kwargs): func(*args, **kwargs) # BUG: result is silently discarded return wrapper def good_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) # always return return wrapper ``` If the original function returns a value and the wrapper doesn't return it, callers silently get `None` — often hard to notice until tests fail. **2. Returning the wrapper call instead of the wrapper:** ```python def bad_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper() # BUG: calls wrapper immediately, returns result def good_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper # correct: return the function object, not a call ``` **3. Forgetting `@functools.wraps`:** ```python help(greet) # shows 'wrapper' instead of 'greet' docs greet.__name__ # 'wrapper' — breaks introspection-based tools ``` **When NOT to use a decorator** Decorators add a call indirection and hide the original function's logic. Prefer a decorator when: - The behaviour applies to many unrelated functions - It's purely additive (logging, timing, caching, validation) - It doesn't change the function's signature or return type Avoid decorators when the behaviour is specific to one function, when it needs to change the return type, or when it makes the code significantly harder to follow — in those cases a plain helper function or a base class is usually clearer. **Preview: built-in method decorators** Python ships `@property`, `@classmethod`, and `@staticmethod` as built-ins. These decorate methods on a class rather than standalone functions, and they interact with Python's descriptor protocol — a separate mechanism from the function-decorator pattern described in this section. They are covered in full in the next topic.
01

Timer decorator

#

Write a decorator `timer` that measures how long the decorated function takes to run and prints the result in the format: `'greet took 0.0001s'`. Use `time.time()` for measurement.

import time

def timer(func):
    pass


@timer
def greet(name):
    return f'Hello, {name}!'

greet('Alice')  # prints: greet took 0.0001s (approx)
Solution
import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f'{func.__name__} took {elapsed:.4f}s')
        return result
    return wrapper


@timer
def greet(name):
    return f'Hello, {name}!'

greet('Alice')
02

log_calls decorator

#

Write a decorator `log_calls` that prints the function name, its positional arguments, and return value each time it is called. Format: `'add called with (2, 3) -> 5'`.

def log_calls(func):
    pass


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

add(2, 3)   # prints: add called with (2, 3) -> 5
add(10, 20) # prints: add called with (10, 20) -> 30
Solution
import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f'{func.__name__} called with {args} -> {result}')
        return result
    return wrapper


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

add(2, 3)
add(10, 20)
03

memoize decorator

#

Write a decorator `memoize` that caches function results. On repeated calls with the same arguments, return the cached value without calling the function again. Store results in a dict keyed by `args`.

def memoize(func):
    pass


@memoize
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(10))  # 55
print(fib(30))  # 832040 (fast with cache)
Solution
import functools

def memoize(func):
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper


@memoize
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(10))  # 55
print(fib(30))  # 832040
04

count_calls decorator

#

Write a decorator `count_calls` that tracks how many times the decorated function has been called. Store the count as a `.count` attribute on the wrapper so callers can read it.

def count_calls(func):
    pass


@count_calls
def say_hello():
    print('Hello!')

say_hello()
say_hello()
say_hello()
print(say_hello.count)  # 3
Solution
import functools

def count_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.count += 1
        return func(*args, **kwargs)
    wrapper.count = 0
    return wrapper


@count_calls
def say_hello():
    print('Hello!')

say_hello()
say_hello()
say_hello()
print(say_hello.count)  # 3
05

validate_positive decorator

#

Write a decorator `validate_positive` that checks every positional argument before calling the function. If any argument is not a positive number (≤ 0), raise `ValueError` with the message `'All arguments must be positive'`.

def validate_positive(func):
    pass


@validate_positive
def area(width, height):
    return width * height

print(area(3, 4))   # 12
print(area(-1, 5))  # ValueError: All arguments must be positive
Solution
import functools

def validate_positive(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if any(a <= 0 for a in args):
            raise ValueError('All arguments must be positive')
        return func(*args, **kwargs)
    return wrapper


@validate_positive
def area(width, height):
    return width * height

print(area(3, 4))  # 12
try:
    area(-1, 5)
except ValueError as e:
    print(e)  # All arguments must be positive
06

once decorator

#

Write a decorator `once` that ensures the decorated function executes only once. On the first call, run the function and store its result. On all subsequent calls, return the stored result without executing again.

def once(func):
    pass


@once
def load_config():
    print('Loading config...')
    return {'debug': True}

c1 = load_config()  # prints: Loading config...
c2 = load_config()  # prints nothing
c3 = load_config()  # prints nothing
print(c1 is c2)     # True
Solution
import functools

def once(func):
    sentinel = object()
    result = sentinel
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal result
        if result is sentinel:
            result = func(*args, **kwargs)
        return result
    return wrapper


@once
def load_config():
    print('Loading config...')
    return {'debug': True}

c1 = load_config()
c2 = load_config()
print(c1 is c2)  # True
07

retry decorator factory

#

Write a decorator factory `retry(n)` that retries the decorated function up to `n` times if it raises any exception. If all attempts fail, re-raise the last exception. Print `'Attempt X failed'` on each failure before retrying.

def retry(n):
    pass


attempts = [0]

@retry(3)
def flaky():
    attempts[0] += 1
    if attempts[0] < 3:
        raise RuntimeError('not ready')
    return 'success'

print(flaky())  # Attempt 1 failed, Attempt 2 failed, then: 'success'
Solution
import functools

def retry(n):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, n + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == n:
                        raise
                    print(f'Attempt {attempt} failed')
        return wrapper
    return decorator


attempts = [0]

@retry(3)
def flaky():
    attempts[0] += 1
    if attempts[0] < 3:
        raise RuntimeError('not ready')
    return 'success'

print(flaky())
08

@property: Temperature class

#

Create a `Temperature` class that stores temperature in Celsius internally. Use `@property` to expose a `celsius` property (getter and setter) and a `fahrenheit` property that also has a setter which converts from Fahrenheit to Celsius. Formula: `F = C * 9/5 + 32`.

class Temperature:
    def __init__(self, celsius=0):
        pass

    # Add celsius property (getter + setter)
    # Add fahrenheit property (getter + setter)


t = Temperature(100)
print(t.celsius)      # 100
print(t.fahrenheit)   # 212.0
t.fahrenheit = 32
print(t.celsius)      # 0.0
Solution
class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5 / 9


t = Temperature(100)
print(t.celsius)     # 100
print(t.fahrenheit)  # 212.0
t.fahrenheit = 32
print(t.celsius)     # 0.0
09

requires_role decorator factory

#

Write a decorator factory `requires_role(role)` that protects a function. The decorated function always receives a `user` dict as its first argument. If `user['role']` does not equal `role`, raise `PermissionError` with `'Access denied'`. Otherwise, call the function normally.

def requires_role(role):
    pass


@requires_role('admin')
def delete_record(user, record_id):
    return f"Record {record_id} deleted by {user['name']}"

admin = {'name': 'Alice', 'role': 'admin'}
guest = {'name': 'Bob', 'role': 'guest'}

print(delete_record(admin, 42))  # Record 42 deleted by Alice
delete_record(guest, 42)         # PermissionError: Access denied
Solution
import functools

def requires_role(role):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(user, *args, **kwargs):
            if user.get('role') != role:
                raise PermissionError('Access denied')
            return func(user, *args, **kwargs)
        return wrapper
    return decorator


@requires_role('admin')
def delete_record(user, record_id):
    return f"Record {record_id} deleted by {user['name']}"

admin = {'name': 'Alice', 'role': 'admin'}
guest = {'name': 'Bob', 'role': 'guest'}

print(delete_record(admin, 42))
try:
    delete_record(guest, 42)
except PermissionError as e:
    print(e)  # Access denied
10

Stacking decorators

#

Stack the `timer` and `log_calls` decorators on the same function `compute(n)` that returns the sum of squares from 1 to n. Apply `@timer` on the outside and `@log_calls` on the inside. Verify the output shows both the log line and the timing line.

import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f'{func.__name__} took {time.time() - start:.4f}s')
        return result
    return wrapper

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f'{func.__name__} called with {args} -> {result}')
        return result
    return wrapper


# Stack both decorators on compute:
def compute(n):
    return sum(i * i for i in range(1, n + 1))

compute(5)
Solution
import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f'{func.__name__} took {time.time() - start:.4f}s')
        return result
    return wrapper

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f'{func.__name__} called with {args} -> {result}')
        return result
    return wrapper


@timer
@log_calls
def compute(n):
    return sum(i * i for i in range(1, n + 1))

compute(5)
# log_calls prints: compute called with (5,) -> 55
# timer prints: compute took 0.0000s