Blog

PYTHON · ADVANCED PATTERNS · BACKEND

Decorators & Wrappers —
The Magic Behind @.

You've seen @staticmethod, @property, @login_required. But what's actually happening when Python sees that @? This is the complete mental model — from first-class functions all the way to decorators with arguments and class-based decorators.

anuragdevon 23rd May 2020 12 min read Python Deep Dive
01

What Is a Decorator?

A decorator is a function that takes another function as input and returns a new function — usually one that does something extra before or after calling the original.

That's the whole idea. Everything else is implementation detail.

The @decorator syntax is just shorthand. These two blocks are identical:

PYTHON
# With @ syntax
@my_decorator
def greet():
    print("Hello!")

# Without @ syntax — exactly equivalent
def greet():
    print("Hello!")

greet = my_decorator(greet)

The @ sign is syntactic sugar. Python sees @my_decorator above a function definition and immediately executes greet = my_decorator(greet) after the function is defined. That's it. No magic.

To understand how my_decorator can receive a function and return another function, we need to understand first-class functions.

02

First-Class Functions

In Python, functions are objects. You can assign them to variables, store them in lists, pass them as arguments, and return them from other functions — the same way you'd handle a string or an integer.

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

# Assign to a variable — no () means no call, just reference
fn = say_hello
fn()  # prints "Hello!"

# Pass as an argument
def run_twice(func):
    func()
    func()

run_twice(say_hello)  # prints "Hello!" twice

# Return from a function
def get_greeter():
    def inner():
        print("Hi from inner!")
    return inner  # returning the function object, not calling it

greeter = get_greeter()
greeter()  # prints "Hi from inner!"

The last example is key: get_greeter defines a function inside itself and returns it. The inner function is called a closure — it has access to variables from its enclosing scope even after the outer function has returned.

This is the mechanism decorators are built on.

03

Your First Decorator

Let's build a decorator that prints a log message before and after any function runs. This pattern is called a "wrapper" — you're wrapping the original function in new behaviour.

PYTHON
def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}...")
        result = func(*args, **kwargs)
        print(f"{func.__name__} finished.")
        return result
    return wrapper

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

result = add(3, 5)
# Calling add...
# add finished.
print(result)  # 8

Walk through what's happening:

  1. log_calls receives the add function as func
  2. It defines a new function wrapper that calls func in the middle
  3. It returns wrappernot calling it, just returning the object
  4. Python replaces add with the returned wrapper
  5. From now on, calling add(3, 5) actually calls wrapper(3, 5), which calls the original add

The *args, **kwargs in wrapper's signature is important — it means "accept any arguments and pass them through." This makes the decorator generic: it works on functions with any signature.

// PATTERN

Every decorator follows this shape: outer function receives func, defines an inner wrapper(*args, **kwargs) that calls func(*args, **kwargs), outer function returns wrapper. Memorise this skeleton — everything else is just adding logic before/after the func() call.

04

functools.wraps

There's a subtle problem with the decorator above. When you decorate a function, Python replaces it with wrapper. That means the original function's metadata — its name, docstring, and module — is gone:

PYTHON
@log_calls
def add(a, b):
    """Adds two numbers."""
    return a + b

print(add.__name__)   # "wrapper" — not "add"!
print(add.__doc__)    # None — docstring is gone!

This breaks introspection tools, auto-generated docs, and debugging. The fix is functools.wraps — a decorator for your wrapper that copies the original function's metadata onto it:

PYTHON
import functools

def log_calls(func):
    @functools.wraps(func)  # ← add this always
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}...")
        result = func(*args, **kwargs)
        print(f"{func.__name__} finished.")
        return result
    return wrapper

@log_calls
def add(a, b):
    """Adds two numbers."""
    return a + b

print(add.__name__)  # "add" ✓
print(add.__doc__)   # "Adds two numbers." ✓

Rule of thumb: always put @functools.wraps(func) on your wrapper function. No exceptions. The cost is one line; the benefit is that your decorator is invisible to everything that inspects function metadata.

05

Decorators with Arguments

What if you want to configure a decorator? Something like @retry(times=3) or @cache(ttl=60)? This requires one more layer of nesting — a function that creates and returns a decorator.

PYTHON
import functools

def repeat(times):
    # This is the "decorator factory" — it receives the config
    def decorator(func):
        # This is the actual decorator — it receives the function
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # This is the wrapper — it does the actual work
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

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

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

Three layers, three jobs:

  1. Outermost function (repeat) — receives decorator arguments, returns the decorator
  2. Middle function (decorator) — receives the function to decorate, returns the wrapper
  3. Inner function (wrapper) — receives the call arguments, does the work

When you write @repeat(times=3), Python first calls repeat(times=3) to get a decorator, then applies that decorator to the function. The parentheses trigger the outer call; the @ triggers the middle call.

// MENTAL MODEL

Think of it as: repeat(3)(say_hi). The first call returns a decorator. The second call applies it. The @ syntax is doing both for you.

06

Real Patterns

Here are three decorators you'll write or reach for constantly in real Python projects.

@timer — measure execution time

PYTHON
import time, functools

def timer(func):
    @functools.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

@timer
def slow_query():
    time.sleep(0.5)
    return "done"

slow_query()  # slow_query took 0.5002s

@retry — retry on exception

PYTHON
import functools, time

def retry(times=3, delay=1.0, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    if attempt == times - 1:
                        raise
                    print(f"Attempt {attempt+1} failed: {e}. Retrying...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(times=3, delay=0.5, exceptions=(ConnectionError,))
def fetch_data(url):
    # might fail on flaky network
    return requests.get(url)

@cache — simple memoization

PYTHON
import functools

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

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

# fibonacci(40) without cache: ~40 seconds
# fibonacci(40) with cache: instant
// TIP

Python's standard library has functools.lru_cache and (since 3.9) functools.cache — both are production-grade memoization decorators. Use those in real code. Building your own is a great exercise for understanding how they work.

07

Class Decorators

A decorator doesn't have to be a function. Any callable works — and a class with a __call__ method is callable. Class decorators can be useful when your decorator needs to maintain state across multiple calls.

PYTHON
import functools

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"Call #{self.call_count}")
        return self.func(*args, **kwargs)

@CountCalls
def process(x):
    return x * 2

process(5)  # Call #1
process(5)  # Call #2
process(5)  # Call #3
print(process.call_count)  # 3

When @CountCalls is applied, Python calls CountCalls(process) — which hits __init__ and stores the function. Every subsequent call to process(x) hits __call__. The instance's call_count persists across all calls — something you'd need a closure variable to achieve with a function decorator.

Use class decorators when your decorator has meaningful state. Use function decorators for everything else — they're lighter and simpler.

08

Stacking & Cheatsheet

You can stack decorators. They apply bottom-up — the decorator closest to the function definition applies first.

PYTHON
@timer        # applied second (outermost)
@log_calls   # applied first (innermost)
def my_func():
    ...

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

Calling my_func() runs through timer first, then log_calls, then the original function, then back out.

Quick reference for everything we covered:

Basic decoratorouter(func) → inner wrapper(*args, **kwargs)
Always use@functools.wraps(func) on wrapper
Decorator with argsAdd one more outer layer (factory → decorator → wrapper)
Class decorator__init__ receives func, __call__ is the wrapper
Stacking orderBottom decorator applies first, top applies last
Built-in decorators@property, @staticmethod, @classmethod, @functools.lru_cache
END

Dig deeper into Python?

I write about Python internals, Django patterns, and backend engineering. More on Dev.to and GitHub.

Written in May 2020 — part of my Python deep-dive series.

Contact