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
is equivalent to:
def f():
pass
f = deco(f)
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!!
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
What actually happened?
-
count_callsreceived the originalparse_magnitudefunction. - It created a new function called
wrapper. - The wrapper increments a counter, calls the original function, and returns its result.
-
count_callsreturns the wrapper, which replaces the original function under the nameparse_magnitude.
Three things to notice:
-
wrapperaccepts*args, **kwargsand 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
returnand every call silently returnsNone.
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"]
This is the same thing as:
def place_name(feature):
return feature["properties"]["place"]
place_name = count_calls(place_name)
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
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.
@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
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)
Output:
parse_depth('22') -> 22.0
parse_depth('10', unit='m') -> 0.01
parse_depth('n/a') raised ValueError
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 becomesNone. -
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
means:
def my_function():
pass
my_function = decorator(my_function)
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():
...
That's where things get even more interesting.
What was the moment Python decorators finally clicked for you?
Top comments (0)