Python Decorators

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 it
print(say_hello("Riya")) # we call it through the new name

Output

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:

  • shout takes a function called func.
  • Inside, it builds a small new function wrapper that calls func and then does something extra with the result.
  • shout returns wrapper. It does not call it. It just hands it back.
  • So loud_greet is now the wrapped version of greet.

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
@shout
def 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
@timer
def 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 seconds
7

Here is what each piece is doing:

  • *args, **kwargs let 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_calls
def add(a, b):
return a + b
print(add(2, 5))

Output

Calling add...
Finished add.
7

Same 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_calls
def add(a, b):
return a + b
print(add.__name__)

Output

wrapper

That 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_calls
def add(a, b):
return a + b
print(add.__name__)

Output

add

It 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 greet hands over the function. Passing greet() calls it first and hands over the result, which is usually not what you want.
# ❌ Avoid: this calls greet right away
loud = shout(greet("Alex"))
# ✅ Good: pass the function itself, let the decorator call it later
loud = 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, **kwargs so 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, **kwargs in the wrapper so the decorator works on many different functions.
  • Always return the 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 @decorator syntax.
  • ✅ 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. 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. 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. 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. 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.

Closures

Share & Connect