Python

Advanced Python

Decorators

A decorator is a function that takes another function, wraps it with extra behavior, and returns the wrapped version — without modifying the original function's

JrCodex·7 min read

Jr Codex Python Notes

Level: Advanced Prerequisites: Chapter 1 Time to complete: ~30 minutes


Table of Contents

  1. What is a Decorator?
  2. Functions Wrapping Functions — the Manual Version
  3. The @ Syntax
  4. Preserving Function Identity with functools.wraps
  5. Decorators That Accept Arguments
  6. Stacking Multiple Decorators
  7. Practical Decorators You'll Actually Use
  8. Class-Based Decorators (Preview)
  9. Summary & Next Steps

1. What is a Decorator?

A decorator is a function that takes another function, wraps it with extra behavior, and returns the wrapped version — without modifying the original function's own code. You've already used decorators without necessarily building one: @property, @staticmethod, @classmethod (Module 4), and @abstractmethod.

Decorator Concept
─────────────────────────────────────────
  original_function
        │
        ▼
  decorator(original_function)   ← wraps it, adds behavior
        │
        ▼
  decorated_function   ← same name, extra behavior added around the original
─────────────────────────────────────────

This entire mechanism is built on two ideas you already know: functions as first-class objects (Module 2, Chapter 1) and closures (Module 2, Chapter 3).


2. Functions Wrapping Functions — the Manual Version

Before the @ syntax, let's build this by hand to see exactly what's happening:

def greet(name):
    return f"Hello, {name}!"
 
def shout_decorator(func):
    def wrapper(name):
        result = func(name)              # call the ORIGINAL function
        return result.upper()               # then add extra behavior
    return wrapper                            # return the NEW, wrapped function
 
loud_greet = shout_decorator(greet)      # manually wrap greet
print(loud_greet("Alice"))                  # "HELLO, ALICE!"
print(greet("Alice"))                         # "Hello, Alice!" — original is untouched
What Just Happened
─────────────────────────────────────────
  shout_decorator(greet)
    creates a closure (`wrapper`) that REMEMBERS `func` (= greet)
    and calls it, then modifies the result before returning.

  loud_greet is now `wrapper`, permanently bound to greet via the closure.
─────────────────────────────────────────

3. The @ Syntax

@decorator_name above a function definition is pure syntax sugar for exactly the manual wrapping above:

def shout_decorator(func):
    def wrapper(name):
        result = func(name)
        return result.upper()
    return wrapper
 
@shout_decorator                # equivalent to: greet = shout_decorator(greet)
def greet(name):
    return f"Hello, {name}!"
 
print(greet("Alice"))     # "HELLO, ALICE!" — greet has ALREADY been replaced by wrapper
@shout_decorator                        greet = shout_decorator(greet)
def greet(name):              ≡        def greet(name):
    return f"Hello, {name}!"                return f"Hello, {name}!"
─────────────────────────────────────────────────────────────
  Both lines do EXACTLY the same thing — @ is just cleaner to read.

A more general decorator using *args/**kwargs (Module 2, Chapter 2) works with any function signature, not just one specific parameter list:

def logging_decorator(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
 
@logging_decorator
def add(a, b):
    return a + b
 
add(3, 5)
# Calling add with args=(3, 5), kwargs={}
# add returned 8

4. Preserving Function Identity with functools.wraps

There's a subtle problem with the decorators above: the decorated function loses its original name and docstring, since wrapper has replaced it entirely.

@logging_decorator
def add(a, b):
    """Add two numbers together."""
    return a + b
 
print(add.__name__)      # "wrapper" — WRONG! Should say "add"
print(add.__doc__)         # None — WRONG! The original docstring is gone

The fix: functools.wraps copies the original function's metadata (__name__, __doc__, etc.) onto the wrapper.

from functools import wraps
 
def logging_decorator(func):
    @wraps(func)                # copies func's __name__, __doc__, etc. onto wrapper
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper
 
@logging_decorator
def add(a, b):
    """Add two numbers together."""
    return a + b
 
print(add.__name__)      # "add" — correct now!
print(add.__doc__)         # "Add two numbers together." — correct now!

Rule of thumb: always use @wraps(func) when writing your own decorators — skipping it silently breaks introspection (debugging, documentation tools, and anything that reads __name__).


5. Decorators That Accept Arguments

Sometimes you want to configure a decorator — this requires one extra layer of nesting: a function that returns a decorator.

from functools import wraps
 
def repeat(times):
    """A decorator FACTORY — returns an actual decorator, configured with `times`."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator
 
@repeat(times=3)
def greet(name):
    print(f"Hello, {name}!")
 
greet("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!
Three Levels of Nesting — Why
─────────────────────────────────────────
  @repeat(times=3)
  def greet(name): ...

  is equivalent to:
  greet = repeat(times=3)(greet)
             │            │
             │            └── decorator(func) — wraps greet
             └── repeat(times=3) — returns the actual decorator, configured
─────────────────────────────────────────

6. Stacking Multiple Decorators

Decorators can be stacked — they apply bottom-up, closest to the function first:

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>
Order of Application — bottom to top
─────────────────────────────────────────
  @bold                  Step 2: bold wraps the ALREADY-italicized result
  @italic                Step 1: italic wraps greet FIRST (closest to the function)
  def greet(name): ...

  greet("Alice")
    → italic's wrapper runs first: "<i>Hello, Alice</i>"
    → bold's wrapper wraps THAT:   "<b><i>Hello, Alice</i></b>"
─────────────────────────────────────────

7. Practical Decorators You'll Actually Use

import time
from functools import wraps
 
def timer(func):
    """Measure and print how long a function takes to run."""
    @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} seconds")
        return result
    return wrapper
 
@timer
def slow_function():
    time.sleep(1)
    return "done"
 
slow_function()     # "slow_function took 1.0012 seconds"
from functools import wraps
 
def validate_positive(func):
    """Ensure all numeric arguments are positive before running the function."""
    @wraps(func)
    def wrapper(*args, **kwargs):
        for arg in args:
            if isinstance(arg, (int, float)) and arg <= 0:
                raise ValueError(f"Expected a positive number, got {arg}")
        return func(*args, **kwargs)
    return wrapper
 
@validate_positive
def calculate_area(radius):
    return 3.14159 * radius ** 2
 
print(calculate_area(5))      # 78.54
calculate_area(-2)                # ValueError: Expected a positive number, got -2

Recall from Module 2, Chapter 4 that @lru_cache (from functools) is also just a decorator — one that caches a function's return values to avoid recomputation. You've been using this pattern since that chapter, now you know exactly how it works.


8. Class-Based Decorators (Preview)

A decorator doesn't have to be a plain function — any callable (Module 4, Chapter 7's __call__) can act as one. This is a preview; most everyday decorators are simpler function-based ones like above.

class CountCalls:
    """A class-based decorator that tracks how many times a function is called."""
    def __init__(self, func):
        self.func = func
        self.call_count = 0
 
    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"Call #{self.call_count} to {self.func.__name__}")
        return self.func(*args, **kwargs)
 
@CountCalls
def say_hello():
    print("Hello!")
 
say_hello()      # Call #1 to say_hello / Hello!
say_hello()        # Call #2 to say_hello / Hello!
print(say_hello.call_count)   # 2

9. Summary & Next Steps

Key Takeaways

  • A decorator is a function (or callable) that wraps another function, adding behavior without modifying the original function's code — built entirely on closures and first-class functions.
  • @decorator above a function is sugar for func = decorator(func).
  • Always use @functools.wraps(func) inside your wrapper to preserve the original function's __name__ and __doc__.
  • A decorator that takes arguments needs an extra layer of nesting (a decorator factory); stacked decorators apply bottom-up, closest to the function first.

Concept Check

  1. What does @my_decorator above a function definition actually translate to?
  2. Why is @wraps(func) important, and what breaks if you omit it?
  3. Given @bold then @italic stacked on the same function, which one runs first when the function is called?

Next Chapter

Chapter 3: Context Managers & Type Hints


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index