Python Decorators
Table of Contents + −
In the last lesson you learned about Generators. They give you values one at a time instead of all at once. Now we look at another neat Python tool. It feels a bit like magic the first time you see it. It is called the decorator. It lets you add behaviour to a function without ever opening that function and changing its code.
🤔 Why Decorators?
Say you have a function that does one clear job. Then someone asks you to also log every time it runs. Then time how long it takes. Then check the user is logged in. So now you go back and stuff all that extra code inside your nice clean function. Do that to ten functions and you have copied the same logging code ten times.
The pain is simple. You keep mixing extra behaviour into code that should stay focused on one thing.
A decorator fixes that. It is a function that wraps your function and adds the extra behaviour around it. Your original function stays exactly as it was.
🧱 Functions Are Objects You Can Pass Around
Before decorators make sense, one idea has to click first. In Python a function is just an object, like a number or a string. So you can hand a function to another function. And you can return a function from a function.
Here we store a function in a variable and call it through that variable.
def greet(name): return f"Hello, {name}!"
say_hello = greet # no parentheses, so we are NOT calling itprint(say_hello("Riya")) # we call it through the new nameOutput
Hello, Riya!Notice we wrote greet with no parentheses. That hands over the function itself. If we had written greet("Riya") we would have called it and stored the result instead.
Now the part that powers decorators. A function can take another function as an input. Then it can return a brand new function that wraps it.
def shout(func): def wrapper(name): result = func(name) # call the original function return result.upper() # add extra behaviour around it return wrapper # hand back the new wrapped function
loud_greet = shout(greet)print(loud_greet("Alex"))Output
HELLO, ALEX!Let’s read that slowly:
shouttakes a function calledfunc.- Inside, it builds a small new function
wrapperthat callsfuncand then does something extra with the result. shoutreturnswrapper. It does not call it. It just hands it back.- So
loud_greetis now the wrapped version ofgreet.
That is the whole engine of a decorator. Everything else is just nicer syntax on top.
🎀 The @ Syntax
Writing greet = shout(greet) by hand works. But Python gives you a cleaner way to say the same thing. You put @shout on the line above the function. The @ symbol means “wrap this function with shout right now.”
This is the same wrapping as before, just written with the @ shortcut.
def shout(func): def wrapper(name): return func(name).upper() return wrapper
@shoutdef greet(name): return f"Hello, {name}!"
print(greet("Arjun"))Output
HELLO, ARJUN!When Python reads @shout above greet, it quietly does greet = shout(greet) for you. So from that point on, the name greet points to the wrapped version. You did not change a single line inside greet. That is the win.
Note
Think of a decorator like gift wrapping. The gift inside stays the same. The wrapping adds something around it, nice paper and a bow, without changing what is in the box.
⏱️ A Real Example: Timing How Long a Function Takes
Let’s build something you would actually use. Imagine you want to know how slow a function is. You could add timing code inside every function, but that gets repeated everywhere. A decorator does it once and you reuse it.
This decorator records the time before and after the function runs. Then it prints how long it took.
import time
def timer(func): def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) # run the real function end = time.perf_counter() print(f"{func.__name__} took {end - start:.4f} seconds") return result return wrapper
@timerdef slow_add(a, b): time.sleep(1) # pretend this is heavy work return a + b
print(slow_add(3, 4))Output
slow_add took 1.0011 seconds7Here is what each piece is doing:
*args, **kwargslet the wrapper accept any arguments. So this one decorator works on any function no matter how many inputs it has.time.perf_counter()reads a precise clock before and after the real call.- We print the gap, then return the real result so nothing downstream breaks.
The exact seconds will wobble a little each run, since timing depends on your machine. The point is you got timing for free. And slow_add itself stayed clean.
🧾 A Logging Example (Before and After)
A very common use is to print a message before and after a function runs. This is great for following what your program is doing.
This decorator announces when the function starts and when it finishes.
def log_calls(func): def wrapper(*args, **kwargs): print(f"Calling {func.__name__}...") result = func(*args, **kwargs) print(f"Finished {func.__name__}.") return result return wrapper
@log_callsdef add(a, b): return a + b
print(add(2, 5))Output
Calling add...Finished add.7Same shape as the timer. Do something before, call the real function, do something after, return the result. Once you see this pattern, most decorators look familiar.
🏷️ Keep the Name with functools.wraps
There is one small catch. When you wrap a function, the wrapped version forgets its own identity. Ask it for its name and it says wrapper, not the real name.
Watch what __name__ reports after wrapping.
@log_callsdef add(a, b): return a + b
print(add.__name__)Output
wrapperThat is confusing during debugging, since every decorated function looks like it is named wrapper. The fix is a helper from the standard library called functools.wraps. You put it on the inner wrapper and it copies the original name and details over.
Here we add @wraps(func) so the real name survives.
from functools import wraps
def log_calls(func): @wraps(func) # copy the original name and details def wrapper(*args, **kwargs): print(f"Calling {func.__name__}...") return func(*args, **kwargs) return wrapper
@log_callsdef add(a, b): return a + b
print(add.__name__)Output
addIt is a small habit, but a good one. Whenever you write a decorator, add @wraps(func) so the wrapped function keeps its real identity.
⚠️ Common Mistakes
A few things trip people up early on.
- Calling the function when you mean to pass it. Passing
greethands over the function. Passinggreet()calls it first and hands over the result, which is usually not what you want.
# ❌ Avoid: this calls greet right awayloud = shout(greet("Alex"))
# ✅ Good: pass the function itself, let the decorator call it laterloud = shout(greet)- Forgetting to return the result. If your wrapper calls the real function but never returns its value, the caller gets
None.
def log_calls(func): def wrapper(*args, **kwargs): print("running") func(*args, **kwargs) # ❌ Avoid: result is thrown away return wrapper
def log_calls(func): def wrapper(*args, **kwargs): print("running") return func(*args, **kwargs) # ✅ Good: pass the result back return wrapper- Not accepting arguments in the wrapper. A wrapper written as
wrapper()only works on functions that take no inputs. Use*args, **kwargsso it works with any function.
✅ Best Practices
Keep these habits and your decorators will stay clean and easy to trust.
- Always add
@wraps(func)from functools so the wrapped function keeps its real name. - Use
*args, **kwargsin the wrapper so the decorator works on many different functions. - Always
returnthe result of the real function call. - Keep one decorator focused on one job. A timing decorator times, a logging decorator logs. Do not mix them into one.
| Piece | What it does |
|---|---|
@decorator | Wraps the function below it, same as func = decorator(func) |
wrapper | The inner function that adds behaviour around the original |
*args, **kwargs | Let the wrapper accept any arguments the real function needs |
@wraps(func) | Keeps the original function’s name and details after wrapping |
🧩 What You’ve Learned
A quick recap of what you can now do.
- ✅ You know a decorator wraps a function to add behaviour without changing the original code.
- ✅ You understand that functions are objects you can pass into and return from other functions.
- ✅ You can build a wrapper by hand and then use the cleaner
@decoratorsyntax. - ✅ You wrote real decorators for timing and logging using
*args, **kwargs. - ✅ You know to add
@wraps(func)so the wrapped function keeps its real name.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
What does putting @shout above a function do?
Why: The @ syntax is shorthand for greet = shout(greet), so the name now points to the wrapped version.
- 2
Why do decorators use *args and **kwargs in the wrapper?
Why: Using *args and **kwargs lets one wrapper handle functions with any number of inputs.
- 3
What is the role of functools.wraps?
Why: Without @wraps the wrapped function reports its name as 'wrapper'; @wraps restores the real name.
- 4
What happens if the wrapper calls the real function but does not return its value?
Why: If the wrapper does not return the result, the value is thrown away and the caller receives None.
🚀 What’s Next?
Decorators lean on the idea of an inner function remembering things from the outer function. That idea has its own name. And it unlocks even more once you understand it.