Understand Python decorators from scratch. Learn to write your own decorators for logging, authentication, caching, and more.
Decorators are one of Python's most elegant features and one that intimidates many developers, because the syntax looks like magic and the underlying concept — functions that take functions and return functions — bends the mind at first. But decorators are everywhere in Python, from web frameworks to standard-library tools, and understanding them transforms code you previously copied without comprehending into something you can read and write fluently. This tutorial builds decorators from first principles to advanced patterns.
The foundation that makes decorators possible is that in Python, functions are first-class objects: you can assign a function to a variable, pass it as an argument to another function, and return it from a function, just like any other value. This is not true in every language, and it is the single insight everything about decorators depends on. Once you accept that a function is just an object you can pass around and manipulate, the idea of a function that takes another function and gives back a modified version stops being strange. Internalizing that functions are values you can hand around is the prerequisite for everything that follows.
A decorator is a function that takes another function and returns a new function, usually one that wraps the original to add behavior before or after it runs. The decorator does not change the original function's code; it wraps it in additional logic. This wrapping pattern is what lets you add functionality — logging, timing, access checks — to a function without modifying the function itself. Understanding a decorator as a wrapper-maker, a function whose job is to take your function and return an enhanced version of it, is the core concept, and everything else is syntax and refinement around this idea.
Python provides the @ symbol as syntactic sugar for applying a decorator. Writing the decorator name with an @ on the line above a function definition tells Python to pass that function through the decorator and replace it with the result:
@my_decorator
def greet():
print("Hello")
This is exactly equivalent to defining the function and then reassigning it to the decorated version, just more readable. The @ syntax is purely convenience — it does nothing you could not do manually — but it places the decorator visibly above the function, making the enhancement clear. Recognizing that the symbol is shorthand for wrapping the function demystifies what looks like special magic.
Writing the desugared form out once reveals a timing detail that trips up many developers:
def greet():
print("Hello")
greet = my_decorator(greet)
The decorator runs once, when the def statement executes, which for a module-level function means when the module is imported. It does not run each time greet() is called; only the wrapper it returns does. Any code in the decorator body but outside the wrapper is therefore import-time code. Registration decorators depend on exactly this (a URL router or plugin registry collects functions as their modules are imported), and it is also a classic source of bugs: an expensive computation, a database query or a settings lookup placed in the outer body happens once at import, possibly before the application is configured, and its result is frozen for the lifetime of the process.
def register(func):
print(f"registering {func.__name__}") # runs at import time
return func
@register
def handler():
print("handling")
# Output on import: registering handler
# Nothing else prints until handler() is actually called.
The classic first decorator wraps a function to run code before and after it. Inside the decorator you define a wrapper function that does something, calls the original function, does something else, and returns the result, then the decorator returns that wrapper:
def logged(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print("Done")
return result
return wrapper
Applying this with @logged makes the function print messages around its execution without changing its own code. This pattern — define a wrapper, call the original inside it, return the wrapper — is the template nearly every decorator follows.
A decorator should work on functions regardless of what arguments they take, which is why the wrapper accepts *args and **kwargs — capturing any positional and keyword arguments — and passes them through to the original function. This makes the decorator general: it does not need to know the signature of the function it wraps, it simply forwards whatever arguments it receives. Understanding this is what lets one decorator apply to many different functions with different signatures. The *args, **kwargs pattern in the wrapper is what gives a decorator its reusability across all the varied functions you might want to enhance.
A subtle problem with the basic pattern is that the wrapper replaces the original function, so the decorated function loses its original name, docstring, and other metadata — it now reports itself as the wrapper. The fix is the functools.wraps decorator, applied to your wrapper, which copies the original function's metadata onto the wrapper so the decorated function still looks like itself. This is a small but important detail for writing well-behaved decorators, because losing the function's identity breaks introspection and documentation tools. Using functools.wraps in every decorator you write is a best practice that keeps decorated functions transparent about what they really are.
Sometimes you want a decorator you can configure — for example, one that retries a function a specified number of times. This requires an extra layer: a function that takes the configuration arguments and returns a decorator, which in turn takes the function and returns the wrapper. The three nested levels look daunting but follow logically from the two-level pattern, with one more layer added to capture the arguments. Understanding that a parameterized decorator is just a function that builds and returns a decorator based on its arguments is what lets you read and write the configurable decorators that appear throughout real codebases and frameworks.
import functools
import logging
import random
import time
log = logging.getLogger(__name__)
def retry(times=3, *, exceptions=(Exception,), base_delay=0.5, max_delay=10.0):
"""Retry func on the given exceptions with exponential backoff and jitter."""
if times < 1:
raise ValueError("times must be at least 1")
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as exc:
if attempt == times:
raise # give up: re-raise the last error
delay = min(max_delay, base_delay * 2 ** (attempt - 1))
delay *= random.uniform(0.5, 1.0) # jitter spreads out retries
log.warning("%s failed (%s), attempt %d/%d, retrying in %.2fs",
func.__qualname__, exc, attempt, times, delay)
time.sleep(delay)
return wrapper
return decorator
@retry(times=5, exceptions=(ConnectionError, TimeoutError))
def fetch_rates():
...
Read the layers from the outside in. retry(times=5, ...) runs first and returns decorator, which closes over the configuration. Python then applies decorator to fetch_rates, which returns wrapper, closing over both the configuration and the function. Argument validation lives in the outermost layer, so a misconfiguration fails at import time rather than at 3 a.m. on the first transient error.
The design choices are where production experience shows. Catch a narrow tuple of exceptions: retrying on bare Exception will retry a TypeError caused by a plain bug five times before failing, multiplying the latency of every failure. Only retry idempotent operations, because retrying a payment charge after a timed-out response can charge the customer twice. Jitter prevents many clients from retrying in lockstep against a service that is trying to recover. And the bare raise re-raises the original exception with its traceback intact instead of replacing it with something less informative.
You can apply more than one decorator to a function by stacking them, each on its own line above the function. They apply from the bottom up — the one closest to the function wraps it first, then the next wraps that, and so on — so the order matters. Stacking lets you compose behaviors, combining a timing decorator with a logging decorator with an access-check decorator on a single function. Understanding the bottom-up application order is important because it determines the sequence in which the wrappers run, and getting it right is necessary when the decorators interact, such as when one must run before another for the combination to behave correctly.
def tag(name):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"enter {name}")
result = func(*args, **kwargs)
print(f"exit {name}")
return result
return wrapper
return decorator
@tag("outer")
@tag("inner")
def work():
print("working")
work()
# enter outer
# enter inner
# working
# exit inner
# exit outer
This is equivalent to work = tag("outer")(tag("inner")(work)). Application goes bottom-up, but at call time the outermost (top) wrapper runs its before-logic first and its after-logic last, like layers of an onion. A useful rule: read the stack top-down to see the order in which things happen during a call. That gives these practical ordering rules:
| Combination | Put on top (outer) | Why |
|---|---|---|
| Access check + cache | Access check | A cache outside the check could serve a result computed for one user to a user who is not allowed to see it. |
| Timing + retry | Depends on the question | Timing outside measures total time including backoff sleeps; timing inside measures each attempt separately. |
| Logging + retry | Usually logging | Outside logs once per logical call; inside logs every attempt. |
@classmethod or @staticmethod + your decorator | @classmethod / @staticmethod | A function-based decorator expects a plain function; the descriptor should wrap the finished result. |
@property + your decorator | @property | Same reason: decorate the getter function, then turn it into a property. |
| Route or handler registration + anything | Registration | A registering decorator records the object it receives; if it sits lower, the registry holds a function that bypasses the wrappers above it. |
Decorators are not limited to functions. You can write a decorator as a class, where the class wraps the function and its call method runs the wrapping logic, which is useful when the decorator needs to maintain state. You can also decorate classes themselves, modifying a class definition. These are more advanced uses, but they follow the same principle of taking something and returning an enhanced version. Knowing that the decorator pattern extends to classes — both writing decorators as classes and applying decorators to classes — rounds out your understanding and prepares you for the full range of decorator usage you will encounter in sophisticated Python code.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func) # copy metadata onto the instance
self.func = func
self.calls = 0
def __call__(self, *args, **kwargs):
self.calls += 1
return self.func(*args, **kwargs)
@CountCalls
def ping():
return "pong"
ping(); ping()
print(ping.calls) # 2
print(ping.__name__) # ping
@CountCalls calls the class with the function, so ping becomes an instance whose __call__ does the wrapping, and the counter is an ordinary attribute you can inspect or reset. functools.wraps is shaped for decorating a wrapper function, so on an instance you call functools.update_wrapper directly.
There is a trap: apply this CountCalls to a method and calling it fails with a missing-argument error, because self is never passed. Plain functions become bound methods through the descriptor protocol, since they implement __get__, and an ordinary class instance does not. The fix is to implement __get__ yourself:
import types
class CountCalls:
... # __init__ and __call__ as above
def __get__(self, instance, owner):
if instance is None:
return self # accessed on the class itself
return types.MethodType(self, instance) # bind like a normal method
This subtlety is why many experienced developers prefer function-based decorators that keep state on the wrapper (wrapper.calls = 0) or in a nonlocal variable; those work on methods for free. Reach for a class when the decorator has substantial state or behavior of its own, such as several methods, a reset() or rich configuration, that deserves a real interface.
Decorators are pervasive in real Python, which is the practical reason to understand them. Web frameworks use them to mark routes, require login, or cache responses; the standard library provides decorators for caching results, defining properties, and registering methods; testing tools use them to parameterize and skip tests. Once you understand decorators, all of this becomes readable rather than mysterious, and you can write your own to eliminate repetition in your code. Seeing how widely the pattern is used reinforces why it is worth mastering: decorators are not an obscure trick but a fundamental Python idiom you will meet constantly.
Decorators shine for cross-cutting concerns — behavior you want to add to many functions without repeating it, like logging, timing, caching, access control, or retrying. They keep that behavior in one place and apply it cleanly with a single line. The caution is not to overuse them or hide too much logic in a decorator, where it becomes invisible action at a distance that obscures what a function really does. Used judiciously for genuine cross-cutting concerns, decorators make code cleaner and more expressive; used excessively, they can make it harder to follow. Knowing when they add clarity rather than hide it is the final piece of using them well.
Decorators rely on a concept worth naming: closures. When the inner wrapper function refers to the original function passed into the decorator, it "closes over" that function, retaining access to it even after the decorator has finished running. This is what lets the wrapper call the original later. Closures — inner functions that remember the variables from the scope they were created in — are the mechanism that makes the decorator pattern work. Understanding closures clarifies why the wrapper can still reach the original function it wraps, and it is a powerful Python concept in its own right that appears beyond decorators wherever a function needs to carry some context with it.
A few uses dominate in practice. Timing decorators measure how long a function takes, useful for performance work. Logging decorators record when functions are called and with what, aiding debugging. Caching decorators store a function's results so repeated calls with the same arguments return the saved result instead of recomputing. Access-control decorators check permissions before allowing a function to run. Retry decorators re-attempt a function that fails. Recognizing these common patterns helps you both understand decorators you encounter and identify where writing your own would clean up repetition in your code, since these recurring needs are exactly what decorators handle elegantly.
One practical wrinkle is that decorators add a layer between you and the original function, which can complicate debugging — a traceback may show the wrapper, and the function's identity can be obscured if you did not preserve its metadata. This is precisely why using the metadata-preserving helper matters, and why keeping decorators simple and well-tested is wise. When debugging code that uses decorators, remembering that there is a wrapping layer helps you interpret what you see. Understanding that decorators introduce indirection — and handling it by preserving metadata and keeping wrappers clear — keeps the convenience of decorators from turning into confusion when something goes wrong inside a decorated function.
Most decorator bugs produce one of a handful of recognizable symptoms:
| Symptom | Likely cause | Fix |
|---|---|---|
Decorated function always returns None | Wrapper calls func but does not return its result | return func(*args, **kwargs) |
TypeError: 'NoneType' object is not callable when calling the function | Decorator forgot return wrapper | Return the wrapper from the decorator |
Every function reports its name as wrapper; registries complain about duplicates | Missing functools.wraps | Add @functools.wraps(func) to the wrapper |
| "missing 1 required positional argument" on a method | Class-based decorator without __get__ | Implement __get__, or use a function-based decorator |
| Calling the function returns a function | Parameterized decorator used without parentheses | Write @retry() with parentheses |
| "missing 1 required positional argument: 'func'" at import | Plain decorator used with parentheses | Write @logged, not @logged() |
| "coroutine was never awaited" warning, or timings near zero | Synchronous wrapper around an async def function | Write an async wrapper that awaits |
| Side effect happens once, at startup, not per call | Code placed in the decorator body instead of the wrapper | Move it inside the wrapper |
A few principles make for good decorators. Keep them focused on one concern, so a decorator does a single clear thing. Always preserve the wrapped function's metadata. Make sure the wrapper passes through arguments and return values faithfully, so the decorated function behaves like the original plus the added behavior. And do not hide too much surprising logic, since a decorator that drastically changes behavior invisibly is hard to reason about. Following these principles produces decorators that are reusable, transparent, and pleasant to work with. Understanding what separates a good decorator from a confusing one helps you write enhancements that genuinely clean up your code rather than adding hidden complexity.
A normal wrapper around an async def function does not fail loudly, which is what makes this bug dangerous. Calling an async function only creates a coroutine object; the body runs when something awaits it. A synchronous timing wrapper therefore measures the few microseconds needed to create the coroutine, a synchronous retry wrapper never sees the exceptions (they are raised later, when the caller awaits), and a try/finally runs its cleanup before the work has even started. The wrapper has to be a coroutine function itself and await the original:
import asyncio
import functools
import inspect
import time
def timed(func):
if inspect.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return await func(*args, **kwargs)
finally:
print(f"{func.__qualname__}: {time.perf_counter() - start:.3f}s")
return async_wrapper
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
print(f"{func.__qualname__}: {time.perf_counter() - start:.3f}s")
return sync_wrapper
@timed
async def load():
await asyncio.sleep(0.2)
asyncio.run(load()) # load: 0.200s (approximately)
Checking inspect.iscoroutinefunction once at decoration time lets one decorator serve both kinds of function, and because async_wrapper is itself declared with async def, it still reports as a coroutine function to anything stacked above it, including frameworks that dispatch differently for sync and async handlers. Inside async wrappers, never block the event loop: an async retry decorator must use await asyncio.sleep(delay), because time.sleep would freeze every other task on the loop for the duration of the backoff.
Decorators rest on a single idea: in Python, functions are objects you can pass around and return, so a decorator is simply a function that takes a function and returns an enhanced, wrapping version of it. The @ syntax is convenient shorthand for this wrapping. The standard pattern defines a wrapper that accepts *args, **kwargs so it works on any function, runs logic before and after calling the original, and returns the result — and applying functools.wraps keeps the decorated function's identity intact. From there, decorators that take arguments add one nesting layer, stacking composes multiple decorators bottom-up, and the pattern extends to classes. Decorators are everywhere in real Python — frameworks, the standard library, testing tools — which is why understanding them turns mysterious code readable. Use them for cross-cutting concerns to eliminate repetition cleanly, and with this foundation, what once looked like magic becomes a tool you wield with confidence.