Python: Decorators
Decorators are one of Python's most elegant features. They let you wrap a function or class with reusable logic without touching its source code, keeping cross-cutting concerns like logging, timing, and caching completely separate.
Functions as First-Class Objects
In Python, functions are objects just like integers, strings, or lists. They can be assigned to variables, stored in data structures, passed as arguments to other functions, and returned as values. This property is called first-class, and it is the foundation that makes decorators possible.
A function that accepts another function as an argument or returns a function as its result is called a higher-order function. map(),filter(), and sorted(key=...) are all higher-order functions from the standard library.
Functions Are Objects
PythonAssigning, passing, and returning functions just like any other value.
Closures Revisited
A closure is an inner function that captures and remembers variables from its enclosing scope, even after that outer function has returned and its local scope no longer exists. The captured variables are stored in the closure's__closure__ attribute as cell objects.
Closures matter here because every decorator wrapper is a closure. The wrapper function captures a reference to the original function from the enclosing decorator factory scope, and that reference stays alive as long as the wrapper does.
A Closure Captures Its Enclosing Scope
PythonThe inner function remembers 'multiplier' long after make_multiplier has returned.
The Basic Decorator Pattern
A decorator is a function that takes another function as its argument, defines a wrapper function around it, and returns that wrapper. The @decorator syntax is pure syntactic sugar. Writing @my_decorator above a function definition is exactly equivalent to writing my_func = my_decorator(my_func) directly after the definition.
The Three Steps of Every Decorator
Every basic decorator follows the same structure, regardless of what it does.
- 1. Accept the original function as an argument.
- 2. Define a wrapper function that adds behaviour before and/or after calling the original.
- 3. Return the wrapper function (not its result; the function object itself).
Writing and Applying a Decorator
PythonThe @ syntax and its explicit equivalent side by side.
functools.wraps
Without extra care, applying a decorator replaces the original function with the wrapper. That means __name__, __doc__, __annotations__, and other metadata all point to the wrapper, not the original. This breaks documentation tools, introspection, and debuggers.
The fix is @functools.wraps(func) applied to the wrapper. It copies all the important metadata from the original function onto the wrapper, so from the outside, the decorated function looks just like the original.
Preserving Metadata with functools.wraps
PythonWithout wraps, introspection breaks; with it, everything looks natural.
Decorators with Arguments
Sometimes you need to configure a decorator at application time, for example specifying how many times to retry or what log level to use. To do this, you add one more layer of nesting. The outermost function accepts the decorator's arguments and returns the actual decorator. That decorator then accepts the function and returns the wrapper, exactly as before.
Think of it as a decorator factory: calling it with arguments produces a plain decorator, which is then applied to the function.
A Decorator That Accepts Configuration
PythonThree levels of nesting: factory, decorator, wrapper.
Class-Based Decorators
A class can act as a decorator if it implements __call__. The class receives the original function in __init__ and stores it. When the decorated name is called, Python calls __call__, which runs the wrapping logic. This approach is especially useful when the decorator needs to maintain complex state between calls, since instance variables are more readable than closure variables in that case.
A Class-Based Call Counter
PythonUsing __init__ to receive the function and __call__ to wrap it.
Chaining Multiple Decorators
You can stack any number of decorators on a single function. They are applied bottom to top: the decorator immediately above the function is applied first, and the topmost decorator is applied last. When the function is called, the execution flows top to bottom through the wrappers, with the original function running in the middle.
Stacking Three Decorators
PythonBottom to top on definition, top to bottom on execution.
@property, @classmethod, and @staticmethod Revisited
Python's built-in decorators are not special syntax; they follow the exact same decorator protocol you have just learned. property, classmethod, andstaticmethod are simply callables that accept a function and return a descriptor object. Understanding decorators clarifies exactly what these do and why they work the way they do.
Built-in Decorators in Context
Python@property, @classmethod, and @staticmethod are all just decorators.
Practical Decorator Examples
The best way to solidify decorator knowledge is to see the patterns that appear repeatedly in real codebases. The five examples below cover timing, logging, memoization, retry logic, and access control. Each one follows the same structural template, but the logic inside the wrapper is different.
Timer Decorator
Wrapping a function with a timer lets you measure its execution time without modifying the function itself. This is useful during profiling or when you want a transparent benchmark across many functions.
Timer Decorator
PythonMeasuring elapsed time without touching the original function.
Logger Decorator
A logger decorator records every call to a function, including its arguments and return value. This is the clean alternative to sprinkling print statements throughout your code for debugging.
Logger Decorator
PythonAutomatically recording inputs and outputs for any function.
Memoization Decorator
Memoization caches function results keyed by their arguments. On repeated calls with the same inputs, the cached result is returned immediately without re-running the function. The arguments must be hashable to serve as dictionary keys. This is the hand-rolled version; Python also ships functools.lru_cache andfunctools.cache as production-grade implementations of the same idea.
Memoization Decorator
PythonCaching results to avoid redundant computation.
Retry Decorator
Network requests, database queries, and file locks can fail transiently. A retry decorator automatically re-runs a function on failure, up to a configured limit, with an optional delay between attempts. This removes boilerplate retry loops from your application code entirely.
Retry Decorator with Configurable Attempts and Delay
PythonTransparent retry logic that wraps any fallible function.
Access Control Decorator
In web frameworks and CLI tools, certain functions should only be callable by users who hold the right permissions. An access control decorator enforces this rule at the boundary, raising an error before the function body ever runs, rather than scattering permission checks throughout the business logic.
Role-Based Access Control
PythonGuarding functions with a permission check before execution.
functools.lru_cache
functools.lru_cache is a production-grade memoization decorator from the standard library. LRU stands for Least Recently Used: when the cache reaches its maximum size, the entry that was accessed least recently is evicted to make room for a new one. The default maxsize is 128. Setting it toNone disables eviction, giving you an unbounded cache.
The decorated function gains a cache_info() method that reports hits, misses, the current cache size, and the max size. It also gains cache_clear() to wipe the cache manually. All arguments to the wrapped function must be hashable, since they are used as the cache key.
functools.lru_cache in Practice
PythonRecursive Fibonacci with a bounded LRU cache.
functools.cache (Python 3.9+)
functools.cache was added in Python 3.9 as a simpler, faster alternative tolru_cache(maxsize=None). Because it never needs to track access order for eviction, its implementation is leaner and its per-call overhead is slightly lower. Use it when you know the set of unique inputs is bounded and you never want automatic eviction.
The tradeoff is that an unbounded cache can grow indefinitely if called with many distinct arguments, which can be a memory leak in a long-running server process. In those cases, lru_cache with a reasonable maxsize is the safer choice.
functools.cache
PythonAn unbounded cache with slightly lower overhead than lru_cache.
Choosing Between lru_cache and cache
Pick based on whether the growth of unique inputs is bounded.
- Use @cache when input space is small and known: tree traversal, recursive math, config lookups.
- Use @lru_cache(maxsize=N) when inputs could be unbounded: user IDs, file paths, API responses.
- Both require arguments to be hashable. Lists and dicts cannot be used as cache keys.
- @cache is equivalent to @lru_cache(maxsize=None) but is faster due to its simpler internals.
Putting It All Together
The example below combines timer, logger, and retry into a single decorated function to show how stacking works in a realistic scenario. Each decorator handles exactly one concern, and they compose cleanly without any of them knowing about the others.
Stacking Practical Decorators
PythonThree independent concerns, zero changes to the business logic.
Quiz - Test Your Knowledge
Ten questions spanning first-class functions, the decorator protocol, argument passing, chaining order, class-based decorators, and the standard library caching tools. Read each question carefully; several options are designed to be close.
Knowledge Check
1. What does it mean for functions to be "first-class objects" in Python?
2. What is a closure?
3. What does @my_decorator above a function definition actually do?
4. Why should you use @functools.wraps(func) inside a decorator?
5. How do you create a decorator that itself accepts arguments?
6. When multiple decorators are stacked, in what order are they applied?
7. What does functools.lru_cache do?
8. What is the key difference between functools.lru_cache and functools.cache (Python 3.9+)?
9. For a class to work as a decorator, which method must it implement?
10. In the memoization pattern, why must the function arguments be hashable?