The Case for Python Decorators (from a guy who learned it the hard way)

When I first heard “Python decorators”, I thought it’s something for fancy people. Like… people who drink coffee from small cups and say “elegant”.

But then I met reality: I had 20 functions. All needed the same extra behavior. Logging. Timing. Permission check. Retry. Whatever.

So I did what every developer does first: copy-paste. And then I did what every developer does second: regret.

Decorators are Python’s way to say: “Stop copy-pasting. Wrap the function.”


What is a decorator, in normal human words?

Decorator is a thing you put on top of a function:

@something
def my_func():
    ...

This means basically:

“Before calling my_func, run it through something.”

More exact: my_func = something(my_func)

So decorator is just a function that takes a function and returns a new function.

Yes. Function sandwich.


Why you should care

1) Because your function stays clean

Without decorator, you put logging/timing everywhere inside the function.

Then your “business logic” becomes:

  • 20% real logic
  • 80% “print start”, “print end”, “try/except”, “measure time”

With decorator, your function body stays focused. The extra behavior is outside, like a jacket.

2) Because it makes patterns obvious

When I see this:

  • @timed
  • @retry
  • @admin_only

I understand the function immediately, without reading all the code. It’s like labels on food. “Spicy”. “Gluten”. “May destroy your weekend”.

3) Because it’s reusable and consistent

If you fix the decorator, you fix it for all functions using it.

No more “Oops I forgot to update this one file from 2019”.


Code example 1: timing a function (the classic)

This one is super useful and very honest. It tells you if your function is slow. Like your Wi-Fi.

import time
from functools import wraps

def timed(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        end = time.perf_counter()
        print(f"{fn.__name__} took {(end - start)*1000:.1f} ms")
        return result
    return wrapper

Usage:

@timed
def load_stuff():
    time.sleep(0.2)
    return "ok"

Now load_stuff() works the same, but prints timing. This is the main magic: no change inside the function.

(Also @wraps is important, otherwise Python thinks your function name is wrapper, and debugging becomes “surprise”.)


Code example 2: decorator with arguments (the “ok now I get it” step)

Sometimes you want config, like “retry 3 times”.

So you write decorator factory. Big word, simple idea: function that returns a decorator.

import time
from functools import wraps

def retry(times=3, delay=0.2):
    def decorator(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            last_error = None
            for _ in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last_error = e
                    time.sleep(delay)
            raise last_error
        return wrapper
    return decorator

Usage:

@retry(times=3, delay=0.1)
def call_api():
    ...

Yes it’s more nested. But now you have a clean pattern you can reuse everywhere.


The dark side (because nothing is perfect)

Decorators are great, but you can also abuse them.

Problem 1: too many decorators = “what is even happening”

If you do:

@a
@b
@c
@d
@e
def my_func():
    ...

Now your function call is like: a(b(c(d(e(fn))))) And debugging becomes a small adventure.

Rule I try to follow: if I need to scroll to see all decorators, maybe I need a different design.

Problem 2: hiding important behavior

If a decorator changes return values, swallows errors, or does “secret” stuff, future you will hate past you.

Try to keep decorators:

  • small
  • predictable
  • easy to explain in one sentence

Code example 3: decorators for “guard rails” (simple permission check)

This is very common in web apps / APIs.

from functools import wraps

def admin_only(fn):
    @wraps(fn)
    def wrapper(user, *args, **kwargs):
        if not user.is_admin:
            raise PermissionError("Admins only")
        return fn(user, *args, **kwargs)
    return wrapper

Usage:

@admin_only
def delete_user(user, user_id):
    ...

Now the rule is always enforced, and you didn’t copy-paste it to 10 places.


So… what is the case for decorators?

My case is simple:

Decorators are for cross-cutting concerns.

Stuff that is not the main job of the function, but surrounds it:

  • logging
  • timing
  • caching
  • retries
  • auth checks
  • metrics
  • tracing

They help you keep code clean, consistent, and easier to read.

And also, they make you feel a little like a wizard. Even if your English is not perfect. 🙂

If you want, tell me what kind of project you write (web, scripts, data, etc.) and I can suggest 3 decorators that are actually useful there.