Skip to main content

Command Palette

Search for a command to run...

Understanding Python Decorators From Scratch

Functions are objects. Once that clicks, everything else follows.

Published
8 min read
Understanding Python Decorators From Scratch
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.

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:

  • *args lets a function accept any number of positional arguments. It packs them into a tuple.

  • **kwargs does 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!")
💡
Call each of the decorated functions, and analyze the results.

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
💡
That's the pattern. Memorize it now that you understand the idea behind it. Once this clicks, you can write any decorator by just filling in the before and after parts.

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.
💡
I'd advise you always use @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 print vs return trap, decorators with arguments, stacking, and practice exercises.

Python Decorators From the Ground Up

Part 5 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.

Start from the beginning

Class-Based Python Decorators: When Functions Aren't Enough

Closures hold state. Classes give it a home.