A Decorator Is Simply a Function That Takes Another Function as an Argument

Decorators look like magic when you first encounter them. The @ symbol, the nested functions, the wrapping -- it can feel like a new language on top of Python. But strip away the syntax and the terminology, and a decorator is one of the simplest ideas in the language: a function that takes another function as its argument, adds something to it, and returns a new function. This article builds that idea from the ground up, one concept at a time, with code you can run at every step.

The entire concept rests on three properties of Python functions: they can be assigned to variables, they can be passed as arguments, and they can be returned from other functions. These properties are what make Python functions "first-class objects." Once you see how these three capabilities combine, decorators stop being mysterious.

Functions Are Objects You Can Pass Around

In Python, a function is not a special construct that exists only in your code. It is an object, like a string, an integer, or a list. When you define a function with def, Python creates a function object and binds it to the name you gave it. That name is just a variable. You can assign the function to another variable, and both names will point to the same function object:

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


# Assign the function to another variable (no parentheses = no call)
say_hello = greet

# Both names point to the same function object
print(greet("Kandi"))      # Hello, Kandi
print(say_hello("Kandi"))  # Hello, Kandi
print(greet is say_hello)  # True

greet and say_hello are two names for the same function. Notice that we wrote say_hello = greet without parentheses. If we had written say_hello = greet("Kandi"), we would be calling the function and assigning its return value (the string "Hello, Kandi") instead of the function itself. The distinction between a function and a function call is the foundation of everything that follows.

Python Pop Quiz

What does say_hello = greet (without parentheses) assign to say_hello?

A Function That Takes a Function

Because functions are objects, they can be passed as arguments to other functions. A function that accepts another function as an argument is called a higher-order function. You have probably used one already: Python's built-in sorted() function accepts a key argument that is a function. The fundamental rule that allows functions to be passed as arguments is that Python treats them as first-class objects. PEP 318, which introduced the @ decorator syntax in Python 2.4, defines a decorator as a callable that accepts a function as its argument and returns either that same function or a new callable in its place.

def shout(text):
    return text.upper()


def whisper(text):
    return text.lower()


def apply_voice(func, message):
    """Takes a function and a message, applies the function to the message."""
    return func(message)


print(apply_voice(shout, "hello"))    # HELLO
print(apply_voice(whisper, "HELLO"))  # hello

apply_voice does not know or care what func does. It receives a function, calls it with message, and returns the result. This is the first piece of the decorator pattern: a function that receives another function as an argument and does something with it.

Returning a New Function

Functions can also be created inside other functions and returned as values. When you define a function inside another function and return it, the caller receives a brand-new function object:

def make_multiplier(factor):
    """Returns a NEW function that multiplies its input by factor."""
    def multiplier(x):
        return x * factor
    return multiplier


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

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

make_multiplier does not do the multiplication itself. It creates a new function (multiplier) that does the multiplication, and returns it. double and triple are two different function objects, each remembering a different factor. This is the second piece: a function that returns a new function.

The Closure: Remembering the Original

In the make_multiplier example above, the inner function multiplier uses the variable factor from the outer function. Even after make_multiplier has finished running, the inner function still remembers factor. This is called a closure: an inner function that retains access to variables from the scope where it was created.

Closures are critical for decorators. When a decorator defines a wrapper function inside itself, that wrapper needs to remember the original function so it can call it. The closure is the mechanism that makes this possible:

def remember_and_call(func):
    """Returns a function that remembers 'func' and calls it."""
    def wrapper():
        print(f"I remember the function: {func.__name__}")
        return func()
    return wrapper


def say_hi():
    return "Hi there!"


# Pass say_hi to remember_and_call, get back the wrapper
new_function = remember_and_call(say_hi)

# Call the wrapper. It still remembers say_hi.
print(new_function())
# I remember the function: say_hi
# Hi there!

remember_and_call takes a function, defines a wrapper that remembers it, and returns the wrapper. The wrapper is a closure over func. When new_function() is called later, the wrapper still has access to say_hi through the closure, even though remember_and_call finished running long ago.

Note

You now have all three pieces: functions can be passed as arguments (Section 2), functions can be returned from other functions (Section 3), and inner functions remember variables from their enclosing scope (this section). A decorator combines all three.

Python Pop Quiz

In the make_multiplier example, double = make_multiplier(2) finishes running. Later you call double(5). How does the inner function still know that factor is 2?

Putting It All Together: Your First Decorator

A decorator is a function that takes another function as its argument, defines a wrapper function that adds behavior before or after calling the original, and returns the wrapper. That is the entire pattern:

def announce(func):
    """A decorator: takes a function, returns an enhanced version."""
    def wrapper(*args, **kwargs):
        print(f"About to call: {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Finished calling: {func.__name__}")
        return result
    return wrapper


def add(a, b):
    return a + b


# Manually apply the decorator
add = announce(add)

print(add(3, 7))
# About to call: add
# Finished calling: add
# 10

Walk through this line by line:

  1. announce receives the add function as func.
  2. Inside announce, a new function called wrapper is defined. It prints a message, calls the original func with whatever arguments were passed, prints another message, and returns the result.
  3. announce returns the wrapper function.
  4. The line add = announce(add) replaces the name add with the wrapper.
  5. When add(3, 7) is called, it is calling the wrapper, which calls the original add internally.

That is all a decorator does. It takes a function, wraps it with additional behavior, and returns the wrapped version. The original function is still called inside the wrapper. The caller does not need to know that the wrapping happened.

Handling Any Arguments

The *args, **kwargs pattern in the wrapper's signature is important. It means the wrapper accepts any number of positional arguments and any keyword arguments. This makes the decorator general-purpose: it can wrap functions that take no arguments, one argument, or twenty arguments without modification:

def announce(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper


# Works with no arguments
def say_hello():
    print("Hello!")

say_hello = announce(say_hello)
say_hello()
# Calling say_hello
# Hello!


# Works with positional arguments
def multiply(a, b):
    return a * b

multiply = announce(multiply)
print(multiply(4, 5))
# Calling multiply
# 20


# Works with keyword arguments
def greet(name, greeting="Hi"):
    return f"{greeting}, {name}!"

greet = announce(greet)
print(greet("Kandi", greeting="Welcome"))
# Calling greet
# Welcome, Kandi!

The same announce decorator works on all three functions without any changes. The *args, **kwargs pattern passes through whatever arguments the caller provides, preserving the original function's interface.

The @ Shortcut

Writing add = announce(add) every time you want to decorate a function is repetitive. Python provides the @ symbol as a shortcut. Placing @announce on the line directly above the function definition does the same thing automatically:

def announce(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Done with {func.__name__}")
        return result
    return wrapper


@announce
def divide(a, b):
    return a / b


print(divide(10, 3))
# Calling divide
# Done with divide
# 3.3333333333333335

@announce above def divide is syntactic sugar for divide = announce(divide). It does not add any capability that was not already there. It just makes the code cleaner by keeping the decoration visible at the point where the function is defined, instead of buried somewhere below it.

Pro Tip

When reading code that uses @decorator, mentally translate it to func = decorator(func). This translation resolves all the mystery: the decorator receives the function, does something with it, and whatever it returns replaces the original name.

A Practical Example: Timing a Function

Here is a decorator you might use in a real project. It measures how long a function takes to execute:

import time


def timer(func):
    """Decorator that measures execution time."""
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f} seconds")
        return result
    return wrapper


@timer
def compute_sum(n):
    """Sum all numbers from 0 to n."""
    return sum(range(n))


result = compute_sum(10_000_000)
print(f"Result: {result}")
# compute_sum took 0.1842 seconds
# Result: 49999995000000

The timer decorator follows the exact same pattern: it takes func, defines a wrapper that records the time before and after calling func, and returns the wrapper. The original compute_sum function has no timing code inside it. The timing was added entirely from the outside, without modifying a single line of the original function.

Python Pop Quiz

After running add = announce(add), what does the name add now point to?

The Identity Problem: Why functools.wraps Exists

There is one side effect of wrapping that every tutorial covers eventually, but many forget to mention early enough: when a decorator replaces a function with a wrapper, the wrapper's identity replaces the original's. From Python's perspective, the name now points to the wrapper function, not the original. That means the __name__, __doc__, and __qualname__ attributes belong to the wrapper:

def announce(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper


@announce
def add(a, b):
    """Add two numbers and return the result."""
    return a + b


print(add.__name__)   # wrapper  <-- not "add"
print(add.__doc__)    # None     <-- the docstring is gone

This matters because debugging tools, profilers, documentation generators, and frameworks like Flask and FastAPI all read __name__ and __doc__ to identify functions. If every decorated function reports as wrapper, those tools give you useless output.

The fix is functools.wraps, a decorator in Python's standard library. The Python documentation describes it as a shorthand for calling update_wrapper as a decorator, and specifies that it copies __module__, __name__, __qualname__, __annotations__, and __doc__ from the wrapped function onto the wrapper. Apply it to the wrapper function inside your decorator:

import functools


def announce(func):
    @functools.wraps(func)          # copies __name__, __doc__, etc.
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper


@announce
def add(a, b):
    """Add two numbers and return the result."""
    return a + b


print(add.__name__)   # add     <-- correct
print(add.__doc__)    # Add two numbers and return the result.  <-- preserved

The updated timer example from the previous section should include @functools.wraps for exactly this reason:

import time
import functools


def timer(func):
    """Decorator that measures execution time."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f} seconds")
        return result
    return wrapper


@timer
def compute_sum(n):
    """Sum all numbers from 0 to n."""
    return sum(range(n))


print(compute_sum.__name__)  # compute_sum  (not "wrapper")
print(compute_sum.__doc__)   # Sum all numbers from 0 to n.
Rule of thumb

Every decorator you write in production code should have @functools.wraps(func) on the wrapper. It costs one import and one line, and it prevents a class of bugs that are difficult to trace because they only surface in tools that rely on function metadata—not in the output of the function itself.

Python Pop Quiz

A decorator wraps compute_sum but the wrapper does not use @functools.wraps(func). What does compute_sum.__name__ print after decoration?

Frequently Asked Questions

What is a Python decorator in simple terms?

A decorator is a function that takes another function as its argument, adds behavior to it, and returns a new function. The new function replaces the original, so when you call the original name, you get the enhanced version instead.

Why do functions need to be first-class objects for decorators to work?

First-class means functions can be assigned to variables, passed as arguments, and returned from other functions. Decorators rely on all three: the original function is passed as an argument to the decorator, the decorator defines a new function internally, and that new function is returned and assigned to the original name.

What is a closure and why do decorators need one?

A closure is a function that retains access to variables from the scope where it was created, even after that scope has finished executing. In a decorator, the wrapper function is a closure because it remembers the original function (func) from the decorator's scope. Without closures, the wrapper would lose access to the original function the moment the decorator finished running.

What does the @ symbol do above a function definition?

The @ symbol is syntactic sugar, introduced in Python 2.4 by PEP 318. Writing @decorator above a function definition is equivalent to writing func = decorator(func) after the definition. It passes the function to the decorator and replaces the original name with whatever the decorator returns.

Can any function be used as a decorator?

Any callable that accepts a single argument (the function to decorate) and returns a callable can be used as a decorator. This includes regular functions and class instances that implement __call__. Lambda expressions cannot be used with @ syntax because the @ form requires a name, but a lambda can be applied manually as func = (lambda f: ...)(func)—though this is rarely useful in practice.

Why should I use functools.wraps inside a decorator?

When a decorator replaces a function with a wrapper, the wrapper's __name__, __doc__, __qualname__, and __annotations__ become visible instead of the original function's. This breaks debugging tools, documentation generators, and introspection. Applying @functools.wraps(func) to the wrapper copies the original function's metadata onto the wrapper so it behaves as if it were the original function. The Python documentation describes functools.wraps as a shorthand for invoking update_wrapper directly on the wrapper function.

How to Write a Python Decorator

  1. Understand that functions are objects. In Python, a function can be assigned to a variable, passed as an argument, and returned from another function. This is the prerequisite for all decorator patterns.
  2. Write a function that accepts another function as its argument. Define an outer function that takes a function parameter. This outer function will become your decorator.
  3. Define a wrapper function inside the decorator. Inside the outer function, define an inner function (conventionally called wrapper) that will add behavior before and after calling the original function.
  4. Use *args, **kwargs in the wrapper signature. This makes the wrapper accept and forward any combination of positional and keyword arguments, so the decorator works with any function regardless of its signature. See the complete guide to *args and **kwargs for a full treatment of how these parameters work.
  5. Call the original function inside the wrapper and return its result. Inside the wrapper, call func(*args, **kwargs) and return the result. Place any added behavior before or after this call.
  6. Apply @functools.wraps(func) to the wrapper. Import functools and place @functools.wraps(func) on the line directly above the wrapper's def. This copies the original function's __name__, __doc__, __qualname__, and __annotations__ onto the wrapper so debugging tools and documentation generators see the correct identity.
  7. Return the wrapper from the decorator. At the end of the outer function, return the wrapper function without calling it. This is what replaces the original function name.
  8. Apply the decorator with @ syntax. Place @decorator_name on the line directly above the function definition you want to enhance. This is equivalent to writing func = decorator(func) immediately after the definition.

Key Takeaways

  1. Functions are first-class objects in Python. They can be assigned to variables, passed as arguments, and returned from other functions. These three properties are the prerequisite for decorators.
  2. A decorator is a function that takes another function as its argument. It defines a wrapper function inside itself that adds behavior before, after, or around the original function call, and returns that wrapper. PEP 318 introduced the @ syntax for this pattern in Python 2.4.
  3. The wrapper is a closure. It remembers the original function from the enclosing scope, which is how it can call the original function even after the decorator has finished running.
  4. *args, **kwargs makes the wrapper universal. By accepting and forwarding all arguments, the wrapper works with any function regardless of its signature.
  5. The @ symbol is a shortcut, not a requirement. Writing @decorator above a function is identical to writing func = decorator(func) after it. The @ syntax makes the decoration visible where the function is defined.
  6. @functools.wraps(func) preserves function identity. Without it, the wrapper replaces the original function's __name__, __doc__, and __qualname__, breaking debugging tools and documentation generators. Adding @functools.wraps(func) to every wrapper is a production best practice.

A decorator is not a new language feature. It is a pattern built from three things Python already does: passing functions as arguments, returning functions from functions, and closures. The @ syntax is a convenience introduced in Python 2.4 by PEP 318. Once you understand that a decorator is a function that takes a function and returns a function—and that @functools.wraps(func) preserves the wrapped function's identity—every decorator you encounter, from @property to @app.route to @functools.wraps itself, is a variation on the same pattern: receive, wrap, preserve identity, return.