DEV Community

Cover image for Demystifying Python Decorators, Part 2: The Pythonic Way and Advanced Usage
Aaron Rose
Aaron Rose

Posted on

Demystifying Python Decorators, Part 2: The Pythonic Way and Advanced Usage

Welcome back! In Part 1, we discovered that decorators are just functions that enhance other functions, and we built one manually using my_function = decorator(my_function). Now we're ready to learn Python's elegant @ syntax and tackle the crucial details that separate amateur decorators from professional ones.

The Pythonic Way: The @ Syntax

The manual reassignment from Part 1 works perfectly, but it's clunky. Python provides cleaner syntax: the @ symbol.

The @timer placed above a function is simply "syntactic sugar" for greet = timer(greet). It keeps the enhancement visible right where the function is defined.

import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        try:
            result = func(*args, **kwargs)
        except Exception as e:
            end = time.time()
            print(f"{func.__name__} took {end - start:.2f} seconds to run (with error).")
            raise e
        end = time.time()
        print(f"{func.__name__} took {end - start:.2f} seconds to run.")
        return result
    return wrapper

@timer
def greet(name):
    time.sleep(1)
    print(f"Hello, {name}!")

greet("Charlie")
Enter fullscreen mode Exit fullscreen mode

Much cleaner! When you see @timer, you instantly know the function below has timing functionality.

A Crucial Detail: Preserving Function Metadata

Notice that mysterious @functools.wraps(func) line? It solves a critical problem with our wrapper function.

The Problem: Our wrapper "hides" the original function's identity. Without functools.wraps, this happens:

def timer_bad(func):
    def wrapper(*args, **kwargs):
        # ... timing logic ...
        return func(*args, **kwargs)
    return wrapper

@timer_bad
def greet(name):
    """Greets a person by name."""
    pass

print(greet.__name__)  # Prints: wrapper (not greet!)
print(greet.__doc__)   # Prints: None (lost the docstring!)
Enter fullscreen mode Exit fullscreen mode

This breaks debugging tools and documentation.

The Solution: @functools.wraps(func) copies the original function's metadata (like __name__, __doc__, and __module__) to the wrapper.

print(greet.__name__)  # Now prints: greet
print(greet.__doc__)   # Now prints: Greets a person by name.
Enter fullscreen mode Exit fullscreen mode

Always use functools.wraps in your decorators - it's the mark of professional Python code.

Taking It Further: Decorators with Arguments

What if we want our timer to display milliseconds instead of seconds? We need a decorator factory - a function that creates decorators based on arguments.

import time
import functools

def timer(unit='s'):  # 1. Takes decorator arguments
    def decorator(func):  # 2. The actual decorator
        @functools.wraps(func)
        def wrapper(*args, **kwargs):  # 3. The wrapper function
            start = time.time()
            exception_occurred = False
            caught_exception = None

            try:
                result = func(*args, **kwargs)
            except Exception as e:
                exception_occurred = True
                caught_exception = e
                result = None

            end = time.time()
            elapsed = (end - start) * 1000 if unit == 'ms' else (end - start)
            unit_name = 'milliseconds' if unit == 'ms' else 'seconds'
            status = ' (with error)' if exception_occurred else ''
            print(f"{func.__name__} took {elapsed:.2f} {unit_name} to run{status}.")

            if exception_occurred:
                raise caught_exception
            return result
        return wrapper
    return decorator

@timer(unit='ms')
def fast_function():
    time.sleep(0.001)

@timer()  # Uses default unit 's'
def slow_function():
    time.sleep(2)

fast_function()
slow_function()
Enter fullscreen mode Exit fullscreen mode

This three-layer pattern works like this:

  1. timer(unit='ms') creates and returns the decorator
  2. That decorator decorates fast_function
  3. The wrapper handles the actual function calls

Important: When using decorator factories, always include parentheses - even for defaults. Use @timer() not @timer (which would pass the function directly to the factory instead of the decorator).

Key Takeaways

You've now mastered the essential decorator patterns:

  • The @ syntax provides clean, readable decoration
  • functools.wraps preserves function metadata - always use it
  • Decorator factories enable flexible, configurable decorators

These patterns power many Python features, from Flask's @app.route('/home') to @property decorators. You now have the tools to create elegant, reusable functionality that makes your Python code cleaner and more maintainable.

Decorators transform repetitive code into clean, declarative enhancements. With this foundation, you're ready to recognize and create decorator patterns that will make your Python code truly shine.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)