def make_uppercase(func):
def wrapper():
= func()
original_result return original_result.upper()
return wrapper
@make_uppercase
def greet2():
return "Hello, world!"
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.
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:
- How to handle a fixed number of arguments
- How to handle any number of arguments (using *args and **kwargs)
- 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
= func(first_name, last_name)
original_result return original_result.upper()
return wrapper
@make_uppercase
def greet(first_name, last_name):
return f"hello, {first_name} {last_name}"
"Han", "Solo") greet(
'HELLO, HAN SOLO'
To clarify how the greet
function parameters (first_name
, last_name
) are handled here’s what happens:
- When Python sees
@make_uppercase
, it passes thegreet
function tomake_uppercase
make_uppercase
creates and returns thewrapper
function- Python then replaces
greet
with thiswrapper
function - So when we call
greet("Han", "Solo")
, we’re actually callingwrapper("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):
= func(*args, **kwargs)
original_result 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)
callsfunc(*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”}
- For
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
= func(*args, **kwargs)
original_result 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)
:
- First,
uppercase_with_repetition(times=3)
is called, setting up thetimes
parameter - It returns the
decorator
function, which Python then uses as the actual decorator - The
decorator
function receives ourgreet
function as itsfunc
parameter decorator
returns thewrapper
function, which becomes the newgreet
- When we call
greet("Yoda")
, we’re actually callingwrapper("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:
validate_range(min_val=0, max_val=10)
creates a decorator with these validation parameters- The decorator wraps our
calculate_square
function - 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:
- Class decorators (decorating entire classes instead of functions)
- Decorators with state (remembering information between calls)
- Stacking multiple decorators
- 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:
= 0
instances def __init__(self, *args, **kwargs):
+= 1
CountedClass.instances self.wrapped = cls(*args, **kwargs)
return CountedClass
@make_countable
class Person:
def __init__(self, name):
self.name = name
# Create some instances
= Person("Alice")
p1 = Person("Bob")
p2 = Person("Charlie")
p3
# 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:
- The Decorator Function:
def make_countable(cls): # Takes the original Person class as 'cls'
- The Wrapper Class:
class CountedClass:
= 0 # Class variable shared by all instances instances
- What happens during decoration:
- When Python sees
@make_countable
aboveclass Person
- It passes the
Person
class tomake_countable
make_countable
returnsCountedClass
Person
actually becomesCountedClass
- What happens when we create a Person:
= Person("Alice") # This actually creates a CountedClass instance p1
CountedClass.__init__
runs- Increments the shared
instances
counter - Creates a real
Person
instance and stores it inself.wrapped
- So when we do:
# Gets CountedClass.instances
Person.instances # Gets the name from the wrapped Person instance p1.wrapped.name
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
= super().__getattribute__('wrapped').__getattribute__(name)
attr
# If it's a method, add logging
if callable(attr):
def logged_method(*args, **kwargs):
print(f"Calling {name} with args: {args}, kwargs: {kwargs}")
= attr(*args, **kwargs)
result 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
= Calculator(start=10)
calc 5)
calc.add(2) calc.multiply(
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:
@add_logging
creates a newLoggedClass
that wraps ourCalculator
- When we create a
Calculator
, it’s actually creating aLoggedClass
instance __getattribute__
intercepts every attribute access:- If the attribute is a method, it wraps it with logging
- If not, it returns the original attribute
- 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
= 0
count
def wrapper(*args, **kwargs):
nonlocal count
+= 1
count 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
:
- Closure:
- When
count_calls
runs, it creates thecount
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
- The
nonlocal
keyword:
- Without
nonlocal
, Python would create a new localcount
variable in wrapper nonlocal
tells Python to use thecount
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):
= 0
count 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):
= 0
count def wrapper(*args, **kwargs):
nonlocal count
print(f"Current count is {count}")
+= 1 # Modifying needs nonlocal
count 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:
- Read-only version (
make_counter_read_only
):
def make_counter_read_only(func):
= 0 # Creates count variable in closure
count 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
withoutnonlocal
because we’re only reading
- Modifying version (
make_counter_with_modify
):
def make_counter_with_modify(func):
= 0 # Creates count variable in closure
count def wrapper(*args, **kwargs):
nonlocal count # Tells Python we want to modify outer count
print(f"Current count is {count}")
+= 1 # Modifies the count
count return func(*args, **kwargs)
return wrapper
- We need
nonlocal
because we’re modifyingcount
- 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:
- State (
count
) is stored as an instance variable __call__
makes the class instance callable, replacing our previouswrapper
function- No need for
nonlocal
since we’re using instance attributes
Note: CountCalls
is the decorator itself, not a decorated class.
When we write
@CountCalls
abovegreet
, Python does:= CountCalls(greet) greet
This creates an instance of
CountCalls
that:- Stores the original
greet
function asself.func
- Has a
count
attribute starting at 0 - Can be called like a function (due to
__call__
)
- Stores the original
Now when we call
greet("Alice")
, Python actually calls:__call__(greet_instance, "Alice") CountCalls.
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):
= func(*args, **kwargs)
result return result.upper()
return wrapper
def add_stars(func):
def wrapper(*args, **kwargs):
= func(*args, **kwargs)
result 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:
- When Python sees the stacked decorators, it’s equivalent to:
= add_stars(uppercase(greet)) greet
- The process happens from bottom to top:
# First, @uppercase is applied:
def greet(name):
return f"hello, {name}"
= uppercase(greet) # Now greet is the uppercase wrapper
greet
# Then @add_stars is applied to that result:
= add_stars(greet) # Now greet is the stars wrapper greet
- When we call
greet("alice")
, the flow is:
"alice")
greet(
→ add_stars wrapper runs
→ uppercase wrapper runs"hello, alice"
→ original greet runs: "HELLO, ALICE"
← uppercase returns: "*** HELLO, ALICE ***" ← add_stars returns:
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):
= func(*args, **kwargs)
result return f"{emoji} {result} {emoji}"
return wrapper
return decorator
def add_bold(func):
def wrapper(*args, **kwargs):
= func(*args, **kwargs)
result return f"**{result}**"
return wrapper
def add_sparkles(func):
def wrapper(*args, **kwargs):
= func(*args, **kwargs)
result 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:
- 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
- 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 forfunction = 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.