Decorators in Python #
Decorators are built on a few simple Python concepts: functions are objects, functions can be passed around, and functions can return other functions. In this post, we’ll build up those concepts step by step, understand how decorators work under the hood, and look at a few practical examples along the way.
Functions Are Objects #
Before decorators make sense, lets understand one thing:
In Python, functions are first-class objects.
That means functions can be assigned to variables, passed as arguments, returned from other functions, and stored in data structures.
def greet(name):
return f"Hello, {name}"
say_hello = greet # assigning to a variable
print(say_hello("Vinay")) # Hello, Vinay
Functions can also be passed around just like any other value:
def apply(func, value): # passing a function as an argument
return func(value)
print(apply(greet, "world")) # Hello, world
Functions Remember Their Environment (Closures) #
Functions can be defined inside other functions.
More importantly, an inner function can access variables from the outer function even after the outer function has finished executing.
def outer():
message = "I am inside outer"
def inner():
print(message) # inner can see outer's scope
return inner # return the function itself, not the result
fn = outer()
fn() # prints: I am inside outer
Even though outer() has already returned, inner() still remembers the value of message.
This is called a closure. Decorators rely heavily on closures because they allow wrapper functions to keep references to the original function they’re decorating.
What Is a Decorator? #
A decorator is a function that takes another function as input, wraps some behavior around it, and returns a new function.
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Before the function runs")
result = func(*args, **kwargs)
print("After the function runs")
return result
return wrapper
def say_hello(name):
print(f"Hello, {name}!")
say_hello = my_decorator(say_hello)
say_hello("Vinay")
Output:
Before the function runs
Hello, Vinay!
After the function runs
The original function still runs, but now additional behavior executes before and after it.
# Using @ syntax
@my_decorator
def say_hello(name):
print(f"Hello, {name}!")
# Equivalent to:
def say_hello(name):
print(f"Hello, {name}!")
say_hello = my_decorator(say_hello)
The @my_decorator line runs at definition time, not at call time. When Python sees @my_decorator above a function, it calls my_decorator(say_hello) immediately and binds the result back to say_hello.
Why Decorators Exist #
Decorators are useful because they let us separate business logic from supporting concerns.
Imagine we want to measure execution time for several functions.
Without decorators:
import time
def process_orders():
start = time.perf_counter()
# business logic here
end = time.perf_counter()
print(f"Took {end - start:.4f}s")
Every function would need to repeat the same timing code.
With a decorator:
@timer
def process_orders():
# business logic here
pass
The function focuses on its actual job, while the timing logic lives elsewhere.
Some of the other usecases of decorator
- Logging
- Authentication
- Authorization
- Retry logic
- Caching
- Metrics collection
- Rate limiting
Decorators help keep those concerns separate from application code.
Decorators with Arguments #
What if you want the decorator itself to accept configuration? Say you want a @retry(times=3) decorator. The trick is to add another layer of nesting you write a function that returns a decorator.
import functools
import time
def retry(times=3, delay=1.0):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
print(f"Attempt {attempt + 1} failed: {e}")
if attempt < times - 1:
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(times=3, delay=0.5)
def fetch_data(url):
# imagine this calls an external API
raise ConnectionError("Service temporarily unavailable")
fetch_data("https://api.example.com/data")
The call chain here: @retry(times=3, delay=0.5) first calls retry(times=3, delay=0.5) which returns decorator. Then Python applies that to fetch_data. So fetch_data = decorator(fetch_data) = wrapper.
Three levels deep — retry → decorator → wrapper — but each level has a clear job.
Class-Based Decorators #
Decorators don’t have to be functions. Any callable works — including a class with a __call__ method. This becomes useful when the decorator needs to maintain state across calls.
import functools
class CountCalls:
def __init__(self, func):
functools.update_wrapper(self, func) # equivalent of @functools.wraps for classes
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call #{self.count} to {self.func.__name__!r}")
return self.func(*args, **kwargs)
@CountCalls
def process():
print("Processing...")
process() # Call #1 to 'process'
process() # Call #2 to 'process'
process() # Call #3 to 'process'
print(process.count) # 3
When Python sees @CountCalls, it calls CountCalls(process), which creates an instance. When you later call process(...), Python invokes __call__ on that instance. The instance persists between calls, so self.count accumulates.
Stacking Decorators #
Multiple decorators can be applied to a single function. They compose from bottom to top:
@decorator_a
@decorator_b
@decorator_c
def my_func():
pass
# equivalent to:
my_func = decorator_a(decorator_b(decorator_c(my_func)))
The decorator closest to the function runs first.
decorator_c is applied first, then decorator_b wraps that result, then decorator_a wraps that.