Python decorators: A super useful feature

Python decorators are one of my favorite features—they allow you to extend the functionality of existing functions or methods in a reusable and elegant way. Over the years, I’ve found them invaluable for streamlining repetitive tasks across different functions in my projects. Below are some interesting examples I’ve worked on

  • Prometheus Histogram Timing Decorators
  • OpenTelemetry (OTel) Manual Span Decorators

Common Use Cases for Python Decorators

1. Timing Functions with Prometheus

Measuring the execution time of functions is a classic use case for decorators. While custom decorators can do the job, the Prometheus Python client library provides a built-in time decorator, making this task even simpler.

Using the Built-In time Decorator

from prometheus_client import Histogram

REQUEST_LATENCY = Histogram('request_latency_seconds', 'Time spent processing request')

@REQUEST_LATENCY.time()
def process_request(data):
    # Simulate processing
    import time
    time.sleep(2)
    return f"Processed {data}"

process_request("example")

This time() decorator wraps the function, measures its execution time, and updates the histogram. It’s concise, readable, and integrates seamlessly with Prometheus.

How the time Decorator Works Internally

Here’s a simplified example of how the time decorator is implemented in the Prometheus Python library:

from time import time
from functools import wraps

class Histogram:
    def __init__(self, name, description):
        self.name = name
        self.description = description
        self.observations = []

    def observe(self, value):
        self.observations.append(value)
        print(f"Observed {value} seconds for {self.name}")

    def time(self):
        def decorator(func):
            @wraps(func)
            def wrapper(*args, **kwargs):
                start_time = time()
                try:
                    return func(*args, **kwargs)
                finally:
                    duration = time() - start_time
                    self.observe(duration)
            return wrapper
        return decorator

This decorator is a great example of how reusable logic is encapsulated to ensure consistent behavior.

Custom Timing Decorator

If you need custom behavior beyond just timing, you can create your own decorator:

import time

def timing_decorator(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"{func.__name__} executed in {end_time - start_time:.4f} seconds")
        return result
    return wrapper

@timing_decorator
def slow_function():
    time.sleep(2)
    print("Finished slow_function")

slow_function()

2. Tracing Functions with OpenTelemetry

When implementing distributed tracing, decorators can simplify the process of adding spans to functions. Here’s an example using OpenTelemetry:

from opentelemetry.trace import get_tracer

tracer = get_tracer(__name__)

def otel_span_decorator(span_name):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            with tracer.start_as_current_span(span_name):
                return func(*args, **kwargs)
        return wrapper
    return decorator

@otel_span_decorator("process_request_span")
def process_request(data):
    # Simulate request processing
    print(f"Processing {data}")

process_request("example")

This approach ensures spans are consistently created and closed without manually repeating the tracing logic in each function.

Side Note: Retaining Function Signatures

One important thing to watch out for when working with decorators is that they can alter the original function’s signature. This affects tools like help(), inspect, or frameworks that rely on function metadata (e.g., Flask, FastAPI).

To retain the original signature, use functools.wraps:

from functools import wraps

def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def greet(name):
    """Greets the user by name."""
    return f"Hello, {name}!"

print(greet.__name__)  # Outputs: greet
print(greet.__doc__)   # Outputs: Greets the user by name.

Tip: Always use @wraps when writing decorators to avoid unexpected behavior!

Additional Use Cases for Decorators

Here are other practical examples of Python decorators which I’ve seen being used. I will update this space if I come across any.

  1. Retrying Functions: Handle transient errors with retries.
  2. Setting a Maximum Execution Time: Enforce timeouts using signal.
  3. Logging Functions: Log function calls, inputs, and outputs.
  4. Simple Debugging: Print function inputs and outputs during development.
  5. Validating Function Inputs/Outputs: Ensure inputs/outputs meet certain criteria.
  6. Waiting/Rate-Limiting: Add delays to prevent overloading APIs.
  7. Caching/Memoization: Use functools.lru_cache for expensive calculations.
  8. Handling Database Transactions: Ensure proper commit/rollback.
  9. Synchronization: Use locks to prevent race conditions.
  10. Authentication: Enforce access control for web applications.

References

End
Built with Hugo
Theme Stack designed by Jimmy