A decorator is a function (or class) that takes another function and returns a new function with extra behaviour wrapped around it — without you editing the original. You apply one with the @ syntax placed directly above a function or method. Decorators let you keep code DRY and pull cross-cutting concerns — logging, timing, caching, authentication, retries, rate limiting — out of your business logic and into small, reusable wrappers.
If you have ever written @property, @staticmethod, or @app.route("/") in a web framework, you have already used decorators. This guide builds them up from first principles using current Python 3.12 / 3.13, then walks through the patterns we reach for most often on real projects.
Functions are first-class objects (the foundation)
Decorators only make sense once you internalise two facts about Python:
- Functions are objects. You can assign them to variables, pass them as arguments, and return them from other functions.
- Inner functions form closures. A nested function remembers the variables from the scope where it was defined, even after the outer function has returned.
That closure is exactly how a decorator "remembers" the original function it wrapped.
def outer(message):
# `message` is captured by the closure below
def inner():
print(f"Closure says: {message}")
return inner # return the function object itself, do not call it
greet = outer("hello")
greet() # Closure says: hello
print(greet.__closure__[0].cell_contents) # helloWriting a basic decorator
A decorator is just a function that accepts a function and returns a replacement. The replacement (wrapper) calls the original somewhere in the middle and can run code before and after it. Use *args, **kwargs so the wrapper works for any signature.
def simple_logger(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished {func.__name__}")
return result
return wrapper
@simple_logger
def add(a, b):
return a + b
print(add(2, 3))
# Calling add
# Finished add
# 5The @simple_logger line is pure syntactic sugar. It is exactly equivalent to reassigning the name yourself:
def add(a, b):
return a + b
add = simple_logger(add) # @simple_logger is shorthand for thisWhy functools.wraps matters
There is a subtle bug in the basic version above. Because the original function is replaced by wrapper, its identity is lost — add.__name__ is now "wrapper", the docstring is gone, and tools that introspect functions (debuggers, Sphinx docs, framework routers, help()) see the wrong metadata.
functools.wraps is itself a decorator you apply to the wrapper. It copies __name__, __doc__, __module__, __qualname__, the signature, and __wrapped__ from the original onto the wrapper. Always use it.
import functools
def simple_logger(func):
@functools.wraps(func) # preserve the wrapped function's metadata
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@simple_logger
def add(a, b):
"""Return the sum of a and b."""
return a + b
print(add.__name__) # add (without wraps -> 'wrapper')
print(add.__doc__) # Return the sum of a and b.
print(add.__wrapped__) # <function add ...> -> access the originalDecorators that take arguments (the decorator factory)
To pass options to a decorator — like @retry(times=3) — you need three nested levels. The outermost function accepts the decorator's arguments and returns a real decorator; the middle level is the decorator that receives the function; the inner level is the wrapper that runs at call time.
repeat(times=3) -> returns decorator
decorator(func) -> returns wrapper
wrapper(*a) -> runs the actual call
import functools
def repeat(times):
"""A decorator factory: returns a decorator that runs `func` `times` times."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def ping():
print("ping")
ping()
# ping
# ping
# pingStacking decorators (order matters)
You can apply several decorators to one function. They are applied bottom-up (the one nearest the function wraps first), but they execute top-down at call time (the outermost runs first). Reading the stack as nested calls makes the order obvious.
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
@bold
@italic
def greet(name):
return f"Hi {name}"
print(greet("Sam")) # <b><i>Hi Sam</i></b>
# Equivalent to: bold(italic(greet)) -> bold runs outermostClass-based decorators with __call__
Any object whose class implements __call__ is callable, so a class instance can act as a decorator. The function is captured in __init__, and __call__ runs on every invocation. Classes are handy when the decorator needs to hold state between calls (a call counter, a cache, accumulated metrics). Remember to apply functools.wraps to the instance so introspection still works.
For a refresher on __call__ and the other dunder methods, see our Python magic methods reference.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func) # the class equivalent of @wraps
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call #{self.count} of {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hi():
print("hi")
say_hi() # Call #1 of say_hi / hi
say_hi() # Call #2 of say_hi / hi
print(say_hi.count) # 2 -> state lives on the instanceFunction decorator vs class-based decorator
Both approaches solve the same problem; pick based on whether you need to carry state.
| Aspect | Function decorator | Class-based decorator |
|---|---|---|
| Boilerplate | Minimal | More (__init__ + __call__) |
| Holding state between calls | Awkward (needs closures/attributes) | Natural (instance attributes) |
| Preserving metadata | @functools.wraps(func) |
functools.update_wrapper(self, func) |
| Readability for simple cases | Best | Heavier |
| Configurable parameters | Triple-nested factory | Args in __init__ |
| Typical use | Logging, timing, auth checks | Counters, caches, registries |
Decorating methods
Decorating an instance method works the same way as a plain function — the wrapper just needs to accept self as its first positional argument. Because *args already captures self, a well-written generic decorator usually needs no changes at all.
import functools
def log_method(func):
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
print(f"{type(self).__name__}.{func.__name__} called")
return func(self, *args, **kwargs)
return wrapper
class Account:
def __init__(self, balance):
self.balance = balance
@log_method
def deposit(self, amount):
self.balance += amount
return self.balance
Account(100).deposit(50) # Account.deposit calledBuilt-in and standard-library decorators
You do not have to write everything yourself — Python ships with decorators that cover the most common needs. These are the ones worth knowing by heart.
| Decorator | What it does |
|---|---|
@property |
Expose a method as a read-only attribute (with optional .setter/.deleter). See our Python properties guide. |
@staticmethod |
A method that takes neither self nor cls — namespaced under the class. |
@classmethod |
Receives the class (cls) instead of an instance — great for alternative constructors. |
@functools.lru_cache(maxsize=128) |
Memoise results in a bounded LRU cache. |
@functools.cache (3.9+) |
Unbounded memoisation — lru_cache(maxsize=None) with a shorter name. |
@functools.cached_property |
Compute a property once, then cache it on the instance. |
@dataclasses.dataclass |
Auto-generate __init__, __repr__, __eq__ and more for a data class. |
@functools.wraps |
Copy metadata from the wrapped function onto your wrapper. |
@functools.singledispatch |
Function overloading dispatched on the first argument's type. |
@abc.abstractmethod |
Mark a method that subclasses must implement. |
import functools
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2 # used like c.area, no ()
@classmethod
def unit(cls):
return cls(radius=1) # alternative constructor
@staticmethod
def describe():
return "A round shape"
@functools.cache # 3.9+, unbounded memoisation
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(Circle(2).area) # 12.56636
print(Circle.unit().radius) # 1
print(fib(50)) # fast, thanks to cachingReal-world decorator patterns
These are the patterns we reach for on day-to-day work. They are all production-shaped and copy-paste runnable on Python 3.12+.
Timing how long a function takes
import functools
import time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return wrapper
@timed
def crunch():
sum(range(1_000_000))
crunch() # crunch took 0.0123sRetrying on failure with backoff
import functools
import time
def retry(times=3, delay=0.5, exceptions=(Exception,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as exc:
last_error = exc
print(f"Attempt {attempt} failed: {exc}")
if attempt < times:
time.sleep(delay * attempt) # linear backoff
raise last_error
return wrapper
return decorator
@retry(times=3, delay=1, exceptions=(ConnectionError,))
def fetch():
raise ConnectionError("network down")An auth / permission check
This is the pattern behind framework decorators such as Django's @login_required or @permission_required. We build similar guards on the Django and FastAPI projects we deliver, where decorators (and FastAPI dependencies) keep authorisation logic out of every view.
import functools
class PermissionError403(Exception):
pass
def require_role(role):
def decorator(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if role not in getattr(user, "roles", ()):
raise PermissionError403(f"{user.name} lacks role '{role}'")
return func(user, *args, **kwargs)
return wrapper
return decorator
class User:
def __init__(self, name, roles):
self.name, self.roles = name, roles
@require_role("admin")
def delete_everything(user):
return "deleted"
print(delete_everything(User("Ada", {"admin"}))) # deleted
# delete_everything(User("Bob", {"viewer"})) -> raises PermissionError403Caching and rate limiting
For caching, prefer the battle-tested functools.lru_cache / functools.cache over hand-rolled dictionaries unless you need custom keys or TTLs. A minimal rate limiter shows how a class-based decorator naturally holds the per-call timestamps it needs as state.
import functools
import time
# Caching: let the standard library do the work.
@functools.lru_cache(maxsize=256)
def expensive_lookup(key):
print(f"computing {key}")
return key.upper()
expensive_lookup("a") # computing a
expensive_lookup("a") # (cached, no print)
print(expensive_lookup.cache_info()) # hits=1, misses=1, ...
class RateLimit:
"""Allow at most `calls` invocations per `period` seconds."""
def __init__(self, calls, period):
self.calls, self.period = calls, period
self.timestamps = []
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
now = time.monotonic()
self.timestamps = [t for t in self.timestamps if now - t < self.period]
if len(self.timestamps) >= self.calls:
raise RuntimeError("rate limit exceeded")
self.timestamps.append(now)
return func(*args, **kwargs)
return wrapper
@RateLimit(calls=2, period=60)
def send_email(to):
return f"sent to {to}"Where decorators fit in production code
Used well, decorators make intent obvious — a reader sees @timed, @retry(times=3), @require_role("admin") and instantly knows the cross-cutting behaviour without wading through it. Used badly, deeply nested or magic-heavy decorators hide control flow and make debugging painful. Our rule of thumb: keep each decorator small and single-purpose, always apply functools.wraps, and reach for the standard library before writing your own.
At MicroPyramid we have spent 12+ years and 50+ projects building Python, Django and FastAPI systems, and clean decorator usage is one of the small things that keeps a large codebase readable. If you want a team that writes Python like this, see our Python development services.
Frequently Asked Questions
What is a decorator in Python?
A decorator is a callable that takes a function (or class) and returns a modified version of it, letting you add behaviour — logging, timing, caching, access control — without editing the original code. You apply one with the @decorator syntax placed directly above the definition. Under the hood, applying @deco above def f(): ... is simply shorthand for f = deco(f).
Why should I use functools.wraps in my decorators?
When your wrapper replaces the original function, it overwrites the function's metadata — __name__ becomes "wrapper", the docstring disappears, and the signature is lost. functools.wraps copies __name__, __doc__, __qualname__, the signature, and a __wrapped__ reference from the original onto the wrapper, so debuggers, documentation tools and framework routers still see the correct information. Always add it.
How do I write a decorator that takes arguments?
Use three nested levels (a "decorator factory"). The outer function accepts the decorator's arguments and returns a decorator; that decorator accepts the function and returns a wrapper; the wrapper runs at call time. So @repeat(times=3) first calls repeat(times=3), which returns the actual decorator that wraps your function.
In what order do stacked decorators run?
Decorators are applied from the bottom up — the one closest to the function wraps it first — but they execute from the top down at call time, so the outermost decorator runs first. @a on top of @b over f is equivalent to a(b(f)): a is the outer layer that runs first, then hands control to b, then to f.
When should I use a class-based decorator instead of a function?
Reach for a class-based decorator (one that implements __call__) when the decorator needs to carry state between calls — a counter, an in-memory cache, accumulated metrics, or a registry. Instance attributes make that state natural to manage. For simple, stateless concerns like logging or timing, a plain function decorator is shorter and clearer.
Are @property, @staticmethod and @classmethod decorators?
Yes. They are all built-in decorators. @property turns a method into a managed attribute, @staticmethod defines a method that takes neither self nor cls, and @classmethod passes the class as the first argument (ideal for alternative constructors). Other stdlib favourites include @functools.lru_cache, @functools.cache (Python 3.9+), @functools.cached_property and @dataclasses.dataclass.
Do decorators slow down my code?
A decorator adds one extra function call per invocation, which is negligible for almost all workloads. The bigger performance story is usually positive: caching decorators like @functools.lru_cache can turn an expensive recomputation into an instant dictionary lookup. If a hot path is truly performance-critical, profile it — but in normal application code the readability benefit far outweighs the tiny call overhead.