Python Decorators Explained with Real Examples
Decorators demystified: what the @ line actually does, why functools.wraps matters, and five decorators worth writing or knowing, from a timer and a configurable retry to functools.cache, a registry that collects plugins, and a permission check, plus the order in which stacked decorators run.
GloryTecks Team
Training & Placement Faculty
Last updated: 24 September 2026
A decorator is a function that takes a function and returns a replacement for it, usually one that does something extra before or after calling the original. That is the whole idea. The @ symbol is just a convenient way to apply one, and once you see what it expands to, decorators stop being magic and become one of the most useful tools for removing repetition from code.
What the @ line does
These two definitions are exactly equivalent (PEP 318):
import functools
import time
def shout(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@shout
def greet(name):
"""Return a greeting."""
return f"hello, {name}"
def greet_plain(name):
return f"hello, {name}"
greet_plain = shout(greet_plain) # what @shout does for you
print(greet("asha"), greet_plain("ravi"))
print(greet.__name__, greet.__doc__)HELLO, ASHA HELLO, RAVI
wrapper NoneDecoration happens once, when the function is defined: the name greet now refers to wrapper, which calls the original. The *args, **kwargs in the wrapper pass through whatever arguments the caller supplies, the technique explained in Python args and kwargs explained.
The last line shows a problem. The decorated function reports its name as wrapper and has lost its docstring, which confuses debuggers, logs, documentation tools and anything that inspects functions. functools.wraps fixes it by copying those attributes onto the wrapper (Python docs: functools). Every decorator you write should use it.
1. Timing a function
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
print(f"{func.__name__} took {time.perf_counter() - start:.1f}s")
return wrapper
@timed
def build_report(rows):
"""Pretend to build a report."""
time.sleep(0.1)
return f"{rows} rows"
print(build_report(500), "|", build_report.__name__, "|", build_report.__doc__)build_report took 0.1s
500 rows | build_report | Pretend to build a report.The try/finally makes the timing line appear even when the function raises. Thanks to wraps, the name and docstring survive.
2. Retrying, with arguments
A decorator that takes its own arguments, such as @retry(times=3), needs one more layer: the outer function receives the arguments and returns the actual decorator.
def retry(times=3, on=(ConnectionError,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except on as exc:
if attempt == times:
raise
print(f"{func.__name__}: attempt {attempt} failed ({exc})")
return wrapper
return decorator
calls = {"n": 0}
@retry(times=3)
def fetch_rates():
calls["n"] += 1
if calls["n"] < 3:
raise ConnectionError("gateway timeout")
return {"USD": 83.2}
print(fetch_rates())fetch_rates: attempt 1 failed (gateway timeout)
fetch_rates: attempt 2 failed (gateway timeout)
{'USD': 83.2}Read @retry(times=3) as two steps: retry(times=3) runs first and returns decorator, which is then applied to fetch_rates. The retry policy now lives in one place instead of being copied into every network call. The exchange rate is sample data. For when retries are and are not safe, see error handling in Python.
3. Caching results with functools.cache
The standard library ships ready-made decorators. functools.cache remembers the result for each set of arguments, so repeated calls return instantly:
@functools.cache
def shipping_zone(pincode: str) -> str:
time.sleep(0.05) # stands in for a slow lookup
return "south" if pincode.startswith("5") else "other"
for pin in ["500001", "411001", "500001", "500001"]:
shipping_zone(pin)
print(shipping_zone.cache_info())CacheInfo(hits=2, misses=2, maxsize=None, currsize=2)Two distinct pincodes, so two misses; the repeats were hits. Use it only on functions whose result depends solely on their arguments, and remember the cache grows without limit. functools.lru_cache(maxsize=1024) caps it, discarding the least recently used entries.
4. Registering plugins
A decorator does not have to wrap anything. This one records the function in a registry and returns it unchanged, a pattern frameworks use for routes, commands and event handlers:
EXPORTERS = {}
def exporter(fmt):
def register(func):
EXPORTERS[fmt] = func
return func
return register
@exporter("csv")
def to_csv(rows):
return "\n".join(",".join(map(str, r)) for r in rows)
@exporter("tsv")
def to_tsv(rows):
return "\n".join("\t".join(map(str, r)) for r in rows)
print(sorted(EXPORTERS), repr(EXPORTERS["csv"]([(1, "Asha"), (2, "Ravi")])))['csv', 'tsv'] '1,Asha\n2,Ravi'Adding a format now means writing one decorated function; the code that picks an exporter by name never changes. Web frameworks' @app.get("/path") works on this principle.
5. Checking permissions
class PermissionDenied(Exception):
pass
def requires_role(role):
def decorator(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if role not in user.get("roles", ()):
raise PermissionDenied(f"{user['name']} needs role {role!r} for {func.__name__}")
return func(user, *args, **kwargs)
return wrapper
return decorator
@requires_role("admin")
def delete_course(user, slug):
return f"{slug} deleted by {user['name']}"
print(delete_course({"name": "Meena", "roles": ["admin"]}, "old-batch"))
try:
delete_course({"name": "Kiran", "roles": ["editor"]}, "old-batch")
except PermissionDenied as exc:
print(exc)old-batch deleted by Meena
Kiran needs role 'admin' for delete_courseThe rule is visible at the top of every protected function, and cannot be forgotten inside its body.
Stacking decorators
Several decorators apply from the bottom up: the one nearest the def wraps the function first (PEP 318). So in
@timed
@requires_role("admin")
def publish(user):
return "published"
print(publish({"name": "Meena", "roles": ["admin"]}))publish took 0.0s
publishedthe permission check wraps publish, and the timer wraps the result, so the timing includes the check. Order matters whenever a decorator can stop the call: put authentication outside caching, for example, or a cached result could be served to someone who is not allowed to see it.
Decorating methods
The same decorators work on methods inside a class, because a method is just a function whose first argument is the instance. A wrapper written with *args, **kwargs passes self through untouched. A decorator that inspects specific arguments, like requires_role above, must account for self coming first when it is used on methods.
Testing decorated code
Test the decorator once, on a small dummy function, and test the business logic separately. functools.wraps also stores the original function as __wrapped__, so a test can call build_report.__wrapped__(500) to exercise the logic without the timing, caching or retries. That keeps tests fast and makes failures point at the right layer.
Mistakes that break decorators
- Forgetting to return the wrapper, or forgetting to return the original function's result inside it. The decorated function then returns
None. - Mixing up the two forms. A decorator that takes arguments must be used with parentheses,
@retry()or@retry(times=5); writing plain@retrypasses the function in astimesand fails later in a confusing way. - Leaving out functools.wraps, which hides the real name from logs and tracebacks.
- Hiding too much. A decorator that silently swallows exceptions or changes arguments makes the decorated function behave differently from what its body says.
Decorators you already use
@property, @classmethod and @staticmethod are decorators built into the language, and @dataclass is a decorator applied to a class (Python glossary: decorator). Framework decorators for routes, tests (@pytest.fixture) and tasks are the registry pattern above.
What is the difference between a decorator and a closure?
Can a decorator be a class?
Do decorators slow code down?
When should I not write a decorator?
Wrapping a whole function is not the only option; sometimes the extra behaviour belongs around a block of code instead. Decorators vs context managers explains when to choose each. Decorators and closures, alongside iterators, generators and context managers, form the advanced concepts module of the Python Programming course.
Sources
- PEP 318 – Decorators for Functions and Methods — Python Enhancement Proposals
- functools — Higher-order functions — Python documentation
- Glossary: decorator — Python documentation
GloryTecks Team
Training & Placement Faculty
The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.
Want to master Python?
Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.
Related articles
Learn Python from Scratch: A Complete 2026 Roadmap
How to learn Python from zero: install a current version (including the new Windows install manager), then work through seven stages, from values and loops to files, virtual environments, classes and tests, each ending with a small program you should be able to write on your own.
Python Interview Questions: Top 50 with Answers
Fifty conceptual Python interview questions with short, precise answers, grouped by topic: basics, types and mutability, data structures, functions and scope, OOP, iterators and decorators, errors and files, and internals, with runnable examples for the ones people most often get wrong.
Python for Data Science: The Essential Libraries
The Python data science stack explained by the job each library does: NumPy for arrays, pandas for tables, Matplotlib and seaborn for charts, SciPy and statsmodels for statistics, scikit-learn for models and Jupyter for exploration, with one runnable example that passes a real dataset through them.