Python Decorators Explained with Real Examples

Decorators demystified: what the @ line actually does, why functools.wraps matters, and five decorators worth writing or knowing, from a timer and a configurable retry to functools.cache, a registry that collects plugins, and a permission check, plus the order in which stacked decorators run.

GloryTecks Team

Training & Placement Faculty

6 May 2026 7 min read

Last updated: 24 September 2026

Share:

A decorator is a function that takes a function and returns a replacement for it, usually one that does something extra before or after calling the original. That is the whole idea. The @ symbol is just a convenient way to apply one, and once you see what it expands to, decorators stop being magic and become one of the most useful tools for removing repetition from code.

What the @ line does

These two definitions are exactly equivalent (PEP 318):

python
import functools
import time

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

@shout
def greet(name):
    """Return a greeting."""
    return f"hello, {name}"

def greet_plain(name):
    return f"hello, {name}"
greet_plain = shout(greet_plain)       # what @shout does for you

print(greet("asha"), greet_plain("ravi"))
print(greet.__name__, greet.__doc__)
text
HELLO, ASHA HELLO, RAVI
wrapper None

Decoration happens once, when the function is defined: the name greet now refers to wrapper, which calls the original. The *args, **kwargs in the wrapper pass through whatever arguments the caller supplies, the technique explained in Python args and kwargs explained.

The last line shows a problem. The decorated function reports its name as wrapper and has lost its docstring, which confuses debuggers, logs, documentation tools and anything that inspects functions. functools.wraps fixes it by copying those attributes onto the wrapper (Python docs: functools). Every decorator you write should use it.

1. Timing a function

python
def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            print(f"{func.__name__} took {time.perf_counter() - start:.1f}s")
    return wrapper

@timed
def build_report(rows):
    """Pretend to build a report."""
    time.sleep(0.1)
    return f"{rows} rows"

print(build_report(500), "|", build_report.__name__, "|", build_report.__doc__)
text
build_report took 0.1s
500 rows | build_report | Pretend to build a report.

The try/finally makes the timing line appear even when the function raises. Thanks to wraps, the name and docstring survive.

2. Retrying, with arguments

A decorator that takes its own arguments, such as @retry(times=3), needs one more layer: the outer function receives the arguments and returns the actual decorator.

python
def retry(times=3, on=(ConnectionError,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except on as exc:
                    if attempt == times:
                        raise
                    print(f"{func.__name__}: attempt {attempt} failed ({exc})")
        return wrapper
    return decorator

calls = {"n": 0}

@retry(times=3)
def fetch_rates():
    calls["n"] += 1
    if calls["n"] < 3:
        raise ConnectionError("gateway timeout")
    return {"USD": 83.2}

print(fetch_rates())
text
fetch_rates: attempt 1 failed (gateway timeout)
fetch_rates: attempt 2 failed (gateway timeout)
{'USD': 83.2}

Read @retry(times=3) as two steps: retry(times=3) runs first and returns decorator, which is then applied to fetch_rates. The retry policy now lives in one place instead of being copied into every network call. The exchange rate is sample data. For when retries are and are not safe, see error handling in Python.

3. Caching results with functools.cache

The standard library ships ready-made decorators. functools.cache remembers the result for each set of arguments, so repeated calls return instantly:

python
@functools.cache
def shipping_zone(pincode: str) -> str:
    time.sleep(0.05)                  # stands in for a slow lookup
    return "south" if pincode.startswith("5") else "other"

for pin in ["500001", "411001", "500001", "500001"]:
    shipping_zone(pin)
print(shipping_zone.cache_info())
text
CacheInfo(hits=2, misses=2, maxsize=None, currsize=2)

Two distinct pincodes, so two misses; the repeats were hits. Use it only on functions whose result depends solely on their arguments, and remember the cache grows without limit. functools.lru_cache(maxsize=1024) caps it, discarding the least recently used entries.

4. Registering plugins

A decorator does not have to wrap anything. This one records the function in a registry and returns it unchanged, a pattern frameworks use for routes, commands and event handlers:

python
EXPORTERS = {}

def exporter(fmt):
    def register(func):
        EXPORTERS[fmt] = func
        return func
    return register

@exporter("csv")
def to_csv(rows):
    return "\n".join(",".join(map(str, r)) for r in rows)

@exporter("tsv")
def to_tsv(rows):
    return "\n".join("\t".join(map(str, r)) for r in rows)

print(sorted(EXPORTERS), repr(EXPORTERS["csv"]([(1, "Asha"), (2, "Ravi")])))
text
['csv', 'tsv'] '1,Asha\n2,Ravi'

Adding a format now means writing one decorated function; the code that picks an exporter by name never changes. Web frameworks' @app.get("/path") works on this principle.

5. Checking permissions

python
class PermissionDenied(Exception):
    pass

def requires_role(role):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(user, *args, **kwargs):
            if role not in user.get("roles", ()):
                raise PermissionDenied(f"{user['name']} needs role {role!r} for {func.__name__}")
            return func(user, *args, **kwargs)
        return wrapper
    return decorator

@requires_role("admin")
def delete_course(user, slug):
    return f"{slug} deleted by {user['name']}"

print(delete_course({"name": "Meena", "roles": ["admin"]}, "old-batch"))
try:
    delete_course({"name": "Kiran", "roles": ["editor"]}, "old-batch")
except PermissionDenied as exc:
    print(exc)
text
old-batch deleted by Meena
Kiran needs role 'admin' for delete_course

The rule is visible at the top of every protected function, and cannot be forgotten inside its body.

Stacking decorators

Several decorators apply from the bottom up: the one nearest the def wraps the function first (PEP 318). So in

python
@timed
@requires_role("admin")
def publish(user):
    return "published"

print(publish({"name": "Meena", "roles": ["admin"]}))
text
publish took 0.0s
published

the permission check wraps publish, and the timer wraps the result, so the timing includes the check. Order matters whenever a decorator can stop the call: put authentication outside caching, for example, or a cached result could be served to someone who is not allowed to see it.

Decorating methods

The same decorators work on methods inside a class, because a method is just a function whose first argument is the instance. A wrapper written with *args, **kwargs passes self through untouched. A decorator that inspects specific arguments, like requires_role above, must account for self coming first when it is used on methods.

Testing decorated code

Test the decorator once, on a small dummy function, and test the business logic separately. functools.wraps also stores the original function as __wrapped__, so a test can call build_report.__wrapped__(500) to exercise the logic without the timing, caching or retries. That keeps tests fast and makes failures point at the right layer.

Mistakes that break decorators

  • Forgetting to return the wrapper, or forgetting to return the original function's result inside it. The decorated function then returns None.
  • Mixing up the two forms. A decorator that takes arguments must be used with parentheses, @retry() or @retry(times=5); writing plain @retry passes the function in as times and fails later in a confusing way.
  • Leaving out functools.wraps, which hides the real name from logs and tracebacks.
  • Hiding too much. A decorator that silently swallows exceptions or changes arguments makes the decorated function behave differently from what its body says.

Decorators you already use

@property, @classmethod and @staticmethod are decorators built into the language, and @dataclass is a decorator applied to a class (Python glossary: decorator). Framework decorators for routes, tests (@pytest.fixture) and tasks are the registry pattern above.

What is the difference between a decorator and a closure?

A closure is an inner function that remembers variables from the function that created it. Most decorators are built from closures: wrapper remembers func, and a decorator with arguments also remembers those arguments.

Can a decorator be a class?

Yes. Any callable that takes a function and returns a callable works, so a class with a __call__ method can act as a decorator, which is handy when it needs to keep state such as counters.

Do decorators slow code down?

Each call passes through one extra Python function call, which costs well under a microsecond. That matters only inside very hot loops that run millions of times; for functions that do real work, such as I/O, queries or reports, the overhead is negligible.

When should I not write a decorator?

When the extra behaviour applies to one function only, or when the wrapper would need to change what the function does in ways a reader cannot see from the call. Hidden control flow is the main cost of decorators, so keep them focused on cross-cutting concerns such as timing, caching, retries and access control.

Wrapping a whole function is not the only option; sometimes the extra behaviour belongs around a block of code instead. Decorators vs context managers explains when to choose each. Decorators and closures, alongside iterators, generators and context managers, form the advanced concepts module of the Python Programming course.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master Python?

Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.

Explore course