Decorators in Python | Python Course | Dataplexa

Decorators in Python

A decorator is a function that wraps another function to extend or modify its behaviour — without changing the original function's code at all. You have already seen decorators: @classmethod, @staticmethod, @property, and @abstractmethod are all built-in decorators. Now you will learn how they work from the ground up, and how to build your own.

Decorators are used everywhere in professional Python — logging, authentication, caching, rate limiting, input validation, timing, and more. Understanding them unlocks a huge portion of real-world Python codebases.

Functions Are First-Class Objects

Before decorators make sense, you need one foundational idea: in Python, functions are objects. You can assign them to variables, pass them as arguments, return them from other functions, and store them in data structures — just like integers or strings.

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

# Assign to a variable
say_hello = greet
print(say_hello("Alice"))   # Hello, Alice!

# Pass as an argument
def shout(func, name):
    return func(name).upper()

print(shout(greet, "Bob"))   # HELLO, BOB!

# Return from a function — closure / factory pattern
def make_greeter(prefix):
    def inner(name):           # inner remembers prefix from enclosing scope
        return f"{prefix}, {name}!"
    return inner               # return the function itself, not its result

hi  = make_greeter("Hi")
hey = make_greeter("Hey")
print(hi("Carol"))    # Hi, Carol!
print(hey("Dave"))    # Hey, Dave!

# Functions can be stored in data structures too
ops = {"add": lambda a,b: a+b, "mul": lambda a,b: a*b}
print(ops["add"](3, 4))   # 7
Hello, Alice! HELLO, BOB! Hi, Carol! Hey, Dave! 7
  • A function defined inside another function is a closure — it remembers the enclosing scope's variables even after the outer function returns.
  • Returning a function (not calling it) is the key pattern that makes decorators possible.
  • The make_greeter pattern — a function that creates and returns a customised function — is called a factory function.

How a Decorator Works

A decorator is a function that takes a function as input, defines a wrapper inside, and returns the wrapper. The @ syntax is shorthand for applying it.

def shout_decorator(func):
    """Wraps func so its return value is uppercased."""
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)   # call the original
        return result.upper()            # modify the result
    return wrapper                        # return wrapper — not its result

# Manual application — exactly what @ does
def greet(name):
    return f"Hello, {name}!"

greet = shout_decorator(greet)
print(greet("Alice"))     # HELLO, ALICE!

# @ syntax — cleaner, identical result
@shout_decorator
def farewell(name):
    return f"Goodbye, {name}!"

print(farewell("Bob"))    # GOODBYE, BOB!

# Proof they're equivalent
@shout_decorator
def welcome(name): return f"Welcome, {name}!"

print(welcome.__name__)   # wrapper ← metadata lost (fixed next section)
HELLO, ALICE! GOODBYE, BOB! wrapper
  • @shout_decorator is exactly equivalent to func = shout_decorator(func) — one clean line vs two.
  • *args, **kwargs in the wrapper means it accepts whatever arguments the original function takes.
  • The original function is still called inside the wrapper — decorators wrap, they do not replace.

Preserving Metadata — functools.wraps

Wrapping a function loses its name, docstring, and metadata because the wrapper replaces it. functools.wraps fixes this with one line — always use it.

from functools import wraps

def my_decorator(func):
    @wraps(func)              # copies name, docstring, annotations onto wrapper
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def add(a, b):
    """Returns the sum of a and b."""
    return a + b

print(add(2, 3))        # 5
print(add.__name__)     # add      ← correct, not 'wrapper'
print(add.__doc__)      # Returns the sum of a and b.

# Without @wraps, introspection tools (help(), sphinx, IDEs) get confused
# With @wraps, the decorated function looks identical to the original
Calling add 5 add Returns the sum of a and b.

Practical Decorators — Timing and Logging

import time
from functools import wraps

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

def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[LOG] {func.__name__} | args={args} kwargs={kwargs}")
        result = func(*args, **kwargs)
        print(f"[LOG] {func.__name__} returned {result!r}")
        return result
    return wrapper

@timer
def slow_sum(n):
    """Sums 0 to n."""
    return sum(range(n))

@log_calls
def divide(a, b):
    return a / b

slow_sum(10_000_000)
divide(10, 4)
slow_sum took 0.3187s [LOG] divide | args=(10, 4) kwargs={} [LOG] divide returned 2.5

Practical Decorator — Retry with Backoff

A retry decorator automatically re-runs a failing function up to N times — a common real-world pattern for network calls and database queries.

import time
from functools import wraps

def retry(times=3, delay=0.5, exceptions=(Exception,)):
    """Retry a function up to `times` times on given exceptions."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_err = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_err = e
                    print(f"[RETRY] {func.__name__} attempt {attempt} failed: {e}")
                    if attempt < times:
                        time.sleep(delay)
            raise last_err
        return wrapper
    return decorator

# Simulating a flaky network call
_call_count = 0

@retry(times=3, delay=0, exceptions=(ConnectionError,))
def fetch_data():
    global _call_count
    _call_count += 1
    if _call_count < 3:
        raise ConnectionError("Network timeout")
    return {"status": "ok", "data": [1, 2, 3]}

result = fetch_data()
print("Got:", result)
[RETRY] fetch_data attempt 1 failed: Network timeout [RETRY] fetch_data attempt 2 failed: Network timeout Got: {'status': 'ok', 'data': [1, 2, 3]}

Decorator Factories — Decorators with Arguments

When a decorator needs configuration, add one outer layer: a factory function that accepts arguments and returns the decorator. This is the pattern behind @app.route("/path") and @pytest.mark.parametrize.

from functools import wraps

def repeat(times):
    """Factory — returns a decorator that runs func `times` times."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for i in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator   # return the decorator, not the wrapper

@repeat(times=3)
def say(message):
    print(message)

say("Hello!")
# @repeat(times=3) calls repeat(3) → returns decorator → applied to say
Hello! Hello! Hello!
  • Three-layer structure: factory (takes arguments) → decorator (takes the function) → wrapper (runs the function).
  • @repeat(times=3) first calls repeat(3) which returns decorator, then Python applies that decorator to say.

Stacking Multiple Decorators

Stack decorators by listing them one per line above the function. Python applies them bottom-up — the one closest to the function wraps first.

from functools import wraps

def bold(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return "**" + func(*args, **kwargs) + "**"
    return wrapper

def uppercase(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

def exclaim(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs) + "!!!"
    return wrapper

@bold           # 3rd — outermost
@uppercase      # 2nd
@exclaim        # 1st — innermost, applied first
def greet(name):
    return f"hello, {name}"

print(greet("Alice"))
# exclaim wraps greet:   "hello, alice!!!"
# uppercase wraps that:  "HELLO, ALICE!!!"
# bold wraps that:       "**HELLO, ALICE!!!**"
**HELLO, ALICE!!!**

Class-Based Decorators

Any callable works as a decorator — a class with __call__ is callable. Class-based decorators are ideal when the decorator needs to maintain state between calls.

from functools import wraps, update_wrapper

class CallCounter:
    """Counts calls and tracks the last result."""
    def __init__(self, func):
        update_wrapper(self, func)   # equivalent to @wraps on a class
        self.func       = func
        self.call_count = 0
        self.last_result = None

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        result = self.func(*args, **kwargs)
        self.last_result = result
        print(f"[{self.func.__name__}] call #{self.call_count} → {result!r}")
        return result

    def reset(self):
        self.call_count  = 0
        self.last_result = None

@CallCounter
def process(data):
    return data.strip().title()

process("  hello world  ")
process("  python decorators  ")
process("  dataplexa  ")

print(f"Total calls: {process.call_count}")
print(f"Last result: {process.last_result!r}")
[process] call #1 → 'Hello World' [process] call #2 → 'Python Decorators' [process] call #3 → 'Dataplexa' Total calls: 3 Last result: 'Dataplexa'
  • update_wrapper(self, func) is the class equivalent of @wraps — copies name, docstring, and annotations onto the instance.
  • Instance attributes like self.call_count persist across calls — something a plain function decorator cannot do without a mutable container.
  • The reset() method shows how class decorators can expose their own API.

Quick Reference Table

PatternStructureUse When
Basic decoratordef dec(func): def wrapper(...): ... return wrapperAdding fixed behaviour to any function
With argumentsThree layers — factory → decorator → wrapperConfigurable behaviour at decoration time
Stacked decorators@dec1 above @dec2 above functionCombining independent concerns
Class-based__init__ receives func; __call__ runs itDecorator needs to maintain state
@wraps(func)Applied to wrapper inside the decoratorAlways — preserves name, docstring, metadata

Practice

What does the @ symbol before a function definition do?



Why should you always use @wraps(func) inside a decorator?



In what order are stacked decorators applied?



What is a decorator factory?



Why would you use a class-based decorator over a function-based one?



What do you call a function defined inside another function that remembers the enclosing scope's variables?



Quick Quiz

What does a decorator function always return?





Why does the wrapper inside a decorator use *args, **kwargs?





Which module provides the wraps helper for decorators?





Given @bold above @uppercase above a function, which decorator's wrapper is the outermost?





Which built-in Python decorators have you already used in this course?





What is the class-based equivalent of @wraps(func) when using a class decorator?





NEXT UP
Context Managers in Python
Managing resources cleanly with the with statement — files, locks, database connections, and how to build your own context managers.