Python Decorators

Several days ago, I created an API call to the Amplitude API for events data using python.

For modular error handling, I nested my request call in a function to raise custom exceptions based on what error the request returned.

In my main script, I wrapped my request function in retry logic. Based on what exception the request function returned, it would either retry or exit the loop. As you can imagine, this led to some chunky code that was hard to follow and debug.

Eventually, I refactored and shifted my retry code into a decorator. The end result was a much cleaner script. As a bonus, I now had a decorator that I could easily apply to a host of other functions that I had created.

What is a Decorator?

A decorator is essentially a wrapper that allows you to modularize code without having to adjust the code itself. It is a powerful and efficient way to apply custom logic to any function. This blog will cover the basics of how decorators work.

Basic Structure of a Decorator

At its heart, a decorator is just a function. Its basic structure will look something like this:

def decorator(func):
    def wrapper():
        return func()
    return wrapper

A function object is passed into the outer decorator function. The decorator function returns the wrapper function object.

Let’s say we have the following function:

def add_func():
    return 1+1

When we pass this function into our decorator, the outer function will return the wrapper function object. The key here is that the wrapper function isn’t executed immediately (at least in our set up).

Execution occurs when I call on the function with wrapper(). Once I call my wrapper function, my add_func is executed.

Building on Decorator Logic

Let’s build on this logic by including an argument in add_func():

def add_func(x):
    return x+1

Now, we need to refactor our wrapper function to also accept the argument:

def decorator(func):
    def wrapper(x):
        return func(x)
    return wrapper

When I execute the wrapper, I would call on it by passing an argument. The wrapper function would in turn pass the argument to add_func:

wrapper(x=1)
    return add_func(x=1)

But, what if we want our decorator to be able to accept a wide range of arguments, not just x? We can do so by inserting *args and **kwargs (this technique deserves its own blog – suffice to say for now, *args and **kwargs is a catch-all for arguments):

def decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

Applying our Decorator

The neat thing about python is that we don’t have to manually pass our function into our decorator. We can literally ‘decorate’ our function with @decorator, or whatever we decide to name our decorator:

@decorator
def add_func(x)

When we later execute our add_func function, we would be executing the wrapper. In essence:

add_func == wrapper
add_func(x=1) == wrapper(x=1)

By simply using @decorator on top of our functions, we can quickly and easily wrap them in additional logic.

Summary

This blog was about the fundamental structure of and logic behind decorators. We can build on top of this to wrap our function in more advanced logic. For example, I’ve used it to:

1.       Execute another function after my decorated function runs successfully

2.       As an alternative form of logging

3.       And now, to retry functions

Hope this was a helpful primer into decorators and starts you on a path toward creating your own wrappers!

Author:
Charles Yi
Powered by The Information Lab
1st Floor, 25 Watling Street, London, EC4M 9BR
Subscribe
to our Newsletter
Get the lastest news about The Data School and application tips
Subscribe now
© 2025 The Information Lab