🔵 Intermediate

Decorators

📖 Lesson 18 ⏱ 40 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Understand what a decorator is and what problem it solves
  • Build decorators from scratch using closures
  • Use functools.wraps to preserve metadata
  • Write decorators that accept arguments
  • Stack multiple decorators correctly
  • Use class-based decorators
  • Apply common built-in decorators: @property, @staticmethod, @classmethod, @lru_cache

What is a Decorator?

A decorator is a function that wraps another function, adding behaviour before and/or after it runs — without modifying the original function's source code.

Decorators are used everywhere in Python: logging, timing, access control, caching, validation, retry logic, and more. Understanding them unlocks frameworks like Flask, FastAPI, and Django.

# The concept: wrap a function to add behaviour
def shout(func):
    def wrapper(*args, **kwargs):
        print("Before the function runs")
        result = func(*args, **kwargs)
        print("After the function runs")
        return result
    return wrapper

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

# Manually wrap it
greet = shout(greet)
greet("Alice")
# Before the function runs
# Hello, Alice!
# After the function runs
concept.py
Decorators rely on two features you already know: functions are first-class objects (they can be passed and returned), and closures (inner functions remember variables from the enclosing scope).

The @ Syntax

Python provides the @ syntax as shorthand for func = decorator(func):

def shout(func):
    def wrapper(*args, **kwargs):
        print("Before")
        result = func(*args, **kwargs)
        print("After")
        return result
    return wrapper

# ── Using @ syntax ──
@shout
def greet(name):
    print(f"Hello, {name}!")

# Exactly equivalent to: greet = shout(greet)

greet("Bob")
# Before
# Hello, Bob!
# After
at_syntax.py

Preserving Metadata with functools.wraps

Without wraps, the wrapped function loses its name, docstring, and other metadata — because Python sees the wrapper function, not the original:

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

@shout
def greet(name):
    """Return a greeting."""
    return f"Hello, {name}"

print(greet.__name__)  # wrapper  ← WRONG!
print(greet.__doc__)   # None     ← WRONG!
no_wraps.py
import functools

def shout(func):
    @functools.wraps(func)   # ← copies __name__, __doc__, __module__, etc.
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@shout
def greet(name):
    """Return a greeting."""
    return f"Hello, {name}"

print(greet.__name__)   # greet   ✓
print(greet.__doc__)    # Return a greeting.  ✓
with_wraps.py
Always use @functools.wraps(func) inside your wrapper. Without it, debugging tools, help(), logging, and test frameworks will see "wrapper" everywhere instead of the real function names.

Practical Examples

Timer decorator

import time
import functools

def timer(func):
    """Print how long the decorated function takes to run."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start  = time.perf_counter()
        result = func(*args, **kwargs)
        end    = time.perf_counter()
        print(f"{func.__name__!r} took {end - start:.4f}s")
        return result
    return wrapper

@timer
def slow_sum(n):
    return sum(range(n))

slow_sum(10_000_000)   # 'slow_sum' took 0.2341s
timer.py

Logger decorator

import functools

def log_calls(func):
    """Log every call and its return value."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        arg_str = ", ".join(
            [repr(a) for a in args] +
            [f"{k}={v!r}" for k, v in kwargs.items()]
        )
        print(f"Calling {func.__name__}({arg_str})")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

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

add(3, 5)
# Calling add(3, 5)
# add returned 8
logger.py

Retry decorator

import functools, time

def retry(times=3, delay=0.5, exceptions=(Exception,)):
    """Retry the function up to `times` times on failure."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_exc = e
                    print(f"Attempt {attempt} failed: {e}")
                    if attempt < times:
                        time.sleep(delay)
            raise last_exc
        return wrapper
    return decorator

# This decorator takes arguments — see next section for how it works
@retry(times=3, delay=0, exceptions=(ValueError,))
def flaky(x):
    import random
    if random.random() < 0.7:
        raise ValueError("random failure")
    return x * 2
retry.py

Decorators with Arguments

To pass arguments to a decorator you add one more layer of nesting — a decorator factory that returns the actual decorator:

import functools

# 3 levels:
# repeat(n)         ← outer function: accepts the argument
#   decorator(func) ← middle: accepts the function
#     wrapper(...)  ← inner: replaces the function

def repeat(n):
    """Run the decorated function n times."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say_hello():
    print("Hello!")

say_hello()
# Hello!
# Hello!
# Hello!
decorator_factory.py
Pattern: @decorator with no arguments → 2-level nesting (decorator + wrapper). @decorator(arg) with arguments → 3-level nesting (factory + decorator + wrapper).

Optional-argument decorator (both usages work)

import functools

def repeat(_func=None, *, n=1):
    """Can be used as @repeat or @repeat(n=3)."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                func(*args, **kwargs)
        return wrapper

    if _func is not None:
        # Called as @repeat — func passed directly
        return decorator(_func)
    # Called as @repeat(n=3) — return the decorator
    return decorator

@repeat          # no parentheses — runs once
def ping():
    print("ping")

@repeat(n=3)     # with argument — runs three times
def pong():
    print("pong")
optional_args.py

Stacking Decorators

Multiple decorators are applied bottom-up — the one closest to the function definition is applied first:

import functools

def bold(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return "**" + func(*args, **kwargs) + "**"
    return wrapper

def upper(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

@bold           # applied second (outermost)
@upper          # applied first (innermost)
def greet(name):
    return f"hello, {name}"

print(greet("alice"))
# **HELLO, ALICE**

# Equivalent to: greet = bold(upper(greet))
stacking.py
Read stacked decorators from bottom to top to understand the application order. The function is wrapped by the innermost decorator first, and that wrapped result is then wrapped by the next one up.

Class-Based Decorators

A decorator can also be a class that implements __call__. This is useful when the decorator needs to maintain state between calls:

import functools

class CallCounter:
    """Decorator that counts how many times a function is called."""

    def __init__(self, func):
        functools.update_wrapper(self, func)  # equivalent to @wraps
        self.func  = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.func(*args, **kwargs)

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

add(1, 2)
add(3, 4)
add(5, 6)
print(add.count)   # 3
print(add(10, 20)) # 30
print(add.count)   # 4
class_decorator.py

Built-in Decorators

@property

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        """Get the radius."""
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

    @property
    def area(self):
        import math
        return math.pi * self._radius ** 2

c = Circle(5)
print(c.radius)   # 5  — accessed like an attribute
print(c.area)     # 78.53...
c.radius = 10     # calls the setter
# c.radius = -1   # raises ValueError
property.py

@staticmethod and @classmethod

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @staticmethod
    def celsius_to_fahrenheit(c):
        """Utility — doesn't need self or cls."""
        return c * 9/5 + 32

    @classmethod
    def from_fahrenheit(cls, f):
        """Alternative constructor — receives the class, not an instance."""
        return cls((f - 32) * 5/9)

    def __repr__(self):
        return f"Temperature({self.celsius:.1f}°C)"

print(Temperature.celsius_to_fahrenheit(100))  # 212.0
t = Temperature.from_fahrenheit(212)
print(t)   # Temperature(100.0°C)
static_class_method.py

@functools.lru_cache — memoisation

import functools

@functools.lru_cache(maxsize=None)   # None = unlimited cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(50))   # 12586269025  — instant, even for large n
print(fibonacci.cache_info())
# CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)

# cache_clear() to reset
fibonacci.cache_clear()

# Python 3.9+ shorthand
@functools.cache   # equivalent to lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n-1) + fib(n-2)
lru_cache.py

Decorating Classes

Decorators are not limited to functions — you can decorate entire classes too:

from dataclasses import dataclass

# @dataclass is a class decorator that auto-generates
# __init__, __repr__, __eq__, and more
@dataclass
class Point:
    x: float
    y: float

    def distance_from_origin(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5

p = Point(3.0, 4.0)
print(p)                          # Point(x=3.0, y=4.0)
print(p.distance_from_origin())   # 5.0
print(p == Point(3.0, 4.0))       # True
class_decorating.py

Best Practices

  • Always use @functools.wraps(func) in every wrapper function.
  • Keep decorators focused — one concern per decorator (timing, logging, auth, caching…).
  • Accept *args, **kwargs in the wrapper so the decorator works with any function signature.
  • Return the wrapper's result — don't forget return result or you'll silently break functions that return values.
  • Use class-based decorators when you need to store state between calls.
  • Document your decorators with a docstring — they're part of your API.
import functools

# ✓ Complete, correct decorator template
def my_decorator(func):
    """One-line description of what this decorator does."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # ... before ...
        result = func(*args, **kwargs)
        # ... after ...
        return result   # ← don't forget!
    return wrapper
template.py
🤖

Ask your AI tutor! Confused about the three-level nesting for decorators with arguments? Want to see how Flask's @app.route works under the hood? Building your own caching or auth decorator? Great topics to explore together.

💻 Exercises

01 Timing Decorator

Write a decorator @timed that:

  • Measures how long the decorated function takes (use time.perf_counter())
  • Prints the result in the format: greet took 0.0001s
  • Returns the function's original return value unchanged
  • Preserves the function's name and docstring

Test it on a function that sleeps for 0.1 seconds.

Show solution
import time
import functools

def timed(func):
    """Measure and print the execution time of the decorated function."""
    @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

@timed
def slow_greet(name):
    """Return a greeting after a short delay."""
    time.sleep(0.1)
    return f"Hello, {name}!"

print(slow_greet("Alice"))
# slow_greet took 0.1003s
# Hello, Alice!
print(slow_greet.__name__)  # slow_greet
print(slow_greet.__doc__)   # Return a greeting after a short delay.
02 Access Control Decorator

Write a decorator factory @require_role(role) that:

  • Accepts a role string argument (e.g. "admin")
  • The decorated function must accept a user dict as its first argument (with a "role" key)
  • If user["role"] != role, raises PermissionError with a clear message
  • Otherwise calls and returns the original function normally
@require_role("admin")
def delete_user(user, target_id):
    return f"Deleted user {target_id}"

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

print(delete_user(admin, 42))   # Deleted user 42
delete_user(guest, 42)          # PermissionError
Show solution
import functools

def require_role(role):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(user, *args, **kwargs):
            if user.get("role") != role:
                raise PermissionError(
                    f"{func.__name__} requires role '{role}', "
                    f"but user '{user.get('name')}' has role '{user.get('role')}'"
                )
            return func(user, *args, **kwargs)
        return wrapper
    return decorator

@require_role("admin")
def delete_user(user, target_id):
    """Delete a user by ID (admin only)."""
    return f"Deleted user {target_id}"

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

print(delete_user(admin, 42))   # Deleted user 42

try:
    delete_user(guest, 42)
except PermissionError as e:
    print(e)
# delete_user requires role 'admin', but user 'Bob' has role 'guest'
03 Memoisation Decorator

Implement your own @memoize decorator (without using functools.lru_cache) that:

  • Caches results in a dict keyed by the function's arguments
  • Returns the cached result on repeated calls with the same arguments
  • Stores the cache as an attribute on the wrapper (wrapper.cache) so it can be inspected
  • Preserves function metadata with @functools.wraps

Test it on a recursive Fibonacci function and verify that repeated calls use the cache.

Show solution
import functools

def memoize(func):
    """Cache results of the decorated function by its arguments."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        key = (args, tuple(sorted(kwargs.items())))
        if key not in wrapper.cache:
            wrapper.cache[key] = func(*args, **kwargs)
        return wrapper.cache[key]
    wrapper.cache = {}
    return wrapper

@memoize
def fib(n):
    """Return the nth Fibonacci number."""
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(10))    # 55
print(fib(40))    # 102334155  — fast because of caching
print(len(fib.cache))  # 41 unique calls cached

# Verify cache works
fib.cache.clear()
fib(5)
print(fib.cache)
# {((0,), ()): 0, ((1,), ()): 1, ((2,), ()): 1,
#  ((3,), ()): 2, ((4,), ()): 3, ((5,), ()): 5}