Decorators #

In many applications, there’s behavior that needs to be applied to many functions at once: logging every time a function is called, measuring execution time, checking user authentication before running the main logic, or caching computation results. Without decorators, you’d have to copy the same code into every function — repetitive and prone to inconsistency. Decorators let you wrap a function with extra behavior without touching its original implementation, keeping the code DRY and well-structured.

Functions as First-Class Objects #

Decorators exist because Python treats functions as first-class objects — functions can be stored in variables, passed as arguments, and returned from other functions. This is the foundation to understand before writing decorators.

def greet():
    print("Hello!")

# Functions can be stored in variables
action = greet
action()  # Hello!

# Functions can be passed as arguments
def run(func):
    func()

run(greet)  # Hello!

# Functions can be returned from other functions
def make_greeting(name):
    def greet_name():
        print(f"Hello, {name}!")
    return greet_name  # return the function, not the result of calling it

greet_budi = make_greeting("Budi")
greet_budi()  # Hello, Budi!

A decorator is essentially a function that takes a function as input and returns a new function as output.


Basic Decorators #

The simplest form of a decorator: a function that wraps another function with extra behavior.

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"[LOG] Calling '{func.__name__}'")
        result = func(*args, **kwargs)
        print(f"[LOG] '{func.__name__}' finished")
        return result
    return wrapper

@log_call
def compute_total(a, b):
    return a + b

total = compute_total(3, 7)
print(f"Total: {total}")
# [LOG] Calling 'compute_total'
# [LOG] 'compute_total' finished
# Total: 10

To visualize how the execution flow moves from the caller, into the decorator, to running the original function, look at the flow diagram below:

flowchart TD
    Caller["Caller: compute_total(3, 7)"] --> Decorator["Wrapper: wrapper(*args, **kwargs)"]
    subgraph Wrapper ["Inside the Wrapper Function"]
        Decorator --> Pre["Logic Before Execution (e.g. print start log)"]
        Pre --> RunFunc["Call the Original Function: compute_total(a, b)"]
        RunFunc --> Post["Logic After Execution (e.g. print done log)"]
    end
    Post --> ReturnVal["Return the Result to the Caller"]

The @log_call syntax above is equivalent to writing:

def compute_total(a, b):
    return a + b

compute_total = log_call(compute_total)  # equivalent to @log_call

The Function Identity Problem and Its Solution: @wraps #

There’s a common trap when creating decorators: the original function loses its identity.

# ANTI-PATTERN: a decorator without @wraps breaks function metadata
def log_call(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@log_call
def compute_total(a, b):
    """Computes the sum of two numbers."""
    return a + b

print(compute_total.__name__)  # 'wrapper' — not 'compute_total'!
print(compute_total.__doc__)   # None — the docstring is gone!

# CORRECT: use @wraps to preserve the original function's metadata
from functools import wraps

def log_call(func):
    @wraps(func)  # copy metadata from func to the wrapper
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@log_call
def compute_total(a, b):
    """Computes the sum of two numbers."""
    return a + b

print(compute_total.__name__)  # 'compute_total' ✓
print(compute_total.__doc__)   # 'Computes the sum of two numbers.' ✓
Always use @functools.wraps(func) inside every decorator you create. Without it, tools like debuggers, auto-documentation, and help() will show incorrect information — which can be very confusing when debugging.

Practical Decorators #

Here are some decorators often needed in real applications:

Timer — Measuring Execution Time #

import time
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        duration = time.perf_counter() - start
        print(f"[TIMER] '{func.__name__}' finished in {duration:.4f} seconds")
        return result
    return wrapper

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

process_data(10_000_000)
# [TIMER] 'process_data' finished in 0.3142 seconds

Retry — Retry on Failure #

import time
from functools import wraps

def retry(max_attempts=3, delay=1.0):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    print(f"[RETRY] Attempt {attempt} failed: {e}. Retrying in {delay}s...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def fetch_api_data(url):
    import random
    if random.random() < 0.7:  # simulated 70% failure chance
        raise ConnectionError("Connection lost")
    return f"Data from {url}"

try:
    result = fetch_api_data("https://api.example.com/data")
    print(result)
except ConnectionError:
    print("Failed after 3 attempts.")

Decorators with Arguments #

When a decorator needs configuration, you need one extra function layer — the decorator factory.

from functools import wraps

def limit_access(allowed_roles):
    """Decorator that restricts access based on the user's role."""
    def decorator(func):
        @wraps(func)
        def wrapper(user, *args, **kwargs):
            if user.get("role") not in allowed_roles:
                raise PermissionError(
                    f"Role '{user.get('role')}' is not allowed to access '{func.__name__}'"
                )
            return func(user, *args, **kwargs)
        return wrapper
    return decorator

@limit_access(allowed_roles=["admin", "manager"])
def delete_user(user, target_id):
    print(f"User {target_id} deleted by {user['name']}.")

# Succeeds
delete_user({"name": "Budi", "role": "admin"}, target_id=42)

# Fails
try:
    delete_user({"name": "Sari", "role": "user"}, target_id=42)
except PermissionError as e:
    print(e)

A decorator with arguments always has a three-layer structure:

def decorator_with_args(argument):     ← layer 1: receive configuration
    def decorator(func):               ← layer 2: receive the function
        @wraps(func)
        def wrapper(*args, **kwargs):  ← layer 3: run the logic
            # before
            result = func(*args, **kwargs)
            # after
            return result
        return wrapper
    return decorator

Stacking Decorators #

Several decorators can be stacked on one function. Their execution order is inside-out — the decorator closest to the function runs first.

from functools import wraps

def decorator_a(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("A: before")
        result = func(*args, **kwargs)
        print("A: after")
        return result
    return wrapper

def decorator_b(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("B: before")
        result = func(*args, **kwargs)
        print("B: after")
        return result
    return wrapper

@decorator_a
@decorator_b
def my_function():
    print("Main function")

my_function()
# B: before  ← decorator_b is closer to the function, so it runs first
# A: before
# Main function
# A: after
# B: after
The stacking order @decorator_a @decorator_b def f():
    is equivalent to: decorator_a(decorator_b(f))

Execution order:
    decorator_a.wrapper starts
        decorator_b.wrapper starts
            f() runs
        decorator_b.wrapper finishes
    decorator_a.wrapper finishes

Decorators for Classes #

Decorators on Methods #

from functools import wraps

def validate_positive(func):
    """Make sure all numeric arguments are positive."""
    @wraps(func)
    def wrapper(self, *args, **kwargs):
        for arg in args:
            if isinstance(arg, (int, float)) and arg <= 0:
                raise ValueError(f"Arguments must be positive, got: {arg}")
        return func(self, *args, **kwargs)
    return wrapper

class Calculator:
    @validate_positive
    def sqrt(self, n):
        import math
        return math.sqrt(n)

    @validate_positive
    def log(self, n):
        import math
        return math.log(n)

calc = Calculator()
print(calc.sqrt(16))   # 4.0
print(calc.log(100))   # 4.605...

try:
    calc.sqrt(-5)
except ValueError as e:
    print(e)  # Arguments must be positive, got: -5

Python’s Built-in Decorators #

Python has three built-in decorators frequently used in class definitions:

class Circle:
    PI = 3.14159

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

    @property
    def radius(self):
        """Getter — accessed like a plain attribute."""
        return self._radius

    @radius.setter
    def radius(self, value):
        """Setter — validation before storing."""
        if value <= 0:
            raise ValueError("The radius must be positive")
        self._radius = value

    @property
    def area(self):
        """Computed property — no setter needed."""
        return self.PI * self._radius ** 2

    @classmethod
    def from_diameter(cls, diameter):
        """Factory method — create an instance from an alternative parameter."""
        return cls(diameter / 2)

    @staticmethod
    def is_valid(value):
        """Utility method — needs no access to the instance or class."""
        return value > 0

# Usage
circle = Circle(5)
print(circle.area)       # 78.53975

circle.radius = 10       # calls the setter
print(circle.area)       # 314.159

c2 = Circle.from_diameter(20)  # classmethod
print(c2.radius)         # 10.0

print(Circle.is_valid(-1))  # False

Summary #

  • A decorator is a function that wraps another function — it takes a function as an argument and returns a new function with extra behavior.
  • Always use @functools.wraps(func) — without it, the original function’s metadata (__name__, __doc__) is lost, making debugging harder.
  • Use *args, **kwargs in the wrapper — so the decorator works with functions taking any arguments.
  • Decorators with arguments need three function layers — the outermost receives the configuration, the middle receives the function, the innermost runs the logic.
  • Stacked decorators execute inside-out — the decorator closest to the function runs first.
  • @property for computed attributes with setter validation; @classmethod for factory methods; @staticmethod for utility methods that don’t need access to self or cls.
  • Use decorators for cross-cutting concerns — logging, timing, retry, authentication, caching — keeping business logic clean of infrastructure code.

← Previous: Context Managers   Next: Sockets →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact