This article was originally written by Shaoni Mukherjee (AI Technical Writer)
Key takeaways
- Python decorators allow additional functionality to be added to functions without changing the original function code.
- Decorators help reduce repeated code and improve code reusability.
- The
@decorator_namesyntax is a cleaner way of wrapping functions. - Decorators are commonly used for logging, authentication, caching, validation, and performance monitoring.
-
*argsand**kwargsmake decorators flexible enough to work with different function arguments. -
functools.wrapshelps preserve the original function metadata and should be considered a best practice. - Multiple decorators can be chained together to add multiple layers of functionality.
- Frameworks like Flask and Django rely heavily on decorators for routing, authentication, and request handling.
- Decorators should be kept simple and focused to maintain readability and easier debugging.
- Understanding decorators is important for writing cleaner and more maintainable Python applications.
Introduction
While building real-world Python applications, a common challenge is the repetition of certain logic codes, such as logging, authentication, validation, time, or performance monitoring across multiple functions. For instance, API endpoints often require user authentication checks, and performance-critical functions may need execution time tracking.
Adding the same logic code within each function often leads to cluttered code, reduced readability, and increased maintenance effort. Decorators address this problem by creating the separation of such cross-cutting concerns into reusable components that can be applied to functions in a clean and consistent manner. In frameworks like Flask, the @app.route("/") decorator links a URL to a function without requiring explicit routing logic, while in Django, decorators such as @login_required enforce access control by restricting views to authenticated users. This approach promotes modularity, improves code clarity, and simplifies the overall structure of applications.
What are Python decorators?
Decorators are basically a wrapper around a function to modify it for better use. The function remains the same, but the decorator adds an extra something to the function.
The core idea
Say you have a simple function:
def greet():
print("Hello, world!")
Now imagine you want to print a line before and after every function you write, without modifying each one. A decorator lets you do exactly that:
def my_decorator(func):
def wrapper():
print("--- Before ---")
func() # calls the original function
print("--- After ---")
return wrapper
@my_decorator
def greet():
print("Hello, world!")
greet()
Output:
--- Before ---
Hello, world!
--- After ---
The @my_decorator line is just shorthand for greet = my_decorator(greet). Python replaces your function with the wrapped version automatically.
To understand the concept better, let us take a real-world example of timing a function:
import time
def timer(func):
def wrapper(*args, **kwargs): # *args lets it work with ANY function
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
@timer
def slow_task():
time.sleep(1)
print("Task done!")
slow_task()
Output:
Task done!
slow_task took 1.0012 seconds
Why decorators matter (especially in real projects)
They're everywhere in Python. Common use cases include:
-
@staticmethod/@classmethod— built into Python for class methods -
@app.route('/home')— Flask/Django use them to define web routes -
@login_required— Django uses this to protect pages behind authentication - Logging, caching, retrying failed requests — all cleanly handled with decorators
A decorator takes a function, adds behavior around it, and returns a new function without touching the original code.
How decorators work internally
To understand decorators better, we will first need to understand a few core Python concepts:
Foundation: Functions are objects in Python
In Python, functions aren't special, but they're just objects like integers or strings.
def say_hello():
print("Hello!")
# Pass a function as an argument
def run_it(func):
func()
run_it(say_hello) # prints: Hello!
# Assign a function to a variable
my_func = say_hello
my_func() # prints: Hello!
# Return a function from another function
def get_greeter():
def say_hi():
print("Hi!")
return say_hi # returning the function, not calling it
greeter = get_greeter()
greeter() # prints: Hi!
This is the entire foundation that decorators are built on.
Why are decorators needed?
Imagine there are many functions in a project, and each function needs logging.
Without decorators:
def add(a, b):
print("Function started")
result = a + b
print("Function ended")
return result
def multiply(a, b):
print("Function started")
result = a * b
print("Function ended")
return result
Problem:
- Repeated code
- Hard to maintain in large projects
- If logging changes, every function must be updated
Decorators solve this problem by reusing common functionality.
With decorators:
Using decorators, the repeated code ("Function started" and "Function ended") can be moved into a single reusable decorator.
Instead of writing the same lines inside every function, the decorator handles it automatically.
Step 1: Create the decorator
def log_function(func):
def wrapper(a, b):
print("Function started")
result = func(a, b)
print("Function ended")
return result
return wrapper
Step 2: Apply the Decorator
@log_function
def add(a, b):
return a + b
@log_function
def multiply(a, b):
return a * b
Calling the Functions
print(add(2, 3))
print(multiply(4, 5))
Output:
Function started
Function ended
5
Function started
Function ended
20
What changed?
The functions now only contain their main logic:
return a + b
and
return a * b
The extra behavior (logging) is handled by the decorator separately.
Visual understanding
When this runs:
add(2, 3)
Python internally does this:
add = log_function(add)
So the actual flow becomes:
wrapper()
├── print("Function started")
├── call original add()
├── print("Function ended")
└── return result
Better Version Using *args and **kwargs
The previous decorator only works for functions with two arguments.
A more reusable decorator looks like this:
def log_function(func):
def wrapper(*args, **kwargs):
print("Function started")
result = func(*args, **kwargs)
print("Function ended")
return result
return wrapper
Now it works with:
- any number of arguments
- positional arguments
- keyword arguments
Why this is powerful
Imagine 100 functions needing logging.
Without decorators:
- repeated code everywhere
With decorators:
- write logging once
- reuse everywhere
This is one of the biggest reasons decorators are widely used in real-world Python projects and frameworks like:
Common practical examples of Python decorators
A few of the most common practical examples are listed here, from solo projects to production systems.
1. Timing and performance measurement
Useful when profiling slow functions or benchmarking code.
import time
from functools import wraps
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"{func.__name__} ran in {end - start:.4f}s")
return result
return wrapper
@timer
def process_data(n):
total = sum(range(n))
return total
process_data(1_000_000)
# process_data ran in 0.0312s
perf_counter() is preferred over time.time() for short measurements, and it's higher resolution and is not affected by system clock adjustments.
2. Logging
Instead of adding print statements everywhere, a logging decorator handles it in one place.
import logging
from functools import wraps
logging.basicConfig(level=logging.INFO)
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
logging.info(f"Calling {func.__name__} | args={args} kwargs={kwargs}")
result = func(*args, **kwargs)
logging.info(f"{func.__name__} returned {result}")
return result
return wrapper
@log_calls
def multiply(a, b):
return a * b
multiply(4, 5)
# INFO: Calling multiply | args=(4, 5) kwargs={}
# INFO: multiply returned 20
In production, you'd swap logging.info for a structured logger like structlog or a cloud logging sink.
3. Retry on failure
Critical for network calls, API requests, or anything that can fail transiently.
import time
from functools import wraps
def retry(times=3, delay=1):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt} failed: {e}")
if attempt < times:
time.sleep(delay)
raise Exception(f"{func.__name__} failed after {times} attempts")
return wrapper
return decorator
@retry(times=3, delay=2)
def fetch_data(url):
import requests
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.json()
fetch_data("https://api.example.com/data")
# Attempt 1 failed: Connection timeout
# Attempt 2 failed: Connection timeout
# Attempt 3 failed: Connection timeout
# Exception: fetch_data failed after 3 attempts
Notice this is a decorator factory — retry(times=3) returns the actual decorator. This is how you pass arguments to decorators.
4. Caching memoization
Avoids recomputing expensive results by storing previous outputs.
from functools import wraps
def memoize(func):
cache = {}
@wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
print(f"Cache miss — computing for {args}")
else:
print(f"Cache hit for {args}")
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(6)
# Cache miss — computing for (6,)
# Cache miss — computing for (5,)
# ...
fibonacci(6)
# Cache hit for (6,) ← instantly returns stored result
Python actually ships a production-grade version of this built in:
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
lru_cache (Least Recently Used) is thread-safe and evicts old entries when the cache is full — use it over a hand-rolled version in real projects.
5. Access control authorization
A staple in web frameworks like Flask and Django.
from functools import wraps
def require_role(role):
def decorator(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if user.get("role") != role:
raise PermissionError(f"Access denied. Required role: {role}")
return func(user, *args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_user(user, user_id):
print(f"Deleting user {user_id}")
admin = {"name": "Shaoni", "role": "admin"}
guest = {"name": "Guest", "role": "viewer"}
delete_user(admin, 42) # Deleting user 42
delete_user(guest, 42) # PermissionError: Access denied. Required role: admin
Django's @login_required and @permission_required follow this exact pattern internally.
6. Input validation
Validate arguments before they even reach your function's logic.
from functools import wraps
def validate_positive(*arg_positions):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for i in arg_positions:
if args[i] <= 0:
raise ValueError(
f"Argument at position {i} must be positive, got {args[i]}"
)
return func(*args, **kwargs)
return wrapper
return decorator
@validate_positive(0, 1)
def calculate_area(width, height):
return width * height
calculate_area(5, 10) # 50
calculate_area(-3, 10) # ValueError: Argument at position 0 must be positive
7. Rate Limiting
Preventing a function from being called too frequently is very common in API clients.
import time
from functools import wraps
def rate_limit(calls_per_second=1):
min_interval = 1.0 / calls_per_second
last_called = [0.0] # mutable container to hold state in closure
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.time() - last_called[0]
wait = min_interval - elapsed
if wait > 0:
print(f"Rate limit: waiting {wait:.2f}s")
time.sleep(wait)
last_called[0] = time.time()
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(calls_per_second=2)
def call_api(endpoint):
print(f"Calling {endpoint}")
call_api("/users")
call_api("/posts") # Rate limit: waiting 0.49s
call_api("/comments") # Rate limit: waiting 0.49s
Quick reference
| Decorator | Use Case | Real-world Equivalent |
|---|---|---|
@timer |
Measure execution time | Profiling, benchmarking |
@log_calls |
Audit function calls | Observability, debugging |
@retry |
Handle transient failures | API clients, DB connections |
@lru_cache |
Cache expensive results | ML inference, DB queries |
@require_role |
Guard endpoints by role | Django, Flask auth |
@validate_positive |
Sanitize inputs early | Data pipelines, APIs |
@rate_limit |
Throttle call frequency | External API clients |
Real-world use cases in frameworks
Decorators are heavily used in modern Python frameworks because they provide a clean and reusable way to add functionality to applications without modifying the core business logic.
Frameworks such as Flask and Django use decorators for:
- Routing
- Authentication
- Authorization
- Caching
- Request validation
- Restricting HTTP methods
- Logging
These decorators make applications cleaner, easier to maintain, and more readable.
Flask routing decorator
One of the most common examples of decorators appears in Flask routing.
Using Flask:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Homepage"
Here:
@app.route("/")
is a decorator.
It tells Flask:
“When a user visits /, execute the home() function.”
Flask authentication decorator
Decorators are also commonly used for authentication.
Example:
@app.route("/dashboard")
@login_required
def dashboard():
return "Dashboard"
Here:
@login_required
checks whether the user is logged in before allowing access to the dashboard.
Why this is useful
Without decorators, authentication checks would need to be repeated inside every protected function.
Example without decorator:
def dashboard():
if not logged_in:
return "Please log in"
return "Dashboard"
Using decorators:
- avoids repeated code
- keeps route definitions clean
- centralizes authentication logic
This becomes extremely useful in large applications with many protected routes.
Django authentication decorator
Django also uses decorators extensively.
Example:
from django.contrib.auth.decorators import login_required
@login_required
def dashboard(request):
return HttpResponse("Welcome")
The @login_required decorator ensures:
- only authenticated users can access the view
- unauthorized users are redirected to the login page
Benefits
- Reusable security checks
- Cleaner view functions
- Better maintainability
- Centralized authentication handling
Django HTTP method restriction
Django provides decorators to restrict HTTP request methods.
Example:
from django.views.decorators.http import require_POST
@require_POST
def submit(request):
return HttpResponse("Submitted")
The decorator:
@require_POST
ensures the function only accepts POST requests.
If a GET request is sent, Django automatically returns an error.
Why this matters
This helps:
- enforce API rules
- improve security
- prevent invalid request types
- simplify validation logic
Without decorators, manual checks would be needed inside every function.
Django caching decorator
Decorators are also used for performance optimization.
Example:
from django.views.decorators.cache import cache_page
@cache_page(60)
def my_view(request):
return HttpResponse("Cached")
Here:
@cache_page(60)
stores the response for 60 seconds.
If another user requests the same page during that time:
- Django serves the cached version
- the function does not run again
Advanced decorator concepts
Once the basic concepts are understood, the next step is to learn how decorators are implemented in production-grade Python applications. Advanced decorator patterns solve practical problems such as preserving function metadata, creating configurable decorators, and combining multiple decorators together.
These concepts are widely used in frameworks, libraries, and enterprise-level Python applications.
Preserving function metadata with functools.wraps
One common issue with decorators is that they replace the original function with the wrapper function. As a result, important metadata such as the function name, documentation string, annotations, and debugging information may be lost.
Consider the following decorator:
def decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Using it:
@decorator
def greet():
"""This function greets the user"""
print("Hello")
Now checking the function name:
print(greet.__name__)
Output:
wrapper
Instead of returning "greet", Python returns "wrapper" because the original metadata has been overridden by the wrapper function.
This creates problems for:
- debugging
- logging
- API documentation
- introspection
- testing frameworks
To solve this problem, Python provides functools.wraps.
Using functools.wraps
from functools import wraps
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Using it again:
@decorator
def greet():
"""This function greets the user"""
print("Hello")
Now:
print(greet.__name__)
Output:
greet
The @wraps(func) decorator copies the original function metadata into the wrapper function. This is considered a best practice when writing decorators in production applications.
Decorators with arguments
In many real-world scenarios, decorators need configuration values. This requires creating decorators that accept arguments.
A decorator with arguments introduces an additional level of nesting.
Example:
def repeat(n):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(n):
func(*args, **kwargs)
return wrapper
return decorator
Using it:
@repeat(3)
def greet():
print("Hello")
Calling:
greet()
Output:
Hello
Hello
Hello
Understanding the structure
This example contains three functions:
repeat() → accepts decorator arguments
decorator() → accepts the original function
wrapper() → executes additional logic
The execution flow becomes:
greet = repeat(3)(greet)
This pattern is heavily used in:
- retry mechanisms
- caching systems
- rate limiting
- authorization frameworks
- logging systems
- timeout handling
For example, a retry decorator may accept the number of retries:
@retry(5)
A caching decorator may accept an expiration time:
@cache(expire=60)
Decorator arguments make decorators significantly more flexible and reusable.
Chaining Multiple Decorators
Python allows multiple decorators to be applied to the same function.
Example:
@decorator_one
@decorator_two
def func():
pass
This is internally interpreted as:
func = decorator_one(decorator_two(func))
The execution order is important.
Python applies decorators from bottom to top:
-
decorator_twowraps the function first -
decorator_onewraps the result next
Example of chained decorators
def decorator_one(func):
def wrapper():
print("Decorator One - Before")
func()
print("Decorator One - After")
return wrapper
def decorator_two(func):
def wrapper():
print("Decorator Two - Before")
func()
print("Decorator Two - After")
return wrapper
Applying both decorators:
@decorator_one
@decorator_two
def greet():
print("Hello")
Calling:
greet()
Output:
Decorator One - Before
Decorator Two - Before
Hello
Decorator Two - After
Decorator One - After
Understanding the execution flow
The function call stack becomes:
decorator_one(
decorator_two(
greet
)
)
This creates nested execution layers where each decorator adds behavior before and after the wrapped function. Decorator chaining is extensively used in frameworks. For example, a web route may simultaneously use:
- authentication
- caching
- rate limiting
- logging
Example:
@app.route("/dashboard")
@login_required
@cache_page(60)
def dashboard():
return "Dashboard"
Each decorator contributes a separate layer of functionality while keeping the core business logic clean and isolated.
Conclusion
Python decorators provide a clean and powerful way to add extra functionality to functions without modifying the original code. They help reduce code duplication, improve reusability, and make applications easier to maintain.
From simple logging examples to advanced use cases in frameworks like Flask and Django, decorators play an important role in modern Python development. Understanding how decorators work helps in writing cleaner, more scalable, and more professional Python code.
Top comments (0)