Python Advance 📂 Advance topics · 2 of 3 56 min read

Python Decorators — From Simple Functions to Class-Based & Parameterised

Master Python decorators end-to-end — the "gift wrapper" pattern that lets you add logging, timing, caching, and authentication to any function without touching its code. Covers function decorators, decorators that take arguments, class-based decorators, stacking, and a real-world @login_required flow. Loaded with runnable examples, output blocks, hyperparameter-style reference tables, and golden rules.

Section 01

The Story That Explains Decorators

The Gift Wrapping Counter at a Mall
You buy a plain book from a bookstore. Before handing it over, the staff wraps it in shiny paper, adds a ribbon, sticks a "To: ___" tag, and hands it back. The book hasn't changed — same pages, same story — but what you receive is enhanced. Tomorrow the same wrapping counter could wrap a watch, a mug, or a laptop the same way.

That is exactly what a decorator does in Python. It takes a function (the "gift"), wraps extra behaviour around it (logging, timing, authentication, caching), and returns the wrapped version — without ever modifying the original function's code. The same decorator can wrap dozens of different functions.

In Python, functions are first-class objects. You can pass them to other functions, return them from functions, and store them in variables. A decorator is simply a function (or class) that takes another function as input and returns a new function that usually calls the original — but with something extra bolted on before, after, or around it.

💡
The Core Insight

A decorator lets you add reusable behaviour — logging, timing, access control, caching, retries — to any function by writing @my_decorator on the line above it. One line. Zero changes to the function itself. This is Python's most elegant way to achieve separation of concerns.


Section 02

The Foundation — Functions Are Objects

Before decorators make sense, you must accept one fact: in Python, a function is just another object. You can assign it to a variable, put it in a list, pass it as an argument, or return it from another function.

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

# 1. Assign the function to a new name (no parentheses!)
say_hi = greet
print(say_hi("Mohit"))       # -> Hello, Mohit!

# 2. Pass a function as an argument
def shout(fn, name):
    return fn(name).upper()

print(shout(greet, "Mohit"))   # -> HELLO, MOHIT!

# 3. Return a function from another function
def make_multiplier(n):
    def multiplier(x):
        return x * n
    return multiplier

double = make_multiplier(2)
print(double(7))               # -> 14
OUTPUT
Hello, Mohit! HELLO, MOHIT! 14
🔑
Closures — The Building Block

Notice how multiplier remembered the value of n even after make_multiplier finished executing. This is called a closure — an inner function that captures variables from its enclosing scope. Every decorator you write relies on this mechanism.


Section 03

Your First Decorator — Written by Hand

Let's build a decorator that prints a message before and after any function runs. We will do it without the @ syntax first, so you see what's really happening.

def announce(fn):                     # takes a function
    def wrapper():                    # builds a new function
        print("-- before --")
        result = fn()                # call the original
        print("-- after  --")
        return result
    return wrapper                    # returns the wrapper

def say_hello():
    print("Hello!")

# Manually wrap it
say_hello = announce(say_hello)
say_hello()
OUTPUT
-- before -- Hello! -- after --

Now the same thing using the @ syntax — this is pure syntactic sugar for say_hello = announce(say_hello). Nothing more, nothing less.

def announce(fn):
    def wrapper():
        print("-- before --")
        result = fn()
        print("-- after  --")
        return result
    return wrapper

@announce                    # the magic line
def say_hello():
    print("Hello!")

say_hello()
The @ Syntax Explained

When Python sees @announce above a function definition, it executes say_hello = announce(say_hello) immediately after the definition. That's the entire trick. Decorators are not a new language feature — they're a convenient way to write function reassignment.


Section 04

Handling Arguments — *args and **kwargs

Real functions take arguments. To make your decorator work with any function regardless of its signature, use *args, **kwargs in the wrapper. This captures every positional and keyword argument and forwards them intact.

def announce(fn):
    def wrapper(*args, **kwargs):
        print(f"Calling {fn.__name__} with {args} {kwargs}")
        result = fn(*args, **kwargs)
        print(f"{fn.__name__} returned {result!r}")
        return result
    return wrapper

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

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

add(3, 4)
greet("Mohit", greeting="Namaste")
OUTPUT
Calling add with (3, 4) {} add returned 7 Calling greet with ('Mohit',) {'greeting': 'Namaste'} greet returned 'Namaste, Mohit!'
⚠️
Always Return the Result

A common bug: forgetting return result inside the wrapper. If your original function returns something, the wrapper must return it too — otherwise the decorated function silently starts returning None. This breaks silently and is a nightmare to debug.


Section 05

Preserving Metadata — functools.wraps

When you decorate a function, its __name__, __doc__, and other introspection attributes get replaced by the wrapper's. This confuses debuggers, IDEs, and documentation tools. The fix is one line: @functools.wraps(fn).

❌ Without @wraps
AttributeValue
add.__name__'wrapper'
add.__doc__None
help(add)Shows wrapper
✅ With @wraps
AttributeValue
add.__name__'add'
add.__doc__'Add two numbers.'
help(add)Shows add
from functools import wraps

def announce(fn):
    @wraps(fn)                     # preserves metadata
    def wrapper(*args, **kwargs):
        print(f"Calling {fn.__name__}")
        return fn(*args, **kwargs)
    return wrapper

@announce
def add(a, b):
    """Add two numbers."""
    return a + b

print(add.__name__)              # -> add (not 'wrapper')
print(add.__doc__)               # -> Add two numbers.
🔑
Non-Negotiable Rule

Every decorator you write in production code must use @functools.wraps(fn) on its wrapper. It costs nothing and prevents broken tracebacks, misleading logs, and unusable documentation.


Section 06

Visual Diagram — How a Decorator Wraps a Function

This is the mental model. The original function is the gift; the decorator is the wrapping paper. Once wrapped, every call goes through the wrapper — which does its "before" work, calls the inner function, does its "after" work, and returns the result.

FIGURE 1 — Anatomy of a Decorated Function
@announce → wrapper( ) 1. BEFORE code print("-- before --") 2. ORIGINAL fn( ) say_hello() (never modified) 3. AFTER code print("-- after --") CALL: say_hello() RETURN: result The dashed amber box is the wrapper — it surrounds the original function without modifying it.

The wrapper "sandwiches" the original function between before and after logic. The green box (original) is never edited — the decorator only replaces the name in the namespace.

Execution Timeline — What Happens When You Write @decorator

FIGURE 2 — Two Distinct Phases: Definition vs Call
PHASE 1 — Definition Time (once) PHASE 2 — Call Time (every call) 1. def say_hello(): ... 2. wrapper = announce(say_hello) 3. say_hello = wrapper Runs ONCE when the module loads 1. say_hello() → wrapper() 2. print("-- before --") 3. fn() + print("-- after --") Runs EVERY time say_hello() is called The @ symbol is Python doing steps 2 & 3 automatically for you.

Phase 1 rebinds the name once. Phase 2 is what users see — every call flows through the wrapper.


Section 07

Decorators That Take Arguments

What if you want @repeat(3) to run a function three times, or @retry(times=5) to retry on failure? Now the decorator itself needs arguments. This requires three levels of nested functions:

🌲 The Three-Layer Pattern
Layer 1
The outer function receives the decorator arguments (e.g. times=3).
Layer 2
The middle function is the real decorator — receives the target function fn.
Layer 3
The inner wrapper receives runtime arguments *args, **kwargs and executes.
FIGURE 3 — Three Nested Layers of a Parameterised Decorator
LAYER 1 — receives decorator arguments def repeat(times): LAYER 2 — receives the target function def decorator(fn): LAYER 3 — receives runtime arguments def wrapper(*args, **kwargs): for _ in range(times): result = fn(*args, **kwargs) return result return wrapper return decorator gets (3) gets greet gets ("Mohit",)

@repeat(3) above def greet(name) desugars to greet = repeat(3)(greet). Two chained calls → two extra layers of nesting. Each layer captures its own set of arguments via closure.

Call Flow: @repeat(3) → greet("Mohit")

FIGURE 4 — Execution Order Through the Three Layers
STEP 1 repeat(3) returns decorator STEP 2 decorator(greet) returns wrapper STEP 3 wrapper("Mohit") loops 3× → calls greet STEP 4 greet("Mohit") ×3 prints 3 times ↑ ONE-TIME setup (at @-line) ↑ ↑ EVERY call to greet() ↑ Definition phase Runtime phase

Steps 1–2 run once. Steps 3–4 run on every call. This is why decorator arguments (like times=3) are captured in a closure that survives across all calls.

from functools import wraps

def repeat(times):                              # Layer 1 — takes decorator args
    def decorator(fn):                          # Layer 2 — real decorator
        @wraps(fn)
        def wrapper(*args, **kwargs):            # Layer 3 — runtime
            result = None
            for _ in range(times):
                result = fn(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)                                        # note: @repeat(3), not @repeat
def greet(name):
    print(f"Hi, {name}!")

greet("Mohit")
OUTPUT
Hi, Mohit! Hi, Mohit! Hi, Mohit!
📈
Why Three Layers?

@repeat(3) is really repeat(3)(greet). First repeat(3) is called and returns the actual decorator, then that decorator is applied to greet. Two calls → two extra levels of nesting. This is the standard pattern for every parameterised decorator you'll ever write.


Section 08

Practical Example — @timer, @retry, @cache

@timer — measure execution time

import time
from functools import wraps

def timer(fn):
    @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*1000:.2f} ms")
        return result
    return wrapper

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

slow_sum(1_000_000)
OUTPUT
slow_sum took 18.42 ms

@retry — auto-retry on exception (takes arguments)

import time, random
from functools import wraps

def retry(times=3, delay=1.0):
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last_error = e
                    print(f"[attempt {attempt}] failed: {e}")
                    time.sleep(delay)
            raise last_error
        return wrapper
    return decorator

@retry(times=3, delay=0.5)
def flaky_api_call():
    if random.random() < 0.7:
        raise ConnectionError("server busy")
    return "OK"

print(flaky_api_call())
OUTPUT
[attempt 1] failed: server busy [attempt 2] failed: server busy OK

@cache — memoise expensive results

from functools import wraps

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

@cache
def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

print(fib(50))                # instant, without cache would hang
OUTPUT
12586269025
Use functools.lru_cache in Real Code

The @cache above is a teaching version. In production use @functools.lru_cache(maxsize=128) — it's C-optimised, thread-safe, and supports keyword arguments and size limits.


Section 09

Class-Based Decorators

A decorator doesn't have to be a function — any callable works. A class becomes a decorator by implementing __init__ (receives the function) and __call__ (runs when the decorated function is invoked).

🛠️
__init__
receives fn
Runs once, when the decorator is applied. Store the wrapped function and any state (counters, caches, config) as instance attributes.
🔌
__call__
runs every call
Runs every time the decorated function is invoked. Accept *args, **kwargs, do your before/after work, call self.fn(...), return the result.
📊
Why Class?
state + methods
Classes shine when the decorator needs persistent state (call counts, statistics) or extra methods (reset, dump stats). Functions with closures work too, but classes are cleaner for anything non-trivial.

CountCalls — a class-based decorator with state

from functools import wraps

class CountCalls:
    def __init__(self, fn):
        wraps(fn)(self)              # preserve metadata
        self.fn    = fn
        self.count = 0

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

    def reset(self):
        self.count = 0

@CountCalls
def say_hi(name):
    print(f"Hi, {name}!")

say_hi("Mohit")
say_hi("Aarav")
say_hi("Priya")
print(f"Total: {say_hi.count} calls")   # -> 3
say_hi.reset()
print(f"After reset: {say_hi.count}") # -> 0
OUTPUT
say_hi call #1 Hi, Mohit! say_hi call #2 Hi, Aarav! say_hi call #3 Hi, Priya! Total: 3 calls After reset: 0

Class-based decorator that takes arguments

from functools import wraps

class Repeat:
    def __init__(self, times):        # Layer 1 — receives args
        self.times = times

    def __call__(self, fn):           # Layer 2 — receives fn
        @wraps(fn)
        def wrapper(*args, **kwargs):  # Layer 3 — runtime
            result = None
            for _ in range(self.times):
                result = fn(*args, **kwargs)
            return result
        return wrapper

@Repeat(2)
def shout(msg):
    print(msg.upper())

shout("hello")
OUTPUT
HELLO HELLO

Section 10

Function vs Class-Based — When to Use Which

Aspect Function-Based Class-Based
Syntax weight Lightweight — a few nested defs Heavier — needs a class body
Persistent state Possible via closures or nonlocal Natural — use self.attr
Extra methods (reset, stats) Awkward — attach via .attribute First-class — just add methods
Readability for beginners Reads top-to-bottom Requires OOP understanding
Best fit Small, stateless enhancements (log, time) Stateful (counters, rate-limiters, registries)
💡
Practical Rule

Start with a function decorator. If you find yourself using nonlocal to mutate state, or attaching methods like .reset() or .stats() to the wrapper, that's your signal to refactor into a class-based decorator.


Section 11

Stacking Multiple Decorators

You can stack decorators. They apply bottom-up — the decorator closest to the function wraps it first, then the next one wraps that, and so on. But the wrappers execute top-down when the function is called.

def bold(fn):
    def wrapper(*a, **k):
        return f"<b>{fn(*a, **k)}</b>"
    return wrapper

def italic(fn):
    def wrapper(*a, **k):
        return f"<i>{fn(*a, **k)}</i>"
    return wrapper

@bold                          # applied SECOND — outermost
@italic                        # applied FIRST  — innermost
def say(msg):
    return msg

print(say("hello"))
# Equivalent to: say = bold(italic(say))
OUTPUT
<b><i>hello</i></b>
FIGURE 5 — Decorator Stacking: Wrap Bottom-Up, Execute Top-Down
WRAPPING (bottom-up) @bold (applied 2nd) @italic (applied 1st) say() original Equivalent to: say = bold( italic( say)) EXECUTION (top-down) 1. bold wrapper starts 2. italic wrapper starts 3. say() runs, returns "hi" 4. italic wraps → <i>hi</i> 5. bold wraps → <b><i>hi</i></b>

Left: how the decorators wrap the function (innermost decorator runs first at definition time). Right: how a call flows through them (outermost decorator's before code runs first at call time).

⚠️
Order Matters — A Lot

Reversing to @italic @bold would produce <i><b>hello</b></i>. For security-sensitive stacks like @login_required @cache, always put authentication decorators outermost so they run first and can reject unauthorised calls before any cached data is even touched.


Section 12

Real-World Example — @login_required

Now let's build the classic authentication decorator. In web frameworks like Flask and Django, @login_required is the canonical use case for decorators — it guards view functions so only authenticated users can call them.

FIGURE 6 — @login_required Request Flow
USER clicks link call() @login_required wrapper checks: session["user"]? the security gate NO YES ✗ BLOCKED raise PermissionError view function never runs view_dashboard() original function runs business logic here 200 OK Every call passes through the decorator gate first view_dashboard() has zero auth code inside it — the decorator handles everything = caller = decorator = protected view = rejection path

The decorator is a gate. Unauthenticated calls are rejected before the view function ever executes. The view function stays pure business logic — no if user is None clutter.

Version 1 — Simple @login_required

from functools import wraps

# Fake session — in a real app this comes from Flask/Django
current_session = {"user": None}

def login_required(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        user = current_session.get("user")
        if user is None:
            raise PermissionError("Login required")
        return fn(*args, **kwargs)
    return wrapper

@login_required
def view_dashboard():
    return f"Welcome, {current_session['user']['name']}!"

# --- Try it without logging in ---
try:
    view_dashboard()
except PermissionError as e:
    print(f"Blocked: {e}")

# --- Log in and try again ---
current_session["user"] = {"name": "Mohit", "role": "admin"}
print(view_dashboard())
OUTPUT
Blocked: Login required Welcome, Mohit!

Version 2 — @requires_role("admin") with arguments

Now let's parameterise it. We want @requires_role("admin") to only allow users whose role matches. This uses the three-layer pattern from Section 07.

from functools import wraps

current_session = {"user": None}

def requires_role(*allowed_roles):        # Layer 1 — decorator args
    def decorator(fn):                    # Layer 2 — real decorator
        @wraps(fn)
        def wrapper(*args, **kwargs):      # Layer 3 — runtime
            user = current_session.get("user")
            if user is None:
                raise PermissionError("Login required")
            if user["role"] not in allowed_roles:
                raise PermissionError(
                    f"Role '{user['role']}' not in {allowed_roles}"
                )
            return fn(*args, **kwargs)
        return wrapper
    return decorator

@requires_role("admin")
def delete_all_users():
    return "All users deleted!"

@requires_role("admin", "editor")
def publish_article(title):
    return f"Published: {title}"

# --- Editor tries admin-only action ---
current_session["user"] = {"name": "Priya", "role": "editor"}
try:
    delete_all_users()
except PermissionError as e:
    print(f"Blocked: {e}")

print(publish_article("Python Decorators"))

# --- Admin can do both ---
current_session["user"] = {"name": "Mohit", "role": "admin"}
print(delete_all_users())
OUTPUT
Blocked: Role 'editor' not in ('admin',) Published: Python Decorators All users deleted!

Version 3 — Class-Based @LoginRequired with Audit Log

Class-based version — persistent state lets us log every attempt and expose statistics.

from functools import wraps
from datetime import datetime

current_session = {"user": None}

class LoginRequired:
    def __init__(self, fn):
        wraps(fn)(self)
        self.fn      = fn
        self.granted = 0
        self.denied  = 0
        self.log     = []

    def __call__(self, *args, **kwargs):
        user = current_session.get("user")
        ts   = datetime.now().strftime("%H:%M:%S")
        if user is None:
            self.denied += 1
            self.log.append((ts, "DENIED", self.fn.__name__))
            raise PermissionError("Login required")
        self.granted += 1
        self.log.append((ts, "GRANTED", user["name"]))
        return self.fn(*args, **kwargs)

    def stats(self):
        return {"granted": self.granted, "denied": self.denied}

@LoginRequired
def view_profile():
    return f"Profile of {current_session['user']['name']}"

# Simulate a mix of logged-in and logged-out attempts
for user in [None, {"name": "Mohit"}, None, {"name": "Aarav"}]:
    current_session["user"] = user
    try:
        view_profile()
    except PermissionError:
        pass

print(view_profile.stats())
for entry in view_profile.log:
    print(entry)
OUTPUT
{'granted': 2, 'denied': 2} ('14:22:11', 'DENIED', 'view_profile') ('14:22:11', 'GRANTED', 'Mohit') ('14:22:11', 'DENIED', 'view_profile') ('14:22:11', 'GRANTED', 'Aarav')
🏆
This Is Production-Grade Thinking

Everything here — auth check, role check, audit log, stats endpoint — comes from one decorator. The view_profile function itself has zero authentication code inside it. Business logic and security concerns stay cleanly separated. This is why Flask's @login_required and Django's @permission_required are decorators.


Section 13

Built-in Decorators You Should Know

Decorator Purpose Where It Lives
@staticmethod Method that doesn't need self or cls built-in
@classmethod Method that receives the class cls instead of self built-in
@property Access a method like an attribute (obj.name not obj.name()) built-in
@functools.wraps Preserve metadata of decorated function functools
@functools.lru_cache Memoise function results (with size limit) functools
@functools.cached_property Compute once, cache on instance functools
@dataclass Auto-generate __init__, __repr__, __eq__ dataclasses
@contextmanager Turn a generator into a with-compatible context manager contextlib
class User:
    def __init__(self, first, last):
        self._first = first
        self._last  = last

    @property                          # access as attribute
    def full_name(self):
        return f"{self._first} {self._last}"

    @classmethod                        # alternative constructor
    def from_string(cls, s):
        first, last = s.split(" ", 1)
        return cls(first, last)

    @staticmethod                       # utility, no self/cls
    def is_valid_name(s):
        return len(s.split()) >= 2

u = User.from_string("Mohit Kumar")
print(u.full_name)                    # -> Mohit Kumar (no parens!)
print(User.is_valid_name("Mohit Kumar"))
OUTPUT
Mohit Kumar True

Section 14

Common Pitfalls

Forgetting @wraps
Loses function name, docstring, and signature. Breaks Sphinx docs, Flask URL routing, and pytest test discovery.
always @wraps(fn)
Forgetting to return result
Wrapper calls fn() but doesn't return it, so decorated function silently returns None. Nightmare debugging.
return fn(*args, **kwargs)
Confusing @deco vs @deco()
Plain decorator uses @deco. Parameterised decorator uses @deco() — even with zero args, the parentheses are required.
the ( ) matter!
Wrong Stack Order
Put security decorators (@login_required) outermost. Put caching innermost so it doesn't cache unauthorised responses.
auth on top, cache below
Decorating Methods
A method's first arg is self. Since your wrapper uses *args, it captures self automatically. No special handling needed.
*args captures self
Testing Decorators
The original function is still accessible via decorated.__wrapped__ when you used @wraps. Handy for unit tests.
fn.__wrapped__

Section 15

Golden Rules

🌲 Python Decorators — Non-Negotiable Rules
1
Always use @functools.wraps(fn) on your wrapper. It preserves __name__, __doc__, and __wrapped__. Skipping it breaks tracebacks, docs, and framework introspection.
2
Always accept *args, **kwargs in the wrapper and forward them untouched. This makes your decorator work with any function signature — including methods where the first arg is self.
3
Always return the result of fn(*args, **kwargs). Forgetting this makes the decorated function silently return None — one of the hardest bugs to spot in code review.
4
Remember the three-layer pattern for parameterised decorators: outer takes decorator args, middle takes the function, inner wrapper takes runtime args. @repeat(3) is really repeat(3)(fn).
5
Stack decorators with intent. Security decorators (@login_required, @requires_role) go outermost. Caching decorators go innermost. This prevents unauthorised access to cached data.
6
Prefer function-based decorators for simple, stateless behaviour. Move to class-based when you need persistent state, extra methods (.reset(), .stats()), or clear OOP structure.
7
Don't reinvent the wheel — functools.lru_cache, functools.cached_property, dataclasses.dataclass, and contextlib.contextmanager already exist. Reach for them before writing your own.
8
Decorators should be transparent. The user of a decorated function should not need to know it's decorated. Never change its return type, don't swallow exceptions silently, and don't alter its documented behaviour.