Report this

What is the reason for this report?

Python Decorators: From Basics to Real-World Use Cases

Published on May 8, 2026
Shaoni Mukherjee

By Shaoni Mukherjee

AI Technical Writer

Python Decorators: From Basics to Real-World Use Cases

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.

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.

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 / 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 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

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:

  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

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.

FAQ’s

1. What are the most common mistakes beginners make with decorators?

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.

2. Why is functools.wraps considered important?

When a decorator wraps a function, Python replaces the original function with the wrapper function. This causes metadata such as:

  • function name
  • docstrings
  • annotations
  • debugging information

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.

3. Can multiple decorators be used on the same function?

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.

4. When should decorators NOT be used?

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.

5. What are the best practices for writing decorators?

Decorators should remain simple and focused on a single responsibility. A good decorator:

  • performs one task clearly
  • has meaningful naming
  • preserves metadata
  • avoids excessive nesting

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.

6. Why are decorators widely used in frameworks?

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:

  • routing
  • authentication
  • caching
  • request validation
  • permissions

This creates cleaner and more maintainable applications.

7. Are decorators slower than normal functions?

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.

8. Can decorators modify function arguments or return values?

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:

  • validation
  • formatting
  • serialization
  • caching
  • data transformation

9. What is the difference between a decorator and a normal function?

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.

10. Are decorators only used with functions?

No. Decorators can also be applied to:

  • classes
  • methods
  • static methods
  • properties

Python internally uses decorators such as:

@property
@staticmethod
@classmethod

These built-in decorators modify class behavior in different ways.

11. Why do decorators improve code maintainability?

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.

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.

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about our products

About the author

Shaoni Mukherjee
Shaoni Mukherjee
Author
AI Technical Writer
See author profile

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.

Category:

Still looking for an answer?

Was this helpful?


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!

Creative CommonsThis work is licensed under a Creative Commons Attribution-NonCommercial- ShareAlike 4.0 International License.
Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

The developer cloud

Scale up as you grow — whether you're running one virtual machine or ten thousand.

Start building today

From GPU-powered inference and Kubernetes to managed databases and storage, get everything you need to build, scale, and deploy intelligent applications.

Dark mode is coming soon.