Skip to content
C Codeloom
Python

Python Decorators: The Beginner-Friendly Guide

A practical introduction to Python decorators — functions as objects, wrapping, the @ syntax, functools.wraps, a logging and timing decorator, and decorators with arguments.

·9 min read · By Yash Kesharwani
Advanced 12 min read

What you'll learn

  • Why functions are first-class objects in Python
  • How a wrapper function can extend another function without modifying it
  • The @ syntax and what it actually does
  • Why functools.wraps belongs in every decorator
  • A practical logging and timing decorator
  • A first look at decorators that take arguments

Prerequisites

A decorator is a function that wraps another function to extend its behaviour without changing its code. Once you understand the few ingredients — functions as values, closures, and a tiny piece of syntactic sugar — decorators stop being magical and become one of Python’s most useful tools. This post builds the idea from the ground up, ending with the patterns you’ll actually use: timing, logging, and decorators that take arguments.

Functions are objects

In Python, a function is a value like any other. You can assign it to a variable, pass it as an argument, return it from another function, and store it in a list or dictionary:

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

f = greet               # assign to a name
print(f("Alice"))       # Hello, Alice!

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

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

This first-class treatment is the entire foundation decorators rest on. If a function can be passed in and returned, then a function can take a function, wrap it in additional behaviour, and return the wrapped version. That’s a decorator.

A wrapper, by hand

Suppose you have a function and you want to print something before and after every call. Without changing the original function, you can write a wrapper:

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

def with_logging(func):
    def wrapper(a, b):
        print(f"calling {func.__name__} with {a}, {b}")
        result = func(a, b)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

logged_add = with_logging(add)
print(logged_add(3, 4))
# calling add with 3, 4
# add returned 7
# 7

Three things to notice:

  • with_logging takes a function and returns a new function
  • The inner wrapper closes over func — it remembers which function it’s wrapping
  • Calling logged_add(3, 4) runs the wrapper, which calls the original and adds behaviour around it

You have just written a decorator by hand. The @ syntax we’re about to meet is purely a shortcut for this pattern.

The @ syntax

Writing logged_add = with_logging(add) is fine, but a little awkward when you’re decorating a function you’ve just defined. Python’s @ syntax lets you apply the decorator at the point of definition:

def with_logging(func):
    def wrapper(a, b):
        print(f"calling {func.__name__}")
        result = func(a, b)
        print(f"returned {result}")
        return result
    return wrapper

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

print(add(3, 4))
# calling add
# returned 7
# 7

@with_logging placed above def add is exactly equivalent to:

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

add = with_logging(add)

That’s it. There is no other magic. The @ is a one-line shortcut for “replace this name with the result of passing it through that decorator.”

Handling any signature with *args and **kwargs

The wrapper above only accepts (a, b). If you tried to decorate a function with three parameters, it would break. The general form of a wrapper accepts any signature and forwards it:

def with_logging(func):
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"returned {result!r}")
        return result
    return wrapper

@with_logging
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

print(greet("Alice"))
print(greet("Bob", greeting="Hi"))

*args catches every positional argument, **kwargs catches every keyword argument, and the matching unpacking inside the call forwards them to the wrapped function unchanged. This is the standard shape — write your wrappers this way unless you have a specific reason not to. See Default and Keyword Arguments for the underlying mechanics.

functools.wraps — the one detail not to skip

A subtle problem with the wrapper as written: it hides the original function’s name, docstring, and other metadata. After decoration, add.__name__ is "wrapper", not "add". This confuses tracebacks, debuggers, and documentation tools.

functools.wraps fixes this in one line:

from functools import wraps

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

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

print(add.__name__)    # add
print(add.__doc__)     # Return the sum of a and b.

@wraps(func) copies the wrapped function’s __name__, __doc__, and a few other attributes onto the wrapper. Treat it as required boilerplate — every decorator you write should use it.

Try it yourself. Write a decorator @uppercase_result that takes any function returning a string and returns the uppercase version of its result. Apply it to a function greet(name) that returns f"hello, {name}" and confirm greet("alice") returns "HELLO, ALICE". Don’t forget @wraps.

A real one: timing

Decorators come into their own when you want to apply the same cross-cutting behaviour to many functions. Timing is a perfect example:

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(i * i for i in range(n))

print(slow_sum(1_000_000))
# slow_sum took 0.0820s
# 333332833333500000

Notice what @timed did not do: it did not touch slow_sum’s logic. The function still does what it always did. Timing is a separate concern, expressed in a separate place, applied with a single line. That separation is the whole point.

A real one: logging with levels

A slightly more elaborate example — a decorator that logs entry and exit, including the arguments and the return value, using the standard logging module:

import logging
from functools import wraps

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger(__name__)

def logged(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        log.info("call %s args=%r kwargs=%r", func.__name__, args, kwargs)
        try:
            result = func(*args, **kwargs)
        except Exception:
            log.exception("%s raised", func.__name__)
            raise
        log.info("%s -> %r", func.__name__, result)
        return result
    return wrapper

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

divide(10, 2)
# INFO call divide args=(10, 2) kwargs={}
# INFO divide -> 5.0

If divide(10, 0) is called, the ZeroDivisionError is logged with a full traceback and then re-raised — the decorator doesn’t swallow it. See Error Handling for more on the re-raising pattern.

Stacking decorators

You can apply more than one decorator to the same function. They compose bottom-up — the one closest to def runs first:

@timed
@logged
def slow_divide(a, b):
    time.sleep(0.1)
    return a / b

That is equivalent to slow_divide = timed(logged(slow_divide)). When you call slow_divide(...), the timed wrapper runs, which calls the logged wrapper, which calls the real function. Reading the stack as “outer to inner from the top down” is the right mental model.

Decorators with arguments

Sometimes you want to configure a decorator. For example, “retry up to N times” or “log at level X.” This needs one more layer — a factory that takes the configuration and returns the actual decorator:

from functools import wraps

def retry(times):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_exc = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_exc = exc
                    print(f"attempt {attempt} failed: {exc}")
            raise last_exc
        return wrapper
    return decorator

@retry(times=3)
def fragile_call():
    import random
    if random.random() < 0.7:
        raise RuntimeError("bad luck")
    return "ok"

print(fragile_call())

Read the layers:

  • retry(times=3) is called first and returns decorator
  • decorator is then applied to fragile_call, returning wrapper
  • wrapper is what gets called when you invoke fragile_call()

It looks denser than it is. The shape is always: outer factory takes config, middle layer takes the function, inner layer is the wrapper. Once you’ve written one, you can copy the skeleton.

Try it yourself. Write a decorator @repeat(n) that runs the wrapped function n times and returns a list of the results. Apply it to a function roll() that returns a random number 1–6, and call roll() to get a list of n rolls.

Where decorators show up in real code

Once you know what to look for, decorators are everywhere:

  • @property — turn a method into a read-only attribute
  • @staticmethod, @classmethod — alter how methods are bound to a class
  • @functools.lru_cache — memoise expensive functions for free
  • Web frameworks@app.route("/users") registers a function as a URL handler
  • Testing@pytest.fixture, @pytest.mark.parametrize
  • Authentication@login_required, @admin_only

The same idea every time: take a function, return a new function that does extra work around it. The frameworks are doing exactly what you’ve just learned, with more elaborate wrappers.

When to write your own

A decorator is the right tool when:

  • You want to apply the same cross-cutting concern (logging, timing, caching, auth, retries) to many functions
  • The behaviour is genuinely separate from the function’s logic
  • You want to be able to add or remove the behaviour by adding or removing a single line

It is the wrong tool when:

  • The concern only applies to one function (just put the code inline)
  • The wrapper would need to know intimate details of the function’s signature or body
  • A plain helper function would do the job with less indirection

Like most powerful features, decorators are easy to overuse. The test is: does the decorated function read more clearly with the decorator than without?

Recap

You now know:

  • Functions in Python are objects — they can be passed, returned, and stored
  • A decorator is a function that takes a function and returns a new function
  • @decorator above def f is shorthand for f = decorator(f)
  • Wrappers should accept *args, **kwargs to handle any signature
  • @functools.wraps(func) preserves the wrapped function’s name and docstring
  • Decorators stack bottom-up; the closest to def runs first
  • A decorator that takes arguments is a factory: config → decorator → wrapper

Next steps

Decorators are a culmination of several ideas — functions as values, closures, and *args/**kwargs. From here, two natural directions: classes (where decorators like @property, @classmethod, and @staticmethod live), and the standard library’s functools module, which ships ready-made decorators like lru_cache you can drop into real code today.

→ See also: Classes and Objects in Python

Questions or feedback? Email codeloomdevv@gmail.com.