Understanding Python Decorators From Scratch
Functions are objects. Once that clicks, everything else follows.

If you've ever seen the @ symbol sitting on top of a Python function and wondered what sorcery that is, this article is for you.
Decorators are one of those Python concepts that look intimidating but are built on ideas you already know. The problem is that most tutorials jump straight to the syntax without building the mental model first. So you end up memorizing the pattern without truly understanding why it works.
Let's fix that. We're going to build up to decorators one concept at a time, and by the end, you'll be writing your own with confidence
Functions Are Objects: That Changes Everything
Here is the thing you need to internalize: in Python, a function is just an object. Like a string, like a number, like a list, you can store it in a variable, pass it around, and do whatever you want with it.
def say_hello():
print("Hello!")
# Store the function in another variable (no parentheses!)
my_function = say_hello
# Both names point to the same function
say_hello() # Hello!
my_function() # Hello!
Notice something critical here: say_hello without parentheses is the function itself, the object. say_hello() with parentheses calls the function and gives you the result. This distinction matters for everything that follows.
Think of a recipe card. say_hello is the card itself. You can hand it to someone, photocopy it, pin it on a wall. say_hello() is you actually cooking the recipe.
You Can Pass a Function to Another Function
Since functions are objects, you can hand one to another function as an argument, just like you'd pass a string or a number.
def say_hello():
print("Hello!")
def say_goodbye():
print("Goodbye!")
def call_twice(func):
func()
func()
call_twice(say_hello)
# Hello!
# Hello!
call_twice(say_goodbye)
# Goodbye!
# Goodbye!
call_twice doesn't know or care about which function you give it. It just calls whatever arrives, twice. We're passing say_hello (the object), not say_hello() (the result).
This might feel strange if you're coming from another language, but it's one of Python's superpowers. Functions are first-class citizens. They can go anywhere any other value can go.
A Function Can Create and Return Another Function
This where things get interesting. A function can build a new function inside itself and give it back to you:
def make_greeter(name):
def greeter():
print(f"Hello, {name}!")
return greeter
greet_moussa = make_greeter("Moussa")
greet_kalam = make_greeter("Kalam")
greet_moussa() # Hello, Moussa!
greet_kalam() # Hello, Kalam!
Each call to make_greeter creates a brand-new function that remembers the name it was created with. The inner function greeter holds onto the name variable even after make_greeter has finished executing.
This pattern is called closure, a function that remembers values from the scope where it was created. Closures are the engine that powers decorators, so make sure this concept clicks before moving on.
Combining the Pieces: Take a Function, Return a New One
Now let's combine everything. What if we write a function that takes a function as input and returns a new function as output?
def make_louder(func):
def new_function():
print("I'M ABOUT TO DO SOMETHING!")
func()
print("I'M DONE!")
return new_function
def say_hello():
print("Hello!")
louder_hello = make_louder(say_hello)
# The original still works normally
say_hello()
# Hello!
# The new version adds extra behavior
louder_hello()
# I'M ABOUT TO DO SOMETHING!
# Hello!
# I'M DONE!
Please read that carefully! make_louder received say_hello, wrapped it in extra behavior (printing before and after), and returned a brand new function. The original say_hello is untouched.
This is the entire idea behind decorators. Everything else is just syntax and polish.
Replacing the Original
Instead of keeping both the old and new versions, what if we replace say_hello with the wrapped version?
say_hello = make_louder(say_hello)
say_hello()
# I'M ABOUT TO DO SOMETHING!
# Hello!
# I'M DONE!
Look at this line: say_hello = make_louder(say_hello). We're saying: "take the old say_hello, wrap it and store the result back as say_hello."
Now whenever anyone calls say_hello(), they get the enhanced the version. And that one line is so common in Python that the language gives us a shortcut for it.
The @ Symbol Is Just a Shortcut
def make_louder(func):
def new_function():
print("I'M ABOUT TO DO SOMETHING!")
func()
print("I'M DONE!")
return new_function
@make_louder
def say_hello():
print("Hello!")
say_hello()
# I'M ABOUT TO DO SOMETHING!
# Hello!
# I'M DONE!
@make_louder on top of say_hello is exactly identical to writing say_hello = make_louder(say_hello) after the function definition. It's just cleaner syntax, what Python calls "syntactic sugar".
That's literally all the @ symbol does. No magic. No special mechanism. Just a shortcut for a pattern we already understand.
make_louder is a decorator.
The Arguments Problem
There's a catch with our current approach. What if the decorated function takes arguments?
def log_calls(func):
def wrapper(): # takes NO arguments!
print(f"Calling {func.__name__}")
func() # passes NO arguments!
return wrapper
@log_calls
def add(a, b):
return a + b
add(2, 3) # TypeError: wrapper() takes 0 arguments but 2 were given
It breaks because add(2, 3) now actually calls wrapper(2, 3), but wrapper doesn't accept any arguments.
The fix is *args and **kwargs.
You've probably seen these before, but here's what they actually do:
*argslets a function accept any number of positional arguments. It packs them into a tuple.**kwargsdoes the same for keyword arguments. It packs them into a dictionary.
You can read more *args and **kwargs in this article I wrote.
Put them together in the wrapper, and it can accept literally any combination of arguments and pass them straight through to the original function. It doesn't need to know what the function expects. It just forwards everything.
def log_calls(func):
def wrapper(*args, **kwargs): # accept anything
print(f"Calling {func.__name__}")
result = func(*args, **kwargs) # forward everything
return result # return the result
return wrapper
@log_calls
def add(a, b):
return a + b
@log_calls
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(add(2, 3)) # Calling add → 5
print(greet("Moussa", greeting="Hey")) # Calling greet → Hey, Moussa!
By using *args and **kwargs, the wrapper doesn't need to know anything about the original function's signature. It catches everything and forwards it blindly. This is what makes the decorator generic, working with any function.
A Practical Example: Logging
Let's see why this pattern is actually useful. Imagine you want to log every time certain functions are called:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f">>> Calling {func.__name__}()")
result = func(*args, **kwargs)
print(f">>> {func.__name__}() finished")
return result
return wrapper
@log_calls
def process_payment():
print("Payment processed!")
@log_calls
def send_email():
print("Email sent!")
@log_calls
def update_database():
print("Database updated!")
You wrote the logging logic once and applied it to three functions. Without decorators, you'd have to copy and paste those print statement into every single function. And when you want to change the log format, you change it in one place instead of hunting through your entire codebase.
The Universal Template
Every decorator follows the same recipe:
def my_decorator(func): # 1. Take a function in
def wrapper(*args, **kwargs): # 2. Create a new function
# do something before
result = func(*args, **kwargs) # 3. Call the original
# do something after
return result # 4. Return the result
return wrapper # 5. Return the wrapper
@my_decorator # 6. Replace the original
def my_function():
pass
One Last Best Practice: functools.wraps
When you wrap a function, the wrapper replaces the original's metadata: its name, docstring, and other attributes. This can cause confusion during debugging.
@log_calls
def add(a, b):
"""Add two numbers.""" # Here is the docstring
return a + b
print(add.__name__) # "wrapper" — not "add"!
print(add.__doc__) # None — the docstring is gone!
Fix this with functools.wraps
from functools import wraps
def log_calls(func):
@wraps(func) # preserves func's name, docstring, etc.
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
return result
return wrapper
@log_calls
def add(a, b):
"""Add two numbers.""" # Here is the docstring
return a + b
print(add.__name__) # add
print(add.__doc__) # Add two numbers.
@wraps. It's a one-line addition that prevents real headaches down the road.What's Next?
You now understand the complete foundation of Python decorators. In the next article, we'll tackle a subtle but critical trap that catches almost every developer learning decorators: the difference between print and return inside wrappers, and why confusing them makes your values silently disappear.
This is Part 1 of my Python Decorators series. Follow along for the next parts where we cover the
returntrap, decorators with arguments, stacking, and practice exercises.






