10 Exercises to Master Python Decorators (With Solutions)
The part where reading stops and building starts.

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
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
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 innerinner 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.
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 decoratorTwo important details:
result = Nonebefore the loop handles the edge case wherenis 0return resultis after the loop, not inside it. Puttingreturninside 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, MOUSSAThe 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.
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 wrapperwrapper.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
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**kwargsin the wrapper. It makes your decorator too rigid.Forgetting
return resultin the wrapper. Your values silently becomeNone.Putting
returninside 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 metadataClass-based decorators: using
__call__to make objects behave like decoratorsfunctools.lru_cache: the production-grade version of your Exercise 10@property: a decorator that turns methods into computed attributesFramework decorators: dig into how Flask's
@app.route()or pytest's@fixturework 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.






