Decorators #
In many applications, there’s behavior that needs to be applied to many functions at once: logging every time a function is called, measuring execution time, checking user authentication before running the main logic, or caching computation results. Without decorators, you’d have to copy the same code into every function — repetitive and prone to inconsistency. Decorators let you wrap a function with extra behavior without touching its original implementation, keeping the code DRY and well-structured.
Functions as First-Class Objects #
Decorators exist because Python treats functions as first-class objects — functions can be stored in variables, passed as arguments, and returned from other functions. This is the foundation to understand before writing decorators.
def greet():
print("Hello!")
# Functions can be stored in variables
action = greet
action() # Hello!
# Functions can be passed as arguments
def run(func):
func()
run(greet) # Hello!
# Functions can be returned from other functions
def make_greeting(name):
def greet_name():
print(f"Hello, {name}!")
return greet_name # return the function, not the result of calling it
greet_budi = make_greeting("Budi")
greet_budi() # Hello, Budi!
A decorator is essentially a function that takes a function as input and returns a new function as output.
Basic Decorators #
The simplest form of a decorator: a function that wraps another function with extra behavior.
def log_call(func):
def wrapper(*args, **kwargs):
print(f"[LOG] Calling '{func.__name__}'")
result = func(*args, **kwargs)
print(f"[LOG] '{func.__name__}' finished")
return result
return wrapper
@log_call
def compute_total(a, b):
return a + b
total = compute_total(3, 7)
print(f"Total: {total}")
# [LOG] Calling 'compute_total'
# [LOG] 'compute_total' finished
# Total: 10
To visualize how the execution flow moves from the caller, into the decorator, to running the original function, look at the flow diagram below:
flowchart TD
Caller["Caller: compute_total(3, 7)"] --> Decorator["Wrapper: wrapper(*args, **kwargs)"]
subgraph Wrapper ["Inside the Wrapper Function"]
Decorator --> Pre["Logic Before Execution (e.g. print start log)"]
Pre --> RunFunc["Call the Original Function: compute_total(a, b)"]
RunFunc --> Post["Logic After Execution (e.g. print done log)"]
end
Post --> ReturnVal["Return the Result to the Caller"]The @log_call syntax above is equivalent to writing:
def compute_total(a, b):
return a + b
compute_total = log_call(compute_total) # equivalent to @log_call
The Function Identity Problem and Its Solution: @wraps
#
There’s a common trap when creating decorators: the original function loses its identity.
# ANTI-PATTERN: a decorator without @wraps breaks function metadata
def log_call(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_call
def compute_total(a, b):
"""Computes the sum of two numbers."""
return a + b
print(compute_total.__name__) # 'wrapper' — not 'compute_total'!
print(compute_total.__doc__) # None — the docstring is gone!
# CORRECT: use @wraps to preserve the original function's metadata
from functools import wraps
def log_call(func):
@wraps(func) # copy metadata from func to the wrapper
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@log_call
def compute_total(a, b):
"""Computes the sum of two numbers."""
return a + b
print(compute_total.__name__) # 'compute_total' ✓
print(compute_total.__doc__) # 'Computes the sum of two numbers.' ✓
Always use@functools.wraps(func)inside every decorator you create. Without it, tools like debuggers, auto-documentation, andhelp()will show incorrect information — which can be very confusing when debugging.
Practical Decorators #
Here are some decorators often needed in real applications:
Timer — Measuring Execution Time #
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
duration = time.perf_counter() - start
print(f"[TIMER] '{func.__name__}' finished in {duration:.4f} seconds")
return result
return wrapper
@timer
def process_data(n):
return sum(range(n))
process_data(10_000_000)
# [TIMER] 'process_data' finished in 0.3142 seconds
Retry — Retry on Failure #
import time
from functools import wraps
def retry(max_attempts=3, delay=1.0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
raise
print(f"[RETRY] Attempt {attempt} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def fetch_api_data(url):
import random
if random.random() < 0.7: # simulated 70% failure chance
raise ConnectionError("Connection lost")
return f"Data from {url}"
try:
result = fetch_api_data("https://api.example.com/data")
print(result)
except ConnectionError:
print("Failed after 3 attempts.")
Decorators with Arguments #
When a decorator needs configuration, you need one extra function layer — the decorator factory.
from functools import wraps
def limit_access(allowed_roles):
"""Decorator that restricts access based on the user's role."""
def decorator(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if user.get("role") not in allowed_roles:
raise PermissionError(
f"Role '{user.get('role')}' is not allowed to access '{func.__name__}'"
)
return func(user, *args, **kwargs)
return wrapper
return decorator
@limit_access(allowed_roles=["admin", "manager"])
def delete_user(user, target_id):
print(f"User {target_id} deleted by {user['name']}.")
# Succeeds
delete_user({"name": "Budi", "role": "admin"}, target_id=42)
# Fails
try:
delete_user({"name": "Sari", "role": "user"}, target_id=42)
except PermissionError as e:
print(e)
A decorator with arguments always has a three-layer structure:
def decorator_with_args(argument): ← layer 1: receive configuration
def decorator(func): ← layer 2: receive the function
@wraps(func)
def wrapper(*args, **kwargs): ← layer 3: run the logic
# before
result = func(*args, **kwargs)
# after
return result
return wrapper
return decorator
Stacking Decorators #
Several decorators can be stacked on one function. Their execution order is inside-out — the decorator closest to the function runs first.
from functools import wraps
def decorator_a(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("A: before")
result = func(*args, **kwargs)
print("A: after")
return result
return wrapper
def decorator_b(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("B: before")
result = func(*args, **kwargs)
print("B: after")
return result
return wrapper
@decorator_a
@decorator_b
def my_function():
print("Main function")
my_function()
# B: before ← decorator_b is closer to the function, so it runs first
# A: before
# Main function
# A: after
# B: after
The stacking order @decorator_a @decorator_b def f():
is equivalent to: decorator_a(decorator_b(f))
Execution order:
decorator_a.wrapper starts
decorator_b.wrapper starts
f() runs
decorator_b.wrapper finishes
decorator_a.wrapper finishes
Decorators for Classes #
Decorators on Methods #
from functools import wraps
def validate_positive(func):
"""Make sure all numeric arguments are positive."""
@wraps(func)
def wrapper(self, *args, **kwargs):
for arg in args:
if isinstance(arg, (int, float)) and arg <= 0:
raise ValueError(f"Arguments must be positive, got: {arg}")
return func(self, *args, **kwargs)
return wrapper
class Calculator:
@validate_positive
def sqrt(self, n):
import math
return math.sqrt(n)
@validate_positive
def log(self, n):
import math
return math.log(n)
calc = Calculator()
print(calc.sqrt(16)) # 4.0
print(calc.log(100)) # 4.605...
try:
calc.sqrt(-5)
except ValueError as e:
print(e) # Arguments must be positive, got: -5
Python’s Built-in Decorators #
Python has three built-in decorators frequently used in class definitions:
class Circle:
PI = 3.14159
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""Getter — accessed like a plain attribute."""
return self._radius
@radius.setter
def radius(self, value):
"""Setter — validation before storing."""
if value <= 0:
raise ValueError("The radius must be positive")
self._radius = value
@property
def area(self):
"""Computed property — no setter needed."""
return self.PI * self._radius ** 2
@classmethod
def from_diameter(cls, diameter):
"""Factory method — create an instance from an alternative parameter."""
return cls(diameter / 2)
@staticmethod
def is_valid(value):
"""Utility method — needs no access to the instance or class."""
return value > 0
# Usage
circle = Circle(5)
print(circle.area) # 78.53975
circle.radius = 10 # calls the setter
print(circle.area) # 314.159
c2 = Circle.from_diameter(20) # classmethod
print(c2.radius) # 10.0
print(Circle.is_valid(-1)) # False
Summary #
- A decorator is a function that wraps another function — it takes a function as an argument and returns a new function with extra behavior.
- Always use
@functools.wraps(func)— without it, the original function’s metadata (__name__,__doc__) is lost, making debugging harder.- Use
*args, **kwargsin the wrapper — so the decorator works with functions taking any arguments.- Decorators with arguments need three function layers — the outermost receives the configuration, the middle receives the function, the innermost runs the logic.
- Stacked decorators execute inside-out — the decorator closest to the function runs first.
@propertyfor computed attributes with setter validation;@classmethodfor factory methods;@staticmethodfor utility methods that don’t need access toselforcls.- Use decorators for cross-cutting concerns — logging, timing, retry, authentication, caching — keeping business logic clean of infrastructure code.