If you've spent any time with Python, you've almost certainly seen the @
symbol above a function definition. This is a decorator, a powerful feature that can seem a bit magical at first. But what if I told you that decorators are just a logical extension of a core Python principle you already know?
Think of a decorator like a gift wrapper for a function. It doesn't change the gift inside (the function's core logic), but it adds a beautiful new layer of functionality on the outside.
In this first part of our series, we'll demystify the core idea behind decorators by understanding the problem they solve and the fundamental concept that makes them possible. By the end, you'll see how the manual decorator pattern we build here leads naturally to Python's elegant @decorator
syntax in Part 2.
The Problem: Adding Functionality the Hard Way
Imagine you have a few simple functions and you want to time how long each one takes to run. A common, but flawed, approach is to add the timing code directly to each function.
import time
def greet(name):
# Timing code
start = time.time()
time.sleep(1) # Simulate some work
print(f"Hello, {name}!")
# Timing code
end = time.time()
print(f"greet took {end - start:.2f} seconds to run.")
def calculate_sum(a, b):
# Timing code
start = time.time()
time.sleep(0.5) # Simulate some work
result = a + b
# Timing code
end = time.time()
print(f"calculate_sum took {end - start:.2f} seconds to run.")
return result
greet("Alice")
calculate_sum(5, 7)
This works, but it's terrible practice. We're repeating the exact same timing logic, violating the DRY (Don't Repeat Yourself) principle. If we need to change how we measure time, we have to edit every function. There has to be a better way to reuse this code.
The Solution: Functions as First-Class Objects
The secret to solving this problem lies in one of Python's most powerful features: functions are first-class objects. This means you can treat functions just like any other variable in Python. Specifically, you can assign them to variables, pass them as arguments to other functions, and return them from functions just like you would with strings, numbers, or lists.
This is the key that unlocks decorators. Let's use this idea to create a timer
function that takes another function as an argument, adds timing to it, and returns a new function with the added capability.
import time
def timer(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
# Let's try it out!
def greet(name):
time.sleep(1)
print(f"Hello, {name}!")
# Note: All timing logic has been removed and is now handled by the decorator!
# Manually "decorate" the function by reassigning it
greet = timer(greet)
Here, we pass the original greet
function to timer()
. The timer
function returns the new wrapper
function, and we reassign the name greet
to this new function. Now, when we call greet("Bob")
, we are actually calling wrapper("Bob")
.
# Now, when we call greet(), it runs the timing code first!
greet("Bob")
Let's break down what's happening in the timer
function:
-
Accept a function:
timer(func)
takes any function as its parameter -
Define a wrapper: Inside
timer
, we create a new function calledwrapper
that will replace the original -
Execute the original: The wrapper calls the original function with
func(*args, **kwargs)
, preserving all arguments - Add our enhancement: We wrap the function call with timing logic
-
Return the wrapper:
timer
returns this new enhanced function
The *args
and **kwargs
syntax ensures our wrapper can handle any function, regardless of how many arguments it takes or whether they're positional or keyword arguments.
Why This Matters
Congratulations! You've just created a decorator. The timer
function is a decorator because it "decorates" or enhances another function's behavior without modifying its source code.
This pattern is incredibly powerful because it lets you:
- Add functionality to existing functions without changing their code
- Apply the same enhancement to multiple functions
- Keep your core business logic clean and separate from cross-cutting concerns like timing, logging, or authentication
In our next article, we'll introduce the clean and elegant @
syntax that makes this entire process much simpler and more readable. While this manual pattern works perfectly, it has a small limitation: it slightly obscures the original function's metadata (like its name and docstring). In Part 2, we'll not only learn the clean @
syntax but also how to use functools.wraps
to fix this issue and create perfect decorators. You'll see how @timer
above a function definition is just syntactic sugar for the manual approach we've learned here.
Ready to write more Pythonic code? Follow for practical Python insights that will transform your programming style.
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.
Top comments (0)