Decorators in Python - 2026

Understanding decorators at a low level, we have to peel back Python's syntax sugar and look at how Python treats functions, scopes, and memory.
The Three Pillars of Decorators
Before we even look at a decorator, Python requires three structural features to make them possible:
1. Functions are "First-Class Citizens"
In Python, functions are just objects, exactly like strings, integers, or lists. This means you can:
Assign a function to a variable.
Pass a function as an argument to another function.
Return a function from a function.
def shout(text):
return text.upper()
# Assigning a function to a variable (no parentheses!)
yell = shout
print(yell("hello")) # Outputs: HELLO
2. Inner (Nested) Functions
You can define a function inside another function. The inner function is completely hidden from the outside world. it only exists while the parent function is executing.
def parent():
print("Printing from parent()")
def child():
print("Printing from child()")
child() # Local execution
3. Closures (The Memory Trick)
This is the most critical low-level concept. An inner function retains access to the variables defined in its parent function's scope, even after the parent function has finished executing.
def make_multiplier(x):
# 'x' is in the parent scope
def multiply(n):
return n * x # 'multiply' remembers what 'x' was!
return multiply
times_three = make_multiplier(3)
# 'make_multiplier' has finished running now, but...
print(times_three(10)) # Outputs: 30
When make_multiplier(3) runs, it returns the multiply function object. Python attaches a hidden attribute to that returned function called __closure__, which locks the value x=3 into memory.
Demystifying the @ Syntax (The Low Level)
The @ symbol is purely syntactic sugar. It is a shortcut written into Python's compiler to save you from typing.
Let's look at what happens behind the scenes when you build a standard, 2-layer decorator.
Step A: The Manual Way (No @ symbol)
Here is how you would write and apply a decorator manually using the three pillars:
# 1. The Decorator Function
def my_decorator(func):
def wrapper():
print("1. Something before the function.")
func() # Calls the original function via closure
print("2. Something after the function.")
return wrapper
# 2. A standard function
def say_whee():
print("Whee!")
# 3. The manual decoration step
say_whee = my_decorator(say_whee)
Look closely at line 13: say_whee = my_decorator(say_whee).
You pass the original
say_wheefunction intomy_decorator.my_decoratorcreates thewrapperfunction, packagessay_wheeinto its closure memory, and returnswrapper.You overwrite the variable name
say_wheewith that newwrapperfunction.
Now, when you call say_whee(), you are actually calling wrapper().
Step B: The Syntactic Sugar
The creators of Python noticed developers doing this func = decorator(func) pattern constantly. To make it cleaner, they introduced the @ syntax.
Writing this:
@my_decorator
def say_whee():
print("Whee!")
Is 100% identical to writing this under the hood:
def say_whee():
print("Whee!")
say_whee = my_decorator(say_whee)
The Python interpreter intercepts the function definition, passes it into the decorator, and reassigns the resulting wrapper function to the original function's name.
The @wraps from functools (The Identity Crisis)
Because decorators secretly overwrite your function with the internal wrapper function, your original function loses its identity.
Python
def simple_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@simple_decorator
def add_numbers(a, b):
"""Adds two numbers together."""
return a + b
# Let's inspect the function's metadata
print(add_numbers.__name__) # Outputs: 'wrapper' (Not 'add_numbers'!)
print(add_numbers.__doc__) # Outputs: None (The docstring is gone!)
Why this matters practically:
If your original function's name becomes "wrapper", it makes debugging incredibly frustrating because stack traces and error logs will point to wrapper instead of add_numbers. It also breaks auto-generated documentation tools (like Sphinx) and IDE autocomplete features.
All @wraps(func) does is act as a utility helper. It copies the __name__, __doc__, and module information from your original function (add_numbers) and pastes them onto the wrapper function so it can "fake" its original identity.
from functools import wraps
def simple_decorator(func):
@wraps(func) # Copies metadata over
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
That's it. Hope you learned something 💙



