Python Decorators: A Complete Guide With Code Examples
Table of Contents
Remember function transformations, where a higher-order function takes a function and returns a function with new behavior? Python decorators offer a kind of syntactic sugar around that. ("Syntactic sugar" just means "a more convenient syntax.") In this guide you'll learn what decorators are, how *args and **kwargs let them wrap any function, how to write decorators that take arguments, and how to use functools.lru_cache for memoization.
All the content from our Boot.dev courses are available for free here on the blog. This one is the "Decorators" chapter of Learn Functional Programming in Python. If you want to try the far more immersive version of the course, do check it out!
What Is a Decorator in Python?
from collections.abc import Callable
def vowel_counter(func_to_decorate: Callable[[str], None]) -> Callable[[str], None]:
vowel_count: int = 0
def wrapper(doc: str) -> None:
nonlocal vowel_count
vowels: str = "aeiou"
for char in doc:
if char.lower() in vowels:
vowel_count += 1
print(f"Vowel count: {vowel_count}")
func_to_decorate(doc)
return wrapper
@vowel_counter
def process_doc(doc: str) -> None:
print(f"Document: {doc}")
process_doc("What")
# Vowel count: 1
# Document: What
process_doc("A wonderful")
# Vowel count: 5
# Document: A wonderful
process_doc("world")
# Vowel count: 6
# Document: world
The @vowel_counter line is "decorating" the process_doc function with the vowel_counter function. vowel_counter is called once when process_doc is defined with the @ syntax, but the wrapper function that it returns is called every time process_doc is called. That's why vowel_count is preserved and printed after each time, thanks to the closure.
How Is a Decorator Just Syntactic Sugar?
Python decorators are just another (sometimes simpler) way of writing a higher-order function. These two pieces of code are identical:
@vowel_counter
def process_doc(doc: str) -> None:
print(f"Document: {doc}")
def process_doc(doc: str) -> None:
print(f"Document: {doc}")
process_doc = vowel_counter(process_doc)
The @ form reassigns the name process_doc to the wrapped version. It's the same idea as function transformations in Python, with nicer syntax.
What Are args and *kwargs in Python?
In Python, *args and **kwargs allow a function to accept and deal with a variable number of arguments.
*argscollects positional arguments into a tuple**kwargscollects keyword (named) arguments into a dictionary
def print_arguments(*args: object, **kwargs: object) -> None:
print(f"Positional arguments: {args}")
print(f"Keyword arguments: {kwargs}")
print_arguments("hello", "world", a=1, b=2)
# Positional arguments: ('hello', 'world')
# Keyword arguments: {'a': 1, 'b': 2}
Positional arguments are the ones you're already familiar with, where the order of the arguments matters. Keyword arguments are passed in by name. Order does not matter. Any positional arguments must come before keyword arguments, so sub(b=3, 2) will not work.
How Do Decorators Handle Any Function Signature?
The *args and **kwargs syntax is great for decorators that are intended to work on functions with different signatures. The log_call_count function below doesn't care about the number or the types of the decorated function's (func_to_decorate) arguments. It just wants to count how many times the function is called. However, it still needs to pass any arguments through to the wrapped function.
from collections.abc import Callable
def log_call_count(func_to_decorate: Callable[..., object]) -> Callable[..., object]:
count = 0
def wrapper(*args: object, **kwargs: object) -> object:
nonlocal count
count += 1
print(f"Called {count} times")
# Pass any and all arguments to the decorated function
return func_to_decorate(*args, **kwargs)
return wrapper
Callable[..., object] is the most general type hint for a function. It means "any function that takes any arguments and returns anything." Inside wrapper, the * and ** operators unpack the collected arguments back out when forwarding them to the original function.
How Do You Write a Decorator That Takes Arguments?
You can stack decorators, and you can use currying with decorators.
from collections.abc import Callable
TextFunc = Callable[[str], None]
def to_uppercase(func: TextFunc) -> TextFunc:
def wrapper(document: str) -> None:
func(document.upper())
return wrapper
def get_truncate(length: int) -> Callable[[TextFunc], TextFunc]:
def truncate(func: TextFunc) -> TextFunc:
def wrapper(document: str) -> None:
func(document[:length])
return wrapper
return truncate
@to_uppercase
@get_truncate(9) # currying
def print_input(input: str) -> None:
print(input)
print_input("Keep Calm and Carry On")
# prints: "KEEP CALM"
Notice that get_truncate(9) first returns a decorator, which wraps print_input. Then to_uppercase wraps that already-wrapped function. When print_input is called, the text is converted to uppercase, then truncated to 9 characters before printing.
Do Other Languages Have Decorators?
Not all programming languages have built-in decorators, but most do support higher-order functions and closures. Some of the famous functional languages like Haskell, Erlang, Clojure, and Lisp do not have special syntax decorators, but they have higher-order functions and closures, meaning the decorator pattern can still be used. So if you understand these concepts, they'll serve you well in many different languages.
What Is functools.lru_cache?
lru_cache from the functools module is both a decorator and an example of memoization.
LRU stands for "least recently used." It's a type of cache that stores items up to a certain size limit. When it gets full, it makes space for new items by discarding the least recently used items first. The cache can be effective because items that are used a lot – like frequently repeated calls to the same function – are less likely to be discarded. They stay in-cache.
The lru_cache decorator memoizes the inputs and outputs of the decorated function. It speeds up repeated calls to a slow function with the same inputs. A function that reads from disk, makes network requests, or requires a lot of computation could be a good candidate for LRU caching if it also sees many identical calls.
Here's an example from the Python docs that perfectly illustrates how and why to use lru_cache:
from functools import lru_cache
@lru_cache()
def factorial_r(x: int) -> int:
if x == 0:
return 1
else:
return x * factorial_r(x - 1)
factorial_r(10) # no cached results; makes 11 recursive calls
# 3628800
factorial_r(5) # just looks up cached result value
# 120
factorial_r(12) # makes 2 new recursive calls; the other 11 are cached
# 479001600
Because the factorial function is recursive and the inputs are sequential numbers, it does get called repeatedly with the same inputs. Without caching, the function would be called 30 times in the code above. With lru_cache, the function is only called 13 times. You don't often need to compute factorials, but this example ties together how to use a decorator and memoization and recursion.
Frequently Asked Questions
What is a decorator in Python?
A decorator is a function that takes another function and returns a new function with added behavior. Python's @ syntax is just syntactic sugar for wrapping a function with a higher-order function.
What is the difference between *args and **kwargs?
*args collects extra positional arguments into a tuple, while **kwargs collects extra keyword arguments into a dictionary. Together they let a function or decorator accept any number of arguments.
How do you write a decorator that takes arguments?
Add an extra layer of nesting. An outer function takes the argument and returns the actual decorator, which then takes the function and returns a wrapper. This is sometimes called a decorator factory.
What does functools.lru_cache do?
lru_cache is a decorator that memoizes a function's inputs and outputs. It caches results up to a size limit and discards the least recently used entries when full, which speeds up repeated calls with the same arguments.
