Decorators

Decorators are a way to add bolt on functionality to an existing function, without modifying the function’s code. Decorators are a ‘pythonic’ way to wrap (or decorate) functions, rather than having to write longer, complicated functions or manually repeat code. Instead, you can write concise, modular functions that do one thing well, and use decorators to adapt that functionality for different contexts.

In this session, we will cover:

WarningDownload

You can download the Jupyter Notebook with the completed examples by clicking the “Jupyter” link under “Other Formats”.

If following Code Club live you may wish to download at the start of the session.

What Are Decorators?

Decorators are a code design pattern that allow you to adapt the behaviour of functions. Decorators take a function as an input, extend/modify the behaviour of that function, and return that modified version of the function without changing the input function.

While this might sound like a dry, not particularly exciting concept, decorators can be both elegant and efficient solutions to complex problems. By allowing you to extend a function’s behaviour, decorators make it easier to keep code simple and reduce the need for writing long, complex functions that are fit for lots of different use-cases. Instead, you can write a simple general function that carries out one task, and then you can modify or extend that behaviour depending on context. Not only does this help you keep your functions simple, decorators can also be reused across multiple functions, so they themselves can be clean and modular.

For example, you have a pipeline, made up of multiple functions that ingest, process, and transform raw data. When you run the pipeline there are multiple data quality issues and bottlenecks in the process, so you want to understand what is happening when the pipeline is running and what is causing your problems. Instead of modifying each step in the pipeline and making all your functions more complicated1, you can use decorators to time how long each step takes and to log each step’s outputs in a separate log file. This would help you to identify where the bottlenecks are in the pipeline and which step is producing errors.

How Decorators Work

Decorators take advantage of the fact that Python treats functions as first-order objects that can be directly referred to just like simpler objects such as strings, lists and tuples. It is an object we can assign to a variable or pass to or return from another function.

def stuff():
    return 'doing stuff'

# assign function to a variable        
work = stuff

# work is a function object
print(work)
<function stuff at 0x000001D88FE768E0>
WarningNote

When we print the variable hi we do not see the message “hello”, this is because we haven’t yet called the function. To call the function we must insert closed brackets () after the function name.

If trying to assign a function rather than its result you must not call it when assigning.

When we build a decorator we are building a closure. Closures are functions that are nested inside other functions, and they retain variables from the scope where it was defined, even after the scope has finished executing.

For example:

def make_multiplier(n):
    def multiply(x):
        return x * n  # 'n' is captured from the outer scope
    return multiply

double = make_multiplier(2)
double(5)
10

When a function (in this case make_multiplier(x)) runs, Python creates a temporary space (called a local scope) where the function’s guts (variables, arguments etc.) are held while it runs. Usually, this temporary space is destroyed once a function returns, but in a closure, the inner function holds onto the outer function’s temporary space, even after the function finishes running.

Python creates a local scope containing n = 2 when make_multiplier(2) runs, but n = 2 is kept after make_multiplier is finished running because n is referenced in multiply. Python keeps n alive in the function’s __closure__ attribute.

Decorators leverage this principle to wrap functions and the decorator can be passed arguments that are needed for the inner functions. Decorators can also be applied to multiple other functions and more than one decorator can be applied to a given function.

The following example demonstrates a simple decorator with an inner and outer function.

def my_decorator(func):  # func is the function being decorated
    def wrapper():
        print('Waking up')  # runs before func
        result = func()
        print('Going to sleep')  # runs after func
        return result
    return wrapper  # returns the wrapped version of func

Having created our decorator, we can apply it to our earlier stuff function using =:

stuff = my_decorator(stuff)  # reassign stuff to its decorated version
print(stuff())
Waking up
Going to sleep
doing stuff

The decorated function is reassigned to the same variable name, replacing the original.

The common convention for writing decorators is to use @ instead of =. The @ shorthand does the same thing, but must be placed above the function definition:

@my_decorator
def more_stuff():
    return 'Looks like there was more to do'

print(more_stuff())
Waking up
Going to sleep
Looks like there was more to do

Decorating a function does not alter its code, but can have some unexpected side effects. We will cover these at the end of the session.

Dealing With Arguments

So far our decorator has no arguments. For functions to be useful in practice, they need to handle arguments. You can include deocrators in arguments, but first the wrapper function needs to bet set up to expect them.

The following fails because wrapper in my_decorator doesn’t expect any arguments:

@my_decorator
def drinks(drink, milk, sugar):
    return f"i'd love a {drink} with {milk} and {sugar} sugars"

print(drinks('tea', 'plain', sugar=2))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[7], line 5
      1 @my_decorator
      2 def drinks(drink, milk, sugar):
      3     return f"i'd love a {drink} with {milk} and {sugar} sugars"
----> 5 print(drinks('tea', 'plain', sugar=2))

TypeError: my_decorator.<locals>.wrapper() got an unexpected keyword argument 'sugar'

To fix this, the wrapper needs to accept and pass through any arguments the decorated function expects. We use *args and **kwargs for this, which capture any number of positional and keyword arguments respectively (see Visually Explained for a good explanation).

def drink_decorator(func):
    def wrapper(*args, **kwargs):  # *args and **kwargs pass any arguments through to func
        print('I went to the cafe')
        result = func(*args, **kwargs)
        print('I decided what I want')
        return result
    return wrapper

@drink_decorator
def drinks(drink, milk, sugar):
    return f"i'd love a {drink} with {milk} and {sugar} sugars"

print(drinks('tea', 'milk', sugar=2))
print(drinks('coffee', 'cream', 'no'))
I went to the cafe
I decided what I want
i'd love a tea with milk and 2 sugars
I went to the cafe
I decided what I want
i'd love a coffee with cream and no sugars

Generalising Decorators

So far we’ve used simple decorators with specific use cases. Let’s see what happens when we apply drink_decorator to stuff:

@drink_decorator
def stuff():
    return 'doing stuff'

print(stuff())
I went to the cafe
I decided what I want
doing stuff

The real power of a decorator is in how broadly it can be applied. A well-built decorator can be reused across many functions and even across projects. To achieve this, keep the following in mind:

  • Always allow for differing arguments using *args, **kwargs.
  • Name your outer function generically but meaningfully. func_timer is fine for a timing decorator.
  • Use conventional names internally, where possible. The inner function is normally just called wrapper.
  • Parameterise your decorator where it makes sense to (we’ll cover this below).

The real-world examples below will demonstrate these principles at work.

Real-World Examples

The following three examples cover common use cases for decorators and highlight some of the upsides and issues you may encounter.

Measuring Runtime

A common problem is understanding how long code takes to run. Decorators give us a way to wrap timing functionality around existing functions without touching their code.

In this example we simulate a slow function using time.sleep and use the time module to record start and stop times in the decorator.

import time
from functools import wraps  # explained in example 2

def func_timer(func):
    @wraps(func)  # explained in example 2
    def wrapper(*args, **kwargs):
        start = time.time() #assign start time (and end time) to variable
        result = func(*args, **kwargs)
        end = time.time()
        # print after the decorated function has run but before result is returned
        print(f"[TIME] {func.__name__} took {end - start:.4f} seconds")  # func.__name__ is the name of the decorated function
        return result
    return wrapper

@func_timer
def slow_function(run_time):
    print("i'll get on that")
    time.sleep(run_time)
    print("i've done that")
    return run_time

slow_function(7)
i'll get on that
i've done that
[TIME] slow_function took 7.0009 seconds
7

This is a generic decorator that uses the __name__ method of the decorated function to report back the time it took to run. func_timer uses func.__name__ to report the name of the function that ran, making the output meaningful regardless of which function it’s applied to. Copy this decorator into any project, apply @func_timer to any function, and it works. That portability is the main benefit of writing generic decorators.

There are alterations that we could make here, but we will demonstrate some further examples and then allow you to imagine how you might add further functionality to improve generalisation.

Generating Logs

A common scenario for analysts is knowing that a problem occurred but not understanding what caused it. This decorator logs the function name and arguments to a file each time a decorated function is called, giving you a record to investigate.

The log file is created when the decorated function is first defined and updated on each call.

import os

def log(func):
    os.makedirs("log", exist_ok=True)  # create log folder if it doesn't exist
    
    with open(f"log/{func.__name__}.txt", mode="a") as file_obj:
        file_obj.write(f"[defined] {func.__name__}\n")
    
    def wrapper(*args, **kwargs):
        with open(f"log/{func.__name__}.txt", mode="a") as file_obj:
            file_obj.write(
                f"[called] {func.__name__} "
                f"with positional arguments {args} "
                f"and keyword arguments {kwargs}\n"
            )
        result = func(*args, **kwargs)
        with open(f"log/{func.__name__}.txt", mode="a") as file_obj:
            file_obj.write(f"[finished] {func.__name__}\n")
        return result
    
    return wrapper

While this code now looks more complex than the code we’ve written before, it generalises well. Applying this to multiple functions shows how the decorator creates a separate log file for each:

from datetime import date, timedelta

@log
def list_recipients(names):
    for n in names:
        print(n)

@log
def next_code_club(current_date: date) -> date:
    start_date = date(2025, 5, 1)
    if current_date <= start_date:
        return start_date
    days_since_start = (current_date - start_date).days
    cycles = days_since_start // 14
    next_event = start_date + timedelta(days=(cycles + 1) * 14)
    return next_event

print(next_code_club(date(2026, 4, 29)))
list_recipients(['Michael B. Jordan', 'Adrian Brody', 'Cillian Murphy', 'Brandon Fraser'])
print(next_code_club(date(2023, 1, 1)))
2026-04-30
Michael B. Jordan
Adrian Brody
Cillian Murphy
Brandon Fraser
2025-05-01

Each decorated function gets its own file in the log folder.

It is also possible to stack decorators, which applies multiple decorators to a function. However, it is important to remember that the order matters. Decorators are applied from the inside out, so the bottom decorator wraps the function first.

Here we apply both func_timer and log to the same function:

@func_timer
@log
def slow_function(run_time):
    print("i'll get on that")
    time.sleep(run_time)
    print("i've done that")
    return run_time

print(slow_function(5))
i'll get on that
i've done that
[TIME] wrapper took 5.0021 seconds
5

log wraps slow_function first, producing a wrapper. func_timer then wraps that wrapper. The timer reports wrapper rather than slow_function. This happens because log doesn’t use @wraps, so its wrapper doesn’t inherit __name__ from slow_function.

func_timer does use @wraps. Reversing the order puts func_timer on the inside, so log now sees slow_function correctly:

@log
@func_timer
def slow_function_2(run_time):
    print("i'll get on that")
    time.sleep(run_time)
    print("i've done that")
    return run_time

print(slow_function_2(8))
i'll get on that
i've done that
[TIME] slow_function_2 took 8.0123 seconds
8

The cleaner fix is to add @wraps to every decorator you write so that order doesn’t affect metadata. We will do this in the final example below, and recommend it as standard practice.

Parameterising Reports

Many reports share the same structure but differ in parameters such as organisation code, report name, or run timestamp. A parameterised decorator is a clean way to attach this metadata to any reporting function without repeating boilerplate.

To pass arguments to the decorator itself, we add an outer layer.

from functools import wraps
from datetime import datetime

def report_metadata(org_code: str, report_name: str):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            metadata = {
                "org_code": org_code,
                "report_name": report_name,
                "run_timestamp": datetime.now().isoformat(timespec="seconds")
            }
            print(f"[INFO] running report '{report_name}' for {org_code}")
            print(f"[INFO] metadata: {metadata}")
            return func(*args, metadata=metadata, **kwargs)
        return wrapper
    return decorator

org_code and report_name are passed to the outermost layer. Because decorator and wrapper are defined inside that layer, they can access those variables via the enclosing scope (this is the closure mechanism from the start of the session).

Applying it to a reporting function:

import pandas as pd

@report_metadata(org_code="R1F", report_name="Activity Summary")
def generate_activity_report(df, metadata=None):  # metadata=None ensures safe default handling
    summary = df.describe()
    return {
        "metadata": metadata,
        "summary": summary
    }

df = pd.DataFrame({
    "activity": [10, 12, 9, 14, 11],
    "cost": [200, 240, 180, 300, 220]
})

result = generate_activity_report(df)
print(result["metadata"])
[INFO] running report 'Activity Summary' for R1F
[INFO] metadata: {'org_code': 'R1F', 'report_name': 'Activity Summary', 'run_timestamp': '2026-05-07T13:27:44'}
{'org_code': 'R1F', 'report_name': 'Activity Summary', 'run_timestamp': '2026-05-07T13:27:44'}

Note that the decorated function must be ready to accept the metadata argument the decorator passes in. The metadata=None default means the function still works if called without the decorator.

Common Issues

Loss of Function Metadata

When you wrap a function, the wrapper replaces it. This means that properties like __name__ and __doc__ point to wrapper instead of the original function. Use functools to fix this, importing wraps from functools and applying @wraps(func) to your wrapper:

from functools import wraps

def my_decorator(func):
    @wraps(func)  # wrapper inherits __name__, __doc__ etc. from func
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

It is important to remember to do this so that your decorators do not silently fail.

Wrapper Not Handling Arguments

If a decorated function takes arguments but the wrapper doesn’t, it will fail. Always use *args, **kwargs in both the wrapper signature and the func() call. This lets the decorator handle any function regardless of its arguments.

Incorrect Order When Stacking Decorators

When stacking decorators, each one sees the layer directly beneath it. This affects __name__, execution order, and any decorator that inspects the function it’s wrapping. If something behaves unexpectedly, try reversing the order.

Passing Arguments Directly to Decorators

If the decorator itself needs arguments (not the decorated function, but the decorator), you need a third layer:

def outer(param):       # accepts the decorator's arguments
    def decorator(func):  # accepts the function to decorate
        def wrapper(*args, **kwargs):  # accepts the function's arguments
            return func(*args, **kwargs)
        return wrapper
    return decorator

Hidden Side-Effects

Decorators can introduce unexpected behaviour. For example, logging, caching, or timing code that runs when you don’t expect it, or states that persists between calls. Always test your decorator in isolation before applying it, and test the decorated function afterwards. See Educative for more details.

Conclusion

Decorators are a broad topic and they are conceptually complex for someone new to Python, but they are well worth learning. Once you are comfortable working with decorators, it is much easier to keep your code clean, modular, and readable.

This session is a relatively brief introduction to decorators, but they can be applied to other contexts too, included decorating methods in classes. Though this is out of scope for this session, if you wish to understand more, the following resources are worth checking out:

And of course you can reach out to any of the Code Club team for support or drop a message in the Code Club Help channel.

Footnotes

  1. This doesn’t just make your code more complicated, it also violates the single responsibility principle, which is the idea that each function should have just one responsibility.↩︎