Free GuideIntermediate
40 min read
#python#decorators#functions#closures#functools#wraps

Decorators

Comprehensive guide on Decorators.

Decorators

Overview#

A decorator is a function that takes another function (or class) and extends or modifies its behavior without permanently changing its source code. Decorators are one of Python's most powerful features for eliminating repetitive code — logging, timing, access control, caching, and validation are all commonly implemented as decorators. To really understand them, you first need to understand that in Python, functions are objects that can be passed around like any other value. This notebook builds decorators from first principles: functions as objects, closures, basic decorators, decorators with arguments, decorating classes, and the standard library's most useful decorator tools.

1. Functions Are First-Class Objects#

In Python, functions can be assigned to variables, passed as arguments, returned from other functions, and stored in data structures — exactly like integers or strings.

Python 3.12 (WASM)
def greet(name): return f"Hello, {name}!" say_hello = greet # assign the function itself, not its result print(say_hello("Alice")) # Hello, Alice! def call_twice(func, arg): return func(arg) + " " + func(arg) print(call_twice(greet, "Bob")) # Hello, Bob! Hello, Bob!

2. Nested Functions and Closures#

A function defined inside another function can access variables from the enclosing function's scope, even after the outer function has finished running. This is called a closure.

Python 3.12 (WASM)
def make_multiplier(factor): def multiply(number): return number * factor # factor is "closed over" from the outer scope return multiply double = make_multiplier(2) triple = make_multiplier(3) print(double(5)) # 10 print(triple(5)) # 15

Each call to make_multiplier creates a new, independent closure — double and triple each remember their own factor value.

3. Building a Decorator From Scratch#

A decorator is simply a function that takes a function as input and returns a new function that wraps it.

Python 3.12 (WASM)
def my_decorator(func): def wrapper(): print("Before the function runs") func() print("After the function runs") return wrapper def say_hello(): print("Hello!") say_hello = my_decorator(say_hello) # manually "decorate" say_hello say_hello() # Before the function runs # Hello! # After the function runs

The @ syntax#

The line say_hello = my_decorator(say_hello) is exactly what the @ syntax does automatically.

Python 3.12 (WASM)
def my_decorator(func): def wrapper(): print("Before the function runs") func() print("After the function runs") return wrapper @my_decorator def say_hello(): print("Hello!") say_hello() # identical output to before — @my_decorator IS the assignment

4. Handling Arguments and Return Values#

The example above only works for functions with no arguments and no return value. A general-purpose decorator uses *args and **kwargs to accept and forward any arguments, and returns the wrapped function's result.

Python 3.12 (WASM)
def logger(func): def wrapper(*args, **kwargs): print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}") result = func(*args, **kwargs) print(f"{func.__name__} returned {result}") return result return wrapper @logger def add(a, b): return a + b result = add(3, 5) # Calling add with args=(3, 5), kwargs={} # add returned 8 print(result) # 8

5. Preserving Function Metadata with functools.wraps#

Without extra care, a decorated function loses its original name, docstring, and other metadata — wrapper replaces add entirely as far as introspection is concerned.

Python 3.12 (WASM)
def logger(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @logger def add(a, b): """Add two numbers together.""" return a + b print(add.__name__) # wrapper -- WRONG, hides the real function's identity print(add.__doc__) # None -- lost the original docstring

functools.wraps fixes this by copying the original function's metadata onto the wrapper.

Python 3.12 (WASM)
from functools import wraps def logger(func): @wraps(func) # preserves __name__, __doc__, etc. def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @logger def add(a, b): """Add two numbers together.""" return a + b print(add.__name__) # add -- correct print(add.__doc__) # Add two numbers together.

Always use functools.wraps in any decorator you write — omitting it is one of the most common decorator bugs, and it silently breaks tools that rely on introspection, like documentation generators and debuggers.

6. Decorators with Their Own Arguments#

Sometimes you want to configure a decorator, e.g. @retry(times=3). This requires an extra layer of nesting: a function that takes the decorator's arguments and returns the actual decorator.

Python 3.12 (WASM)
from functools import wraps import time def retry(times): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, times + 1): try: return func(*args, **kwargs) except Exception as e: print(f"Attempt {attempt} failed: {e}") if attempt == times: raise time.sleep(1) return wrapper return decorator @retry(times=3) def unstable_operation(): import random if random.random() < 0.7: raise ConnectionError("Network glitch") return "Success" unstable_operation()

Here, retry(times=3) runs first and returns decorator, which then gets applied to unstable_operation exactly like a normal decorator.

7. Stacking Multiple Decorators#

Decorators can be stacked; they apply from the bottom up, closest to the function first.

Python 3.12 (WASM)
def bold(func): @wraps(func) def wrapper(*args, **kwargs): return f"<b>{func(*args, **kwargs)}</b>" return wrapper def italic(func): @wraps(func) def wrapper(*args, **kwargs): return f"<i>{func(*args, **kwargs)}</i>" return wrapper @bold @italic def greet(name): return f"Hello, {name}" print(greet("Alice")) # <b><i>Hello, Alice</i></b>

italic wraps greet first (closest to the function), then bold wraps the result — so the order in the output is bold(italic(greet(...))).

8. Class-Based Decorators#

A class can act as a decorator if it implements __call__, making instances of it callable like functions. This is useful when the decorator needs to maintain state across calls.

Python 3.12 (WASM)
class CountCalls: def __init__(self, func): self.func = func self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 print(f"Call #{self.count} to {self.func.__name__}") return self.func(*args, **kwargs) @CountCalls def say_hi(): print("Hi!") say_hi() # Call #1 to say_hi / Hi! say_hi() # Call #2 to say_hi / Hi! print(say_hi.count) # 2

9. Decorating Classes#

Decorators aren't limited to functions — they can also be applied to entire classes, receiving the class itself as the argument.

Python 3.12 (WASM)
def add_greeting(cls): cls.greet = lambda self: f"Hello from {cls.__name__}" return cls @add_greeting class Robot: pass r = Robot() print(r.greet()) # Hello from Robot

@dataclass, seen in the OOP notebook, is a well-known real-world example of a class decorator.

10. Useful Built-in and functools Decorators#

DecoratorPurpose
@propertyTurn a method into a computed attribute (see OOP notebook)
@staticmethodMethod that doesn't need self or cls
@classmethodMethod that receives the class (cls) instead of an instance
@functools.lru_cacheCache a function's results based on its arguments
@functools.wrapsPreserve metadata when writing decorators
@functools.singledispatchFunction overloading based on argument type

Caching expensive calls with lru_cache#

Python 3.12 (WASM)
from functools import lru_cache @lru_cache(maxsize=None) def fibonacci(n): if n < 2: return n return fibonacci(n - 1) + fibonacci(n - 2) print(fibonacci(35)) # computed almost instantly thanks to caching print(fibonacci.cache_info()) # shows hits, misses, cache size

Without lru_cache, naive recursive Fibonacci recomputes the same values exponentially many times; caching turns it from exponential to linear time by remembering previous results.

Type-based dispatch with singledispatch#

Python 3.12 (WASM)
from functools import singledispatch @singledispatch def describe(value): return f"A value: {value}" @describe.register def _(value: int): return f"An integer: {value}" @describe.register def _(value: list): return f"A list with {len(value)} items" print(describe(42)) # An integer: 42 print(describe([1, 2, 3])) # A list with 3 items print(describe(3.14)) # A value: 3.14 (falls back to the default)

11. Common Real-World Decorator Patterns#

Python 3.12 (WASM)
from functools import wraps import time def timer(func): @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}s") return result return wrapper def require_positive(func): @wraps(func) def wrapper(n, *args, **kwargs): if n <= 0: raise ValueError(f"{func.__name__} requires a positive number") return func(n, *args, **kwargs) return wrapper @timer @require_positive def compute_factorial(n): result = 1 for i in range(1, n + 1): result *= i return result print(compute_factorial(10))

Notes#

  • A decorator runs once, at definition time, to produce the wrapped function — the returned wrapper is what actually executes on every call afterward.
  • Always accept *args, **kwargs in a general-purpose decorator's wrapper unless you deliberately want to restrict the wrapped function's signature.
  • Forgetting functools.wraps doesn't cause an error, but silently corrupts introspection (__name__, __doc__) — a subtle bug that's easy to miss until debugging tools or documentation break.
  • Decorators that take arguments require one extra layer of function nesting compared to plain decorators — this trips up almost everyone the first time.
  • lru_cache should only be used on pure functions — those whose output depends only on their input, with no side effects — since cached calls don't re-execute the function body at all.

Best Practices Checklist#

  • Always use functools.wraps when writing a decorator
  • Accept *args, **kwargs in the wrapper unless intentionally restricting arguments
  • Return the wrapped function's result from the wrapper, don't silently discard it
  • Add an extra nesting layer only when the decorator itself needs configuration arguments
  • Keep decorator logic focused on one concern (logging, timing, caching) rather than mixing multiple responsibilities
  • Reach for functools.lru_cache for pure, expensive, repeatedly called functions instead of hand-rolled caching
  • Use functools.singledispatch instead of long isinstance chains when behavior should vary by argument type
  • Remember stacked decorators apply bottom-up, closest to the function first
Knowledge Checkpoint

Python Decorators & Metaprogramming Checkpoint

Q1.Why should you always apply `@functools.wraps(func)` on inner decorator wrapper functions?
ATo speed up execution time through Cython compilation.
BTo preserve the original function's name (`__name__`), docstring (`__doc__`), and signature annotations.
CTo force the wrapped function to execute asynchronously.
DTo automatically handle uncaught exceptions.
Q2.How many levels of nested functions are required to implement a decorator that accepts custom configuration arguments (e.g. `@retry(max_attempts=3)`)?
A1 level
B2 levels
C3 levels
D4 levels
Q3.When is a decorator function executed in Python?
AEvery time the decorated function is invoked at runtime.
BOnly once when the module/function definition is first loaded and imported.
CWhen the Python garbage collector runs.
DWhen the script exits.
Track Your Learning

Finished studying this notebook?

Mark this guide as completed to update your course progress roadmap.