Python Lesson 36 – Decorators | Dataplexa

Decorators

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 in action: @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.

# Functions are objects — assign, pass, and return them

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 — factory pattern
def make_greeter(prefix):
    def inner(name):
        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!
Hello, Alice!
HELLO, BOB!
Hi, Carol!
Hey, Dave!
  • A function defined inside another function is called a closure — it remembers the enclosing scope's variables
  • 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 its argument, defines a wrapper function inside, and returns the wrapper. The @ syntax is clean shorthand for applying it.

Why it exists: decorators let you add behaviour to many functions at once without copying code into each one. The cross-cutting concern — logging, timing, auth — lives in one place.

Real-world use: every Flask and FastAPI route uses a decorator (@app.route(), @router.get()) to register a function as a URL handler without any boilerplate registration code.

# Building a decorator from scratch

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

# Apply manually — equivalent to what @ does
def greet(name):
    return f"Hello, {name}!"

greet = shout_decorator(greet)   # greet is now the wrapper
print(greet("Alice"))            # HELLO, ALICE!

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

print(farewell("Bob"))           # GOODBYE, BOB!
HELLO, ALICE!
GOODBYE, BOB!
  • @shout_decorator above a function is exactly equivalent to func = shout_decorator(func) after it
  • *args, **kwargs in the wrapper lets it accept any arguments the original function takes
  • The original function is still called inside the wrapper — decorators wrap, they do not replace

Preserving Metadata — functools.wraps

Without extra care, wrapping a function loses its name, docstring, and other metadata — because the wrapper replaces it. functools.wraps fixes this with one line and should always be used.

# functools.wraps — preserve the original function's metadata

from functools import wraps

def my_decorator(func):
    @wraps(func)                          # copies name, docstring, etc.
    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.
Calling add
5
add
Returns the sum of a and b.
  • Always use @wraps(func) inside your decorator — it is a one-line best practice that prevents subtle bugs
  • Without it, func.__name__ returns 'wrapper' and the docstring is lost — breaking introspection and documentation tools

1. Practical Example — Timing Decorator

A timing decorator measures how long any function takes to run. Apply it to any function with a single line.

# Timing decorator — measures execution time

import time
from functools import wraps

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

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

result = slow_sum(10_000_000)
print("Result:", result)
slow_sum took 0.3214s
Result: 49999995000000

2. Practical Example — Logging Decorator

A logging decorator records every call to a function — its name, arguments, and return value — without touching the function's own code.

# Logging decorator — records every call

from functools import wraps

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

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

divide(10, 2)
divide(9, 3)
[LOG] Calling divide | args=(10, 2) kwargs={}
[LOG] divide returned 5.0
[LOG] Calling divide | args=(9, 3) kwargs={}
[LOG] divide returned 3.0

Decorators with Arguments

Sometimes a decorator itself needs configuration — how many retries, which log level, what permission is required. A decorator with arguments adds one extra layer of nesting: a factory function that returns the decorator.

Real-world use: @retry(times=3) retries a failed network call up to three times before giving up — the number of retries is configured at decoration time.

# Decorator with arguments — three-layer structure

from functools import wraps

def repeat(times):
    """Decorator factory — returns a decorator that runs func `times` times."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ 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!")
Hello!
Hello!
Hello!
  • The three-layer structure: outer function takes decorator arguments → middle takes the function → inner wrapper runs it
  • @repeat(times=3) first calls repeat(3) which returns decorator, then Python applies that to say
  • This is the pattern behind Flask's @app.route("/path") and pytest's @pytest.mark.parametrize

Stacking Multiple Decorators

You can apply more than one decorator to the same function by stacking them. Python applies them bottom-up — the decorator closest to the function is applied first.

# Stacking decorators — applied bottom-up

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

@bold          # applied second — outermost wrapper
@uppercase     # applied first  — innermost wrapper
def greet(name):
    return f"hello, {name}"

print(greet("Alice"))
# uppercase wraps greet first  → "HELLO, ALICE"
# bold wraps that result       → "**HELLO, ALICE**"
**HELLO, ALICE**
  • Read stacked decorators bottom to top — the bottom one wraps the function first
  • Each decorator receives the already-wrapped version from the decorator below it
  • Keep stacks short — three or more decorators can make execution order hard to follow

Class-Based Decorators

A decorator does not have to be a function. Any callable works — and a class with __call__ is callable. Class-based decorators are useful when the decorator needs to maintain state between calls.

# Class-based decorator — maintains state between calls

from functools import wraps

class CallCounter:
    """Counts how many times the decorated function has been called."""
    def __init__(self, func):
        wraps(func)(self)    # copy metadata onto self
        self.func  = func
        self.count = 0

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

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

process("  hello  ")
process("  world  ")
print("Total calls:", process.count)
[process] call #1
[process] call #2
Total calls: 2
  • The class receives the function in __init__ and calls it in __call__
  • Instance attributes like self.count persist across calls — impossible with a plain function decorator
  • Class decorators work identically with @ syntax

Summary Table

Pattern Structure Use When
Basic decorator def dec(func): def wrapper(...): ... return wrapper Adding fixed behaviour to any function
With arguments Three nested functions — factory → decorator → wrapper Configurable behaviour at decoration time
Stacked decorators @dec1 above @dec2 above function Combining independent concerns
Class-based class Dec: def __init__(self, func): ... def __call__(self, ...): ... Decorator needs to maintain state
@wraps(func) Applied to the wrapper inside the decorator Always — preserves name, docstring, metadata

Practice Questions

Practice 1. What does the @ symbol before a function definition do?



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



Practice 3. In what order are stacked decorators applied?



Practice 4. What is a decorator factory?



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



Quiz

Quiz 1. What does a decorator function always return?






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






Quiz 3. Which module provides the wraps helper for decorators?






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






Quiz 5. What built-in Python decorators have you already used in this course?






Next up — Context Managers: managing resources cleanly with the with statement.