DEV Community

Krishna
Krishna

Posted on

Python Decorators Finally Clicked When I Saw This One Line

Decorators confused me for an embarrassingly long time. Every tutorial jumped straight to @app.route or @lru_cache, and I nodded along without actually getting it.

Then someone showed me the one line that makes it click.

This:

@deco
def f():
    pass
Enter fullscreen mode Exit fullscreen mode

is equivalent to:

def f():
    pass

f = deco(f)
Enter fullscreen mode Exit fullscreen mode

That's it.

A decorator takes a function and returns a replacement. Usually, that replacement is another function that adds behavior around the original.

Everything else builds on that idea. Let me prove it by building one from scratch.

Step 1: Functions are values

In Python, a function is just another value. You can pass it into another function:

def add_exclamation(text):
    return text + "!"

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

print(apply_twice(add_exclamation, "hello"))
# hello!!
Enter fullscreen mode Exit fullscreen mode

We passed add_exclamation into apply_twice without calling it ourselves. Then apply_twice called it twice.

A decorator uses exactly this ability: it takes a function, builds a wrapper around it, and returns the wrapper.

Step 2: Build a decorator by hand

Say I want to count how many times a function gets called:

def count_calls(func):
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return func(*args, **kwargs)

    wrapper.calls = 0
    return wrapper

def parse_magnitude(text):
    return float(text)

parse_magnitude = count_calls(parse_magnitude)

print(parse_magnitude("5.5"))    # 5.5
print(parse_magnitude(" 4.7 "))  # 4.7
print(parse_magnitude.calls)     # 2
Enter fullscreen mode Exit fullscreen mode

What actually happened?

  1. count_calls received the original parse_magnitude function.
  2. It created a new function called wrapper.
  3. The wrapper increments a counter, calls the original function, and returns its result.
  4. count_calls returns the wrapper, which replaces the original function under the name parse_magnitude.

Three things to notice:

  • wrapper accepts *args, **kwargs and passes them through unchanged, so it can handle different function signatures.
  • State (the count) lives on the wrapper itself.
  • The wrapper returns the original's result. Forget that return and every call silently returns None.

One subtle detail: the counter increments before calling the original function. That means failed calls count too.

Step 3: The @ shorthand

That last assignment is so common Python gave it its own syntax:

@count_calls
def place_name(feature):
    return feature["properties"]["place"]
Enter fullscreen mode Exit fullscreen mode

This is the same thing as:

def place_name(feature):
    return feature["properties"]["place"]

place_name = count_calls(place_name)
Enter fullscreen mode Exit fullscreen mode

The name now refers to the wrapper.

There's no special execution engine hiding behind @. Python defines the function, passes it to the decorator, and assigns the returned object to the name.

Once I understood that, decorators stopped looking like magic.

Step 4: Don't lose the original function's identity

There's one catch.

Because the decorated name now points at the wrapper, inspecting the function can give you the wrapper's metadata instead of the original's.

Using our first decorator:

print(place_name.__name__)  # wrapper
Enter fullscreen mode Exit fullscreen mode

That gets confusing when you're debugging, logging, or inspecting functions.

The standard library gives us functools.wraps to fix this:

import functools

def count_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return func(*args, **kwargs)

    wrapper.calls = 0
    return wrapper

@count_calls
def place_name(feature):
    """Return the place name from a feature."""
    return feature["properties"]["place"]

print(place_name.__name__)  # place_name
print(place_name.__doc__)
# Return the place name from a feature.
Enter fullscreen mode Exit fullscreen mode

@functools.wraps(func) preserves useful metadata from the original function, including its name and docstring. It also keeps a reference to the wrapped function.

Make it a habit when writing wrappers.

And yes, @wraps is itself a decorator.

Step 5: A realistic one — logging with failures

Wrappers can run code before and after a function call, and they can see exceptions too.

Imagine you're processing incoming data and want to know which functions were called, what arguments they received, and whether they succeeded.

Instead of adding logging to every function, we can write one reusable decorator.

import functools

log = []

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        parts = [repr(a) for a in args]
        parts += [
            f"{k}={v!r}"
            for k, v in kwargs.items()
        ]

        call = f"{func.__name__}({', '.join(parts)})"

        try:
            result = func(*args, **kwargs)
        except Exception as err:
            log.append(
                f"{call} raised {type(err).__name__}"
            )
            raise

        log.append(f"{call} -> {result!r}")
        return result

    return wrapper
Enter fullscreen mode Exit fullscreen mode

Now let's apply it to a parser that converts depth measurements into kilometers:

@log_calls
def parse_depth(text, unit="km"):
    value = float(text)
    return value / 1000 if unit == "m" else value

parse_depth("22")
parse_depth("10", unit="m")

try:
    parse_depth("n/a")
except ValueError:
    pass

for line in log:
    print(line)
Enter fullscreen mode Exit fullscreen mode

Output:

parse_depth('22') -> 22.0
parse_depth('10', unit='m') -> 0.01
parse_depth('n/a') raised ValueError
Enter fullscreen mode Exit fullscreen mode

Notice what happened.

Our decorator:

  • Recorded the function name and arguments, including keyword arguments.
  • Logged the result when the call succeeded.
  • Captured the exception type when parsing failed.
  • Re-raised the exception so the caller still knew something went wrong.

The raise matters. Without it, our wrapper would hide errors and make failed operations appear successful.

And the actual parse_depth function knows nothing about logging.

That's the useful part of decorators: we added behavior without modifying the function's main job.

When to actually use them

Decorators are useful for behavior that multiple functions need in the same way, but that isn't their main responsibility.

Think logging, timing, caching, retrying, validating arguments, or checking permissions.

If you find yourself copy-pasting the same five lines into every function, that's a decorator worth considering.

And a few classic gotchas, so you don't learn them the hard way:

  • Forgetting return wrapper — the decorated name becomes None.
  • Calling the original without returning its result — callers receive None.
  • Using fixed parameters instead of *args, **kwargs — your wrapper only supports certain function signatures.
  • Swallowing exceptions — failures disappear when callers may need to handle them.
  • Skipping @functools.wraps — the decorated function exposes the wrapper's name and other metadata.

You don't need a decorator for everything. Sometimes a normal function is simpler.

But when the same supporting behavior belongs around many functions, decorators are a clean way to reuse it.

The one line to remember

If you forget everything else from this post, remember:

@decorator
def my_function():
    pass
Enter fullscreen mode Exit fullscreen mode

means:

def my_function():
    pass

my_function = decorator(my_function)
Enter fullscreen mode Exit fullscreen mode

A function goes in. A replacement comes out.

Once that clicked, the rest of decorators started making sense.

Go deeper, hands-on

Reading about decorators is one thing. Writing them yourself and seeing exactly what breaks is another.

I built a free interactive lesson on this. You can run Python directly in your browser and work through two coding challenges that test the concepts we covered.

Practice Python decorators interactively on Runtime

It's part of Runtime, a free, hands-on learning platform I'm building for Python and SQL.

The idea is simple: learn a concept, write real code, and test whether you actually understand it.

If decorators finally make sense, the next lesson covers decorators that take their own settings, like:

@retry_on(TimeoutError, attempts=3)
def fetch_data():
    ...
Enter fullscreen mode Exit fullscreen mode

That's where things get even more interesting.

What was the moment Python decorators finally clicked for you?

Top comments (0)