🎯 Learning Objectives
- Understand what a decorator is and what problem it solves
- Build decorators from scratch using closures
- Use
functools.wrapsto preserve metadata - Write decorators that accept arguments
- Stack multiple decorators correctly
- Use class-based decorators
- Apply common built-in decorators:
@property,@staticmethod,@classmethod,@lru_cache
What is a Decorator?
A decorator is a function that wraps another function, adding behaviour before and/or after it runs — without modifying the original function's source code.
Decorators are used everywhere in Python: logging, timing, access control, caching, validation, retry logic, and more. Understanding them unlocks frameworks like Flask, FastAPI, and Django.
# The concept: wrap a function to add behaviour
def shout(func):
def wrapper(*args, **kwargs):
print("Before the function runs")
result = func(*args, **kwargs)
print("After the function runs")
return result
return wrapper
def greet(name):
print(f"Hello, {name}!")
# Manually wrap it
greet = shout(greet)
greet("Alice")
# Before the function runs
# Hello, Alice!
# After the function runs
concept.py
The @ Syntax
Python provides the @ syntax as shorthand for
func = decorator(func):
def shout(func):
def wrapper(*args, **kwargs):
print("Before")
result = func(*args, **kwargs)
print("After")
return result
return wrapper
# ── Using @ syntax ──
@shout
def greet(name):
print(f"Hello, {name}!")
# Exactly equivalent to: greet = shout(greet)
greet("Bob")
# Before
# Hello, Bob!
# After
at_syntax.py
Preserving Metadata with functools.wraps
Without wraps, the wrapped function loses its name, docstring, and
other metadata — because Python sees the wrapper function, not the original:
def shout(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@shout
def greet(name):
"""Return a greeting."""
return f"Hello, {name}"
print(greet.__name__) # wrapper ← WRONG!
print(greet.__doc__) # None ← WRONG!
no_wraps.py
import functools
def shout(func):
@functools.wraps(func) # ← copies __name__, __doc__, __module__, etc.
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@shout
def greet(name):
"""Return a greeting."""
return f"Hello, {name}"
print(greet.__name__) # greet ✓
print(greet.__doc__) # Return a greeting. ✓
with_wraps.py
@functools.wraps(func) inside your wrapper.
Without it, debugging tools, help(), logging, and test frameworks will see
"wrapper" everywhere instead of the real function names.
Practical Examples
Timer decorator
import time
import functools
def timer(func):
"""Print how long the decorated function takes to run."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"{func.__name__!r} took {end - start:.4f}s")
return result
return wrapper
@timer
def slow_sum(n):
return sum(range(n))
slow_sum(10_000_000) # 'slow_sum' took 0.2341s
timer.py
Logger decorator
import functools
def log_calls(func):
"""Log every call and its return value."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
arg_str = ", ".join(
[repr(a) for a in args] +
[f"{k}={v!r}" for k, v in kwargs.items()]
)
print(f"Calling {func.__name__}({arg_str})")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(3, 5)
# Calling add(3, 5)
# add returned 8
logger.py
Retry decorator
import functools, time
def retry(times=3, delay=0.5, exceptions=(Exception,)):
"""Retry the function up to `times` times on failure."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exc = e
print(f"Attempt {attempt} failed: {e}")
if attempt < times:
time.sleep(delay)
raise last_exc
return wrapper
return decorator
# This decorator takes arguments — see next section for how it works
@retry(times=3, delay=0, exceptions=(ValueError,))
def flaky(x):
import random
if random.random() < 0.7:
raise ValueError("random failure")
return x * 2
retry.py
Decorators with Arguments
To pass arguments to a decorator you add one more layer of nesting — a decorator factory that returns the actual decorator:
import functools
# 3 levels:
# repeat(n) ← outer function: accepts the argument
# decorator(func) ← middle: accepts the function
# wrapper(...) ← inner: replaces the function
def repeat(n):
"""Run the decorated function n times."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def say_hello():
print("Hello!")
say_hello()
# Hello!
# Hello!
# Hello!
decorator_factory.py
@decorator with no arguments → 2-level nesting (decorator + wrapper).
@decorator(arg) with arguments → 3-level nesting (factory + decorator + wrapper).
Optional-argument decorator (both usages work)
import functools
def repeat(_func=None, *, n=1):
"""Can be used as @repeat or @repeat(n=3)."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(n):
func(*args, **kwargs)
return wrapper
if _func is not None:
# Called as @repeat — func passed directly
return decorator(_func)
# Called as @repeat(n=3) — return the decorator
return decorator
@repeat # no parentheses — runs once
def ping():
print("ping")
@repeat(n=3) # with argument — runs three times
def pong():
print("pong")
optional_args.py
Stacking Decorators
Multiple decorators are applied bottom-up — the one closest to the function definition is applied first:
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return "**" + func(*args, **kwargs) + "**"
return wrapper
def upper(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs).upper()
return wrapper
@bold # applied second (outermost)
@upper # applied first (innermost)
def greet(name):
return f"hello, {name}"
print(greet("alice"))
# **HELLO, ALICE**
# Equivalent to: greet = bold(upper(greet))
stacking.py
Class-Based Decorators
A decorator can also be a class that implements __call__.
This is useful when the decorator needs to maintain state between calls:
import functools
class CallCounter:
"""Decorator that counts how many times a function is called."""
def __init__(self, func):
functools.update_wrapper(self, func) # equivalent to @wraps
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CallCounter
def add(a, b):
return a + b
add(1, 2)
add(3, 4)
add(5, 6)
print(add.count) # 3
print(add(10, 20)) # 30
print(add.count) # 4
class_decorator.py
Built-in Decorators
@property
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""Get the radius."""
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
@property
def area(self):
import math
return math.pi * self._radius ** 2
c = Circle(5)
print(c.radius) # 5 — accessed like an attribute
print(c.area) # 78.53...
c.radius = 10 # calls the setter
# c.radius = -1 # raises ValueError
property.py
@staticmethod and @classmethod
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@staticmethod
def celsius_to_fahrenheit(c):
"""Utility — doesn't need self or cls."""
return c * 9/5 + 32
@classmethod
def from_fahrenheit(cls, f):
"""Alternative constructor — receives the class, not an instance."""
return cls((f - 32) * 5/9)
def __repr__(self):
return f"Temperature({self.celsius:.1f}°C)"
print(Temperature.celsius_to_fahrenheit(100)) # 212.0
t = Temperature.from_fahrenheit(212)
print(t) # Temperature(100.0°C)
static_class_method.py
@functools.lru_cache — memoisation
import functools
@functools.lru_cache(maxsize=None) # None = unlimited cache
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # 12586269025 — instant, even for large n
print(fibonacci.cache_info())
# CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)
# cache_clear() to reset
fibonacci.cache_clear()
# Python 3.9+ shorthand
@functools.cache # equivalent to lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)
lru_cache.py
Decorating Classes
Decorators are not limited to functions — you can decorate entire classes too:
from dataclasses import dataclass
# @dataclass is a class decorator that auto-generates
# __init__, __repr__, __eq__, and more
@dataclass
class Point:
x: float
y: float
def distance_from_origin(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
p = Point(3.0, 4.0)
print(p) # Point(x=3.0, y=4.0)
print(p.distance_from_origin()) # 5.0
print(p == Point(3.0, 4.0)) # True
class_decorating.py
Best Practices
- Always use
@functools.wraps(func)in every wrapper function. - Keep decorators focused — one concern per decorator (timing, logging, auth, caching…).
- Accept
*args, **kwargsin the wrapper so the decorator works with any function signature. - Return the wrapper's result — don't forget
return resultor you'll silently break functions that return values. - Use class-based decorators when you need to store state between calls.
- Document your decorators with a docstring — they're part of your API.
import functools
# ✓ Complete, correct decorator template
def my_decorator(func):
"""One-line description of what this decorator does."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
# ... before ...
result = func(*args, **kwargs)
# ... after ...
return result # ← don't forget!
return wrapper
template.py
Primary sources: Python Docs — Decorator (glossary) · Python Docs — functools.wraps · Python Docs — functools.lru_cache · PEP 318 — Decorators for Functions and Methods
Ask your AI tutor! Confused about the three-level nesting
for decorators with arguments? Want to see how Flask's @app.route
works under the hood? Building your own caching or auth decorator? Great topics
to explore together.
💻 Exercises
Write a decorator @timed that:
- Measures how long the decorated function takes (use
time.perf_counter()) - Prints the result in the format:
greet took 0.0001s - Returns the function's original return value unchanged
- Preserves the function's name and docstring
Test it on a function that sleeps for 0.1 seconds.
Show solution
import time
import functools
def timed(func):
"""Measure and print the execution time of the decorated function."""
@functools.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
@timed
def slow_greet(name):
"""Return a greeting after a short delay."""
time.sleep(0.1)
return f"Hello, {name}!"
print(slow_greet("Alice"))
# slow_greet took 0.1003s
# Hello, Alice!
print(slow_greet.__name__) # slow_greet
print(slow_greet.__doc__) # Return a greeting after a short delay.
Write a decorator factory @require_role(role) that:
- Accepts a
rolestring argument (e.g."admin") - The decorated function must accept a
userdict as its first argument (with a"role"key) - If
user["role"] != role, raisesPermissionErrorwith a clear message - Otherwise calls and returns the original function normally
@require_role("admin")
def delete_user(user, target_id):
return f"Deleted user {target_id}"
admin = {"name": "Alice", "role": "admin"}
guest = {"name": "Bob", "role": "guest"}
print(delete_user(admin, 42)) # Deleted user 42
delete_user(guest, 42) # PermissionError
Show solution
import functools
def require_role(role):
def decorator(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if user.get("role") != role:
raise PermissionError(
f"{func.__name__} requires role '{role}', "
f"but user '{user.get('name')}' has role '{user.get('role')}'"
)
return func(user, *args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_user(user, target_id):
"""Delete a user by ID (admin only)."""
return f"Deleted user {target_id}"
admin = {"name": "Alice", "role": "admin"}
guest = {"name": "Bob", "role": "guest"}
print(delete_user(admin, 42)) # Deleted user 42
try:
delete_user(guest, 42)
except PermissionError as e:
print(e)
# delete_user requires role 'admin', but user 'Bob' has role 'guest'
Implement your own @memoize decorator (without using
functools.lru_cache) that:
- Caches results in a dict keyed by the function's arguments
- Returns the cached result on repeated calls with the same arguments
- Stores the cache as an attribute on the wrapper (
wrapper.cache) so it can be inspected - Preserves function metadata with
@functools.wraps
Test it on a recursive Fibonacci function and verify that repeated calls use the cache.
Show solution
import functools
def memoize(func):
"""Cache results of the decorated function by its arguments."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key not in wrapper.cache:
wrapper.cache[key] = func(*args, **kwargs)
return wrapper.cache[key]
wrapper.cache = {}
return wrapper
@memoize
def fib(n):
"""Return the nth Fibonacci number."""
if n < 2:
return n
return fib(n - 1) + fib(n - 2)
print(fib(10)) # 55
print(fib(40)) # 102334155 — fast because of caching
print(len(fib.cache)) # 41 unique calls cached
# Verify cache works
fib.cache.clear()
fib(5)
print(fib.cache)
# {((0,), ()): 0, ((1,), ()): 1, ((2,), ()): 1,
# ((3,), ()): 2, ((4,), ()): 3, ((5,), ()): 5}