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
Jr Codex Python Notes
Level: Advanced Prerequisites: Chapter 1 Time to complete: ~30 minutes
Table of Contents
- What is a Decorator?
- Functions Wrapping Functions — the Manual Version
- The
@Syntax - Preserving Function Identity with
functools.wraps - Decorators That Accept Arguments
- Stacking Multiple Decorators
- Practical Decorators You'll Actually Use
- Class-Based Decorators (Preview)
- 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 untouchedWhat 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 84. 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 goneThe 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 -2Recall 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) # 29. 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.
@decoratorabove a function is sugar forfunc = 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
- What does
@my_decoratorabove a function definition actually translate to? - Why is
@wraps(func)important, and what breaks if you omit it? - Given
@boldthen@italicstacked 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