A decorator is not a separate language feature with its own rules. It is a higher-order function -- a function that takes a function as input, returns a function as output, or both. Understanding this single fact removes the mystery from decorators entirely. This article builds the connection by examining each higher-order pattern separately, then showing how decorators combine them. By the end, the @ syntax will look like exactly what it is: shorthand for a higher-order function call.
Higher-order functions are one of two capabilities that Python's first-class function support enables (the other is closures). A higher-order function does one or both of the following: it accepts a function as one of its arguments, or it returns a function as its result. Python's standard library is built on this concept. map(), filter(), sorted(), functools.reduce(), and every decorator you have ever used are all higher-order functions.
What Makes a Function Higher-Order
A regular function operates on data values -- numbers, strings, lists. A higher-order function operates on functions themselves, treating them as values that can be passed around, transformed, and returned. There are exactly two ways a function qualifies as higher-order:
# Pattern 1: accepts a function as an argument
def apply(func, value):
return func(value)
print(apply(str.upper, "hello")) # HELLO
print(apply(len, [1, 2, 3])) # 3
# Pattern 2: returns a function as its result
def make_adder(n):
def adder(x):
return x + n
return adder
add_five = make_adder(5)
print(add_five(10)) # 15
apply is higher-order because it takes func as an argument. make_adder is higher-order because it returns adder as its result. A decorator combines both: it takes a function as input and returns a new function as output.
Pattern 1: Functions as Arguments
This pattern is everywhere in Python. Any time you pass a function to another function, you are using a higher-order function.
sorted() With a key Function
students = [
{"name": "Alice", "grade": 88},
{"name": "Bob", "grade": 95},
{"name": "Carol", "grade": 72},
]
# sorted() accepts a function via its 'key' argument
by_grade = sorted(students, key=lambda s: s["grade"])
by_name = sorted(students, key=lambda s: s["name"])
print([s["name"] for s in by_grade]) # ['Carol', 'Alice', 'Bob']
print([s["name"] for s in by_name]) # ['Alice', 'Bob', 'Carol']
sorted() does not know anything about grades or names. It delegates the comparison logic to whatever function you pass as key. This separation of the sorting mechanism from the sorting criteria is the core benefit of the higher-order pattern.
map() and filter()
temperatures_c = [0, 20, 37, 100]
# map() accepts a function and applies it to each element
fahrenheit = list(map(lambda c: c * 9/5 + 32, temperatures_c))
print(fahrenheit) # [32.0, 68.0, 98.6, 212.0]
# filter() accepts a function and keeps elements where it returns True
warm = list(filter(lambda c: c > 25, temperatures_c))
print(warm) # [37, 100]
Writing Your Own: retry()
def retry(func, attempts=3):
"""Higher-order function: calls func up to 'attempts' times."""
for i in range(attempts):
try:
return func()
except Exception:
if i == attempts - 1:
raise
import random
def unreliable():
if random.random() < 0.7:
raise ConnectionError("timeout")
return "success"
result = retry(unreliable, attempts=10)
print(result) # success (after some retries)
In all of these examples, the higher-order function (sorted, map, filter, retry) controls the structure of the operation. The function you pass in controls the behavior. This separation is what makes the pattern powerful.
Pattern 2: Functions as Return Values
A function that returns a function is called a function factory (the same pattern used by decorator factories with arguments). The returned function typically captures the factory's arguments through a closure, creating a specialized version of some general behavior.
def make_validator(min_val, max_val):
"""Factory: returns a validator function customized with bounds."""
def validate(value):
if not (min_val <= value <= max_val):
raise ValueError(f"{value} not in [{min_val}, {max_val}]")
return value
return validate
check_percentage = make_validator(0, 100)
check_temperature = make_validator(-273.15, 1000)
print(check_percentage(85)) # 85
print(check_temperature(-40)) # -40
try:
check_percentage(150)
except ValueError as e:
print(e) # 150 not in [0, 100]
Each call to make_validator produces a new function with its own captured min_val and max_val. The factory creates specialized functions on demand. This is the second half of the higher-order pattern.
functools.partial: Built-in Function Factory
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5)) # 25
print(cube(5)) # 125
functools.partial is a higher-order function in the standard library that creates a new callable with arguments pre-filled. It is a general-purpose function factory.
Decorators Combine Both Patterns
A decorator takes a function as input (Pattern 1) and returns a new function as output (Pattern 2). The @ syntax is shorthand for this two-step process. Here is a decorator written first without @, then with it, to make the higher-order structure visible:
import functools
def log_calls(func): # Pattern 1: accepts a function
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"-> {func.__name__}()")
result = func(*args, **kwargs)
print(f"<- {func.__name__}() returned {result!r}")
return result
return wrapper # Pattern 2: returns a new function
# Without @ syntax: the higher-order call is explicit
def add(a, b):
return a + b
add = log_calls(add) # pass add in, get wrapper out, rebind add
# With @ syntax: identical behavior, different notation
@log_calls
def multiply(a, b):
return a * b
add(3, 4)
# -> add()
# <- add() returned 7
multiply(3, 4)
# -> multiply()
# <- multiply() returned 12
The line add = log_calls(add) is the raw higher-order function call. The @log_calls above multiply does the same thing in a single annotation. There is no separate "decorator mechanism" in Python. The @ symbol is syntactic sugar that calls a higher-order function and rebinds the name. (Notice the use of functools.wraps inside the wrapper -- it preserves the original function's metadata.)
Not all higher-order functions are decorators. map() takes a function as an argument but does not return a function -- it returns an iterator of data. make_validator() returns a function but does not take a function as input. A decorator specifically does both: takes a function in, returns a (wrapped) function out.
Stacking Decorators: Composing Higher-Order Functions
When you stack multiple decorators, you are composing higher-order functions. The innermost decorator (closest to the function) runs first. Each subsequent decorator wraps the result of the previous one:
import functools
def uppercase(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper
def exclaim(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result + "!!!"
return wrapper
@exclaim
@uppercase
def greet(name):
return f"hello, {name}"
print(greet("alice"))
# HELLO, ALICE!!!
# Without @ syntax, the composition is explicit:
# greet = exclaim(uppercase(greet))
The desugared form exclaim(uppercase(greet)) makes the composition clear. uppercase wraps greet first, producing a function that uppercases the result. exclaim wraps that, producing a function that adds exclamation marks to whatever the inner function returns. Each decorator is a higher-order function that transforms a function into a new function.
functools: The Higher-Order Function Toolkit
Python's functools module is entirely built around higher-order functions. Every tool in it either accepts functions as arguments, returns functions, or both:
from functools import wraps, cache, lru_cache, reduce, partial
# wraps: a decorator (HOF) that preserves function metadata
# Used inside other decorators
# cache: a decorator (HOF) that adds unbounded memoization (Python 3.9+)
@cache
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(30)) # 832040 (computed efficiently via caching)
# reduce: a HOF that accepts a function and applies it cumulatively
total = reduce(lambda acc, x: acc + x, [1, 2, 3, 4, 5])
print(total) # 15
# partial: a HOF that returns a new function with pre-filled arguments
int_from_binary = partial(int, base=2)
print(int_from_binary("1010")) # 10
The entire module is named after higher-order functions -- "functools" is tools for working with functions. wraps, cache, and lru_cache are decorators (higher-order functions that both accept and return functions). reduce accepts a function (Pattern 1). partial returns a function (Pattern 2).
When you write @lru_cache(maxsize=128), you are calling lru_cache(maxsize=128) first, which returns a decorator (a higher-order function). That decorator then receives fibonacci as its argument. Parameterized decorators are just higher-order functions that return other higher-order functions.
How to Understand Python Decorators as Higher-Order Functions
Follow these steps in order. Each builds on the last, and by step four you will be writing a complete decorator from scratch with nothing new to learn.
Step 1: Learn What Makes a Function Higher-Order
Understand that a higher-order function either accepts a function as an argument, returns a function as its result, or both. Write a simple apply(func, value) function that takes a function as an argument and a make_adder(n) function that returns a new function. The examples at the top of this article show both.
Step 2: Practice Passing Functions as Arguments
Use Python built-ins like sorted() with a key function, map(), and filter() to see how passing a function as an argument lets you separate the operation's structure from its behavior. The sorting and temperature examples in the Pattern 1 section above give you working code to run.
Step 3: Practice Returning Functions From Functions
Write function factories like make_validator(min_val, max_val) that return specialized functions. Understand that the returned function captures the factory's arguments through a closure. The make_validator example in the Pattern 2 section is a direct model to follow.
Step 4: Combine Both Patterns Into a Decorator
Write a decorator like log_calls that accepts a function (Pattern 1) and returns a wrapper function (Pattern 2). Use @functools.wraps to preserve the original function's metadata. Recognize that @decorator above def f is shorthand for f = decorator(f).
Step 5: Compose Decorators by Stacking Them
Stack multiple decorators using the @ syntax. Understand that @b above @a above def f is equivalent to f = b(a(f)). Each decorator wraps the result of the one below it. The uppercase/exclaim example in the stacking section walks through this exactly.
Step 6: Explore functools as the Higher-Order Toolkit
Use functools.cache for memoization, functools.partial for argument pre-filling, functools.reduce for cumulative operations, and functools.wraps for metadata preservation. Recognize that the entire module is built around higher-order function patterns.
Frequently Asked Questions
What is a higher-order function in Python?
A higher-order function is a function that takes one or more functions as arguments, returns a function as its result, or both. Python's built-in map(), filter(), and sorted() (with a key argument) are higher-order functions because they accept functions as arguments. Function factories and decorators are higher-order functions because they return functions.
How are decorators related to higher-order functions?
A decorator is a specific kind of higher-order function that combines both patterns: it takes a function as input (argument pattern) and returns a new function as output (return pattern). The returned function typically wraps the original, adding behavior before or after it. The @ syntax is shorthand for passing a function to the decorator and rebinding the name to the result.
What is the difference between map() and a decorator?
Both are higher-order functions, but they serve different purposes. map() takes a function and applies it to each element of an iterable, producing transformed data. A decorator takes a function and returns a new function with modified behavior. map() operates on data using a function; a decorator operates on a function to produce a new function.
What is a function factory in Python?
A function factory is a higher-order function that returns a new function customized by the arguments you pass to the factory. For example, a make_multiplier(n) function that returns a function which multiplies its argument by n. Function factories use closures to capture the factory's arguments, making them available to the returned function.
Why does understanding higher-order functions help with learning decorators?
Decorators combine two higher-order function patterns: accepting a function as input and returning a function as output. If you understand each pattern separately (map/filter/sorted for accepting functions, function factories for returning functions), decorators become the natural combination of both. There is no special decorator magic -- just higher-order functions with @ syntax sugar.
Key Takeaways
- A higher-order function takes a function as an argument, returns a function, or both.
map(),filter(), andsorted(key=...)demonstrate the first pattern. Function factories likemake_validator()andfunctools.partial()demonstrate the second. Decorators combine both patterns into one. - A decorator is a higher-order function that takes a function in and returns a function out. The
@decoratorsyntax is shorthand forfunc = decorator(func). There is no special decorator mechanism in the language -- it is a higher-order function call with automatic name rebinding. - Stacking decorators is function composition.
@babove@aabovedef fis equivalent tof = b(a(f)). Each decorator wraps the output of the one below it, creating a chain of higher-order transformations. functoolsis Python's higher-order function toolkit. It provides decorators (wraps,lru_cache,cache,total_ordering), a function factory (partial), a fold operation (reduce), and dispatch (singledispatch) -- all built on the principle of treating functions as values.- Understanding higher-order functions removes the mystery from decorators. Once you can pass a function to
sorted()and build a function withmake_adder(), you already understand the two halves of a decorator. The@symbol just puts them together.
Higher-order functions are the conceptual foundation that decorators are built on. Every decorator you write, every @lru_cache you apply, every stacked @ chain you read -- all of it reduces to functions accepting functions and functions returning functions. The @ syntax exists to make this pattern readable, not to create a new abstraction.