Python Decorators Explained: A Complete Guide with Practical Examples
Decorators are one of Python's most elegant features β and one of the most confusing for developers who encounter them without a solid foundation. Once you understand the mechanics, they become a natural and powerful tool.
This guide builds your understanding step by step, from functions as first-class objects to real-world decorator patterns.
Functions Are First-Class Objects
Before understanding decorators, you need to understand that in Python, functions are objects. They can be assigned to variables, passed as arguments, and returned from other functions.
pythondef greet(name): return f"Hello, {name}" say_hello = greet # assign to variable print(say_hello("Alice")) # Hello, Alice def apply(func, value): # pass function as argument return func(value) print(apply(greet, "Bob")) # Hello, Bob
This is the foundation everything else builds on.
Higher-Order Functions
A function that takes a function as input or returns a function as output is called a higher-order function:
pythondef make_multiplier(factor): def multiply(number): return number * factor return multiply # returning a function double = make_multiplier(2) triple = make_multiplier(3) print(double(5)) # 10 print(triple(5)) # 15
multiply is a closure β it remembers the value of factor from its enclosing scope even after make_multiplier has returned.
What Is a Decorator?
A decorator is a function that takes a function, wraps it with additional behavior, and returns the wrapped version.
pythondef 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_hello(name): print(f"Hello, {name}!") say_hello = my_decorator(say_hello) # manual decoration say_hello("Alice")
Output:
codeBefore the function runs Hello, Alice! After the function runs
The @ Syntax
Python provides syntactic sugar for decoration. @my_decorator above a function definition is exactly equivalent to func = my_decorator(func):
python@my_decorator def say_hello(name): print(f"Hello, {name}!") -- Equivalent to: -- say_hello = my_decorator(say_hello)
Cleaner and more readable. The decorator is declared right where the function is defined.
Preserving Function Metadata
Without extra work, decorating a function replaces its name and docstring with the wrapper's:
python@my_decorator def say_hello(name): """Say hello to someone.""" print(f"Hello, {name}!") print(say_hello.__name__) # "wrapper" β wrong! print(say_hello.__doc__) # None β wrong!
Fix this with functools.wraps:
pythonimport functools def my_decorator(func): @functools.wraps(func) # preserves __name__, __doc__, etc. def wrapper(*args, **kwargs): print("Before") result = func(*args, **kwargs) print("After") return result return wrapper
Always use @functools.wraps in your decorators.
Real-World Decorator Examples
Timing a function
pythonimport functools import time def timer(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) end = time.perf_counter() print(f"{func.__name__} took {end - start:.4f}s") return result return wrapper @timer def slow_function(): time.sleep(0.5) return "done" slow_function() -- slow_function took 0.5003s
Caching / Memoization
pythonimport 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 fibonacci(n): if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2) print(fibonacci(50)) # fast β cached results reused
Python also ships @functools.lru_cache for this:
pythonfrom functools import lru_cache @lru_cache(maxsize=None) def fibonacci(n): if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2)
Retry logic
pythonimport functools import time def retry(max_attempts=3, delay=1.0): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): last_error = None for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except Exception as e: last_error = e print(f"Attempt {attempt} failed: {e}") if attempt < max_attempts: time.sleep(delay) raise last_error return wrapper return decorator @retry(max_attempts=3, delay=2.0) def call_external_api(url): -- simulate a flaky network call import random if random.random() < 0.7: raise ConnectionError("Network timeout") return {"status": "ok"}
Access control
pythonimport functools def require_auth(func): @functools.wraps(func) def wrapper(request, *args, **kwargs): if not request.get("user"): raise PermissionError("Authentication required") return func(request, *args, **kwargs) return wrapper @require_auth def get_profile(request): return {"name": request["user"]["name"]}
This is the same pattern Flask and Django use for @login_required.
Decorators with Arguments
When your decorator needs parameters, you add another layer:
pythondef repeat(n): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for _ in range(n): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(3) def say_hi(): print("Hi!") say_hi() -- Hi! -- Hi! -- Hi!
@repeat(3) calls repeat(3) first, which returns decorator, which then wraps say_hi.
Stacking Decorators
You can apply multiple decorators. They are applied bottom-up:
python@timer @retry(max_attempts=3) @require_auth def fetch_data(request): pass -- Equivalent to: -- fetch_data = timer(retry(max_attempts=3)(require_auth(fetch_data)))
The innermost decorator (require_auth) wraps first, then retry, then timer is outermost.
Class Decorators
Classes can also act as decorators by implementing __call__:
pythonclass CountCalls: def __init__(self, func): functools.update_wrapper(self, func) self.func = func self.call_count = 0 def __call__(self, *args, **kwargs): self.call_count += 1 print(f"Call #{self.call_count} to {self.func.__name__}") return self.func(*args, **kwargs) @CountCalls def greet(name): print(f"Hello, {name}!") greet("Alice") # Call #1 to greet greet("Bob") # Call #2 to greet print(greet.call_count) # 2
Common Interview Questions
Q: What does @staticmethod and @classmethod do?
Both are built-in decorators. @staticmethod defines a method that does not receive self or cls β it is just a regular function namespaced inside the class. @classmethod receives cls (the class itself) instead of self, useful for factory methods.
Q: What is the difference between a decorator and a context manager?
A decorator wraps a function, adding behavior before and after every call. A context manager (with statement) manages a resource for a block of code. contextlib.contextmanager lets you write a context manager using a generator, which looks similar to a decorator but serves a different purpose.
Q: Can decorators be applied to classes?
Yes. A class decorator receives the class as an argument and returns a modified class or a replacement. @dataclass is a well-known example.
Practice Python on Froquiz
Decorators are a common Python interview topic at intermediate and advanced levels. Test your Python skills on Froquiz β covering decorators, generators, comprehensions, OOP, and more.
Summary
- Functions are first-class objects β they can be passed and returned like any value
- A decorator is a function that wraps another function to add behavior
- Always use
@functools.wrapsto preserve the original function's metadata - Add a parameter layer (
def repeat(n): def decorator(func):) for decorators with arguments - Decorators are applied bottom-up when stacked
- Real-world uses: timing, caching, retry, logging, authentication, rate limiting