Ad – 728Γ—90
πŸš€ Advanced

Python Decorators – How to Use and Create Them

Decorators are one of Python's most elegant features. They let you wrap a function with additional behaviour β€” adding logging, timing, access control, or caching β€” without touching the function's original code. Once you understand decorators, a vast amount of Python frameworks (Flask, Django, FastAPI, pytest) suddenly become readable and writable. This lesson builds from first principles to production-ready patterns.

⏱️ 30 min read 🎯 Advanced πŸ“… Updated 2026

What Are Decorators?

A decorator is a function that takes another function as input and returns a new function with added (or modified) behaviour. The @ syntax is just a clean shorthand for applying a decorator.

Before understanding decorators, you need to know three things about Python functions:

  • Functions are first-class objects β€” they can be passed as arguments, returned from functions, and stored in variables.
  • Functions can be defined inside other functions (closures).
  • A function can return another function.
Python
# Functions as first-class objects
def greet(name):
    return f"Hello, {name}!"

# Assign a function to a variable
say_hello = greet
print(say_hello("Alice"))   # Hello, Alice!

# Pass a function as an argument
def apply(func, value):
    return func(value)

print(apply(greet, "Bob"))  # Hello, Bob!

# Return a function from a function
def make_multiplier(n):
    def multiply(x):
        return x * n       # 'n' is captured in the closure
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5))   # 10
print(triple(5))   # 15

Building Your First Decorator

A decorator is nothing more than a function that wraps another function. Here is the pattern without and then with the @ syntax:

Python
# Step 1: Write a wrapper manually
def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before the function runs")
        result = func(*args, **kwargs)
        print("After the function runs")
        return result
    return wrapper

def say_hi():
    print("Hi!")

# Apply the decorator manually
say_hi = my_decorator(say_hi)
say_hi()

# Step 2: The @ syntax does the same thing β€” cleaner!
@my_decorator
def say_bye():
    print("Bye!")

say_bye()
β–Ά Output
Before the function runs Hi! After the function runs Before the function runs Bye! After the function runs
πŸ’‘
@decorator is Just Syntactic Sugar

@my_decorator placed above a function definition is exactly equivalent to writing func = my_decorator(func) right after the function. The @ syntax just makes it visually clear that the decoration happens at definition time.

Preserving Metadata with functools.wraps

Without functools.wraps, decorated functions lose their name, docstring, and other metadata. Always use it in real-world decorators:

Python
from functools import wraps

# Without wraps β€” identity is lost
def bad_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@bad_decorator
def compute():
    """Compute something important."""
    pass

print(compute.__name__)   # wrapper  ← WRONG
print(compute.__doc__)    # None     ← WRONG

# With wraps β€” identity is preserved
def good_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@good_decorator
def compute():
    """Compute something important."""
    pass

print(compute.__name__)   # compute  ← Correct
print(compute.__doc__)    # Compute something important.  ← Correct

Practical Decorator Examples

Timing Decorator

Python
import time
from functools import wraps

def timer(func):
    """Measure and print how long a function takes to run."""
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} finished in {elapsed:.4f}s")
        return result
    return wrapper

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

total = slow_sum(10_000_000)
print(f"Sum: {total}")
β–Ά Output
slow_sum finished in 0.2831s Sum: 49999995000000

Logging Decorator

Python
import logging
from functools import wraps

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

def log_calls(func):
    """Log every call to the decorated function with args and return value."""
    @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()]
        )
        logging.info("Calling %s(%s)", func.__name__, arg_str)
        result = func(*args, **kwargs)
        logging.info("%s returned %r", func.__name__, result)
        return result
    return wrapper

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

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

add(3, 4)
greet("Alice", greeting="Hi")
β–Ά Output
INFO: Calling add(3, 4) INFO: add returned 7 INFO: Calling greet('Alice', greeting='Hi') INFO: greet returned 'Hi, Alice!'
Ad – 336Γ—280

Decorators with Arguments

To pass arguments to a decorator you need one more level of nesting: a function that receives the arguments and returns the actual decorator:

Python
from functools import wraps

def repeat(times):
    """Run the decorated function 'times' number of 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(name):
    print(f"Hello, {name}!")

say_hello("World")

# Another example: retry on exception
def retry(times=3, exceptions=(Exception,)):
    """Retry the function up to 'times' times if one of 'exceptions' is raised."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_error = e
                    print(f"Attempt {attempt}/{times} failed: {e}")
            raise last_error
        return wrapper
    return decorator

import random

@retry(times=4, exceptions=(ConnectionError,))
def unstable_fetch():
    if random.random() < 0.7:
        raise ConnectionError("Network unavailable")
    return "data"
β–Ά Output
Hello, World! Hello, World! Hello, World!

Stacking Multiple Decorators

You can apply several decorators to a single function. They are applied bottom-up at decoration time, but execute top-down at call time:

Python
from functools import wraps

def bold(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"
    return wrapper

def italic(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return f"<i>{func(*args, **kwargs)}</i>"
    return wrapper

@bold        # applied second (outer)
@italic      # applied first (inner)
def greet(name):
    return f"Hello, {name}"

# Equivalent to: greet = bold(italic(greet))
print(greet("Alice"))   # <b><i>Hello, Alice</i></b>

Class Decorators

A class can be used as a decorator if it implements __init__ and __call__. This is useful when your decorator needs to maintain state:

Python
from functools import wraps

class CallCounter:
    """Decorator that counts how many times a function has been called."""
    def __init__(self, func):
        wraps(func)(self)   # copy metadata
        self.func = func
        self.count = 0

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

@CallCounter
def process(item):
    return f"Processed: {item}"

process("apple")
process("banana")
process("cherry")
print(f"Total calls: {process.count}")
β–Ά Output
[process] call #1 [process] call #2 [process] call #3 Total calls: 3

Built-in Decorators: @property, @staticmethod, @classmethod

Python ships with three widely-used built-in decorators for class design:

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

    # @property: access a method like an attribute (getter)
    @property
    def celsius(self):
        return self._celsius

    # @.setter: validate on assignment
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero!")
        self._celsius = value

    # Computed property β€” no setter needed
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32

    # @classmethod: receives the class (cls) instead of the instance
    @classmethod
    def from_fahrenheit(cls, f):
        return cls((f - 32) * 5/9)

    # @staticmethod: no access to instance or class, just a utility
    @staticmethod
    def absolute_zero():
        return -273.15

    def __repr__(self):
        return f"Temperature({self._celsius}Β°C)"

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

t.celsius = 25
print(t.fahrenheit)    # 77.0

body = Temperature.from_fahrenheit(98.6)
print(body)            # Temperature(37.0Β°C)

print(Temperature.absolute_zero())  # -273.15
β–Ά Output
100 212.0 77.0 Temperature(37.0Β°C) -273.15
DecoratorFirst parameterUse case
@propertyselfComputed attributes, validation on set
@classmethodclsAlternative constructors, factory methods
@staticmethodnoneUtility functions that belong to the class namespace

Caching with @functools.lru_cache and @functools.cache

Python's standard library ships with ready-made memoization decorators that can dramatically speed up recursive or expensive functions:

Python
from functools import lru_cache, cache
import time

# Without caching β€” exponential time
def fib_slow(n):
    if n < 2:
        return n
    return fib_slow(n - 1) + fib_slow(n - 2)

# With lru_cache β€” linear time, bounded cache
@lru_cache(maxsize=128)
def fib_fast(n):
    if n < 2:
        return n
    return fib_fast(n - 1) + fib_fast(n - 2)

# @cache (Python 3.9+) β€” unbounded, simpler
@cache
def fib_cached(n):
    if n < 2:
        return n
    return fib_cached(n - 1) + fib_cached(n - 2)

start = time.perf_counter()
print(fib_fast(40))
print(f"Cached: {time.perf_counter() - start:.6f}s")

# Inspect cache statistics
print(fib_fast.cache_info())   # CacheInfo(hits=38, misses=41, maxsize=128, currsize=41)

Real-World: Authentication Decorator

Python
from functools import wraps

# Simulated user session
_current_user = {"username": "alice", "roles": ["editor"]}

def require_role(*roles):
    """Decorator factory: restrict access to users with one of the given roles."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            user_roles = _current_user.get("roles", [])
            if not any(r in user_roles for r in roles):
                raise PermissionError(
                    f"User '{_current_user['username']}' needs one of: {roles}"
                )
            return func(*args, **kwargs)
        return wrapper
    return decorator

@require_role("admin")
def delete_database():
    return "Database deleted!"

@require_role("editor", "admin")
def publish_article(title):
    return f"'{title}' published."

try:
    delete_database()
except PermissionError as e:
    print(f"Access denied: {e}")

print(publish_article("Decorators in Python"))  # Alice is an editor
β–Ά Output
Access denied: User 'alice' needs one of: ('admin',) 'Decorators in Python' published.

πŸ‹οΈ Practical Exercise

Write a @validate_types decorator that checks the types of all arguments against the function's type hints. If any argument doesn't match, raise a TypeError with a clear message. Test it on a function like def add(a: int, b: int) -> int.

Hint: use import inspect; inspect.signature(func).parameters and func.__annotations__.

πŸ”₯ Challenge Exercise

Build a @rate_limit(calls, period) decorator that allows at most calls invocations within any period-second window. If the limit is exceeded, raise a RateLimitError. Use a collections.deque to track call timestamps. Test it with @rate_limit(3, 10) β€” no more than 3 calls per 10 seconds.

Interview Questions

  • What is a decorator in Python? What problem does it solve?
  • What does the @ syntax actually do under the hood?
  • Why is functools.wraps important and what does it do?
  • How do you write a decorator that accepts its own arguments?
  • What is the difference between a function decorator and a class decorator?
  • When are decorators applied β€” at definition time or at call time?
  • What is the difference between @staticmethod and @classmethod?
  • What does @property do, and why is it preferred over getters/setters?
  • How does @lru_cache work? What does LRU stand for?
  • When would you use a class-based decorator instead of a function-based one?

πŸ“‹ Summary

  • Decorators are functions that wrap other functions to add behaviour without changing the original code.
  • @decorator is syntactic sugar for func = decorator(func).
  • Always use @functools.wraps inside a decorator to preserve the wrapped function's metadata.
  • To pass arguments to a decorator, add one more level of nesting (a factory function).
  • Multiple decorators stack bottom-up but execute top-down.
  • Class-based decorators implement __init__ and __call__; they are ideal when state is needed.
  • Built-in decorators: @property (computed attributes), @classmethod (factory methods), @staticmethod (utilities).
  • @functools.lru_cache and @functools.cache provide ready-made memoization.

Frequently Asked Questions

Are decorators applied every time a function is called? +

No. Decorators are applied once at definition time, when Python encounters the @ line. After that, every call goes through the wrapper function, but the decoration itself only runs once. This is why decorators are efficient for one-time setup like caching.

Can I remove a decorator from a function after it's applied? +

Not directly β€” once the function is replaced by the wrapper, the original is only accessible if you saved a reference to it. A common pattern is to expose the original via a wrapper.__wrapped__ attribute, which functools.wraps sets automatically.

What is the difference between a decorator and a mixin? +

Decorators add behaviour to individual functions or classes at definition time, without using inheritance. Mixins add behaviour to classes via multiple inheritance. Decorators are more composable for function-level concerns (timing, auth, caching); mixins are better for adding a family of methods to a class hierarchy.

Can a decorator modify the arguments passed to the original function? +

Yes. Because the wrapper receives *args and **kwargs before passing them along, it can inspect, modify, filter, or augment them. This is how input-validation decorators and dependency-injection frameworks work.