def stuff():
return 'doing stuff'
# assign function to a variable
work = stuff
# work is a function object
print(work)<function stuff at 0x000001D88FE768E0>
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:
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.
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.
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.
<function stuff at 0x000001D88FE768E0>
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:
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.
Having created our decorator, we can apply it to our earlier stuff function using =:
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:
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.
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:
--------------------------------------------------------------------------- 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
So far we’ve used simple decorators with specific use cases. Let’s see what happens when we apply drink_decorator to 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:
*args, **kwargs.func_timer is fine for a timing decorator.wrapper.The real-world examples below will demonstrate these principles at work.
The following three examples cover common use cases for decorators and highlight some of the upsides and issues you may encounter.
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.
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 wrapperWhile 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:
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:
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.
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 decoratororg_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.
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:
It is important to remember to do this so that your decorators do not silently fail.
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.
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.
If the decorator itself needs arguments (not the decorated function, but the decorator), you need a third layer:
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.
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.
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.↩︎