How Do Python Decorators Work? A Complete Guide for Beginners
I remember the first time I saw this in Python code:
@some_namedef my_function(): passI 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:
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 - bEvery 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.
def greet(name): return f"Hello {name}"
say_hello = greet # no parentheses — not calling, just referencingprint(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:
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# 8This 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:
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 + bThat @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:
def log_calls(func): def wrapper(*args, **kwargs): print(f"Calling {func.__name__} with {args}, {kwargs}") return func(*args, **kwargs) return wrapper
@log_callsdef add(a, b): return a + b
@log_callsdef greet(name, greeting="Hello"): return f"{greeting}, {name}"
print(greet("Alice"))# Calling greet with ('Alice',), {}# Hello, AliceNow 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:
def log_calls(func): def wrapper(*args, **kwargs): print(f"Calling {func.__name__}") return func(*args, **kwargs) return wrapper
@log_callsdef 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:
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_callsdef 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(n=3)def greet(name): ...This requires another layer of wrapping:
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 AliceThe call chain works like this:
repeat(3)executes first, returningdecorator- Python applies
decoratortogreet:greet = decorator(greet) decoratorreceivesgreet, 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:
@decorator_a@decorator_bdef my_func(): passThis 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:
@decoratordef func(): pass
# is exactly:def func(): passfunc = 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:
- 👨💻 Official Python Docs: Decorators
- 👨💻 PEP 318 – Decorators for Functions and Methods
- 👨💻 Real Python: Primer on Python Decorators
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments