Python Decorators: The Next-Level Guide (Beginner → Pro)

Author

Kritim Yantra

Aug 15, 2025

Python Decorators: The Next-Level Guide (Beginner → Pro)

Ever copy-pasted the same “logging/timing/auth check” code into a dozen functions? I did too. It felt like taping gadgets onto every tool in my toolbox — messy and hard to maintain.

Decorators are the tidy, grown-up way to add that extra behavior without touching every function’s core logic. Think of them like putting a helpful “attachment” on your function — the function still works, but now it gets superpowers.


What you’ll learn (in plain English)

  • What decorators are (and why they’re awesome).
  • How to write your own (simple → advanced).
  • functools.wraps (the one thing most beginners forget).
  • Parameterized decorators, stacked decorators, and method decoration.
  • Practical patterns: logging, timing, caching, retrying, access control.
  • Class-based decorators, decorating classes, and async-aware decorators.
  • Common pitfalls and a quick cheat-sheet.

1) What is a decorator, really?

A decorator is a function that takes another function and returns a new function.
It’s like putting a jacket on your function: same person, but now warmer and with pockets. 🧥

Without the @ sugar:

def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return str(result).upper()
    return wrapper

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

greet = shout(greet)      # manually decorate
print(greet("Ada"))       # HELLO, ADA

With the @ sugar (preferred):

@shout
def greet(name):
    return f"Hello, {name}"

Takeaway: A decorator wraps a function to add behavior before/after/around the original call.


2) First decorator (the right way) — use functools.wraps

This preserves the original function’s name, docstring, and annotations.
Without it, debugging and docs become confusing.

from functools import wraps

def shout(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        value = func(*args, **kwargs)
        return str(value).upper()
    return wrapper

Pro Tip: Always use @wraps. Trust me, you’ll thank yourself later.


3) Decorator with arguments (aka a decorator factory)

Sometimes you want to configure the decorator (e.g., log level, retries).

from functools import wraps

def repeat(times=2):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last = None
            for _ in range(times):
                last = func(*args, **kwargs)
            return last
        return wrapper
    return decorator

@repeat(times=3)
def ping():
    print("pong")

ping()   # prints "pong" three times

How it works: repeat(3) runs first and returns the actual decorator.


4) Stacking multiple decorators (order matters!)

Decorators closest to the function run first at call time, but are applied last at definition time.

def A(f):
    @wraps(f)
    def w(*a, **k):
        print("A before")
        out = f(*a, **k)
        print("A after")
        return out
    return w

def B(f):
    @wraps(f)
    def w(*a, **k):
        print("B before")
        out = f(*a, **k)
        print("B after")
        return out
    return w

@A
@B
def work():
    print("doing work")

work()
# A before
# B before
# doing work
# B after
# A after

Warning: Changing the order changes behavior. Be intentional.


5) Decorating methods (don’t forget self)

Methods are just functions that receive self as the first argument. Your wrapper should accept *args, **kwargs, and it’ll work fine.

from functools import wraps

def log_call(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__qualname__}")
        return func(*args, **kwargs)
    return wrapper

class User:
    @log_call
    def save(self):
        print("saved!")

User().save()
# Calling User.save
# saved!

Pro Tip: Works the same for @classmethod and @staticmethod.


6) Practical, real-world decorators

a) Logging

from functools import wraps
import logging

logging.basicConfig(level=logging.INFO)

def log_io(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        logging.info("→ %s args=%s kwargs=%s", func.__name__, args, kwargs)
        out = func(*args, **kwargs)
        logging.info("← %s returns %r", func.__name__, out)
        return out
    return wrapper

b) Timing

import time
from functools import wraps

def timing(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        t0 = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            dt = (time.perf_counter() - t0) * 1000
            print(f"{func.__name__} took {dt:.2f} ms")
    return wrapper

c) Caching (built-in!)

from functools import lru_cache

@lru_cache(maxsize=1024)
def fib(n):
    if n < 2: return n
    return fib(n-1) + fib(n-2)

d) Retry with backoff

import time, random
from functools import wraps

def retry(times=3, delay=0.2, factor=2.0):
    def decorator(func):
        @wraps(func)
        def wrapper(*a, **k):
            wait = delay
            for attempt in range(1, times + 1):
                try:
                    return func(*a, **k)
                except Exception as e:
                    if attempt == times:
                        raise
                    time.sleep(wait)
                    wait *= factor
        return wrapper
    return decorator

@retry(times=3, delay=0.1)
def flaky():
    if random.random() < 0.7:
        raise RuntimeError("try again")
    return "ok"

e) Access control (simple role check)

from functools import wraps

def requires_role(role):
    def decorator(func):
        @wraps(func)
        def wrapper(user, *args, **kwargs):
            if role not in getattr(user, "roles", []):
                raise PermissionError("forbidden")
            return func(user, *args, **kwargs)
        return wrapper
    return decorator

7) Class-based decorators (stateful wrappers)

When you need to store state (e.g., count calls), a class with __call__ is handy.

from functools import update_wrapper

class count_calls:
    def __init__(self, func):
        self.func = func
        self.count = 0
        update_wrapper(self, func)  # like @wraps but for classes

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

@count_calls
def hello():
    print("hi")

hello()
hello()
# hello call #1
# hi
# hello call #2
# hi

8) Decorating classes (not just functions!)

A class decorator receives a class and returns a class (or a modified one).

def add_repr(cls):
    def __repr__(self):
        fields = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
        return f"{cls.__name__}({fields})"
    cls.__repr__ = __repr__
    return cls

@add_repr
class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

print(Point(2, 3))
# Point(x=2, y=3)

Use cases: auto-register plugins, validate class attributes, attach mixins.


9) Async-aware decorators (works for async def)

If you decorate async functions, your decorator should detect and await them.

import asyncio, inspect
from functools import wraps

def async_aware(deco):
    """Wrap a decorator so it supports sync + async functions."""
    def outer(func):
        if inspect.iscoroutinefunction(func):
            @wraps(func)
            async def async_wrapper(*a, **k):
                return await deco(func)(*a, **k)
            return async_wrapper
        else:
            @wraps(func)
            def sync_wrapper(*a, **k):
                return deco(func)(*a, **k)
            return sync_wrapper
    return outer

@async_aware
def log_async(func):
    @wraps(func)
    async def wrapper(*a, **k):
        print("before")
        try:
            return await func(*a, **k)
        finally:
            print("after")
    return wrapper

@log_async
async def fetch():
    await asyncio.sleep(0.01)
    return 42

10) Common pitfalls (and how to avoid them)

  • Forgetting @wraps → you’ll lose __name__, __doc__, and type hints.
  • Changing function signatures → keep *args, **kwargs unless you really need otherwise.
  • Decorator order surprises → remember: top decorator runs outermost.
  • Hiding exceptions → log and re-raise; don’t swallow errors silently.
  • Global state in decorators → can cause test flakiness. Prefer arguments or class-based state.

11) Mini cheat-sheet

  • Simple decorator: takes func → returns wrapper.
  • With args: takes *config → returns decorator → returns wrapper.
  • Always: use @wraps(func) inside the wrapper.
  • Stacking: @A above @B ⇒ A wraps B wraps func.
  • Async: detect with inspect.iscoroutinefunction and await.
  • Stateful: use class with __call__ + update_wrapper.

12) Put it all together: a tidy, production-ready pattern

import logging, time, inspect
from functools import wraps, update_wrapper

logging.basicConfig(level=logging.INFO)

def compose(*decorators):
    """Compose multiple decorators as one."""
    def combined(func):
        for d in reversed(decorators):
            func = d(func)
        return func
    return combined

def timeit(label=None):
    def deco(func):
        @wraps(func)
        def wrapper(*a, **k):
            t0 = time.perf_counter()
            try:
                return func(*a, **k)
            finally:
                dt = (time.perf_counter() - t0) * 1000
                logging.info("%s took %.2f ms", label or func.__name__, dt)
        return wrapper
    return deco

def log_call(func):
    @wraps(func)
    def wrapper(*a, **k):
        logging.info("→ %s", func.__qualname__)
        out = func(*a, **k)
        logging.info("← %s", func.__qualname__)
        return out
    return wrapper

@compose(timeit("sum_up"), log_call)
def add_up(n):
    return sum(range(n))

print(add_up(1_000_000))

Tiny mental model (ASCII diagram)

@A
@B
def f(): pass

call → A.wrapper( B.wrapper( f ) )

Remember: A is the outer jacket, B is the inner one.


FAQ (Beginner-friendly)

1) When should I use a decorator vs. a helper function?
Use a decorator when you want the same cross-cutting behavior applied uniformly (logging, timing, auth). Use a helper when the logic is domain-specific to a single function.

2) Why does my decorated function lose its name/docs?
Because your wrapper replaced it. Add @functools.wraps(original_func) on the wrapper to preserve metadata.

3) Can I pass arguments to decorators?
Yes! That’s a decorator factory: @retry(times=3). It’s just one extra function layer returning the actual decorator.


Wrap-up (and your next step)

Decorators help you add behavior without clutter, keep code DRY, and make cross-cutting concerns feel effortless. Start small: add a @timing decorator to one hot path, then try a @retry for flaky calls, and graduate to async-aware or class-based patterns as you go.

Your turn: What’s the first function in your codebase you’d love to decorate — logging, timing, or retrying? Share your pick and why in the comments!

Tags

Comments

No comments yet. Be the first to comment!

Please log in to post a comment:

Sign in with Google

Related Posts