Skip to content

Decorator Fundamentals

A decorator is a function that takes another function as input and returns a modified version of it. Decorators provide a clean syntax for wrapping functions with additional behavior.

Basic Syntax

The @ Syntax

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

@decorator
def greet():
    print("Hello")

# Equivalent to:
# greet = decorator(greet)

The @decorator syntax is syntactic sugar—it automatically passes the function to the decorator and reassigns the result.

Calling Decorated Functions

greet()
# Output:
# Before
# Hello
# After

The Wrapper Pattern

Basic Structure

def decorator(func):
    def wrapper(*args, **kwargs):
        # Before logic (pre-processing)
        result = func(*args, **kwargs)
        # After logic (post-processing)
        return result
    return wrapper

Key elements: - func: The original function being decorated - wrapper: The new function that wraps the original - *args, **kwargs: Accepts any arguments to pass through - return result: Preserves the original return value

Why *args, **kwargs?

def decorator(func):
    def wrapper(*args, **kwargs):  # Accept ANY arguments
        return func(*args, **kwargs)  # Pass them through
    return wrapper

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

@decorator
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

# Both work with the same decorator
add(2, 3)           # 5
greet("Alice")      # "Hello, Alice!"

Execution Time

Decorators Run at Definition Time

def decorator(func):
    print(f"Decorating {func.__name__}")  # Runs immediately!
    return func

@decorator  # Prints "Decorating function" when this line executes
def function():
    pass

# The decorator has already run before we call function()

This is important: the decorator itself runs when Python loads the module, not when you call the decorated function.


Preserving Function Metadata

The Problem

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

@decorator
def greet():
    """Say hello."""
    print("Hello")

print(greet.__name__)  # 'wrapper' — Wrong!
print(greet.__doc__)   # None — Lost!

The Solution: functools.wraps

from functools import wraps

def decorator(func):
    @wraps(func)  # Copies metadata from func to wrapper
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@decorator
def greet():
    """Say hello."""
    print("Hello")

print(greet.__name__)  # 'greet' — Correct!
print(greet.__doc__)   # 'Say hello.' — Preserved!

Always use @wraps—it preserves: - __name__: Function name - __doc__: Docstring - __module__: Module name - __annotations__: Type hints - __dict__: Function attributes


State in Decorators

Using Closure Variables

from functools import wraps

def count_calls(func):
    count = 0

    @wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        print(f"Call #{count}")
        return func(*args, **kwargs)

    return wrapper

@count_calls
def greet():
    print("Hello")

greet()  # Call #1, Hello
greet()  # Call #2, Hello

Using Function Attributes

from functools import wraps

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

    wrapper.calls = 0
    return wrapper

@count_calls
def greet():
    print("Hello")

greet()
greet()
print(greet.calls)  # 2 — Accessible from outside

Closures vs Decorators

Closure

A function that retains access to variables from its enclosing scope:

def make_multiplier(factor):
    def multiply(x):
        return x * factor  # 'factor' captured from enclosing scope
    return multiply

times3 = make_multiplier(3)
print(times3(10))  # 30

Decorator

A function that takes another function as input and returns a new function:

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

Relationship

Decorators are built on closures. The wrapper function inside a decorator is a closure—it captures func from the enclosing scope.

Comparison

Feature Closure Decorator
Purpose Retain state from outer function Modify/enhance function behavior
Returns A nested function A new function wrapping the original
Captures Variables from enclosing scope The decorated function
Used for Function factories, stateful functions Logging, timing, caching, validation

When to Use Each

Closures for function factories:

def make_power(exp):
    def power(base):
        return base ** exp
    return power

square = make_power(2)
cube = make_power(3)

Decorators for cross-cutting concerns:

@timer
def slow_function():
    ...

@cache
def expensive_computation(x):
    ...

Common Decorator Template

from functools import wraps

def decorator_name(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        # === BEFORE the original function ===
        # Pre-processing, validation, logging, etc.

        # === CALL the original function ===
        result = func(*args, **kwargs)

        # === AFTER the original function ===
        # Post-processing, cleanup, etc.

        return result
    return wrapper

Summary

Concept Description
@decorator Syntactic sugar for func = decorator(func)
Wrapper pattern Inner function that wraps the original
*args, **kwargs Accept and pass through any arguments
@wraps(func) Preserve original function's metadata
Execution time Decorator runs at definition, wrapper runs at call
Closure Function + captured environment

Key Takeaways: - Decorators are functions that transform functions - Always use @wraps to preserve metadata - The wrapper pattern is the foundation of most decorators - Decorators run at definition time, not call time - Decorators use closures internally


Runnable Example: decorator_examples.py

"""
Python Decorators - Practical Examples
This file contains various practical examples of decorators in Python.
Run this file to see decorators in action!
"""

import time
import functools

if __name__ == "__main__":

    print("=" * 70)
    print("PYTHON DECORATORS - EXAMPLES")
    print("=" * 70)

    # ============================================================================
    # EXAMPLE 1: Basic Decorator
    # ============================================================================
    print("\n1. BASIC DECORATOR")
    print("-" * 70)

    def simple_decorator(func):
        @functools.wraps(func)
        def wrapper():
            print("Before function call")
            result = func()
            print("After function call")
            return result
        return wrapper

    @simple_decorator
    def say_hello():
        print("Hello, World!")
        return "Greeting complete"

    result = say_hello()
    print(f"Return value: {result}")

    # ============================================================================
    # EXAMPLE 2: Decorator with Function Arguments
    # ============================================================================
    print("\n\n2. DECORATOR WITH FUNCTION ARGUMENTS")
    print("-" * 70)

    def logger(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            print(f"Calling {func.__name__} with:")
            print(f"  args: {args}")
            print(f"  kwargs: {kwargs}")
            result = func(*args, **kwargs)
            print(f"  result: {result}")
            return result
        return wrapper

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

    @logger
    def greet(name, greeting="Hello"):
        return f"{greeting}, {name}!"

    add(5, 3)
    print()
    greet("Alice", greeting="Hi")

    # ============================================================================
    # EXAMPLE 3: Timing Decorator
    # ============================================================================
    print("\n\n3. TIMING DECORATOR")
    print("-" * 70)

    def timer(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            start = time.time()
            result = func(*args, **kwargs)
            end = time.time()
            print(f"{func.__name__} executed in {end - start:.4f} seconds")
            return result
        return wrapper

    @timer
    def fast_function():
        return sum(range(100))

    @timer
    def slow_function():
        time.sleep(0.5)
        return sum(range(1000000))

    print(f"Fast function result: {fast_function()}")
    print(f"Slow function result: {slow_function()}")

    # ============================================================================
    # EXAMPLE 4: Decorator with Parameters
    # ============================================================================
    print("\n\n4. DECORATOR WITH PARAMETERS")
    print("-" * 70)

    def repeat(times):
        def decorator(func):
            @functools.wraps(func)
            def wrapper(*args, **kwargs):
                results = []
                for i in range(times):
                    print(f"  Execution {i+1}/{times}:")
                    result = func(*args, **kwargs)
                    results.append(result)
                return results
            return wrapper
        return decorator

    @repeat(times=3)
    def greet(name):
        greeting = f"Hello, {name}!"
        print(f"    {greeting}")
        return greeting

    print("Calling decorated function:")
    results = greet("Bob")
    print(f"All results: {results}")

    # ============================================================================
    # EXAMPLE 5: Multiple Decorators (Stacking)
    # ============================================================================
    print("\n\n5. STACKING MULTIPLE DECORATORS")
    print("-" * 70)

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

    def exclaim(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return result + "!!!"
        return wrapper

    def quote(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return f'"{result}"'
        return wrapper

    @quote
    @uppercase
    @exclaim
    def greet(name):
        return f"hello, {name}"

    print(greet("Alice"))
    print("Note: Decorators are applied bottom-to-top")

    # ============================================================================
    # EXAMPLE 6: Caching/Memoization Decorator
    # ============================================================================
    print("\n\n6. CACHING/MEMOIZATION DECORATOR")
    print("-" * 70)

    def memoize(func):
        cache = {}
        @functools.wraps(func)
        def wrapper(*args):
            if args in cache:
                print(f"  Returning cached result for {args}")
                return cache[args]
            print(f"  Computing result for {args}")
            result = func(*args)
            cache[args] = result
            return result
        return wrapper

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

    print("Calculating fibonacci(5):")
    print(f"Result: {fibonacci(5)}")
    print("\nCalculating fibonacci(5) again:")
    print(f"Result: {fibonacci(5)}")

    # ============================================================================
    # EXAMPLE 7: Authentication/Authorization Decorator
    # ============================================================================
    print("\n\n7. AUTHENTICATION DECORATOR")
    print("-" * 70)

    # Simulated user system
    current_user = {"name": "Alice", "role": "admin"}

    def require_auth(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            if current_user is None:
                print("Access denied: Not logged in")
                return None
            return func(*args, **kwargs)
        return wrapper

    def require_role(role):
        def decorator(func):
            @functools.wraps(func)
            def wrapper(*args, **kwargs):
                if current_user.get("role") != role:
                    print(f"Access denied: Requires '{role}' role")
                    return None
                return func(*args, **kwargs)
            return wrapper
        return decorator

    @require_auth
    def view_profile():
        return f"Viewing profile for {current_user['name']}"

    @require_role("admin")
    def delete_user(username):
        return f"User {username} deleted by {current_user['name']}"

    print(view_profile())
    print(delete_user("Bob"))

    # Change user role
    current_user["role"] = "user"
    print("\nAfter changing role to 'user':")
    print(delete_user("Bob"))

    # ============================================================================
    # EXAMPLE 8: Validation Decorator
    # ============================================================================
    print("\n\n8. VALIDATION DECORATOR")
    print("-" * 70)

    def validate_positive(func):
        @functools.wraps(func)
        def wrapper(n):
            if n < 0:
                raise ValueError(f"Argument must be positive, got {n}")
            return func(n)
        return wrapper

    def validate_range(min_val, max_val):
        def decorator(func):
            @functools.wraps(func)
            def wrapper(n):
                if not (min_val <= n <= max_val):
                    raise ValueError(f"Argument must be between {min_val} and {max_val}")
                return func(n)
            return wrapper
        return decorator

    @validate_positive
    def square_root(n):
        return n ** 0.5

    @validate_range(0, 100)
    def percentage_to_grade(score):
        if score >= 90: return "A"
        if score >= 80: return "B"
        if score >= 70: return "C"
        return "F"

    print(f"Square root of 16: {square_root(16)}")
    print(f"Grade for 85: {percentage_to_grade(85)}")

    print("\nTrying invalid inputs:")
    try:
        square_root(-4)
    except ValueError as e:
        print(f"  Error: {e}")

    try:
        percentage_to_grade(150)
    except ValueError as e:
        print(f"  Error: {e}")

    # ============================================================================
    # EXAMPLE 9: Retry Decorator
    # ============================================================================
    print("\n\n9. RETRY DECORATOR")
    print("-" * 70)

    def retry(max_attempts=3, delay=0.5):
        def decorator(func):
            @functools.wraps(func)
            def wrapper(*args, **kwargs):
                attempts = 0
                while attempts < max_attempts:
                    try:
                        return func(*args, **kwargs)
                    except Exception as e:
                        attempts += 1
                        if attempts == max_attempts:
                            print(f"  Failed after {max_attempts} attempts")
                            raise
                        print(f"  Attempt {attempts} failed: {e}. Retrying...")
                        time.sleep(delay)
            return wrapper
        return decorator

    # Simulate unreliable function
    call_count = 0

    @retry(max_attempts=3, delay=0.1)
    def unreliable_function():
        global call_count
        call_count += 1
        print(f"  Attempt {call_count}")
        if call_count < 2:
            raise ConnectionError("Network error")
        return "Success!"

    print("Calling unreliable function:")
    result = unreliable_function()
    print(f"Final result: {result}")

    # ============================================================================
    # EXAMPLE 10: Class-Based Decorator
    # ============================================================================
    print("\n\n10. CLASS-BASED DECORATOR")
    print("-" * 70)

    class CountCalls:
        def __init__(self, func):
            functools.update_wrapper(self, func)
            self.func = func
            self.count = 0

        def __call__(self, *args, **kwargs):
            self.count += 1
            print(f"Call #{self.count} to {self.func.__name__}")
            return self.func(*args, **kwargs)

    @CountCalls
    def say_hello(name):
        return f"Hello, {name}!"

    print(say_hello("Alice"))
    print(say_hello("Bob"))
    print(say_hello("Charlie"))
    print(f"\nTotal calls: {say_hello.count}")

    # ============================================================================
    # EXAMPLE 11: Debug Decorator
    # ============================================================================
    print("\n\n11. DEBUG DECORATOR")
    print("-" * 70)

    def debug(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            args_repr = [repr(a) for a in args]
            kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
            signature = ", ".join(args_repr + kwargs_repr)
            print(f"Calling {func.__name__}({signature})")
            result = func(*args, **kwargs)
            print(f"{func.__name__!r} returned {result!r}")
            return result
        return wrapper

    @debug
    def calculate(x, y, operation="+"):
        if operation == "+":
            return x + y
        elif operation == "*":
            return x * y
        return None

    calculate(5, 3, operation="+")
    calculate(5, 3, operation="*")

    # ============================================================================
    # EXAMPLE 12: Rate Limiting Decorator
    # ============================================================================
    print("\n\n12. RATE LIMITING DECORATOR")
    print("-" * 70)

    def rate_limit(max_calls, time_period):
        """Limit function calls to max_calls per time_period seconds"""
        def decorator(func):
            calls = []

            @functools.wraps(func)
            def wrapper(*args, **kwargs):
                now = time.time()
                # Remove old calls outside the time window
                calls[:] = [call for call in calls if now - call < time_period]

                if len(calls) >= max_calls:
                    wait_time = time_period - (now - calls[0])
                    print(f"  Rate limit reached. Wait {wait_time:.2f}s")
                    return None

                calls.append(now)
                return func(*args, **kwargs)
            return wrapper
        return decorator

    @rate_limit(max_calls=3, time_period=2)
    def api_call(endpoint):
        return f"Calling {endpoint}"

    print("Making API calls (max 3 per 2 seconds):")
    for i in range(5):
        result = api_call(f"endpoint_{i}")
        if result:
            print(f"  {result}")
        time.sleep(0.3)

    print("\n" + "=" * 70)
    print("END OF EXAMPLES")
    print("=" * 70)