In the previous tutorial, we learned about generators and iterators. Now let’s learn about decorators — a powerful pattern that lets you modify functions without changing their code.
Decorators are one of Python’s most distinctive features. They are used everywhere: web frameworks (Flask, FastAPI), testing (pytest), caching, authentication, and validation. By the end of this tutorial, you will know how to create your own decorators and understand the ones you encounter in libraries.
First-Class Functions
In Python, functions are objects. You can store them in variables, pass them as arguments, and return them from other functions. This is called “first-class functions”:
def shout(text: str) -> str:
return text.upper() + "!"
def whisper(text: str) -> str:
return text.lower() + "..."
def apply_style(func, text: str) -> str:
"""Apply a text style function to a string."""
return func(text)
print(apply_style(shout, "hello")) # HELLO!
print(apply_style(whisper, "HELLO")) # hello...
We pass shout and whisper as arguments — without calling them (no parentheses). shout is the function object. shout("hello") calls it. This ability is what makes decorators possible.
Functions Returning Functions
You can also return a function from another function:
def make_greeter(greeting: str):
"""Return a function that greets with a specific greeting."""
def greeter(name: str) -> str:
return f"{greeting}, {name}!"
return greeter
hello = make_greeter("Hello")
hi = make_greeter("Hi")
print(hello("Alex")) # Hello, Alex!
print(hi("Sam")) # Hi, Sam!
The inner function greeter captures the greeting variable from the outer function. This is called a closure (covered in Tutorial #5). Closures are the foundation of decorators.
What is a Decorator?
A decorator is a function that takes another function and returns a modified version of it. Here is the simplest useful example:
import functools
from typing import Any, Callable
def log_calls(func: Callable) -> Callable:
"""Decorator that tracks how many times a function is called."""
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
wrapper.call_count += 1
result = func(*args, **kwargs)
wrapper.last_args = args
return result
wrapper.call_count = 0
wrapper.last_args = ()
return wrapper
Apply it with the @ syntax:
@log_calls
def greet(name: str) -> str:
"""Return a greeting."""
return f"Hello, {name}!"
print(greet("Alex")) # Hello, Alex!
print(greet("Sam")) # Hello, Sam!
print(greet.call_count) # 2
print(greet.last_args) # ('Sam',)
When you write @log_calls above greet, Python does this behind the scenes:
greet = log_calls(greet)
The original greet function is replaced by wrapper. Every call to greet now goes through wrapper first. The wrapper adds the call counting, then calls the original function.
functools.wraps
Always use @functools.wraps(func) in your wrapper. It copies the original function’s name, docstring, and other metadata to the wrapper:
# Without @functools.wraps
print(greet.__name__) # "wrapper" — wrong!
print(greet.__doc__) # None — lost!
# With @functools.wraps
print(greet.__name__) # "greet" — correct!
print(greet.__doc__) # "Return a greeting." — preserved!
Without wraps, debugging becomes confusing because every decorated function appears to be called “wrapper” in tracebacks and logging.
Practical Decorators
Timer: Measure Execution Time
import time
def timer(func: Callable) -> Callable:
"""Decorator that measures execution time."""
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
wrapper.last_duration = end - start
return result
wrapper.last_duration = 0.0
return wrapper
@timer
def process_data(items: list) -> int:
time.sleep(0.1) # Simulate work
return len(items)
result = process_data([1, 2, 3])
print(f"Result: {result}")
print(f"Duration: {process_data.last_duration:.4f}s")
Retry: Try Again on Failure
This decorator retries a function when it raises an exception:
def retry(max_attempts: int = 3) -> Callable:
"""Decorator factory that retries a function on failure."""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
last_error = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
last_error = e
wrapper.attempts = attempt
raise last_error
wrapper.attempts = 0
return wrapper
return decorator
@retry(max_attempts=3)
def fetch_data(url: str) -> str:
# Might fail due to network issues
...
Notice that retry takes arguments (max_attempts). This is a decorator factory — a function that returns a decorator. The pattern has three nested functions:
retry(max_attempts=3)— the factory, returns the decoratordecorator(func)— the decorator, returns the wrapperwrapper(*args, **kwargs)— the wrapper, calls the original function
This three-level nesting is confusing at first, but it is the standard pattern for decorators with arguments.
Validate: Check Arguments
def validate_positive(func: Callable) -> Callable:
"""Decorator that validates all numeric arguments are positive."""
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
for arg in (*args, *kwargs.values()):
if isinstance(arg, (int, float)) and arg < 0:
raise ValueError(f"All arguments must be positive, got {arg}")
return func(*args, **kwargs)
return wrapper
@validate_positive
def calculate_area(width: float, height: float) -> float:
return width * height
print(calculate_area(5.0, 3.0)) # 15.0
print(calculate_area(-1.0, 3.0)) # ValueError!
Cache: Store Results
Store function results so repeated calls with the same arguments are fast:
def cache(func: Callable) -> Callable:
"""Simple cache decorator that stores results."""
@functools.wraps(func)
def wrapper(*args: Any) -> Any:
if args not in wrapper.cache_dict:
wrapper.cache_dict[args] = func(*args)
return wrapper.cache_dict[args]
wrapper.cache_dict = {}
return wrapper
@cache
def expensive_computation(n: int) -> int:
"""Sum of numbers from 0 to n."""
return sum(range(n + 1))
print(expensive_computation(100)) # Calculates: 5050
print(expensive_computation(100)) # Returns cached: 5050
print(expensive_computation.cache_dict) # {(100,): 5050}
Built-in: functools.lru_cache
Python has a production-quality cache decorator built in. Use functools.lru_cache instead of writing your own:
@functools.lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
"""Calculate Fibonacci number with memoization."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(30)) # 832040 — fast with caching!
print(fibonacci.cache_info())
# CacheInfo(hits=28, misses=31, maxsize=128, currsize=31)
Without caching, fibonacci(30) would make millions of recursive calls. With lru_cache, each unique input is calculated only once.
Decorators with Arguments
When a decorator needs arguments, you add an extra layer of nesting:
def repeat(times: int = 2) -> Callable:
"""Decorator factory that repeats a function call."""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> list:
results = []
for _ in range(times):
results.append(func(*args, **kwargs))
return results
return wrapper
return decorator
@repeat(times=3)
def say_hello(name: str) -> str:
return f"Hello, {name}!"
print(say_hello("Alex"))
# ["Hello, Alex!", "Hello, Alex!", "Hello, Alex!"]
The @repeat(times=3) syntax works in two steps:
- Python calls
repeat(times=3), which returnsdecorator - Python calls
decorator(say_hello), which returnswrapper say_hellois now replaced bywrapper
Stacking Decorators
You can apply multiple decorators to one function:
@log_calls
@timer
def slow_add(a: int, b: int) -> int:
time.sleep(0.01)
return a + b
Decorators are applied from bottom to top. This is equivalent to:
slow_add = log_calls(timer(slow_add))
So timer wraps the original function first, then log_calls wraps the timer-wrapped function. When you call slow_add, the call goes through log_calls.wrapper -> timer.wrapper -> original slow_add.
Class Decorators
Decorators can also modify classes. A common use is the singleton pattern — ensuring only one instance of a class exists:
def singleton(cls):
"""Class decorator that makes a class a singleton."""
instances = {}
@functools.wraps(cls, updated=())
class SingletonWrapper(cls):
def __new__(inner_cls, *args, **kwargs):
if cls not in instances:
instances[cls] = super().__new__(inner_cls)
return instances[cls]
return SingletonWrapper
@singleton
class DatabaseConnection:
def __init__(self, url: str = "localhost") -> None:
self.url = url
db1 = DatabaseConnection("prod.example.com")
db2 = DatabaseConnection("staging.example.com")
print(db1 is db2) # True — same instance!
You have already seen class decorators: @dataclass, @dataclass(frozen=True), and @singleton. The @ syntax works the same for classes and functions.
Accessing the Original Function
If you need to access the original (unwrapped) function, use __wrapped__:
@log_calls
def greet(name):
return f"Hello, {name}!"
# Call the decorated version (goes through wrapper)
greet("Alex")
# Access the original function (skips the decorator)
original = greet.__wrapped__
original("Alex") # Does not update call_count
This is available because functools.wraps automatically sets the __wrapped__ attribute. It is useful for testing and debugging.
How Decorators Work: The Mental Model
Here is the complete mental model:
# Writing this:
@decorator
def function():
...
# Is exactly the same as:
def function():
...
function = decorator(function)
For decorators with arguments:
# Writing this:
@decorator(arg1, arg2)
def function():
...
# Is exactly the same as:
def function():
...
function = decorator(arg1, arg2)(function)
Decorators in the Wild
You will encounter decorators everywhere in Python:
# Flask / FastAPI — route registration
@app.get("/users/{user_id}")
def get_user(user_id: int):
...
# pytest — test parametrization
@pytest.mark.parametrize("input,expected", [(1, 1), (2, 4)])
def test_square(input, expected):
assert square(input) == expected
# dataclasses — auto-generate methods
@dataclass
class User:
name: str
age: int
# property — getters and setters
@property
def name(self):
return self._name
# classmethod / staticmethod
@classmethod
def from_dict(cls, data):
...
When to Use Decorators
| Use case | Example |
|---|---|
| Logging | Log every function call |
| Timing | Measure execution time |
| Caching | Store results for repeated calls |
| Validation | Check arguments before calling |
| Retry logic | Retry on failure |
| Authentication | Check permissions in web frameworks |
| Rate limiting | Limit how often a function is called |
| Registration | Register routes, plugins, or handlers |
The key principle: decorators add cross-cutting concerns — behavior that applies to many functions but is not part of the function’s core logic.
Common Mistakes
Forgetting functools.wraps
# BAD — loses function metadata
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet(name):
"""Say hello."""
return f"Hello, {name}!"
print(greet.__name__) # "wrapper" — not "greet"!
print(greet.__doc__) # None — docstring lost!
Always add @functools.wraps(func) to your wrapper function. This is critical for debugging, logging, and documentation tools.
Decorator Changing the Return Type
# BAD — the decorator changes what the function returns!
def bad_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return str(result) # Callers expect int, get string
return wrapper
# GOOD — preserve the original return type
def good_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
# Do something with result, but return it unchanged
print(f"Result: {result}")
return result
return wrapper
Too Many Stacked Decorators
# Hard to understand — what order do they run in?
@validate
@cache
@retry(max_attempts=3)
@timer
@log_calls
def complex_function():
...
Many stacked decorators can be hard to read. In frameworks like FastAPI or Flask, stacking 3+ decorators (routing, permissions, validation) is standard practice. But in your own code, if you stack many custom decorators, consider whether some can be combined.
Writing Testable Decorators
Decorators should be tested just like any other code:
def test_log_calls_counts():
@log_calls
def add(a, b):
return a + b
add(1, 2)
add(3, 4)
assert add.call_count == 2
def test_retry_retries_on_failure():
attempt = 0
@retry(max_attempts=3)
def flaky():
nonlocal attempt
attempt += 1
if attempt < 3:
raise ValueError("Not ready")
return "ok"
assert flaky() == "ok"
assert attempt == 3
Create a fresh decorated function inside each test. This keeps tests isolated from each other.
A Note on Performance
Decorators add a small overhead to each function call because the wrapper function runs every time. For most code, this overhead is negligible — a few microseconds per call. But in tight loops that run millions of times, it can add up.
If performance matters, use functools.lru_cache (implemented in C, very fast) instead of a custom cache decorator. For timing, consider using a profiler instead of a timer decorator in production code.
The benefits of decorators (clean code, separation of concerns, reusability) almost always outweigh the tiny performance cost. Optimize only when profiling shows the decorator is actually a bottleneck.
Source Code
You can find the code for this tutorial on GitHub:
kemalcodes/python-tutorial — tutorial-14-decorators
Run the examples:
python src/py14_decorators.py
Run the tests:
python -m pytest tests/test_py14.py -v
What’s Next?
In the next tutorial, we will learn about context managers — the with statement and how to manage resources properly.
Related Articles
- Python Tutorial #13: Generators — yield, iterators, itertools
- Python Tutorial #5: Functions — closures and first-class functions
- Python Cheat Sheet — quick reference for Python syntax