March 26, 20265 min read

Python Decorators Demystified

Build decorators from scratch: closures, @functools.wraps, decorators with arguments, and real-world patterns for timing, logging, caching, and retry logic.

python decorators advanced functional-programming
Ad 336x280

Decorators look like magic the first time you see them. The @ syntax, the nested functions, the fact that they somehow modify your function's behavior without changing its code. But there's no magic -- just functions wrapping functions. Once you see the mechanics, the whole thing clicks.

Prerequisites: Functions Are Objects

In Python, functions are first-class objects. You can assign them to variables, pass them as arguments, and return them from other functions:

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

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

def call_twice(func, arg):
return func(arg) + " " + func(arg)

print(call_twice(greet, "Bob")) # Hello, Bob Hello, Bob

This isn't a trick -- it's just how Python works. Functions are objects with a __call__ method.

Closures: The Foundation

A closure is a function that remembers variables from its enclosing scope:

def make_multiplier(factor):
    def multiply(x):
        return x * factor   # 'factor' is captured from the outer scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5)) # 10
print(triple(5)) # 15

make_multiplier returns a new function each time, and each returned function "remembers" its own factor. This is the mechanism decorators are built on.

Building a Decorator Step by Step

A decorator is a function that takes a function and returns a modified version of it. Here's the simplest one:

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

You can use it manually:

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

add = log_calls(add) # replace 'add' with the wrapped version
add(3, 4)
# Calling add
# add returned 7

The @ syntax is just shorthand for exactly this:

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

# This is identical to: add = log_calls(add)

That's it. That's all @decorator does. It passes your function through another function and replaces it with the result.

Why You Need @functools.wraps

There's a problem with our decorator. The wrapped function loses its identity:

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

print(add.__name__) # "wrapper" -- wrong!
print(add.__doc__) # None -- our docstring is gone!

The fix is @functools.wraps, which copies the original function's metadata onto the wrapper:

import functools

def log_calls(func):
@functools.wraps(func) # <-- preserves __name__, __doc__, etc.
def wrapper(args, *kwargs):
print(f"Calling {func.__name__}")
result = func(args, *kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper

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

print(add.__name__) # "add" -- correct
print(add.__doc__) # "Add two numbers." -- preserved

Always use @functools.wraps. Skipping it breaks help(), documentation generators, and anything else that inspects function metadata. It takes five seconds to add and prevents real debugging headaches.

Decorators with Arguments

What if you want your decorator to be configurable? You need another layer of nesting:

import functools

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

@retry(max_attempts=5, exceptions=(ConnectionError, TimeoutError))
def fetch_data(url):
# might fail due to network issues
...

Three levels: the outermost function takes the decorator's arguments, the middle function takes the decorated function, and the inner function is the actual wrapper. It looks overwhelming at first, but the pattern is always the same.

Real-World Decorator Patterns

Timing:
import functools
import time

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_function():
time.sleep(1)
return "done"

Simple authentication check:
import functools

def require_auth(func):
@functools.wraps(func)
def wrapper(request, args, *kwargs):
if not request.user.is_authenticated:
raise PermissionError("Login required")
return func(request, args, *kwargs)
return wrapper

@require_auth
def delete_account(request):
...

Memoization (caching):

You could write your own, but Python already has one:

from functools import lru_cache

@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)

fibonacci(100) # instant, thanks to caching

@lru_cache stores results for recent inputs and returns cached values instead of recomputing. It turns the naive recursive Fibonacci from exponential to linear.

Built-in Decorators You Already Know

Several Python features are implemented as decorators:

  • @property -- turns a method into a computed attribute
  • @staticmethod -- method that doesn't receive self or cls
  • @classmethod -- method that receives the class as first argument
  • @functools.lru_cache -- memoization
  • @dataclasses.dataclass -- auto-generates __init__, __repr__, __eq__
  • @abc.abstractmethod -- marks methods that subclasses must implement
from dataclasses import dataclass

@dataclass
class Point:
x: float
y: float
# __init__, __repr__, __eq__ all generated automatically

Stacking Decorators

You can apply multiple decorators. They apply bottom-up:

@timer
@require_auth
def important_operation(request):
    ...

# Equivalent to:
# important_operation = timer(require_auth(important_operation))

The function first gets wrapped by require_auth, then that result gets wrapped by timer. So when called, timer's wrapper runs first (starts the clock), then require_auth's wrapper checks authentication, then the actual function executes.

Decorators are one of those features that separate "I can write Python" from "I understand Python." The pattern comes up everywhere -- web frameworks, testing libraries, CLI tools. Once you're comfortable writing your own, you can read framework source code with much more confidence.

If you want to practice building decorators from scratch -- starting with simple wrappers and working up to parameterized decorators -- CodeUp has Python exercises that walk through these patterns interactively.

Ad 728x90