Skip to main content

Python Decorators Explained: A Step-by-Step Guide

Advanced30 min8 exercises145 XP
0/8 exercises

Imagine you have a gift. You wrap it in paper, add a ribbon, attach a card. The gift inside is completely unchanged — but now it has extra features. The paper protects it, the ribbon makes it look nice, the card adds a message.

That's what decorators do to functions. They wrap a function with extra behavior — logging, timing, caching, access control — without touching the function's original code. It's one of the most powerful patterns in Python.

Why Can Functions Be Passed Around Like Variables?

Before we dive into decorators, you need to understand one key fact: in Python, functions are objects. You can assign them to variables, put them in lists, and pass them as arguments to other functions.

Functions are first-class objects
Loading editor...

Notice the difference: greet (no parentheses) is the function object itself. greet('Alice') (with parentheses) calls the function. This distinction is critical for decorators.

How Do Closures Enable Decorators?

A closure is a function that remembers variables from its enclosing scope, even after that scope has finished executing. This is the mechanism that makes decorators work.

A closure remembers its enclosing variables
Loading editor...

The inner function multiply captures factor from its parent. Even after make_multiplier finishes, the inner function still has access to factor. Decorators use this exact pattern to "remember" the original function.

What Is the Basic Decorator Pattern?

A decorator is a function that takes a function as input, creates a wrapper function that adds behavior, and returns the wrapper. Here's the template:

The basic decorator pattern
Loading editor...

When Python sees @my_decorator above a function, it's equivalent to writing say_hello = my_decorator(say_hello). The @ syntax is just a cleaner way to express this.

Predict the Decorator Output
Predict Output

What does this code print? Pay attention to the order of execution.

def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

@shout
def greet(name):
    return f'hello, {name}'

print(greet('world'))

Write one print() statement that produces the exact same output.

Loading editor...

Why Do You Need functools.wraps?

There's a subtle problem with basic decorators. When you replace a function with a wrapper, the wrapper has a different name and docstring. This makes debugging confusing.

Without @wraps — identity is lost
def my_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def add(a, b):
    """Add two numbers."""
    return a + b

print(add.__name__)  # wrapper (!)
With @wraps — identity preserved
from functools import wraps

def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@my_decorator
def add(a, b):
    """Add two numbers."""
    return a + b

print(add.__name__)  # add
Write a Logging Decorator
Write Code

Write a decorator called log_call that prints the function name and arguments before calling the function, then prints the return value after.

Use functools.wraps. Expected behavior:

@log_call
def add(a, b):
    return a + b

result = add(3, 4)

Should print:

Calling add(3, 4)
add returned 7
Loading editor...

How Do You Write a Decorator That Takes Arguments?

Sometimes you want to configure a decorator. For example, a @repeat(3) decorator that calls a function 3 times. This requires an extra layer of nesting — a function that returns a decorator.

A decorator that takes arguments
Loading editor...

The trick is three levels of nesting: the outermost function (repeat) takes the decorator arguments, returns a decorator function, which takes the function, which returns the wrapper. It's a function that returns a function that returns a function.

Write a Repeat Decorator
Write Code

Write a decorator repeat(n) that calls the decorated function n times and collects all return values into a list.

@repeat(3)
def roll_dice():
    return 6

print(roll_dice())
# Output: [6, 6, 6]
Loading editor...

What Happens When You Stack Multiple Decorators?

You can apply multiple decorators to a single function. They're applied bottom-up (closest to the function first), but they execute top-down when the function is called.

Stacking decorators
Loading editor...

The order matters. @bold wraps the result of @italic, which wraps the original function. It's equivalent to writing greet = bold(italic(greet)). The innermost decorator is applied first.

Predict Stacked Decorator Output
Predict Output

What does this code print?

from functools import wraps

def add_prefix(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return '>>> ' + func(*args, **kwargs)
    return wrapper

def add_suffix(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs) + ' <<<'
    return wrapper

@add_prefix
@add_suffix
def message():
    return 'hello'

print(message())

Write one print() statement with the exact output.

Loading editor...

What Are the Most Useful Real-World Decorators?

Decorators shine in real-world applications. Here are three patterns you'll see constantly: timing, caching, and validation.

A timing decorator
Loading editor...
A memoization (cache) decorator
Loading editor...
Write a Validation Decorator
Write Code

Write a decorator validate_positive that checks if all positional arguments to the decorated function are positive numbers (> 0). If any argument is not positive, print 'Error: all arguments must be positive' and return None. Otherwise, call and return the function normally.

@validate_positive
def multiply(a, b):
    return a * b

print(multiply(3, 4))   # 12
print(multiply(-1, 5))  # Error: all arguments must be positive \n None
Loading editor...
Write a Retry Decorator
Write Code

Write a decorator retry(max_attempts) that retries a function up to max_attempts times if it raises an exception. Print a message on each retry. If all attempts fail, print the final error.

Test with a counter that fails twice then succeeds:

call_count = 0

@retry(3)
def flaky_function():
    global call_count
    call_count += 1
    if call_count < 3:
        raise ValueError('not ready yet')
    return 'success!'

print(flaky_function())
Loading editor...

Fix the Broken Decorator
Fix the Bug

This decorator is supposed to count how many times a function is called. But it has two bugs. Find and fix them.

def count_calls(func):
    count = 0
    def wrapper(*args, **kwargs):
        count += 1
        print(f'Call {count} of {func.__name__}')
        return func(*args, **kwargs)
    return wrapper

@count_calls
def hello():
    print('hello!')

hello()
hello()
hello()
Loading editor...
Build a Simple Cache Decorator
Write Code

Write a decorator memoize that caches function results based on arguments. If the function is called with the same arguments again, return the cached result instead of calling the function.

Print 'Computing...' only when actually computing (not when returning cached results).

@memoize
def square(n):
    print('Computing...')
    return n ** 2

print(square(4))  # Computing... \n 16
print(square(4))  # 16 (cached!)
print(square(5))  # Computing... \n 25
Loading editor...

What Should You Remember About Decorators?

Decorators are one of Python's most versatile features. They let you add behavior to functions without modifying them — keeping your code clean, modular, and reusable.

Key takeaways:

  • Functions are objects that can be passed around and wrapped
  • A decorator takes a function, wraps it, and returns the wrapper
  • @decorator is syntactic sugar for func = decorator(func)
  • Always use @functools.wraps to preserve function metadata
  • Decorators with arguments need an extra nesting layer
  • Stacked decorators apply bottom-up, execute top-down
  • Common uses: logging, timing, caching, validation, retry logic
  • Python includes @functools.lru_cache for built-in memoization