DEV Community

Cover image for Python Decorators: From Basics to Real-World Use Cases
DigitalOcean for DigitalOcean

Posted on • Originally published at digitalocean.com

Python Decorators: From Basics to Real-World Use Cases

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_name syntax is a cleaner way of wrapping functions.
  • Decorators are commonly used for logging, authentication, caching, validation, and performance monitoring.
  • *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.
  • 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!")
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Output:

--- Before ---
Hello, world!
--- After ---
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Output:

Task done!
slow_task took 1.0012 seconds
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 2: Apply the Decorator

@log_function
def add(a, b):
    return a + b


@log_function
def multiply(a, b):
    return a * b
Enter fullscreen mode Exit fullscreen mode

Calling the Functions

print(add(2, 3))
print(multiply(4, 5))
Enter fullscreen mode Exit fullscreen mode

Output:

Function started
Function ended
5

Function started
Function ended
20
Enter fullscreen mode Exit fullscreen mode

What changed?

The functions now only contain their main logic:

return a + b
Enter fullscreen mode Exit fullscreen mode

and

return a * b
Enter fullscreen mode Exit fullscreen mode

The extra behavior (logging) is handled by the decorator separately.

Visual understanding

When this runs:

add(2, 3)
Enter fullscreen mode Exit fullscreen mode

Python internally does this:

add = log_function(add)
Enter fullscreen mode Exit fullscreen mode

So the actual flow becomes:

wrapper()
    ├── print("Function started")
    ├── call original add()
    ├── print("Function ended")
    └── return result
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Notice this is a decorator factoryretry(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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Here:

@app.route("/")
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Here:

@login_required
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

The decorator:

@require_POST
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

Here:

@cache_page(60)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Using it:

@decorator
def greet():
   """This function greets the user"""
   print("Hello")
Enter fullscreen mode Exit fullscreen mode

Now checking the function name:

print(greet.__name__)
Enter fullscreen mode Exit fullscreen mode

Output:

wrapper
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Using it again:

@decorator
def greet():
   """This function greets the user"""
   print("Hello")
Enter fullscreen mode Exit fullscreen mode

Now:

print(greet.__name__)
Enter fullscreen mode Exit fullscreen mode

Output:

greet
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Using it:

@repeat(3)
def greet():
   print("Hello")
Enter fullscreen mode Exit fullscreen mode

Calling:

greet()
Enter fullscreen mode Exit fullscreen mode

Output:

Hello
Hello
Hello
Enter fullscreen mode Exit fullscreen mode

Understanding the structure

This example contains three functions:

repeat()         accepts decorator arguments
decorator()      accepts the original function
wrapper()        executes additional logic
Enter fullscreen mode Exit fullscreen mode

The execution flow becomes:

greet = repeat(3)(greet)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

A caching decorator may accept an expiration time:

@cache(expire=60)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This is internally interpreted as:

func = decorator_one(decorator_two(func))
Enter fullscreen mode Exit fullscreen mode

The execution order is important.

Python applies decorators from bottom to top:

  1. decorator_two wraps the function first
  2. decorator_one wraps 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
Enter fullscreen mode Exit fullscreen mode

Applying both decorators:

@decorator_one
@decorator_two
def greet():
   print("Hello")
Enter fullscreen mode Exit fullscreen mode

Calling:

greet()
Enter fullscreen mode Exit fullscreen mode

Output:

Decorator One - Before
Decorator Two - Before
Hello
Decorator Two - After
Decorator One - After
Enter fullscreen mode Exit fullscreen mode

Understanding the execution flow

The function call stack becomes:

decorator_one(
   decorator_two(
       greet
   )
)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)