Skip to main content

Command Palette

Search for a command to run...

Decorators in Python Explained with Practical Examples

Updated
6 min read
Decorators in Python Explained with Practical Examples
H

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 timer decorator."

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:

  1. records start time

  2. executes the original function

  3. records end time

  4. prints execution duration

  5. 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.