Of course! Let's break down Python decorators from the ground up. They are a powerful and elegant feature, but they can seem tricky at first.

The Core Idea: What is a Decorator?
Imagine you have a function, and you want to add some new functionality to it without changing its original code. This is a very common need in programming.
A decorator is a function that takes another function as an argument (the "decorated" function), adds some behavior to it, and returns a new function (usually the original function with the added behavior).
In short, decorators are a way to modify or extend the behavior of functions or methods without permanently modifying them. They are often used for:
- Logging
- Access control and authentication
- Measuring execution time
- Caching
- Debugging
The Building Blocks: Functions are First-Class Citizens
To understand decorators, you first need to understand that in Python, functions are first-class objects. This means you can:

-
Assign a function to a variable:
def say_hello(): print("Hello!") greet = say_hello greet() # Output: Hello! -
Define a function inside another function:
def outer_function(): print("I am the outer function.") def inner_function(): print("I am the inner function.") inner_function() outer_function() # Output: # I am the outer function. # I am the inner function. -
Pass a function as an argument to another function:
def say_bye(): print("Goodbye!") def call_something(func): print("About to call the function...") func() print("...function called.") call_something(say_bye) # Output: # About to call the function... # Goodbye! # ...function called.
This third point is the key to understanding decorators.
Creating a Simple Decorator
Let's build a decorator from scratch. Our goal is to create a decorator that logs a message before and after another function is called.
Step 1: The "Wrapper" Function
We need a function that will wrap our target function. This wrapper will contain the extra logic (the logging) and then call the original function.
def my_decorator(func):
# This is the wrapper function
def wrapper():
print("Something is happening before the function is called.")
func() # Call the original function
print("Something is happening after the function is called.")
return wrapper # Return the wrapper function
Step 2: Applying the Decorator Manually
Now, let's see how to use it. We can apply it manually by reassigning the function name.
def say_greeting():
print("Hello, world!")
# Manually apply the decorator
say_greeting = my_decorator(say_greeting)
# Now, when we call say_greeting(), we are actually calling the wrapper()
say_greeting()
Output:
Something is happening before the function is called.
Hello, world!
Something is happening after the function is called.
Notice that say_greeting now points to the wrapper function returned by my_decorator. The original say_greeting function is still there, but we've wrapped it with new logic.
The Syntax: Syntactic Sugar
Writing say_greeting = my_decorator(say_greeting) every time is a bit clunky. Python provides a much cleaner syntax using the symbol.
This is called the "pie" syntax, and it does the exact same thing as the manual reassignment.
@my_decorator
def say_greeting():
print("Hello, world!")
# Now, calling say_greeting() is clean and readable
say_greeting()
The output is identical. The @my_decorator line is just a shortcut for say_greeting = my_decorator(say_greeting).
A More Practical Example: Timing a Function
Let's create a decorator that measures how long a function takes to run. This is a very common use case.
import time
def timing_decorator(func):
def wrapper():
# Record the start time
start_time = time.time()
# Call the original function
func()
# Record the end time and calculate the duration
end_time = time.time()
print(f"'{func.__name__}' took {end_time - start_time:.4f} seconds to execute.")
return wrapper
@timing_decorator
def long_running_task():
time.sleep(2) # Simulate a task that takes 2 seconds
long_running_task()
Output:
'long_running_task' took 2.0012 seconds to execute.
Decorators with Arguments
What if the function we want to decorate takes arguments? Our current wrapper function doesn't accept any. We need to make it more flexible.
We can use *args and **kwargs to pass any number of positional and keyword arguments to the original function.
Let's modify our timing_decorator to work with functions that have arguments.
import time
def timing_decorator(func):
def wrapper(*args, **kwargs):
start_time = time.time()
# Call the original function with its arguments
result = func(*args, **kwargs)
end_time = time.time()
print(f"'{func.__name__}' took {end_time - start_time:.4f} seconds to execute.")
# It's good practice to return the result of the original function
return result
return wrapper
@timing_decorator
def calculate_sum(n):
total = 0
for i in range(n):
total += i
return total
# Now we can call the decorated function with arguments
result = calculate_sum(1000000)
print(f"The sum is: {result}")
Output:
'calculate_sum' took 0.0481 seconds to execute.
The sum is: 499999500000
Notice that wrapper now accepts *args, **kwargs, passes them to func, and also returns the result of func.
functools.wraps: Preserving Function Metadata
When you decorate a function, the original function's name (__name__), docstring (__doc__), and other metadata are lost because they are replaced by the wrapper function's metadata.
def my_decorator(func):
def wrapper():
"""Wrapper docstring."""
return func()
return wrapper
@my_decorator
def example():
"""Example docstring."""
print("hello")
print(example.__name__) # Output: wrapper
print(example.__doc__) # Output: Wrapper docstring.
This is bad for debugging and introspection. To fix this, use the functools.wraps decorator from the standard library. It's a decorator that you apply to your wrapper function.
from functools import wraps
def my_decorator(func):
@wraps(func) # Apply wraps to the wrapper
def wrapper():
"""Wrapper docstring."""
return func()
return wrapper
@my_decorator
def example():
"""Example docstring."""
print("hello")
print(example.__name__) # Output: example
print(example.__doc__) # Output: Example docstring.
Now the metadata is correctly preserved from the original example function. Always use @wraps when writing your own decorators.
Decorators with Arguments (Advanced)
What if you want to pass arguments to the decorator itself? For example, a logger that can take a log level as an argument.
To do this, you need one more layer of nesting. The decorator becomes a function that returns the actual decorator.
from functools import wraps
def repeat(num_times):
"""Decorator that repeats a function a given number of times."""
def decorator_repeat(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator_repeat
@repeat(num_times=3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
Output:
Hello, Alice!
Hello, Alice!
Hello, Alice!
Here's how it works:
- Python sees
@repeat(num_times=3). - It first calls
repeat(num_times=3). This function returns thedecorator_repeatfunction. - Then, Python applies
@decorator_repeatto thegreetfunction, just like before.
Summary
| Concept | Description | Example |
|---|---|---|
| First-Class Functions | Functions can be passed as arguments, returned from other functions, and assigned to variables. | def foo(): pass, bar = foo, baz(foo) |
| Basic Decorator | A function that takes a function, wraps it with extra logic, and returns the wrapper. | def my_decorator(func): ... return wrapper |
| Syntax | A cleaner, more readable way to apply a decorator. | @my_decorator |
| *`args, kwargs` | Used in the wrapper to make the decorator work with functions that have any number of arguments. | def wrapper(*args, **kwargs): ... |
functools.wraps |
A decorator for decorators that preserves the original function's metadata (like __name__ and __doc__). |
from functools import wraps, @wraps(func) |
| Decorators with Args | A three-level nested function used to pass arguments to the decorator itself. | def repeat(n): def decorator(func): ... |
