Picture three unrelated functions in a small Python project: one that saves a user to a database, one that sends an email, one that generates a report. None of them do the same job. All three, for entirely separate reasons, need to log how long they took to run — the database save because it’s occasionally slow, the email because it depends on an external service, the report because someone asked for performance numbers last week.
import time
def save_user(user):
start = time.time()
# ... actual save logic ...
print(f"save_user took {time.time() - start:.2f}s")
def send_email(to, subject, body):
start = time.time()
# ... actual email logic ...
print(f"send_email took {time.time() - start:.2f}s")
def generate_report(data):
start = time.time()
# ... actual report logic ...
print(f"generate_report took {time.time() - start:.2f}s")The Problem This Duplication Actually Creates
Three lines of timing logic, copied identically into three functions that have nothing else in common. This isn’t just repetitive typing — it’s a genuine maintenance liability. If the timing format needs to change (say, logging to a file instead of printing), that change now has to happen in three separate places, and a fourth function added next month means copying the same three lines a fourth time. The actual behavior being added — “time this and report it” — is identical every single time; only the function being timed changes.
What a Decorator Actually Solves
A decorator lets you write that timing behavior exactly once, then apply it to as many functions as you want, without touching those functions’ own logic at all:
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.time() - start:.2f}s")
return result
return wrapper
@timer
def save_user(user):
# ... actual save logic, nothing about timing in here at all ...
pass
@timer
def send_email(to, subject, body):
# ... actual email logic ...
passThe timing logic now lives in exactly one place — timer — and every function that needs it just gets a single line, @timer, added above its definition. This is the actual problem decorators exist to solve: applying the same wrapping behavior to multiple functions without duplicating that behavior’s code inside each one.
Before and After, Side by Side
| Without a decorator | With a decorator |
|---|---|
| Timing logic copied into every function | Timing logic written once, in timer |
| Changing the timing format means editing every function | Changing the timing format means editing timer once |
| Easy to forget adding timing to a new function | Adding timing to a new function is one line: @timer |
| Timing code mixed directly into business logic | Business logic stays completely separate from timing |
What @timer Actually Does, Mechanically
According to Python’s official glossary entry on decorators, a decorator is a function returning another function, usually applied as a function transformation using the @wrapper syntax — and that phrase, “function transformation,” is the precise mechanism worth understanding directly. @timer above a function definition is exactly equivalent to writing this instead:
def save_user(user):
pass
save_user = timer(save_user)timer(save_user) is called immediately, and whatever it returns — the inner wrapper function — replaces save_user entirely. From that point forward, calling save_user(some_user) actually calls wrapper, which starts the timer, calls the original function (still accessible inside wrapper as func), prints the elapsed time, and returns whatever the original function returned. The @ syntax is purely convenient shorthand for this reassignment pattern — nothing about decorators requires the @ symbol specifically, it’s just considerably easier to read than the manual reassignment version.
Why *args and **kwargs Aren’t Optional Here
The wrapper function above accepts *args, **kwargs specifically so timer can wrap literally any function, regardless of what arguments that function actually takes — save_user takes one argument, send_email takes three, and wrapper doesn’t need to know either function’s specific signature in advance because it just forwards whatever it received directly to func. Writing wrapper without this — say, hardcoding a single parameter — would make timer only work on functions with that exact same signature, defeating the entire point of writing the timing logic once to reuse everywhere.
The Gotcha functools.wraps Actually Fixes
def timer_broken(func):
def wrapper(*args, **kwargs):
# ... timing logic ...
return func(*args, **kwargs)
return wrapper
@timer_broken
def save_user(user):
"""Saves a user to the database."""
pass
print(save_user.__name__) # "wrapper" — not "save_user"!
print(save_user.__doc__) # None — the docstring is gone!Without @wraps(func) applied inside the decorator itself, the decorated function silently loses its own identity — its name, its docstring, and other metadata all get replaced by the inner wrapper function’s own, since wrapper is genuinely what save_user now refers to. This is a real, common source of confusion once decorated functions show up in stack traces, auto-generated documentation, or debugging tools — everything reports “wrapper” instead of the actual function name, with no indication anything is wrong unless you already know to check for it. @wraps(func), imported from Python’s built-in functools module, copies the original function’s name, docstring, and other metadata onto wrapper automatically, which is precisely why the working example earlier in this guide includes it and the broken one above doesn’t.
Decorators With Their Own Arguments
def repeat(times):
def decorator(func):
@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"Hello, {name}!")
greet("Alex") # prints "Hello, Alex!" three timesThis adds one extra layer specifically because @repeat(times=3) needs to receive an argument before it even knows which function it’s decorating — repeat(times=3) runs first and returns decorator, which is then the actual function applied to greet, exactly like timer was applied directly in the earlier example. Once the two-layer version clicks, the extra nesting stops feeling arbitrary — it’s the same mechanism as before, with one additional step to handle the decorator’s own arguments before it gets to wrapping the target function.
A Second Real Use Case: Caching Expensive Results
def cache(func):
stored_results = {}
@wraps(func)
def wrapper(*args):
if args in stored_results:
print(f"Returning cached result for {args}")
return stored_results[args]
result = func(*args)
stored_results[args] = result
return result
return wrapper
@cache
def slow_calculation(n):
# imagine something genuinely expensive here
total = 0
for i in range(n):
total += i
return total
slow_calculation(1000000) # runs the full calculation
slow_calculation(1000000) # returns instantly from stored_resultsThis follows the identical pattern as timer, just storing something instead of measuring something — stored_results lives inside cache‘s own scope, genuinely persisting between calls because Python keeps that enclosing scope alive as long as wrapper itself still exists, a detail worth connecting back to how dictionaries work as the actual storage mechanism here. Worth knowing this exact pattern already exists built into Python’s standard library as functools.lru_cache, so writing a caching decorator by hand like this is genuinely useful for understanding the mechanism, but rarely necessary in real production code where the built-in version already handles the same job more robustly.
Decorators You’ve Already Used, If You’ve Written Classes
If you’ve worked through our Python classes guide, you’ve likely already used decorators without necessarily naming them as such — @staticmethod and @property are both decorators, applying the exact mechanism covered throughout this guide to methods rather than standalone functions. @property specifically transforms a method so it can be accessed like a plain attribute instead of being called with parentheses — the same “wrap and replace” behavior as @timer, just built into Python itself rather than something you wrote by hand.
When a Decorator Isn’t Actually the Right Tool
None of this means every repeated piece of logic should become a decorator. If the shared behavior needs to run somewhere in the middle of a function — not cleanly wrapping the entire call — a decorator forces an awkward fit that a plain helper function, called explicitly where it’s actually needed, handles more directly. Decorators genuinely shine specifically for behavior that wraps an entire function call from the outside: timing, logging, access checks, caching, retry logic. For anything else, reach for the same plain functions covered in our Python functions guide instead — a decorator used where it doesn’t naturally fit is harder to read than the duplication it was meant to remove.
Confirming This Is a Real, Documented Pattern
According to PEP 318, the original proposal that introduced decorator syntax into Python, the motivation was explicitly to make function and method transformations more readable and less error-prone than the pre-existing pattern of manual reassignment this guide demonstrated earlier — confirming that decorators weren’t invented for some exotic advanced use case, but specifically to make an already-common pattern (wrapping a function to add shared behavior) easier to write and read correctly.
Stacking More Than One Decorator
@timer
@cache
def slow_calculation(n):
total = 0
for i in range(n):
total += i
return totalDecorators stack in the order they’re written, applied from the bottom up — cache wraps slow_calculation first, then timer wraps the already-cached version, meaning a cached call still gets timed, but that timing now measures the (very fast) cache lookup rather than the original expensive calculation. This ordering genuinely matters and is a common source of subtle bugs: reversing the two would time the cache-check overhead differently, or in a logging-plus-authentication scenario, could mean logging happens before an access check ever runs rather than after it passes. Once each individual decorator’s behavior is understood on its own, reasoning about a stack of them becomes a matter of tracing that same bottom-up order rather than learning a new, separate rule for combinations.
The Actual Idea Worth Keeping
A decorator is a function that takes a function and returns a replacement for it — nothing more exotic than that underneath the @ syntax. Once that mechanism is genuinely understood, rather than the syntax memorized in isolation, decorators stop looking like special Python magic and start looking like exactly what they are: a specific, well-motivated answer to the duplication problem this guide opened with, and one directly recognizable the next time several unrelated functions need the identical wrapping behavior applied around them.
Related: Python Classes and OOP Explained →

Alex Carter is the editorial name behind Vandutz Academy, a programming blog for beginners. Alex reviews and tests the examples and explanations published on the site, with a focus on making Python, JavaScript, web development, and developer tools easier to understand.