Understanding Python Decorators: Beginner to Advanced

Discover how decorators can transform your functions, manage state, enhance code reusability, and help to make your code more elegant and maintainable.
Python
Tutorial
Author

David Gwyer

Published

December 19, 2024

Python decorators are one of the language’s most elegant and powerful features. They enable you to add functionality, modify behavior, or transform output while keeping the original code intact. In this comprehensive guide, we’ll delve deeply into decorators, starting from basic function decorators and working our way up to more advanced concepts like class-based decorators and state management.

Decorator Basics

First, let’s understand what a decorator really is at its core. It’s really just a function that takes another function as an argument and returns a modified version of that function.

The syntax for using a decorator (introduced in Python 2.4) on a function is a string prefixed with an @ character. e.g. @mydecorator.

Simple Decorator Example

Let’s create a simple decorator that converts a function return value to uppercase.

def make_uppercase(func):
    def wrapper():
        original_result = func()
        return original_result.upper()
    return wrapper

@make_uppercase
def greet2():
    return "Hello, world!"
greet2()
'HELLO, WORLD!'

Let’s break down what’s happening in this example:

The make_uppercase function is our decorator; it takes a function as input. Inside it, we define a wrapper function that: - Calls the original function - Modifies its result - Returns the modified result The decorator then returns this wrapper function

The wrapper function is necessary here because we need to create a new function that has access to the original function (through closure), can execute code before and/or after the original function, and controls when (or if) the original function gets called.

Decorator Function Arguments

Let’s enhance our decorator to be able to handle functions with arguments. The problem with our current decorator is that it only works with functions that take no arguments. We can modify it to be more flexible by showing:

  1. How to handle a fixed number of arguments
  2. How to handle any number of arguments (using *args and **kwargs)
  3. How to create decorators that can themselves take arguments

Fixed Number of Arguments.

Here’s how we can modify our uppercase decorator to handle a function that takes specific arguments:

def make_uppercase(func):
    def wrapper(first_name, last_name):  # Now accepts specific parameters
        original_result = func(first_name, last_name)
        return original_result.upper()
    return wrapper

@make_uppercase
def greet(first_name, last_name):
    return f"hello, {first_name} {last_name}"
greet("Han", "Solo")
'HELLO, HAN SOLO'

To clarify how the greet function parameters (first_name, last_name) are handled here’s what happens:

  1. When Python sees @make_uppercase, it passes the greet function to make_uppercase
  2. make_uppercase creates and returns the wrapper function
  3. Python then replaces greet with this wrapper function
  4. So when we call greet("Han", "Solo"), we’re actually calling wrapper("Han", "Solo")

The parameters never directly go into make_uppercase, instead they go to the wrapper function that make_uppercase created and returned.

Handle Any Number of Arguments

However, our decorator is too specific. It only works with functions that take exactly two arguments named first_name and last_name. We can make this more generic by using *args and **kwargs for flexibility. Here’s how we can make our decorator work with any function, regardless of its arguments:

def make_uppercase(func):
    def wrapper(*args, **kwargs):
        original_result = func(*args, **kwargs)
        return original_result.upper()
    return wrapper

# Example 1: No arguments
@make_uppercase
def greet_simple():
    return "hello"

# Example 2: Positional arguments
@make_uppercase
def greet_name(first, last):
    return f"hello, {first} {last}"

# Example 3: Keyword arguments
@make_uppercase
def greet_formal(title="Mr", first="John", last="Smith"):
    return f"greetings, {title}. {first} {last}"

# Let's try them all:
print(greet_simple())
print(greet_name("Luke", "Skywalker"))
print(greet_formal(title="Dr", first="Stephen", last="Strange"))
HELLO
HELLO, LUKE SKYWALKER
GREETINGS, DR. STEPHEN STRANGE

Here’s how *args and **kwargs make our decorator flexible:

  • *args captures all positional arguments as a tuple
  • **kwargs captures all keyword arguments as a dictionary
  • When wrapper(*args, **kwargs) calls func(*args, **kwargs), it “unpacks” these arguments:
    • For greet_simple(): empty tuple, empty dict
    • For greet_name("Luke", "Skywalker"): args=(“Luke”, “Skywalker”), kwargs={}
    • For greet_formal(title="Dr", ...): args=(), kwargs={“title”: “Dr”, “first”: “Stephen”, “last”: “Strange”}

This way, the wrapper function can accept and pass through any combination of arguments without needing to know the function’s signature in advance.

Decorators That Can Take Arguments

Now let’s take a look at decorators that can take their own arguments. This is where things get really interesting!

Here’s the basic structure of a decorator with arguments:

def uppercase_with_repetition(times=1):  # Outer function for decorator arguments
    def decorator(func):                 # The actual decorator
        def wrapper(*args, **kwargs):    # The wrapper function
            original_result = func(*args, **kwargs)
            return (original_result.upper() + ' ') * times
        return wrapper
    return decorator

We can use this parameterized decorator as follows:

@uppercase_with_repetition(times=3)
def greet(name):
    return f"hello {name}"

@uppercase_with_repetition()  # Using default times=1
def farewell(name):
    return f"goodbye {name}"

# Let's try them:
print(greet("Yoda"))
print(farewell("Obi-Wan"))
HELLO YODA HELLO YODA HELLO YODA 
GOODBYE OBI-WAN 

Notice how the syntax changes - we need parentheses even when using default values. This is because @uppercase_with_repetition(times=3) is actually calling the outer function first, which then returns the decorator.

To clarify how these three nested functions work (for uppercase_with_repetition) together when we use @uppercase_with_repetition(times=3):

  1. First, uppercase_with_repetition(times=3) is called, setting up the times parameter
  2. It returns the decorator function, which Python then uses as the actual decorator
  3. The decorator function receives our greet function as its func parameter
  4. decorator returns the wrapper function, which becomes the new greet
  5. When we call greet("Yoda"), we’re actually calling wrapper("Yoda")

The key difference from before is we need an extra level of nesting to handle the decorator parameters.

Let’s take a look at a real-world example of this in practice. Below is an input validation decorator that checks if arguments meet certain criteria:

def validate_range(min_val=0, max_val=100):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for arg in args:
                if not (min_val <= arg <= max_val):
                    raise ValueError(f"Value {arg} is not between {min_val} and {max_val}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

# Example usage:
@validate_range(min_val=0, max_val=10)
def calculate_square(x):
    return x * x

# Let's try it:
print(calculate_square(5))      # Should work
try:
    print(calculate_square(15)) # Should raise error
except ValueError as e:
    print(f"Error caught: {e}")
25
Error caught: Value 15 is not between 0 and 10

Let’s break down how this works:

  1. validate_range(min_val=0, max_val=10) creates a decorator with these validation parameters
  2. The decorator wraps our calculate_square function
  3. When calculate_square is called, the wrapper:
    • First checks if arguments are within range
    • Only calls the original function if validation passes
    • Raises ValueError if validation fails

Getting More Advanced

In the next section we’ll take a look at more advanced decorators, including:

  1. Class decorators (decorating entire classes instead of functions)
  2. Decorators with state (remembering information between calls)
  3. Stacking multiple decorators
  4. Using functools.wraps to preserve function metadata

Class Decorators

These are a natural progression from function decorators. Take a look at this simple example of how to implement a class decorator:

# Class decorator using a wrapper class
def make_countable(cls):
    class CountedClass:
        instances = 0
        def __init__(self, *args, **kwargs):
            CountedClass.instances += 1
            self.wrapped = cls(*args, **kwargs)
    return CountedClass

@make_countable
class Person:
    def __init__(self, name):
        self.name = name
# Create some instances
p1 = Person("Alice")
p2 = Person("Bob")
p3 = Person("Charlie")

# Check how many instances were created
print(f"Number of Person instances created: {Person.instances}")

# We can still access the wrapped person
print(f"First person's name: {p1.wrapped.name}")
Number of Person instances created: 3
First person's name: Alice

This decorator keeps track of how many instances of the class have been created. Every time we create a new Person, the CountedClass wrapper increments its counter.

Let’s break down how this class decorator works step by step:

  1. The Decorator Function:
def make_countable(cls):  # Takes the original Person class as 'cls'
  1. The Wrapper Class:
class CountedClass:
    instances = 0  # Class variable shared by all instances
  1. What happens during decoration:
  • When Python sees @make_countable above class Person
  • It passes the Person class to make_countable
  • make_countable returns CountedClass
  • Person actually becomes CountedClass
  1. What happens when we create a Person:
p1 = Person("Alice")  # This actually creates a CountedClass instance
  • CountedClass.__init__ runs
  • Increments the shared instances counter
  • Creates a real Person instance and stores it in self.wrapped
  1. So when we do:
Person.instances  # Gets CountedClass.instances
p1.wrapped.name   # Gets the name from the wrapped Person instance

Here’s another example of a class decorator that adds a logging capability to all methods of a class:

def add_logging(cls):
    class LoggedClass:
        def __init__(self, *args, **kwargs):
            self.wrapped = cls(*args, **kwargs)
            
        def __getattribute__(self, name):
            # Get the attribute from the wrapped class
            attr = super().__getattribute__('wrapped').__getattribute__(name)
            
            # If it's a method, add logging
            if callable(attr):
                def logged_method(*args, **kwargs):
                    print(f"Calling {name} with args: {args}, kwargs: {kwargs}")
                    result = attr(*args, **kwargs)
                    print(f"{name} returned: {result}")
                    return result
                return logged_method
            return attr
    
    return LoggedClass
@add_logging
class Calculator:
    def __init__(self, start=0):
        self.value = start
        
    def add(self, x):
        self.value += x
        return self.value
    
    def multiply(self, x):
        self.value *= x
        return self.value

# Let's try it out
calc = Calculator(start=10)
calc.add(5)
calc.multiply(2)
Calling add with args: (5,), kwargs: {}
add returned: 15
Calling multiply with args: (2,), kwargs: {}
multiply returned: 30
30

This will log every method call, showing the arguments and return values. Our logging example works like this:

  1. @add_logging creates a new LoggedClass that wraps our Calculator
  2. When we create a Calculator, it’s actually creating a LoggedClass instance
  3. __getattribute__ intercepts every attribute access:
    • If the attribute is a method, it wraps it with logging
    • If not, it returns the original attribute
  4. The wrapper logs the method call, runs the original method, logs the return value

Let’s break the difference between function decorators and class decorators:

  • Function decorators wrap a function and modify its behavior
  • Class decorators wrap an entire class, allowing you to modify all its methods or add new functionality to the class itself

Key differences:

  • Function decorators return a modified function
  • Class decorators return a modified class
  • Class decorators can affect all methods at once

Just remember that a class decorator takes in a class and returns a modified class, and a standard decorator takes in a function and returns a modified function.

Function Decorator:

def my_function_decorator(func):  # Takes a function
    # Modifies or wraps the function
    return modified_function      # Returns a function

Class Decorator:

def my_class_decorator(cls):     # Takes a class
    # Modifies or wraps the class
    return modified_class        # Returns a class

Both follow the same principle of taking something (function/class), modifying it, and returning the modified version. The main difference is just what they’re modifying.

Decorators With State

Decorators with state are basically decorators that remember information between function calls. Here’s a simple example of a decorator that counts how many times a function has been called:

def count_calls(func):
    # This count variable persists between function calls
    count = 0
    
    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        print(f"This function has been called {count} times")
        return func(*args, **kwargs)
    
    return wrapper

@count_calls
def greet(name):
    return f"Hello, {name}!"
# Let's call the function multiple times
print(greet("Alice"))
print(greet("Bob"))
print(greet("Charlie"))

# The count persists even if we wait between calls
print(greet("David"))

The decorator will keep track of how many times greet has been called, and the count will increase with each call.

Let me expand on this and explain how the state persists through closures and the role of nonlocal:

  1. Closure:
  • When count_calls runs, it creates the count variable
  • The wrapper function “closes over” this variable, keeping it alive
  • Each decorated function gets its own count in its own closure
  • This is why the count persists between calls but is separate for different functions
  1. The nonlocal keyword:
  • Without nonlocal, Python would create a new local count variable in wrapper
  • nonlocal tells Python to use the count from the outer scope
  • This allows us to modify the closed-over variable, not just read it

To clarify, we don’t need nonlocal if we’re just reading the value! nonlocal is only needed when we want to modify the variable from the outer scope.

Let’s see what happens if we try to modify count without using nonlocal? It would raise an error because Python would think we’re trying to create a new local variable.

# Reading only - works fine without nonlocal
def make_counter_read_only(func):
    count = 0
    def wrapper(*args, **kwargs):
        print(f"Current count is {count}")  # Reading works fine
        return func(*args, **kwargs)
    return wrapper

# Modifying - needs nonlocal
def make_counter_with_modify(func):
    count = 0
    def wrapper(*args, **kwargs):
        nonlocal count
        print(f"Current count is {count}")
        count += 1  # Modifying needs nonlocal
        return func(*args, **kwargs)
    return wrapper

@make_counter_read_only
def greet(name):
    return f"Hello, {name}!"

@make_counter_with_modify
def farewell(name):
    return f"Goodbye, {name}!"

Let’s try both decorators multiple times to see the difference:

# Try read-only version multiple times
print("Read-only version:")
print(greet("Alice"))
print(greet("Bob"))
print(greet("Charlie"))  # Count will stay at 0

print("\nModifying version:")
print(farewell("Alice"))
print(farewell("Bob"))
print(farewell("Charlie"))  # Count will increment each time
Read-only version:
Current count is 0
Hello, Alice!
Current count is 0
Hello, Bob!
Current count is 0
Hello, Charlie!

Modifying version:
Current count is 3
Goodbye, Alice!
Current count is 4
Goodbye, Bob!
Current count is 5
Goodbye, Charlie!

The read-only version will always show count as 0 (since it’s just reading), while the modifying version will show the count increasing with each call.

Let’s break down how both decorators are working:

  1. Read-only version (make_counter_read_only):
def make_counter_read_only(func):
    count = 0                    # Creates count variable in closure
    def wrapper(*args, **kwargs):
        print(f"Current count is {count}")  # Just reads count
        return func(*args, **kwargs)        # Calls original function
    return wrapper
  • The count stays 0 forever because we never modify it
  • We can read count without nonlocal because we’re only reading
  1. Modifying version (make_counter_with_modify):
def make_counter_with_modify(func):
    count = 0                    # Creates count variable in closure
    def wrapper(*args, **kwargs):
        nonlocal count          # Tells Python we want to modify outer count
        print(f"Current count is {count}")
        count += 1              # Modifies the count
        return func(*args, **kwargs)
    return wrapper
  • We need nonlocal because we’re modifying count
  • Each call increases count by 1

When we call these functions: - greet("Alice") always shows count=0 because it never changes - farewell("Alice") shows increasing counts because it modifies the value

There is another common way to maintain state in decorators: using a class instead of closure. This can be clearer when you need more complex state. Here’s an example of using a class for decorator state:

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.count = 0
        
    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Call #{self.count} to {self.func.__name__}")
        return self.func(*args, **kwargs)

@CountCalls
def greet(name):
    return f"Hello, {name}!"

# Let's try multiple calls
print(greet("Alice"))
print(greet("Bob"))
print(greet("Charlie"))
Call #1 to greet
Hello, Alice!
Call #2 to greet
Hello, Bob!
Call #3 to greet
Hello, Charlie!

The key differences from our closure version are:

  1. State (count) is stored as an instance variable
  2. __call__ makes the class instance callable, replacing our previous wrapper function
  3. No need for nonlocal since we’re using instance attributes

Note: CountCalls is the decorator itself, not a decorated class.

  1. When we write @CountCalls above greet, Python does:

    greet = CountCalls(greet)
  2. This creates an instance of CountCalls that:

    • Stores the original greet function as self.func
    • Has a count attribute starting at 0
    • Can be called like a function (due to __call__)
  3. Now when we call greet("Alice"), Python actually calls:

    CountCalls.__call__(greet_instance, "Alice")

The key is that CountCalls is the decorator class, not a decorated class. It’s used to decorate other functions, not being decorated itself.

You may be wondering when you’d typically use one approach over the other for maintaining decorator state. Here’s when you might consider choosing each approach:

Use Class-based decorators when:

  • You need complex state management
  • You want to add multiple methods or attributes
  • You need to expose the state or provide methods to interact with it
  • The decorator logic is complex enough that organizing it in a class makes it clearer

Use Closure-based decorators when:

  • You have simple state (like a single counter)
  • The decorator logic is straightforward
  • You don’t need to expose the state externally
  • You want to keep the implementation lighter and more concise

The closure approach is more common for simple cases, while class-based decorators shine when you need more structure or complexity.

Stacking Multiple Decorators

When you stack decorators, they are applied from bottom to top. Here’s a simple example:

def uppercase(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

def add_stars(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return f"*** {result} ***"
    return wrapper

@add_stars
@uppercase
def greet(name):
    return f"hello, {name}"

# Let's see how it works
print(greet("alice"))  # Will uppercase first, then add stars
print("\nLet's break it down:")
print("1. Original: hello, alice")
print("2. After @uppercase: HELLO, ALICE")
print("3. After @add_stars: *** HELLO, ALICE ***")
*** HELLO, ALICE ***

Let's break it down:
1. Original: hello, alice
2. After @uppercase: HELLO, ALICE
3. After @add_stars: *** HELLO, ALICE ***

Let’s break down how the stacking works, step by step:

  1. When Python sees the stacked decorators, it’s equivalent to:
greet = add_stars(uppercase(greet))
  1. The process happens from bottom to top:
# First, @uppercase is applied:
def greet(name):
    return f"hello, {name}"
greet = uppercase(greet)  # Now greet is the uppercase wrapper

# Then @add_stars is applied to that result:
greet = add_stars(greet)  # Now greet is the stars wrapper
  1. When we call greet("alice"), the flow is:
greet("alice")
→ add_stars wrapper runs
  → uppercase wrapper runs
    → original greet runs: "hello, alice"
    ← uppercase returns: "HELLO, ALICE"
  ← add_stars returns: "*** HELLO, ALICE ***"

Each decorator wraps the result of the previous one.

Let’s create another example that may illustrate the differences between outputs for the various decorator stackings:

def add_emoji(emoji):
    def decorator(func):
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return f"{emoji} {result} {emoji}"
        return wrapper
    return decorator

def add_bold(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return f"**{result}**"
    return wrapper

def add_sparkles(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return f"✨ {result} ✨"
    return wrapper

# Try different orders
@add_emoji("🌟")
@add_bold
@add_sparkles
def greet1(name):
    return f"hello, {name}"

@add_sparkles
@add_emoji("🌟")
@add_bold
def greet2(name):
    return f"hello, {name}"

@add_bold
@add_emoji("🌟")
@add_sparkles
def greet3(name):
    return f"hello, {name}"

# Test all versions
print("Style 1:", greet1("alice"))
print("\nStyle 2:", greet2("alice"))
print("\nStyle 3:", greet3("alice"))
Style 1: 🌟 **✨ hello, alice ✨** 🌟

Style 2: ✨ 🌟 **hello, alice** 🌟 ✨

Style 3: **🌟 ✨ hello, alice ✨ 🌟**

Preserve Function Metadata

There is one important practical note about stacking decorators: when you stack multiple decorators, you might want to preserve the original function’s metadata (like its name and docstring). This is where functools.wraps comes in. Let’s see why functools.wraps is important with a before/after example:

from functools import wraps

# Without wraps - problematic
def decorator1(func):
    def wrapper(*args, **kwargs):
        """This is wrapper docstring"""
        return func(*args, **kwargs)
    return wrapper

# With wraps - preserves metadata
def decorator2(func):
    @wraps(func)  # This preserves the original function's metadata
    def wrapper(*args, **kwargs):
        """This is wrapper docstring"""
        return func(*args, **kwargs)
    return wrapper

@decorator1
def greet1(name):
    """Says hello to someone."""
    return f"Hello, {name}!"

@decorator2
def greet2(name):
    """Says hello to someone."""
    return f"Hello, {name}!"

# Let's check the metadata
print("Without wraps:")
print(f"Function name: {greet1.__name__}")
print(f"Docstring: {greet1.__doc__}")

print("\nWith wraps:")
print(f"Function name: {greet2.__name__}")
print(f"Docstring: {greet2.__doc__}")
Without wraps:
Function name: wrapper
Docstring: This is wrapper docstring

With wraps:
Function name: greet2
Docstring: Says hello to someone.

Let’s examine what’s happening here:

Without @wraps:

  • The decorated function greet1 loses its original metadata
  • greet1.__name__ becomes “wrapper” instead of “greet1”
  • The docstring becomes the wrapper’s docstring, not the original function’s
  • This can break tools that rely on function metadata (like help() and documentation generators)

With @wraps:

  • The decorated function greet2 keeps its original metadata
  • greet2.__name__ stays as “greet2”
  • The original docstring (“Says hello to someone”) is preserved
  • All other metadata (like function signature) is also preserved

This is why it’s considered best practice to always use @wraps when writing decorators. It ensures your decorated functions maintain their identity and documentation.

Let’s create an example that shows other important metadata that @wraps preserves:

from functools import wraps
import inspect

def debug_metadata(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

def debug_metadata_wrapped(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@debug_metadata
def function1(x: int, y: str = "default") -> str:
    """Example function with type hints and default values."""
    return y * x

@debug_metadata_wrapped
def function2(x: int, y: str = "default") -> str:
    """Example function with type hints and default values."""
    return y * x

# Let's examine various metadata
print("Without @wraps:")
print(f"Module: {function1.__module__}")
print(f"Annotations: {function1.__annotations__}")
print(f"Default args: {inspect.signature(function1)}")

print("\nWith @wraps:")
print(f"Module: {function2.__module__}")
print(f"Annotations: {function2.__annotations__}")
print(f"Default args: {inspect.signature(function2)}")
Without @wraps:
Module: __main__
Annotations: {}
Default args: (*args, **kwargs)

With @wraps:
Module: __main__
Annotations: {'x': <class 'int'>, 'y': <class 'str'>, 'return': <class 'str'>}
Default args: (x: int, y: str = 'default') -> str

What metadata is preserved by @wraps here? Here’s a break down:

  1. Type Annotations:
  • Without @wraps: Annotations: {}
    • All type hints are lost (int, str, return type)
  • With @wraps: Annotations: {'x': <class 'int'>, 'y': <class 'str'>, 'return': <class 'str'>}
    • Preserves all type hints for parameters and return value
  1. Function Signature:
  • Without @wraps: (*args, **kwargs)
    • Shows only the wrapper’s generic signature
    • Lost the parameter names and default values
  • With @wraps: (x: int, y: str = 'default') -> str
    • Keeps original parameter names
    • Preserves default values
    • Shows return type hint

This matters because: - Type checkers need accurate type hints - Documentation tools need correct signatures - IDE autocompletion relies on this metadata - Testing frameworks often use this information

Summary

We have covered a lot about decorators, from the basics to an advanced level. Let’s recap what we’ve learned:

Core Decorator Concepts

  • A decorator is a function that takes another function/class and extends its behavior
  • It follows the wrapper pattern: wrap original code without modifying it
  • The @ syntax is just syntactic sugar for function = decorator(function)

Types of Decorators

  • Basic function decorators: @decorator
  • Parameterized decorators: @decorator(param)
  • Class decorators: Can modify or wrap entire classes
  • Stacked decorators: Apply multiple decorators in order (bottom to top)

Decorator State Management

  • Using closures: Good for simple state (like counters)
  • Using classes: Better for complex state and when you need additional methods
  • Both approaches maintain state between function calls

Decorator Best Practices

  • Always use @functools.wraps to preserve metadata
  • Keep decorators focused on a single responsibility
  • Consider using class-based decorators for complex functionality
  • Remember decorator order matters when stacking

Tips for Better Understanding

Start simple! Practice with basic decorators before moving to complex ones. Visualize each decorator as a layer wrapping the original function.

Add print statements inside decorators to help understand execution order, and try to use clear naming to track what each decorator does.