Toolmingo
Guides6 min read

Python Decorators Guide: @decorator Syntax with Real Examples

Learn how Python decorators work from the ground up — closures, functools.wraps, decorator factories, stacking, and practical patterns like timing, caching, retry, and access control.

Decorators are one of Python's most powerful features — they let you wrap a function (or class) with reusable behaviour without touching its body. One @ symbol and you can add logging, timing, caching, authentication, or retry logic to any function.


Quick Reference

Pattern Syntax Use case
Basic decorator @my_decorator Wrap any function
Decorator factory @repeat(times=3) Decorator with arguments
Preserve metadata @functools.wraps(fn) Keep __name__, __doc__
Class decorator @dataclass Modify/replace a class
Built-in: property @property Getter as attribute
Built-in: classmethod @classmethod Receives cls, not self
Built-in: staticmethod @staticmethod No implicit first arg
Stack decorators Multiple @ lines Apply in bottom-up order

What Is a Decorator?

A decorator is a callable that takes a function and returns a function. The @ syntax is shorthand for passing a function into another function:

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

# Exactly equivalent to:
def greet():
    print("Hello")
greet = my_decorator(greet)

Decorators work because Python functions are first-class objects — you can pass them as arguments, return them, and store them in variables.


Your First Decorator

def shout(fn):
    def wrapper(*args, **kwargs):
        result = fn(*args, **kwargs)
        print("Done!")
        return result
    return wrapper

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

greet("Alice")
# Hello, Alice
# Done!

The wrapper function accepts *args, **kwargs so it works with any function signature.


Always Use functools.wraps

Without functools.wraps, the decorated function loses its identity:

import functools

def shout(fn):
    @functools.wraps(fn)   # ← preserves __name__, __doc__, __module__
    def wrapper(*args, **kwargs):
        result = fn(*args, **kwargs)
        print("Done!")
        return result
    return wrapper

@shout
def greet(name):
    """Say hello."""
    print(f"Hello, {name}")

print(greet.__name__)  # greet  (not "wrapper")
print(greet.__doc__)   # Say hello.

Without @functools.wraps, greet.__name__ would be "wrapper", breaking introspection tools, debuggers, and help().


Practical Patterns

Timing (Performance Measurement)

import functools
import time

def timer(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{fn.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_query(n):
    time.sleep(n)
    return n * 2

slow_query(0.1)
# slow_query took 0.1002s

Logging

import functools
import logging

logging.basicConfig(level=logging.INFO)

def log_calls(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        logging.info("Calling %s with args=%s kwargs=%s", fn.__name__, args, kwargs)
        result = fn(*args, **kwargs)
        logging.info("%s returned %s", fn.__name__, result)
        return result
    return wrapper

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

add(3, 4)
# INFO:root:Calling add with args=(3, 4) kwargs={}
# INFO:root:add returned 7

Retry with Exponential Backoff

import functools
import time

def retry(max_attempts=3, delay=1.0, backoff=2.0, exceptions=(Exception,)):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            attempt = 0
            wait = delay
            while attempt < max_attempts:
                try:
                    return fn(*args, **kwargs)
                except exceptions as e:
                    attempt += 1
                    if attempt >= max_attempts:
                        raise
                    print(f"Attempt {attempt} failed: {e}. Retrying in {wait}s…")
                    time.sleep(wait)
                    wait *= backoff
        return wrapper
    return decorator

@retry(max_attempts=4, delay=0.5, exceptions=(IOError, TimeoutError))
def fetch_data(url):
    # might raise IOError on network failure
    ...

Simple Cache (Memoize)

import functools

def memoize(fn):
    cache = {}
    @functools.wraps(fn)
    def wrapper(*args):
        if args not in cache:
            cache[args] = fn(*args)
        return cache[args]
    return wrapper

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

print(fibonacci(40))  # fast, even for large n

Tip: Python's standard library already has @functools.lru_cache(maxsize=128) and @functools.cache (unbounded, Python 3.9+). Prefer those over a hand-rolled version.

from functools import lru_cache

@lru_cache(maxsize=256)
def expensive(n):
    ...

Access Control / Authentication

import functools

def require_auth(fn):
    @functools.wraps(fn)
    def wrapper(request, *args, **kwargs):
        if not request.get("user"):
            raise PermissionError("Authentication required")
        return fn(request, *args, **kwargs)
    return wrapper

@require_auth
def get_profile(request):
    return {"name": request["user"]}

Decorator Factories (Decorators with Arguments)

When you need to pass arguments to a decorator, add one more level of nesting:

import functools

def repeat(times):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = fn(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def say_hi():
    print("Hi!")

say_hi()
# Hi!
# Hi!
# Hi!

The call chain: repeat(times=3) returns decorator, which then receives say_hi.


Stacking Decorators

You can apply multiple decorators. They execute bottom-up at decoration time, and top-down at call time:

@timer          # applied second (outermost wrapper)
@log_calls      # applied first (innermost wrapper)
def process(data):
    return sorted(data)

# Equivalent to:
# process = timer(log_calls(process))

When process(data) is called: timer's wrapper runs first, then log_calls's wrapper, then the real function.


Class Decorators

Decorators work on classes too. @dataclass is the most common example:

from dataclasses import dataclass, field

@dataclass
class Point:
    x: float
    y: float
    tags: list = field(default_factory=list)

p = Point(1.0, 2.0)
print(p)        # Point(x=1.0, y=2.0, tags=[])
print(p == Point(1.0, 2.0))  # True — __eq__ generated for free

@dataclass generates __init__, __repr__, __eq__, and optionally __hash__, __lt__, etc.

You can also write your own class decorator:

def singleton(cls):
    instances = {}
    @functools.wraps(cls)
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Config:
    def __init__(self):
        self.debug = False

Built-in Decorators

@property

Turns a method into a read-only attribute, with optional setter:

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

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius must be non-negative")
        self._radius = value

    @property
    def area(self):
        import math
        return math.pi * self._radius ** 2

c = Circle(5)
print(c.area)    # 78.54…
c.radius = 10    # uses setter
c.radius = -1    # raises ValueError

@classmethod and @staticmethod

class Date:
    def __init__(self, year, month, day):
        self.year, self.month, self.day = year, month, day

    @classmethod
    def from_string(cls, s):          # cls = Date
        year, month, day = map(int, s.split("-"))
        return cls(year, month, day)  # works in subclasses too

    @staticmethod
    def is_leap(year):                # no cls, no self
        return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

d = Date.from_string("2026-07-14")
print(Date.is_leap(2024))   # True

6 Common Mistakes

Mistake Problem Fix
Forgetting functools.wraps fn.__name__ becomes "wrapper", breaks introspection Add @functools.wraps(fn) to wrapper
Forgetting return result Decorated function always returns None Always return fn(*args, **kwargs)
Calling the function inside decorator @expensive() vs @expensive confusion @decorator is no-call; @factory() calls factory
Not forwarding *args, **kwargs Wrapper breaks if function takes arguments Use wrapper(*args, **kwargs)
Mutable default in decorator Cache shared across all decorated functions (bug) Define cache = {} inside decorator, not outer scope
Decorator on a method ignoring self wrapper discards self, breaks instance methods *args captures self automatically — no action needed

6 FAQ

Q: What's the difference between @decorator and @decorator()? @decorator passes the function directly to decorator. @decorator() calls decorator() first (returns another callable), then passes the function to that. Use () when your decorator needs arguments.

Q: Can a decorator return something other than a function? Yes. A decorator can return any callable — a function, a lambda, an instance of a class with __call__. It can also return the original function unchanged after inspecting or registering it.

Q: How does @functools.cache differ from @functools.lru_cache? @functools.cache (Python 3.9+) is equivalent to @lru_cache(maxsize=None) — it caches all calls forever and is slightly faster. Use lru_cache when you need a bounded cache to limit memory.

Q: Do decorators work with async functions? Yes, but the wrapper must also be async def and use await:

import functools

def async_timer(fn):
    @functools.wraps(fn)
    async def wrapper(*args, **kwargs):
        import time
        start = time.perf_counter()
        result = await fn(*args, **kwargs)
        print(f"{fn.__name__} took {time.perf_counter() - start:.4f}s")
        return result
    return wrapper

Q: What order do stacked decorators run? At decoration time: bottom-up (innermost first). At call time: top-down (outermost first). Think of it as nested function calls — the outermost wrapper runs first when you call the function.

Q: When should I use a decorator vs a context manager? Use a decorator when the behaviour applies to an entire function (retry logic, logging, caching). Use a context manager (with statement) when the behaviour has a clear enter/exit that should wrap a block of code (file handles, transactions, locks).

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools