
Python Course
Decorators in Python
A decorator is a function that wraps another function to extend or modify its behaviour — without changing the original function's code at all. You have already seen decorators: @classmethod, @staticmethod, @property, and @abstractmethod are all built-in decorators. Now you will learn how they work from the ground up, and how to build your own.
Decorators are used everywhere in professional Python — logging, authentication, caching, rate limiting, input validation, timing, and more. Understanding them unlocks a huge portion of real-world Python codebases.
Functions Are First-Class Objects
Before decorators make sense, you need one foundational idea: in Python, functions are objects. You can assign them to variables, pass them as arguments, return them from other functions, and store them in data structures — just like integers or strings.
def greet(name):
return f"Hello, {name}!"
# Assign to a variable
say_hello = greet
print(say_hello("Alice")) # Hello, Alice!
# Pass as an argument
def shout(func, name):
return func(name).upper()
print(shout(greet, "Bob")) # HELLO, BOB!
# Return from a function — closure / factory pattern
def make_greeter(prefix):
def inner(name): # inner remembers prefix from enclosing scope
return f"{prefix}, {name}!"
return inner # return the function itself, not its result
hi = make_greeter("Hi")
hey = make_greeter("Hey")
print(hi("Carol")) # Hi, Carol!
print(hey("Dave")) # Hey, Dave!
# Functions can be stored in data structures too
ops = {"add": lambda a,b: a+b, "mul": lambda a,b: a*b}
print(ops["add"](3, 4)) # 7- A function defined inside another function is a closure — it remembers the enclosing scope's variables even after the outer function returns.
- Returning a function (not calling it) is the key pattern that makes decorators possible.
- The
make_greeterpattern — a function that creates and returns a customised function — is called a factory function.
How a Decorator Works
A decorator is a function that takes a function as input, defines a wrapper inside, and returns the wrapper. The @ syntax is shorthand for applying it.
def shout_decorator(func):
"""Wraps func so its return value is uppercased."""
def wrapper(*args, **kwargs):
result = func(*args, **kwargs) # call the original
return result.upper() # modify the result
return wrapper # return wrapper — not its result
# Manual application — exactly what @ does
def greet(name):
return f"Hello, {name}!"
greet = shout_decorator(greet)
print(greet("Alice")) # HELLO, ALICE!
# @ syntax — cleaner, identical result
@shout_decorator
def farewell(name):
return f"Goodbye, {name}!"
print(farewell("Bob")) # GOODBYE, BOB!
# Proof they're equivalent
@shout_decorator
def welcome(name): return f"Welcome, {name}!"
print(welcome.__name__) # wrapper ← metadata lost (fixed next section)@shout_decoratoris exactly equivalent tofunc = shout_decorator(func)— one clean line vs two.*args, **kwargsin the wrapper means it accepts whatever arguments the original function takes.- The original function is still called inside the wrapper — decorators wrap, they do not replace.
Preserving Metadata — functools.wraps
Wrapping a function loses its name, docstring, and metadata because the wrapper replaces it. functools.wraps fixes this with one line — always use it.
from functools import wraps
def my_decorator(func):
@wraps(func) # copies name, docstring, annotations onto wrapper
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@my_decorator
def add(a, b):
"""Returns the sum of a and b."""
return a + b
print(add(2, 3)) # 5
print(add.__name__) # add ← correct, not 'wrapper'
print(add.__doc__) # Returns the sum of a and b.
# Without @wraps, introspection tools (help(), sphinx, IDEs) get confused
# With @wraps, the decorated function looks identical to the originalPractical Decorators — Timing and Logging
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[LOG] {func.__name__} | args={args} kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"[LOG] {func.__name__} returned {result!r}")
return result
return wrapper
@timer
def slow_sum(n):
"""Sums 0 to n."""
return sum(range(n))
@log_calls
def divide(a, b):
return a / b
slow_sum(10_000_000)
divide(10, 4)Practical Decorator — Retry with Backoff
A retry decorator automatically re-runs a failing function up to N times — a common real-world pattern for network calls and database queries.
import time
from functools import wraps
def retry(times=3, delay=0.5, exceptions=(Exception,)):
"""Retry a function up to `times` times on given exceptions."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_err = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_err = e
print(f"[RETRY] {func.__name__} attempt {attempt} failed: {e}")
if attempt < times:
time.sleep(delay)
raise last_err
return wrapper
return decorator
# Simulating a flaky network call
_call_count = 0
@retry(times=3, delay=0, exceptions=(ConnectionError,))
def fetch_data():
global _call_count
_call_count += 1
if _call_count < 3:
raise ConnectionError("Network timeout")
return {"status": "ok", "data": [1, 2, 3]}
result = fetch_data()
print("Got:", result)Decorator Factories — Decorators with Arguments
When a decorator needs configuration, add one outer layer: a factory function that accepts arguments and returns the decorator. This is the pattern behind @app.route("/path") and @pytest.mark.parametrize.
from functools import wraps
def repeat(times):
"""Factory — returns a decorator that runs func `times` times."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = None
for i in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator # return the decorator, not the wrapper
@repeat(times=3)
def say(message):
print(message)
say("Hello!")
# @repeat(times=3) calls repeat(3) → returns decorator → applied to say- Three-layer structure: factory (takes arguments) → decorator (takes the function) → wrapper (runs the function).
@repeat(times=3)first callsrepeat(3)which returnsdecorator, then Python applies that decorator tosay.
Stacking Multiple Decorators
Stack decorators by listing them one per line above the function. Python applies them bottom-up — the one closest to the function wraps first.
from functools import wraps
def bold(func):
@wraps(func)
def wrapper(*args, **kwargs):
return "**" + func(*args, **kwargs) + "**"
return wrapper
def uppercase(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
def exclaim(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs) + "!!!"
return wrapper
@bold # 3rd — outermost
@uppercase # 2nd
@exclaim # 1st — innermost, applied first
def greet(name):
return f"hello, {name}"
print(greet("Alice"))
# exclaim wraps greet: "hello, alice!!!"
# uppercase wraps that: "HELLO, ALICE!!!"
# bold wraps that: "**HELLO, ALICE!!!**"Class-Based Decorators
Any callable works as a decorator — a class with __call__ is callable. Class-based decorators are ideal when the decorator needs to maintain state between calls.
from functools import wraps, update_wrapper
class CallCounter:
"""Counts calls and tracks the last result."""
def __init__(self, func):
update_wrapper(self, func) # equivalent to @wraps on a class
self.func = func
self.call_count = 0
self.last_result = None
def __call__(self, *args, **kwargs):
self.call_count += 1
result = self.func(*args, **kwargs)
self.last_result = result
print(f"[{self.func.__name__}] call #{self.call_count} → {result!r}")
return result
def reset(self):
self.call_count = 0
self.last_result = None
@CallCounter
def process(data):
return data.strip().title()
process(" hello world ")
process(" python decorators ")
process(" dataplexa ")
print(f"Total calls: {process.call_count}")
print(f"Last result: {process.last_result!r}")update_wrapper(self, func)is the class equivalent of@wraps— copies name, docstring, and annotations onto the instance.- Instance attributes like
self.call_countpersist across calls — something a plain function decorator cannot do without a mutable container. - The
reset()method shows how class decorators can expose their own API.
Quick Reference Table
| Pattern | Structure | Use When |
|---|---|---|
| Basic decorator | def dec(func): def wrapper(...): ... return wrapper | Adding fixed behaviour to any function |
| With arguments | Three layers — factory → decorator → wrapper | Configurable behaviour at decoration time |
| Stacked decorators | @dec1 above @dec2 above function | Combining independent concerns |
| Class-based | __init__ receives func; __call__ runs it | Decorator needs to maintain state |
@wraps(func) | Applied to wrapper inside the decorator | Always — preserves name, docstring, metadata |
Practice
What does the @ symbol before a function definition do?
Why should you always use @wraps(func) inside a decorator?
In what order are stacked decorators applied?
What is a decorator factory?
Why would you use a class-based decorator over a function-based one?
What do you call a function defined inside another function that remembers the enclosing scope's variables?
Quick Quiz
What does a decorator function always return?
Why does the wrapper inside a decorator use *args, **kwargs?
Which module provides the wraps helper for decorators?
Given @bold above @uppercase above a function, which decorator's wrapper is the outermost?
Which built-in Python decorators have you already used in this course?
What is the class-based equivalent of @wraps(func) when using a class decorator?
with statement — files, locks, database connections, and how to build your own context managers.