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.
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.
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.
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.
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:
announcereceives theaddfunction asfunc.- Inside
announce, a new function calledwrapperis defined. It prints a message, calls the originalfuncwith whatever arguments were passed, prints another message, and returns the result. announcereturns thewrapperfunction.- The line
add = announce(add)replaces the nameaddwith thewrapper. - When
add(3, 7)is called, it is calling thewrapper, which calls the originaladdinternally.
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 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.
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.
Key Takeaways
- 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.
- 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.
- 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.
*args, **kwargsmakes the wrapper universal. By accepting and forwarding all arguments, the wrapper works with any function regardless of its signature.- The
@symbol is a shortcut, not a requirement. Writing@decoratorabove a function is identical to writingfunc = decorator(func)after it. The@syntax makes the decoration visible where the function is defined.
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 just a convenience. Once you understand that a decorator is a function that takes a function and returns a function, every decorator you encounter, from @property to @app.route to @functools.wraps, is a variation on the same three-step process: receive, wrap, return.