Skip to content
C Codeloom
Python

Python Decorators Explained

Learn how Python decorators work from first principles — functions as objects, wrapping callables, preserving metadata with functools.wraps, and writing decorators that take arguments.

·4 min read · By Codeloom
Beginner 10 min read

What you'll learn

  • Why functions are first-class objects in Python
  • How the @decorator syntax desugars
  • Writing a simple logging decorator
  • Preserving function metadata with functools.wraps
  • Creating decorators that accept arguments

Prerequisites

  • Basic Python familiarity

A decorator is a function that takes another function and returns a new function with extra behaviour wrapped around it. The @decorator syntax you see above function definitions is just syntactic sugar for a very ordinary pattern that becomes natural once you understand how Python treats functions.

Functions Are Just Objects

In Python, functions are first-class objects. You can assign them to variables, pass them into other functions, and return them from functions. This is the foundation that makes decorators possible.

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

say_hi = greet
print(say_hi("Ada"))  # Hello, Ada

Because greet is an object, we can wrap it. A decorator does exactly that: it accepts a function, defines a new function that calls the original with extra behaviour, and returns the new function.

Your First Decorator

Here is the simplest useful decorator — one that logs every call to a function.

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

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

add(2, 3)

The @log_calls line above add is equivalent to writing add = log_calls(add). Whenever you call add(2, 3), you are really calling wrapper(2, 3), which prints a message, calls the original add, prints the result, and returns it.

Why *args and **kwargs

The wrapper function uses *args and **kwargs so that it can decorate any function regardless of its signature. If you knew add always took exactly two arguments, you could write def wrapper(a, b) — but most decorators are written generically so they work everywhere.

Preserving Metadata with functools.wraps

There is a subtle problem with the decorator above. After decoration, the wrapped function’s __name__, __doc__, and other attributes belong to wrapper, not the original function.

print(add.__name__)  # wrapper — not what we want

The standard fix is functools.wraps, which copies metadata from the original function onto the wrapper.

import functools

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

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

print(add.__name__)  # add
print(add.__doc__)   # Add two numbers.

Always use functools.wraps. It costs nothing and prevents confusing debugging sessions later.

Decorators with Arguments

Sometimes you want the decorator itself to be configurable — for example, a repeat(n) decorator that runs a function several times. This requires one more layer.

import functools

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def greet(name):
    print(f"Hi {name}")

greet("Ada")

Read it from the inside out. repeat(times=3) returns decorator. decorator(greet) returns wrapper. So greet = repeat(times=3)(greet). The three levels of nesting feel awkward at first, but they follow the same rule every time.

A Practical Example: Timing

Decorators shine when you want to add cross-cutting behaviour without polluting your business logic. Timing is a classic example.

import time
import functools

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

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

slow_sum(1_000_000)

You can drop @timed onto any function in your code and immediately see how long it takes — no editing of the function body required.

Stacking Decorators

You can apply more than one decorator. They are applied bottom-up: the one closest to the function is applied first.

@log_calls
@timed
def work():
    return sum(range(100_000))

This is equivalent to work = log_calls(timed(work)). Reading the stack from bottom to top mirrors the call order.

When to Reach for a Decorator

Decorators are great for behaviour that is orthogonal to the function’s purpose: logging, timing, retrying, caching (see functools.lru_cache), authentication checks, and registering handlers. If you find yourself copy-pasting the same lines into the top and bottom of many functions, a decorator can clean that up.

Avoid hiding important business logic in a decorator. Anything that significantly changes how a function behaves should live where readers can see it. Used with care, decorators make code shorter and intent clearer.

Wrapping Up

Decorators look intimidating at first because of the nested functions, but the building blocks are simple: functions are objects, you can wrap them, and @ is just sugar. Once you have written two or three of your own, the pattern locks in for life.