Skip to content

How Do Python Decorators Work? A Complete Guide for Beginners

I remember the first time I saw this in Python code:

decorator_example.py
@some_name
def my_function():
pass

I had no idea what that @ sign was doing. It didn’t look like anything I’d seen in other languages. When I asked around, people said “it’s a decorator” — as if that explained anything.

So I did what any confused developer does: I wrote some code, broke things, and figured it out from the ground up.

The Real Problem: Repeating Code

I had two functions with the same logging boilerplate:

logger_problem.py
def add(a, b):
print(f"Calling add with {a}, {b}")
return a + b
def subtract(a, b):
print(f"Calling subtract with {a}, {b}")
return a - b

Every new function meant copying the print line. If I wanted to change the logging format, I’d have to edit every function. That’s fragile and tedious.

Step 1: Functions Are Objects

Before understanding decorators, I had to internalize this: functions in Python are objects. You can assign them to variables, pass them as arguments, and return them from other functions.

func_as_object.py
def greet(name):
return f"Hello {name}"
say_hello = greet # no parentheses — not calling, just referencing
print(say_hello("Alice")) # "Hello Alice"

This compiles and runs. No magic. Functions behave like any other value.

Step 2: Writing a Function That Modifies Another Function

If I can pass a function as an argument, I can write another function that wraps it:

manual_wrapper.py
def log_calls(func):
def wrapper(a, b):
print(f"Calling {func.__name__} with {a}, {b}")
return func(a, b)
return wrapper
def add(a, b):
return a + b
add = log_calls(add) # replace original with wrapped version
print(add(3, 5))
# Calling add with 3, 5
# 8

This is the core idea: log_calls takes add, creates a new function that adds logging around the original call, and returns it. I then reassign add to this wrapped version.

Now any code that calls add gets logging for free. I didn’t modify add’s body at all. I decorated it.

Step 3: The @ Syntax (Syntactic Sugar)

Writing add = log_calls(add) after every function definition gets old. Python’s @ syntax does the same thing without the manual reassignment:

decorator_syntax.py
def log_calls(func):
def wrapper(a, b):
print(f"Calling {func.__name__} with {a}, {b}")
return func(a, b)
return wrapper
@log_calls # same as: add = log_calls(add)
def add(a, b):
return a + b

That @log_calls line executes immediately after the function definition. Python passes the function object to log_calls and replaces the original name with whatever log_calls returns.

Step 4: Handling Any Function Signature

The wrapper above only works for functions with exactly two arguments. To make it generic, use *args and **kwargs:

generic_decorator.py
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with {args}, {kwargs}")
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a, b):
return a + b
@log_calls
def greet(name, greeting="Hello"):
return f"{greeting}, {name}"
print(greet("Alice"))
# Calling greet with ('Alice',), {}
# Hello, Alice

Now the same decorator works on any function regardless of its parameters.

Step 5: The functools.wraps Trap

Here’s a bug I hit early on:

metadata_loss.py
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a, b):
"""Returns the sum of two numbers."""
return a + b
print(add.__name__) # "wrapper" — not "add"!
print(add.__doc__) # None — lost the docstring!

The decorator replaced my function with wrapper. The original metadata (name, docstring, annotations) is gone. This breaks tools that rely on introspection — debuggers, IDEs, documentation generators.

The fix is functools.wraps:

with_wraps.py
import functools
def log_calls(func):
@functools.wraps(func) # copies func's metadata to wrapper
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def add(a, b):
"""Returns the sum of two numbers."""
return a + b
print(add.__name__) # "add"
print(add.__doc__) # "Returns the sum of two numbers."

functools.wraps copies __module__, __name__, __qualname__, __doc__, __dict__, and __wrapped__ from the original function to the wrapper. Always use it.

Step 6: Decorators With Arguments

What if I want a decorator that takes parameters? Something like:

repeat_call.py
@repeat(n=3)
def greet(name):
...

This requires another layer of wrapping:

decorator_with_args.py
import functools
def repeat(n):
def decorator(func): # receives the function
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator # returns the decorator
@repeat(3)
def greet(name):
print(f"Hello {name}")
greet("Alice")
# Hello Alice
# Hello Alice
# Hello Alice

The call chain works like this:

  1. repeat(3) executes first, returning decorator
  2. Python applies decorator to greet: greet = decorator(greet)
  3. decorator receives greet, creates the wrapped version, and returns it

The full expansion is: greet = repeat(3)(greet).

Where You Already Use Decorators

Once I understood the pattern, I saw decorators everywhere:

  • @app.route("/") in Flask and FastAPI — registers a function as a route handler
  • @pytest.fixture — marks a function as a test fixture
  • @property — turns a method into a computed attribute
  • @staticmethod / @classmethod — changes how a method binds to its class
  • @cache (functools.cache) — memoizes function results

Each one is just a function that takes a function and returns something — sometimes the modified function, sometimes a completely different object.

Nesting Order Matters

When you stack decorators:

stacked_decorators.py
@decorator_a
@decorator_b
def my_func():
pass

This is equivalent to: my_func = decorator_a(decorator_b(my_func)).

The bottom decorator (decorator_b) applies first, then the result is passed upward. So the order changes behavior. Put logging on the inside (closest to the function) and access control on the outside if you want to skip logging for rejected requests.

What I’d Tell My Past Self

Decorators are not special syntax. They’re just functions that accept a function and return a replacement. The @ is only there so you don’t have to write my_func = decorator(my_func) after every definition.

The mental model that clicked for me:

@decorator
def func():
pass
# is exactly:
def func():
pass
func = decorator(func)

Start by writing a plain wrapper function without the @. Once that works, the @ is just a cleaner way to express the same thing.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments