AI Technical Writer

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.
@decorator_name syntax is a cleaner way of wrapping functions.*args and **kwargs make decorators flexible enough to work with different function arguments.functools.wraps helps preserve the original function metadata and should be considered a best practice.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.
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
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 authenticationA decorator takes a function, adds behavior around it, and returns a new function without touching the original code.
To understand decorators better, we will first need to understand a few core Python concepts:
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.
Imagine there are many functions in a project, and each function needs logging.
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:
Decorators solve this problem by reusing common functionality.
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.
def log_function(func):
def wrapper(a, b):
print("Function started")
result = func(a, b)
print("Function ended")
return result
return wrapper
@log_function
def add(a, b):
return a + b
@log_function
def multiply(a, b):
return a * b
print(add(2, 3))
print(multiply(4, 5))
Output:
Function started
Function ended
5
Function started
Function ended
20
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.
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
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:
Imagine 100 functions needing logging. Without decorators:
With decorators:
This is one of the biggest reasons decorators are widely used in real-world Python projects and frameworks like:
A few of the most common practical examples are listed here, from solo projects to production systems.
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.
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.
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.
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.
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.
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
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
| 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 |
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:
These decorators make applications cleaner, easier to maintain, and more readable.
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.”
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.
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:
This becomes extremely useful in large applications with many protected routes.
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:
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.
This helps:
Without decorators, manual checks would be needed inside every function.
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:
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.
functools.wrapsOne 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:
To solve this problem, Python provides functools.wraps.
functools.wrapsfrom 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.
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
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:
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.
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_two wraps the function firstdecorator_one wraps the result nextdef 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
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:
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.
One of the most common mistakes is forgetting to use *args and **kwargs inside the wrapper function.
Incorrect example:
def decorator(func):
def wrapper():
return func()
return wrapper
This only works for functions without arguments.
A better approach is:
def decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Another common mistake is forgetting to return the original function result. Example:
def decorator(func):
def wrapper(*args, **kwargs):
func(*args, **kwargs)
return wrapper
Here, the wrapper calls the function but does not return its output. The correct version should be:
def decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Metadata loss is also a major issue. Without functools.wraps, the decorated function loses its original name, docstring, and debugging information.
functools.wraps considered important?When a decorator wraps a function, Python replaces the original function with the wrapper function. This causes metadata such as:
to be lost.
Using functools.wraps preserves the original metadata.
Example:
from functools import wraps
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
This is considered a best practice in production-grade Python applications.
Yes. Python allows decorators to be chained together.
Example:
@decorator_one
@decorator_two
def greet():
print("Hello")
Python applies decorators from bottom to top. Internally:
greet = decorator_one(decorator_two(greet))
This pattern is heavily used in frameworks such as Flask and Django for combining authentication, caching, logging, and validation.
Decorators are powerful, but they are not always the right solution. Using decorators for very small or simple logic may introduce unnecessary abstraction. Example:
@print_message
def add(a, b):
return a + b
If the additional behavior is extremely small, directly writing the logic inside the function may be more readable. Decorators can also increase debugging complexity because the execution flow becomes layered through multiple wrappers. In small scripts or beginner projects, excessive decorator usage may lead to over-engineering. Decorators are most valuable when functionality needs to be reused across multiple functions.
Decorators should remain simple and focused on a single responsibility. A good decorator:
Example of clear naming:
@login_required
@cache_page(60)
@retry(3)
These names immediately communicate the purpose of each decorator.
Using functools.wraps should also be standard practice in almost every decorator implementation. Deeply nested decorators should generally be avoided because they reduce readability and make debugging harder.
Decorators help frameworks separate business logic from infrastructure logic. For example:
@app.route("/")
@login_required
def dashboard():
return "Dashboard"
The function focuses only on application logic, while decorators handle:
This creates cleaner and more maintainable applications.
Decorators introduce a small amount of overhead because additional wrapper functions are executed. However, in most applications, the performance impact is negligible. The benefits of cleaner architecture and reusable logic usually outweigh the minor overhead. That said, extremely deep decorator chains in performance-critical systems should be designed carefully.
Yes. Decorators can intercept, validate, modify, or replace both arguments and return values. Example:
def uppercase(func):
def wrapper():
result = func()
return result.upper()
return wrapper
Using it:
@uppercase
def greet():
return "hello"
Output:
HELLO
This capability makes decorators useful for:
A normal function performs a task directly. A decorator modifies or extends the behavior of another function without changing its source code. For example:
def greet():
print("Hello")
This simply executes logic. A decorator wraps additional behavior around that logic. This enables reusable cross-cutting functionality across multiple functions.
No. Decorators can also be applied to:
Python internally uses decorators such as:
@property
@staticmethod
@classmethod
These built-in decorators modify class behavior in different ways.
Decorators centralize repeated functionality into reusable components. Without decorators, logic such as logging or authentication may be duplicated across dozens of functions. With decorators:
@log_function
@login_required
The functionality is written once and reused everywhere. This reduces duplication, simplifies updates, and improves maintainability in large applications.
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.
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
With a strong background in data science and over six years of experience, I am passionate about creating in-depth content on technologies. Currently focused on AI, machine learning, and GPU computing, working on topics ranging from deep learning frameworks to optimizing GPU-based workloads.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!
Reach out to our team for assistance with GPU Droplets, 1-click LLM models, AI Agents, and bare metal GPUs.
Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.
Full documentation for every DigitalOcean product.
The Wave has everything you need to know about building a business, from raising funding to marketing your product.
Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.
New accounts only. By submitting your email you agree to our Privacy Policy
Scale up as you grow — whether you're running one virtual machine or ten thousand.
From GPU-powered inference and Kubernetes to managed databases and storage, get everything you need to build, scale, and deploy intelligent applications.