PYTHON DECORATORS - PRACTICAL GUIDE
Python Decorators
A detailed, practical guide to wrapping behavior around functions and classes -
without changing their core job.
What you will learn
• What a decorator is and the idea behind the @ syntax.
• How closures make decorators possible.
• Why wrappers use *args and **kwargs.
• Real patterns: logging, timing, authorization, caching, retries, validation, and class decorators.
• How to write decorators that are safe, readable, testable, and debuggable.
Core idea: a decorator takes a callable, adds or changes behavior, and returns another callable. It lets you keep
cross-cutting concerns separate from business logic.
A tiny first example
def announce(func):
def wrapper():
print("Before the function")
func()
print("After the function")
return wrapper
@announce
def greet():
print("Hello!")
greet()
# Before the function
# Hello!
# After the function
The line @announce is shorthand for greet = announce(greet). The original function is passed into the decorator
once, at definition time; the returned wrapper is called later.
Page 1
PYTHON DECORATORS - PRACTICAL GUIDE
1. The foundation: functions are objects
Decorators work because Python treats functions as first-class objects. You can assign a function to a variable,
pass it to another function, return it from a function, and store it in a collection.
def say_hi(name):
return f"Hi, {name}!"
message = say_hi
print(message("Ava"))
def apply_twice(func, value):
return func(func(value))
print(apply_twice(lambda n: n + 3, 4)) # 10
A decorator usually has two nested levels:
Part Role
decorator function Receives the original function when Python defines it.
wrapper function Runs when the decorated function is called; it can act before and after the original.
return wrapper Replaces the original name with the wrapper.
Closures are the important detail. The inner wrapper remembers func even after the outer decorator has
finished. That remembered reference is what allows the wrapper to call the original later.
Mental model
@decorate
def work(x):
return x * 2
# Python behaves approximately as if it had written:
def work(x):
return x * 2
work = decorate(work)
Page 2
PYTHON DECORATORS - PRACTICAL GUIDE
2. A correct general-purpose decorator
A decorator should normally accept and return whatever its target function accepts and returns. That is why *args
and **kwargs are central.
from functools import wraps
def trace(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Returned {result!r}")
return result
return wrapper
@trace
def add(a, b=0):
return a + b
add(4, b=6)
What each piece means
Expression Why it exists
*args Collects any positional arguments into a tuple. Example: add(4, 6) gives args = (4, 6).
**kwargs Collects any keyword arguments into a dict. Example: add(a=4, b=6) gives kwargs = {'a': 4,
'b': 6}.
func(*args, **kwargs) Unpacks and forwards those arguments exactly as the caller supplied them.
return result Preserves the original function's return value. Omitting it is a common bug.
@wraps(func) Copies useful metadata such as the name, documentation, annotations, and __wrapped__
reference.
Without *args and **kwargs, your wrapper only supports the exact signature you wrote. A wrapper with no
parameters cannot decorate add(4, 6); one with a single parameter breaks as soon as the target needs two
arguments or keywords.
Forwarding variations
# Most reusable choice: support positional and keyword arguments.
def flexible(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Page 3
PYTHON DECORATORS - PRACTICAL GUIDE
3. Logging decorators: a real-world pattern
Logging is a classic decorator use case because it is a cross-cutting concern: many functions need observability,
but their actual business logic should not be crowded with repeated log statements.
import logging
from functools import wraps
[Link](level=[Link],
format="%(levelname)s %(message)s")
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
[Link]("Calling %s with args=%r kwargs=%r",
func.__qualname__, args, kwargs)
try:
result = func(*args, **kwargs)
except Exception:
[Link]("%s failed", func.__qualname__)
raise
[Link]("%s returned %r", func.__qualname__, result)
return result
return wrapper
@log_calls
def withdraw(balance, amount):
if amount > balance:
raise ValueError("insufficient funds")
return balance - amount
withdraw(100, 25)
How this works
• At decoration time, log_calls captures withdraw and returns wrapper.
• At call time, wrapper logs inputs, calls the captured function, logs the return value, and gives that value back.
• If the original function raises, [Link] records a traceback and raise re-raises the same exception. The
decorator observes the failure; it does not silently hide it.
• Use %r or structured logging carefully: logs can expose passwords, tokens, personal data, or large payloads.
Redact sensitive fields before logging.
A safer production shape
def log_public_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
safe_kwargs = {k: "***" if k in {"password", "token"} else v
for k, v in [Link]()}
[Link]("%s args=%r kwargs=%r",
func.__qualname__, args, safe_kwargs)
return func(*args, **kwargs)
return wrapper
Page 4
PYTHON DECORATORS - PRACTICAL GUIDE
4. Decorators with configuration
Sometimes the decorator itself needs settings: a log level, a retry count, a role, or a cache size. In that case, write a
decorator factory - a function that accepts configuration and returns the actual decorator.
from functools import wraps
def log_at(level):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
[Link](level, "Starting %s", func.__qualname__)
return func(*args, **kwargs)
return wrapper
return decorator
@log_at([Link])
def parse_config(path):
return {"path": path}
# Equivalent: parse_config = log_at([Link])(parse_config)
There are now three layers: log_at(level) stores the configuration; decorator(func) receives the function;
wrapper(*args, **kwargs) handles each call.
Timing example
from time import perf_counter
def timed(label=None):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
started = perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = perf_counter() - started
name = label or func.__qualname__
[Link]("%s took %.4f seconds", name, elapsed)
return wrapper
return decorator
@timed("report generation")
def build_report(rows):
return [[Link]() for row in rows]
The finally block ensures elapsed time is logged even when the function fails.
Page 5
PYTHON DECORATORS - PRACTICAL GUIDE
5. Other useful concepts and examples
Authorization / preconditions
def requires_role(required_role):
def decorator(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if required_role not in [Link]:
raise PermissionError("not allowed")
return func(user, *args, **kwargs)
return wrapper
return decorator
@requires_role("admin")
def delete_account(user, account_id):
return f"deleted {account_id}"
The wrapper intentionally has user first because this decorator needs that particular argument. It still uses *args and **kwargs to
forward everything else.
Retry transient work
import time
def retry(attempts=3, delay=0.2, exceptions=(OSError,)):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, attempts + 1):
try:
return func(*args, **kwargs)
except exceptions:
if attempt == attempts:
raise
[Link](delay)
return wrapper
return decorator
@retry(attempts=4, exceptions=(ConnectionError, TimeoutError))
def fetch_profile(user_id):
...
Only retry errors that are truly transient and safe to repeat. Retrying a payment or a database write without
idempotency protection can create duplicates.
Validation
def non_empty_text(func):
@wraps(func)
def wrapper(text, *args, **kwargs):
if not isinstance(text, str) or not [Link]():
raise ValueError("text must be non-empty")
return func(text, *args, **kwargs)
return wrapper
@non_empty_text
def slugify(text, separator="-"):
return [Link]([Link]().split())
Page 6
PYTHON DECORATORS - PRACTICAL GUIDE
6. Caching, stacking, and built-in decorators
Caching with functools
from functools import lru_cache
@lru_cache(maxsize=256)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(40)) # fast after earlier results are stored
lru_cache remembers results keyed by function arguments. It is excellent for pure, repeatable calculations.
Arguments must be hashable, and caching is inappropriate when a result depends on changing external state
(current time, database contents, random values) unless you deliberately manage invalidation.
Stacking decorators
@log_calls
@timed("import")
def import_file(path):
return open(path).read()
# Equivalent to:
# import_file = log_calls(timed("import")(import_file))
Decoration happens bottom-up. At call time the outer wrapper starts first: here logging surrounds timing, which
surrounds the original function. Order matters: swapping decorators can change what is measured, logged,
cached, or authorized.
Common built-ins that are decorators
Decorator Use
@property Expose a method like a read-only attribute; optionally add a setter.
@staticmethod Keep a utility method on a class without an instance or class parameter.
@classmethod Receive the class as the first argument; often used for alternate constructors.
@dataclass Generate methods such as __init__ and __repr__ from declared fields.
@lru_cache / @cache Memoize function results.
Page 7
PYTHON DECORATORS - PRACTICAL GUIDE
7. Methods, classes, and asynchronous functions
Instance methods work naturally with standard decorators. When a method is called, self is simply the first
positional argument in args.
class Service:
@log_calls
def process(self, item_id, *, dry_run=False):
return {"id": item_id, "dry_run": dry_run}
Service().process(42, dry_run=True)
Class decorators receive a class object and return a class object (often the
same one after adding behavior).
def add_repr(cls):
def __repr__(self):
values = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
return f"{cls.__name__}({values})"
cls.__repr__ = __repr__
return cls
@add_repr
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
Async decorators must await the coroutine. A normal wrapper would return a
coroutine object without running it.
def async_log(func):
@wraps(func)
async def wrapper(*args, **kwargs):
[Link]("Calling %s", func.__qualname__)
result = await func(*args, **kwargs)
[Link]("Done: %s", func.__qualname__)
return result
return wrapper
@async_log
async def load_user(user_id):
return {"id": user_id}
Page 8
PYTHON DECORATORS - PRACTICAL GUIDE
8. Pitfalls, debugging, and a checklist
Pitfall Better approach
Forgetting return func(...) Return the original result unless the decorator deliberately changes it.
Forgetting @wraps Use @wraps(func) on wrappers. It keeps names/docs and makes introspection tools
happier.
Swallowing exceptions Log then re-raise unless converting errors is explicitly part of the decorator contract.
Changing the public signature Forward *args and **kwargs; document any deliberate argument handling.
unexpectedly
Logging secrets Redact passwords, tokens, API keys, and private data.
State shared by accident Be careful with mutable values stored in closure variables.
Decorating everything Use decorators for repeated policy or infrastructure behavior, not to obscure simple
code.
Debugging techniques
• Check function.__name__ and function.__doc__. If they say wrapper or are missing, add @wraps.
• Use [Link](function) to understand a callable. Tools can follow __wrapped__ when [Link]
is used.
• Write focused tests: does the original value return, do errors still raise, and is each wrapper side effect triggered
once?
• If needed, access the original wrapped callable with function.__wrapped__ for inspection or isolated tests (not
usually for application flow).
Practical checklist
Use a decorator when: the same behavior belongs around many functions (logging, tracing, authorization,
caching, validation, metrics).
Keep it small: one clear responsibility.
Make it transparent: preserve inputs, output, errors, and metadata unless intentionally changing them.
Document order: stacked decorators are behavior composition, and order is part of their contract.
Final takeaway
Decorators are controlled function replacement. Their power comes from closures: a wrapper remembers the
original function and can run policy before, after, or around it. In everyday code, the reliable recipe is
@wraps(func), def wrapper(*args, **kwargs), return func(*args, **kwargs), plus only the additional behavior
you truly need.
Page 9