Decorators
Decorators can feel like magic the first time you see the @ symbol sitting above a function. In reality, they rest on a simple idea that Python embraces fully: functions are just objects, and one function can wrap another to add extra behavior without changing its source code.
Functions Are First-Class Objects
In Python, a function is a value like any other. It can be assigned to a variable, stored in a list, passed as an argument to another function, and returned from a function. This is the foundation that makes decorators possible.
def greet(name):
return f"Hello, {name}!"
say_hello = greet # assigned to another variable
print(say_hello("Sonu")) # Hello, Sonu!
def call_twice(func, value):
print(func(value))
print(func(value))
call_twice(greet, "World") # passed as an argument
What a Decorator Actually Is
A decorator is a function that takes another function as input and returns a new function that usually calls the original one, plus a bit of extra behavior before and/or after it. Nothing about the original function's code changes — the decorator just wraps it.
Writing a Custom Decorator
Here is a decorator that logs when a function starts and finishes, using *args, **kwargs in the inner wrapper so it can accept any combination of arguments the decorated function might take:
def log_call(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished {func.__name__}")
return result
return wrapper
@log_call
def add(a, b):
return a + b
print(add(2, 3))
The @log_call line above def add(a, b): is exactly equivalent to writing add = log_call(add) right after the function is defined. Every call to add(...) now actually runs wrapper(...), which logs a message, calls the real add, logs again, and returns the result.
A Timing Decorator Example
Decorators are frequently used to measure how long a function takes to run, without cluttering the function itself with timing code:
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
elapsed = time.time() - start
print(f"{func.__name__} took {elapsed:.4f} seconds")
return result
return wrapper
@timer
def slow_square(n):
return n * n
slow_square(10)
Preserving Metadata with functools.wraps
One side effect of the wrapping pattern above is that add.__name__ would report "wrapper" instead of "add", since the decorator replaced the original function. The functools.wraps decorator fixes this by copying over the original function's name, docstring, and other metadata:
import functools
def log_call(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
Note: It's easy to forget functools.wraps, but skipping it can make debugging and introspection (help(), documentation tools) confusing, since the wrapped function loses its original identity.
Built-in Decorators You Already Know
Decorators aren't just a custom-code concept — you've likely already used a couple of built-in ones inside classes:
- @staticmethod marks a method that doesn't need access to the instance (
self) or the class; it behaves like a plain function that just happens to live inside a class. - @property lets you call a method as if it were a plain attribute, without parentheses, which is useful for computed values.
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2
@staticmethod
def unit_name():
return "radius units"
c = Circle(5)
print(c.area) # accessed like an attribute, no parentheses
print(Circle.unit_name())