Skip to main content

Command Palette

Search for a command to run...

10 Exercises to Master Python Decorators (With Solutions)

The part where reading stops and building starts.

Published
•10 min read
10 Exercises to Master Python Decorators (With Solutions)
M
I'm Moussa, a Beninese Software Engineer based in Rwanda. I took what many considered an unlikely path, pivoting from Diplomacy and International Relations to Software Engineering. That career switch taught me something I carry into everything I do: the best learning happens when you're willing to start from zero. I work as a full-time software engineer and contribute to nonprofits supporting African students and sport talents in their education. I'm passionate about EdTech and AI and their potential to empower learners across Sub-Saharan Africa. Writing is how I process what I learn and give it back — every article here is something I wish I'd found when I was figuring it out myself. Fun facts: I'm a certified IJF judo instructor, I taught myself English through YouTube, and I'm always one rabbit hole away from picking up something new.

You've read the theory. You understood how decorators work. Now it's time to prove to yourself.

These exercises are ordered deliberately. The first few build the foundation skills, and each one adds a layer until you're writing production-style decorators by the end. Try each exercise before looking at the solution. Struggling is where the learning happens.

Let's go.


Exercise 1: Functions as Objects

Task: Write a function called apply that takes a function and a value, and returns the result of calling that function with that value.

def square(x):
    return x * x

def double(x):
    return x * 2

# Your code should make this work:
print(apply(square, 5))   # 25
print(apply(double, 5))   # 10
💡
What this tests: Can you pass a function as an argument and call it?
Click to reveal solution
def apply(func, value):
    return func(value)

Simple. func is just a variable that happens to hold a function. Call it with () and pass the value.


Exercise 2: Returning a Function from a Function

Task: Write a function called multiplier that takes a number n and returns a new function that multiplies any number by n.

# Your code should make this work:
times_three = multiplier(3)
times_ten = multiplier(10)
print(times_three(5))   # 15
print(times_ten(5))     # 50
print(times_three(7))   # 21
💡
What this tests: Can you create a closure, a function that remembers a value from its enclosing scope?

Common mistake: Writing two separate functions times_three and times_ten by hand. The point is that multiplier creates them dynamically.

Click to reveal solution
 def multiplier(n):
    def inner(x):
        return x * n
    return inner

inner remembers n from when it was created. This is a closure, the exact mechanism that powers decorators.


Exercise 3: Your First Decorator

Task: Write a decorator called shout that converts the return value of a function to uppercase.

@shout
def greet(name):
    return f"hello, {name}"

@shout
def farewell(name):
    return f"goodbye, {name}"

print(greet("Moussa"))     # HELLO, MOUSSA
print(farewell("Moussa"))  # GOODBYE, MOUSSA

What this tests: Can you write a basic decorator that transforms a return value?

Click to reveal solution
from functools import wraps

def shout(func):
wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper

Notice the pattern: call the function, catch the result, transform it, return the transformed version.


Exercise 4: A Counting Decorator

Task: Write a decorator called count_calls that tracks how many times a function has been called. After each call, print the count.

@count_calls
def say_hello():
    print("Hello!")

say_hello()
# Hello!
# say_hello has been called 1 time(s)

say_hello()
# Hello!
# say_hello has been called 2 time(s)

say_hello()
# Hello!
# say_hello has been called 3 time(s)

What this tests: Can you maintain state across function calls using function attributes?

Hint: Functions are objects, so you can attach attributes to them. Try wrapper.calls = 0 to initialize a counter on the wrapper function itself. This counter will persist between calls because it lives on the function object, not inside the function body.

Click to reveal solution
from functools import wraps

def count_calls(func):
wraps(func)
def wrapper(*args, **kwargs):
wrapper.calls += 1
result = func(*args, **kwargs)
print(f"{func.name} has been called {wrapper.calls} time(s)")
return result
wrapper.calls = 0
return wrapper

wrapper.calls is set to 0 once when the decorator is applied. Each time wrapper() runs, it increments the counter. The counter lives on the function object, not as a local variable, so it persists between calls.


Exercise 5: A Before-and-After decorator

Task: Write a decorator called surround that adds a line of dashes before and after the function's output.

@surround
def introduce(name, job):
    return f"Hi, I'm {name} and I work as a {job}."

print(introduce("Moussa", "Software Engineer"))
# --------------------
# Hi, I'm Moussa and I work as a Software Engineer.
# --------------------

What this tests: Can you add behavior before and after a function call?

Design question: Should you use print() for the dashes or build them into the return value? Think about which approach would compose better with other decorators.

Click to reveal solution
# Side-effect version (simpler but doesn't compose well):
def surround(func):
    def wrapper(*args, **kwargs):
        print("-" * 20)
        result = func(*args, **kwargs)
        print("-" * 20)
        return result
    return wrapper

Return-value version (composes well with other decorators):

def surround(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return f"{'-' * 20}\n{result}\n{'-' * 20}"
return wrapper

The return-value version is better design. If you stack this with another decorator that transforms return values, everything works smoothly. The side-effect version's dashes can't be intercepted by other decorators.


Exercise 6: Input Validation

Task: Write a decorator called positive_only that checks if all arguments passed to a function are positive numbers. If any argument is negative or zero, print an error message and don't call the function.

@positive_only
def add(a, b):
    return a + b

print(add(3, 5))    # 8
print(add(-1, 5))   # Error: all arguments must be positive!
                     # None
print(add(2, -4))   # Error: all arguments must be positive!
                     # None

What this tests: Can you add conditional logic, running the function only when certain conditions are met?

Click to reveal solution
from functools import wraps

def positive_only(func):
@wraps(func)
def wrapper(*args, **kwargs):
for arg in args:
if arg <= 0:
print("Error: all arguments must be positive!")
return None
return func(*args, **kwargs)
return wrapper

The loop checks each argument. If any fails, we return early without ever calling the original function. Only if all checks pass do we forward the call.


Exercise 7: Decorators with Arguments - @slow_down(seconds)

Task: Write a decorator called slow_down that takes a number of seconds and waits that long before calling the function.

import time

@slow_down(2)
def greet(name):
    print(f"Hello, {name}!")

greet("Moussa")
# (waits 2 seconds)
# Hello, Moussa!

What this tests: Can you add the extra layer needed for decorator arguments?

Remember: @slow_down(2) means Python calls slow_down(2) first, which must return a decorator. So you need three layers: the outer function takes the argument, the middle function takes the function, and the inner function replaces the function.

Click to reveal solution
import time
from functools import wraps

def slow_down(seconds):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
time.sleep(seconds)
return func(*args, **kwargs)
return wrapper
return decorator

Three layers: slow_down(seconds) → decorator(func) → wrapper(*args, **kwargs). Each layer remembers the values from the layer above via closures.


Exercise 8: Decorator with Arguments - @repeat(n)

Task: Write a repeat decorator that takes a number n and runs the function n times. It should return the result of the last call.

@repeat(4)
def say_hi(name):
    print(f"Hi, {name}!")

say_hi("Moussa")
# Hi, Moussa!
# Hi, Moussa!
# Hi, Moussa!
# Hi, Moussa!

What this tests: Combining decorator arguments with loop logic.

âš 
Be careful where you place the return statement. A return inside a for loop exits the function immediately on the first iteration, the loop won't continue.
Click to reveal solution
def repeat(n):
    def decorator(func):
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

Two important details:

  1. result = None before the loop handles the edge case where n is 0

  2. return result is after the loop, not inside it. Putting return inside the loop would exit on the first iteration.


Exercise 9: Stacking Decorators

Task: Using the shout decorator from Exercise 3 and the surround decorator from Exercise 5, predict the output of this code before running it.

@surround
@shout
def greet(name):
    return f"hello, {name}"

result = greet("Moussa")
print(result)

Then swap the order:

@shout
@surround
def greet(name):
    return f"hello, {name}"

result = greet("Moussa")
print(result)

Question: Why are the outputs different? Does one of them produce unexpected results?

What this tests: Do you understand the execution order of stacked decorators?

Click to reveal solution

With the side-effect version of surround:

First version (@surround on top of @shout):

--------------------
--------------------
HELLO, MOUSSA

The dashes print as side effects (immediately), while the string travels silently through the return chain. By the time print(result) runs, both decorators are done and only the final string appears.

Second version (@shout on top of @surround): The same visual issue, dashes appear separately from the content.

With the return-value version of surround:

First version (@surround on top of @shout):

--------------------
HELLO, MOUSSA
--------------------

Second version (@shout on top of @surround):

--------------------
HELLO, MOUSSA
--------------------

(The dashes get uppercased too, but - has no uppercase, so they look the same.)

The lesson: Decorators apply bottom-up. The bottom one wraps first. Order matters, especially when mixing side effect and return values. Prefer the return-value approach for better composition.


Exercise 10: The Boss Challenge - Build a Cache

Task: Write a decorator called cache that remembers the results of previous function calls. If the function is called again with the same arguments, return the saved result instead of running the function again.

import time

@cache
def slow_add(a, b):
    time.sleep(2)  # pretend this is expensive
    return a + b

print(slow_add(2, 3))  # (waits 2 seconds) → 5
print(slow_add(2, 3))  # (instant!) → 5
print(slow_add(1, 1))  # (waits 2 seconds) → 2
print(slow_add(1, 1))  # (instant!) → 2

What this tests: Maintaining state (a dictionary of previous results) across calls, and using tuple arguments as dictionary keys.

💡
Use dictionary to store results. The key can be args since tuples are hashable. Store the dictionary as an attribute on the wrapper function, just like the counter in Exercise 4.

Important: The decorator should return the raw value (like 5), not a formatted string (like "(cached) -> 5"). The caller shouldn't know or care that caching is happening.

Click to reveal solution
def cache(func):
    def wrapper(*args):
        if args in wrapper.memory:
            return wrapper.memory[args]
        result = func(*args)
        wrapper.memory[args] = result
        return result
    wrapper.memory = {}
    return wrapper

wrapper.memory is a dictionary that maps argument tuples to results. On each call, the wrapper checks if it's seen these arguments before. If yes, it returns the saved result without calling the original function

💡
This is the same concept behind Python's built-in functools.lru_cache. You just built a simplified version from scratch.

How Did you Do?

If you solved Exercises 1-6 without peeking, you've got a solid grasp of decorator fundamentals. If you also got 7-8, you understand decorators arguments. And if you nailed 9-10, you're ready to use decorators confidently in real projects.

The most common traps to watch for:

  • Not using *args, and **kwargs in the wrapper. It makes your decorator too rigid.

  • Forgetting return result in the wrapper. Your values silently become None.

  • Putting return inside a loop when you want the loop to complete. It exits on the first iteration.

  • Printing instead of returning from the wrapper. You values go to the screen instead of to the caller.


Where to Go from Here

You now have a complete understanding of Python decorators. Some directions to explore next:

  • functools.wraps: always use it to preserve function metadata

  • Class-based decorators: using __call__ to make objects behave like decorators

  • functools.lru_cache: the production-grade version of your Exercise 10

  • @property: a decorator that turns methods into computed attributes

  • Framework decorators: dig into how Flask's @app.route() or pytest's @fixture work internally

The foundation you've built here will make all of these feel approachable.

Thank you for reading 🙂.


This is Part 4 of my Python Decorators series. The series builds one concept at a time, and each article assumes you've read the ones before it. Start from Part 1 if you haven't already.

Python Decorators From the Ground Up

Part 2 of 5

A 4-part series that builds your understanding of Python decorators from the ground up: no prior knowledge assumed. We start with functions as objects, work through closures and wrappers, tackle the print vs return trap that silently breaks your code, explore decorators with arguments and stacking, and finish with 10 hands-on exercises. Written by a developer who learned the hard way so you don't have to.

Up next

Advanced Python Decorators Patterns: Arguments and Stacking

The patterns you'll actually encounter in real codebases.