Skip to main content

Command Palette

Search for a command to run...

Decorators in Python - 2026

Updated
4 min read
Decorators in Python - 2026
A
I write on topics related to software engineering from beginner to advanced

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_whee function into my_decorator.

  • my_decorator creates the wrapper function, packages say_whee into its closure memory, and returns wrapper.

  • You overwrite the variable name say_whee with that new wrapper function.

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 💙