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:

  1. retry(max_attempts=3) — the factory, returns the decorator
  2. decorator(func) — the decorator, returns the wrapper
  3. wrapper(*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:

  1. Python calls repeat(times=3), which returns decorator
  2. Python calls decorator(say_hello), which returns wrapper
  3. say_hello is now replaced by wrapper

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 caseExample
LoggingLog every function call
TimingMeasure execution time
CachingStore results for repeated calls
ValidationCheck arguments before calling
Retry logicRetry on failure
AuthenticationCheck permissions in web frameworks
Rate limitingLimit how often a function is called
RegistrationRegister 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.