Decorators in Python Explained with Practical Examples

I believe the best way to master a concept is to explain it to someone else. I’m a Full Stack Developer navigating the ecosystems of JavaScript and Python, building everything from responsive frontends to robust backends. I use this space to document my learning journey, break down complex topics, and share practical solutions to the bugs I encounter. Let's learn in public together!
Decorators are one of the most powerful features in Python, but they often feel confusing when you first encounter them.
Let's understand them using a simple real-world analogy.
Think of a Toll Booth
Imagine you're driving on a highway.
Before reaching your destination, you pass through a toll booth.
If you're driving a car, you pay a certain amount.
If you're driving a truck, you pay a different amount.
If you're riding a bike, you might not have to pay anything.
But regardless of what you're driving, everyone passes through the toll booth first. The toll booth adds some extra behavior before allowing you to continue your journey.
Decorators work in a very similar way.
When a function is decorated, it doesn't get called directly anymore. Instead, it passes through another function (the decorator) first.
The decorator can:
add extra functionality
modify behavior
log information
measure execution time
cache results
and then allow the original function to execute.
What is a Decorator?
A decorator is simply a function that takes another function as an argument and returns a new function.
Basic structure:
def decorator(func):
def wrapper():
func()
return wrapper
Notice that:
a function is passed as an argument
another function is returned
This is the foundation of all decorators.
Problem 1: Measuring Function Execution Time
Let's build a decorator that measures how long a function takes to execute.
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} ran in {end - start} time")
return result
return wrapper
@timer
def example_function(n):
time.sleep(n)
example_function(2)
Output:
example_function ran in 2.00...
Understanding what is happening
This line:
@timer
means:
"Before calling this function, pass it through the
timerdecorator."
So Python internally does something similar to:
example_function = timer(example_function)
Now whenever:
example_function(2)
is called, it actually calls:
wrapper(2)
inside the decorator.
The decorator:
records start time
executes the original function
records end time
prints execution duration
returns the result
This is exactly like passing through the toll booth before continuing your journey.
The Conventional way without using @ syntax
Before decorators, we could achieve the same thing manually.
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} ran in {end - start} time")
return result
return wrapper
def example_function(n):
time.sleep(n)
ans = timer(example_function)
ans(2)
Here:
ans = timer(example_function)
returns the wrapper function.
And:
ans(2)
calls that wrapper.
The decorator syntax simply gives us a cleaner way to write the same thing.
Problem 2: Debugging Function Calls
Suppose we want to know:
which function was called
what arguments were passed
We can create a debugging decorator.
def debug(func):
def wrapper(*args, **kwargs):
args_value = ", ".join(str(arg) for arg in args)
kwargs_value = ", ".join(
f"{key}={value}"
for key, value in kwargs.items()
)
print(
f"calling: {func.__name__} "
f"with args {args_value} "
f"and kwargs {kwargs_value}"
)
func(*args, **kwargs)
return wrapper
@debug
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
greet("harsh", greeting="namaste")
Output:
calling: greet with args harsh and kwargs greeting=namaste
namaste, harsh!
Understanding this decorator
Before the original function executes, the decorator prints:
func.__name__
which gives:
greet
Then it prints:
positional arguments (
args)keyword arguments (
kwargs)
Only after that does it execute the actual function.
This makes debugging much easier because you can see exactly how a function was called.
Problem 3: Caching Return Values
Now let's solve a common performance problem.
Suppose a function is expensive and takes several seconds to execute.
If we call it repeatedly with the same arguments, we shouldn't have to recompute the result every time.
That's where caching comes in.
import time
def cache(func):
cache_value = {}
print(cache_value)
def wrapper(*args):
if args in cache_value:
return cache_value[args]
result = func(*args)
cache_value[args] = result
return result
return wrapper
@cache
def long_running_function(a, b):
time.sleep(4)
return a + b
print(long_running_function(1, 2))# takes 4 sec to execute
print(long_running_function(1, 2))# executes immediately
print(long_running_function(3, 4))# takes 4 sec to execute
Output:
{}
3
3
7
The first call takes around 4 seconds, and then for the second call it returns immediately and for the third call, it again takes 4 seconds.
How Caching Works
The decorator creates a dictionary:
cache_value = {}
This dictionary stores:
(arguments) -> result
After the first execution that took 4 seconds, the dictionary looks like this:
{
(1, 2): 3
}
Now when:
long_running_function(1, 2)
is called again, the decorator checks:
if args in cache_value
Since the result already exists, it immediately returns the value "3".
And then we have the third call print(long_running_function(3, 4)) and this was never called, so cache_value wont be having any result for this, so this line will agian take 4 seconds to execute.
Why Decorators are so powerful
Decorators allow us to add functionality without modifying the original function.
Common use cases include:
logging
authentication
authorization
caching
debugging
performance monitoring
rate limiting
validation
The original function remains clean while the decorator handles the additional behavior.
Conclusion
Decorators can initially feel intimidating, but they're simply functions that wrap other functions.
Think back to the toll booth analogy:
the vehicle is your function
the toll booth is the decorator
every function call passes through the decorator first
In this article we built decorators for:
measuring execution time
debugging function calls
caching expensive computations
Once you understand that @decorator is simply a cleaner way of writing:
function = decorator(function)
the magic behind decorators becomes much easier to understand.


