🐍

Python Decorators Explained With Real-World Examples

Python3 min readUpdated October 2026

Understand Python decorators by building a timer, a logger, a retry wrapper, and a cache, with the functools.wraps detail most tutorials skip.

Decorators have a reputation for being confusing. The idea is actually simple: a decorator is a function that takes another function and returns a changed version of it. That's all.

The @ symbol is just a shortcut. Once you see what it's shortcutting, the mystery goes away.

Functions are values

In Python, you can pass a function around like any other value. You can store it in a variable, hand it to another function, or return it.

def shout(text):
    return text.upper() + "!"

def run_twice(func, value):
    return func(func(value))

print(run_twice(shout, "hey"))
HEY!!

That's the foundation. A decorator is a function that takes a function, wraps some extra behavior around it, and returns the wrapper.

Your first decorator

Let's write one that logs every call.

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

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

add = log_calls(add)
add(2, 3)
Calling add with (2, 3) {}
add returned 5

The line add = log_calls(add) replaces add with the wrapped version. Writing that by hand every time is tedious, so Python gives you the @ shortcut:

@log_calls
def multiply(a, b):
    return a * b

multiply(4, 5)
Calling multiply with (4, 5) {}
multiply returned 20

@log_calls above a function means exactly the same as multiply = log_calls(multiply) underneath it.

The *args, **kwargs in the wrapper is what lets it accept whatever arguments the original function takes. If you need a refresher, see the args and kwargs tutorial.

Don't forget functools.wraps

There's a catch. After decoration, the function's name and docstring belong to the wrapper, not the original:

print(multiply.__name__)
wrapper

That breaks debugging tools, help text, and anything that inspects function names. The fix is one line from the standard library:

from functools import wraps

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

@log_calls
def greet(name):
    """Say hello."""
    return f"Hello, {name}"

print(greet.__name__)
print(greet.__doc__)
print(greet("Mia"))
greet
Say hello.
Calling greet
Hello, Mia

Make @wraps(func) a habit. Every decorator you write should have it.

Example: a timer

A timer is the classic first real decorator. It tells you how long a function takes.

import time
from functools import wraps

def timed(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

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

total = slow_sum(2_000_000)
print(total)
slow_sum took 0.0312s
1999999000000

Your time will differ. The point is that you can add timing to any function by adding one line above it, without touching its body.

Example: retrying something that fails

Network calls fail. A retry decorator keeps that logic out of your main code.

import time
from functools import wraps

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

calls = {"n": 0}

@retry(times=3)
def flaky():
    calls["n"] += 1
    if calls["n"] < 3:
        raise ConnectionError("server busy")
    return "success"

print(flaky())
Attempt 1 failed: server busy
Attempt 2 failed: server busy
success

Notice there are three layers here. retry(times=3) runs first and returns a decorator. That decorator receives the function and returns the wrapper. When your decorator takes its own arguments, you need that extra outer layer.

Example: caching results

Some functions are slow and get called with the same inputs over and over. Python already has a built-in decorator for this:

from functools import lru_cache

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

print(fib(80))
23416728348467685

Without the cache, fib(80) would take longer than you'd want to wait. With it, every value is calculated once. Only cache functions that always return the same output for the same input, and be careful with large inputs, since the cache lives in memory.

Stacking decorators

You can apply more than one. They're applied from the bottom up, so the one closest to the function wraps it first.

@timed
@log_calls
def square(x):
    return x * x

square(9)
Calling square
square took 0.0000s

Here log_calls wraps square, and then timed wraps that result. The order matters when decorators change arguments or return values.

When decorators are the right tool

Decorators work best for behavior that applies to many functions and isn't the function's main job: logging, timing, caching, retries, permission checks, and input validation. Web frameworks like Flask use them to register routes.

If you only need the extra behavior once, a decorator is overkill. Just write the code in the function.

Key takeaways

  • A decorator takes a function and returns a new one. @name is shorthand for func = name(func).
  • Use *args, **kwargs inside the wrapper so it works with any signature.
  • Always add @wraps(func) to keep the original name and docstring.
  • If the decorator needs settings, add one more layer of function around it.

Try it yourself

Reading helps, but typing the code is what makes it stick. Open the editor, paste any example from this article, and change it until it breaks.

Open the Python editor

Related Articles