Python Decorators for Beginners: Understanding @syntax and First-Class Functions

,
Updated Feb 6, 2026

The Bug That Makes No Sense

Here’s a function that logs how long something takes to run:

import time

def timer(func):
    def wrapper():
        start = time.perf_counter()
        result = func()
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def fetch_data():
    """Simulate a slow API call."""
    time.sleep(0.5)
    return {"status": "ok"}

print(fetch_data.__name__)
print(fetch_data.__doc__)

Run it. The output:

wrapper
None

Not fetch_data. Not "Simulate a slow API call." The function’s identity is gone — its name, its docstring, everything replaced by the wrapper’s metadata. This trips up everyone who writes their first decorator, and it causes real problems: logging systems that report the wrong function name, documentation generators that lose docstrings, and debugging sessions where stack traces point to wrapper instead of the function you actually care about.

The fix is functools.wraps, and we’ll get to it. But the reason this bug exists at all reveals something fundamental about how Python treats functions — and that’s where the real story starts.

Functions Are Just Objects (And That Changes Everything)

Most languages treat functions as special. Python doesn’t. A function in Python is an object, the same way an integer or a string or a dictionary is an object. It has attributes. You can assign it to a variable. You can stuff it in a list. You can pass it as an argument to another function.

This isn’t some academic curiosity — it’s the entire foundation that makes decorators possible.

def greet(name):
    return f"Hello, {name}"

# Assign to a variable
say_hello = greet
print(say_hello("Alice"))  # Hello, Alice

# Put it in a data structure
operations = [greet, len, str.upper]
print(operations[0]("Bob"))  # Hello, Bob

# Check its attributes
print(type(greet))        # <class 'function'>
print(greet.__name__)     # greet
print(dir(greet)[:5])     # ['__annotations__', '__builtins__', '__call__', '__class__', '__closure__']

That dir() call reveals something interesting — functions have a __call__ method. That’s what makes them callable. Any object with __call__ can be invoked with parentheses, which is why classes can act as decorators too (more on that in Part 3).

The concept of functions-as-objects is what the Python docs call “first-class functions.” Guido van Rossum borrowed the idea from functional programming languages like Lisp and Scheme, where passing functions around is the default way of doing things. In Python, it means you can write a function that takes a function as input and returns a new function as output. That’s literally all a decorator is. The @ syntax is just sugar on top. When you write @timer above a function definition, Python is doing exactly this:

def fetch_data():
    time.sleep(0.5)
    return {"status": "ok"}

fetch_data = timer(fetch_data)

That’s it. No magic. The @ symbol is syntactic sugar that reassigns the function name to whatever the decorator returns.

Higher-Order Functions: The Building Block

A function that accepts or returns another function is called a higher-order function. You’ve probably used them without thinking about it — map(), filter(), sorted() with a key argument. They all take a function as a parameter.

names = ["alice", "Bob", "CHARLIE", "dave"]
sorted_names = sorted(names, key=str.lower)
print(sorted_names)  # ['alice', 'Bob', 'CHARLIE', 'dave']

sorted() doesn’t care what key is, as long as it’s callable. You could pass a lambda, a named function, or even an object with __call__. This flexibility is what makes Python’s decorator pattern work so naturally.

But here’s where it gets interesting — and where closures enter the picture. Watch what happens when a function returns another function:

def make_multiplier(factor):
    def multiply(n):
        return n * factor
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))   # 10
print(triple(5))   # 15
print(double(10))  # 20

When make_multiplier(2) finishes executing, the local variable factor should be dead. The function’s stack frame is gone. But multiply still remembers that factor was 2. How?

Because Python creates a closure — the inner function captures a reference to variables from its enclosing scope. You can actually inspect this:

print(double.__closure__)
# (<cell at 0x...: int object at 0x...>,)

print(double.__closure__[0].cell_contents)
# 2

Closures are the mechanism that lets decorators “remember” their configuration. Without closures, there’d be no way for a wrapper function to retain access to the original function it’s decorating. In more formal terms, a closure binds the free variables of a function ff to the values they had when ff was defined, creating a function-plus-environment pair that the Python runtime preserves on the heap rather than the stack.

Writing Your First Real Decorator

Let’s go back to the timer example and build it properly this time:

import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"[TIMER] {func.__name__}() took {elapsed:.4f}s")
        return result
    return wrapper

Three things changed from the broken version. First, functools.wraps(func) copies the original function’s __name__, __doc__, __module__, and other metadata onto the wrapper. Second, the wrapper now accepts *args, **kwargs so it works with any function signature — not just zero-argument functions. Third, we’re actually returning result so the decorated function’s return value isn’t silently swallowed.

That *args, **kwargs pattern is worth pausing on. Without it, your decorator only works on functions with no parameters. The moment someone tries to decorate a function that takes arguments, it breaks:

# This would fail with the original wrapper()
@timer
def process_batch(items, batch_size=32):
    time.sleep(0.1 * len(items))
    return [items[i:i+batch_size] for i in range(0, len(items), batch_size)]

batches = process_batch([1, 2, 3, 4, 5], batch_size=2)
print(batches)
# [TIMER] process_batch() took 0.5012s
# [[1, 2], [3, 4], [5]]

Now let’s verify the metadata fix actually works:

print(process_batch.__name__)  # process_batch  ✓
print(process_batch.__doc__)   # None (we didn't write one, which is fine)

Why does functools.wraps matter beyond cosmetics? Because inspection tools rely on these attributes. help() uses __doc__. Testing frameworks like pytest use __name__ for test discovery. Flask and Django use __name__ internally for URL routing. If your decorator eats the metadata, things break in ways that are genuinely hard to debug — you’ll see error messages referencing wrapper in a traceback and have no idea which actual function failed.

I’m not entirely sure why functools.wraps isn’t applied automatically by the language. My best guess is that it would require Python to somehow detect when a function is being used as a decorator, which isn’t straightforward since decorators are just regular functions. The explicit @functools.wraps approach at least makes the intent clear.

The Mental Model: Decoration as Function Composition

If you’ve taken a math class that covered function composition, decorators will feel familiar. Given two functions ff and gg, the composition (gf)(x)=g(f(x))(g \circ f)(x) = g(f(x)) applies ff first, then passes the result to gg. A decorator does something analogous — it wraps behavior around a function without changing the function itself.

More precisely, if dd is a decorator and ff is the original function, then @d produces:

fnew=d(f)f_{\text{new}} = d(f)

where fnewf_{\text{new}} is the wrapper that calls ff internally. The key insight is that ff isn’t modified — it still exists inside the closure. The decorator creates a new callable that delegates to ff while adding behavior before or after the call.

When you stack decorators (Part 3 territory), the order matters because composition isn’t commutative in general. Writing:

@decorator_a
@decorator_b
def my_func():
    pass

is equivalent to my_func = decorator_a(decorator_b(my_func)). The innermost decorator runs first during decoration, but the outermost wrapper executes first at call time. That distinction trips people up constantly.

Closures in Practice: A Counting Decorator

Here’s a slightly more useful example — a decorator that tracks how many times a function gets called:

import functools

def call_counter(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.call_count += 1
        print(f"[CALL #{wrapper.call_count}] {func.__name__}()")
        return func(*args, **kwargs)
    wrapper.call_count = 0
    return wrapper

@call_counter
def query_database(query):
    # pretend this hits a real DB
    return f"Results for: {query}"

query_database("SELECT * FROM users")
query_database("SELECT * FROM orders")
query_database("SELECT count(*) FROM logs")

print(f"Total calls: {query_database.call_count}")

Output:

[CALL #1] query_database()
[CALL #2] query_database()
[CALL #3] query_database()
Total calls: 3

Notice the trick: we’re storing call_count as an attribute on the wrapper function itself rather than as a variable in the enclosing scope. Why not just use a regular variable? Because of how Python handles closures with mutable bindings. If you tried count = 0 in the enclosing scope and then count += 1 inside the wrapper, you’d get an UnboundLocalError — Python sees the assignment and treats count as local to the wrapper, but it hasn’t been defined there yet.

You could fix this with nonlocal count (added in Python 3.0, via PEP 3104), and that works fine:

def call_counter_v2(func):
    count = 0
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        return func(*args, **kwargs)
    wrapper.call_count = property(lambda self: count)  # this won't work as expected
    return wrapper

But attaching the counter as an attribute on the wrapper is simpler and makes the count accessible from outside. I’d pick the attribute approach in most cases. The nonlocal version is fine too — it’s more of a style preference than a correctness issue.

Where @ Came From

The @decorator syntax was added in Python 2.4 (PEP 318, authored by Kevin Smith and others). Before that, you had to write the manual reassignment:

def my_func():
    pass
my_func = decorator(my_func)

This was error-prone — the function name appears three times, and it’s easy to forget the reassignment or misspell it. The @ syntax made decorators visible at the point of definition rather than buried below the function body. It also made stacking multiple decorators readable instead of an unreadable nested call like a(b(c(my_func))).

But the @ is purely syntax. There’s no special “decorator protocol” in Python. Any callable that takes a function and returns a callable works. This means you can use classes, lambdas (technically), or even functools.partial objects as decorators. The flexibility is both powerful and occasionally confusing.

Common First Mistakes

Beyond the metadata issue, there’s one mistake that catches almost everyone: forgetting to actually call the function inside the wrapper.

def broken_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("before")
        func  # oops — never called func()
        print("after")
    return wrapper

@broken_decorator
def important_calculation():
    return 42

result = important_calculation()
print(result)  # None

The function silently returns None because the wrapper doesn’t call func(*args, **kwargs) and doesn’t return anything. No error, no warning — just None. In a larger codebase, this kind of bug can hide for weeks if the return value isn’t immediately checked.

Another subtle one: decorating at import time vs. call time. The decorator runs when the module is imported, not when the function is called. That means side effects in the decorator body (not the wrapper) execute once during import:

def register(func):
    print(f"Registering {func.__name__}")  # runs at import time
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")   # runs at call time
        return func(*args, **kwargs)
    return wrapper

@register
def do_work():
    pass

# Just importing this module prints: "Registering do_work"
# Before do_work() is ever called

This behavior is actually useful — Flask’s @app.route() relies on it to build the URL map at startup. But if you’re not expecting it, it can lead to confusing initialization order issues.

What I’d Do Differently

If I were learning decorators again, I’d skip the toy examples (“print before and after!”) and go straight to functools.wraps + *args, **kwargs from the start. The incomplete versions just build bad habits. Every decorator you write in practice needs both — there’s no reason to learn the broken version first.

I’d also spend more time with closures before touching the @ syntax. The syntactic sugar obscures what’s actually happening, and when something goes wrong, you need to understand the underlying mechanics to debug it. Run func.__closure__ on a few examples. Inspect cell_contents. Get comfortable with the idea that functions carry their environment with them.

And start reading the Python docs on functools early. Half the decorators you’d write by hand already exist there — lru_cache, total_ordering, singledispatch. No point reinventing them.

In Part 2, we’ll get into the patterns that actually show up in production code — decorators that take arguments, decorators that modify return values, retry logic, and authentication checks. The jump from “I understand closures” to “I can write a parameterized decorator” is bigger than most tutorials suggest, and there’s a specific three-layer nesting pattern you need to internalize to get it right.

Python Decorators Complete Guide Series (1/3)

Did you find this helpful?

☕ Buy me a coffee

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

TODAY 436 | TOTAL 2,659