Decorators & Advanced Features
Reviewed & published by Brayan K
Decorators are one of Python's most powerful features — used in Django, Flask, FastAPI, TensorFlow, PyTorch, logging systems, authentication, caching, and more. This lesson takes you far beyond the basics.
Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- • What a decorator is and how Python applies @ syntax
- • Writing your own decorators from scratch with functools.wraps
- • Stacking multiple decorators and decorator factories with arguments
- • Practical use cases: timing, logging, access control, caching
- • How real frameworks (Django, FastAPI) use advanced decorator patterns
What You'll Learn
✔ How decorators truly work under the hood
✔ How to pass arguments to decorators
✔ How to stack multiple decorators safely
✔ How to preserve metadata (the functools.wraps problem)
✔ How real frameworks use advanced decorator patterns
✔ How to build your own production-ready decorator utilities
🔥 1. Decorators Refresher (1-Minute Summary)
🏠 Real-World Analogy:
Think of a decorator like gift wrapping. You have a present (your function), and the wrapper adds something extra (like a bow or ribbon) without changing what's inside. The wrapper can add behavior before or after opening the gift!
| Step | What Happens |
|---|---|
| 1. Define decorator | Create a function that takes another function as input |
| 2. Create wrapper | Inside, define a wrapper function that adds behavior |
| 3. Return wrapper | Return the wrapper (not call it!) |
| 4. Apply with @ | Use @decorator_name above a function |
# Step 1: Define the decorator function
def my_decorator(fn): # Takes a function as input
# Step 2: Create an inner "wrapper" function
def wrapper():
print("Before") # Runs BEFORE the original function
fn() # Call the original function
print("After") # Runs AFTER the original function
# Step 3: Return the wrapper (don't call it!)
return wrapper
# Step 4: Apply decorator using @ syntax
@my_decorator
def hello():
print("Hello!")
# When we call hello(), we're actually calling wrapper()!
hello() # Output: Before, Hello!, After
# ✅ Expected output:
# Before
# Hello!
# After# Decorators Practice
import time
from functools import wraps
# Basic decorator
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("Before the function")
result = func(*args, **kwargs)
print("After the function")
return result
return wrapper
@my_decorator
def say_hello(name):
print(f"Hello, {name}!")
say_hello("Alice")
print("\n" + "="*30 + "\n")
# Timer decorator
def timer(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end-start:.4f} seconds")
return result
return wrapper
@timer
def slow_function():
total = sum(range(100000))
return total
result = slow_function()
print(f"Result: {result}")🧠 2. Why Decorators Matter in Real Projects
Every major Python ecosystem uses decorators for:
# Authentication decorator example
def require_login(fn):
def wrapper(user, *args, **kwargs):
if not user.get("logged_in"):
return "Please log in first!"
return fn(user, *args, **kwargs)
return wrapper
@require_login
def get_dashboard(user):
return f"Welcome to your dashboard, {user['name']}!"
# Test with logged in user
logged_in_user = {"name": "Alice", "logged_in": True}
print(get_dashboard(logged_in_user))
# Test with logged out user
logged_out_user = {"name": "Bob", "logged_in": False}
print(get_dashboard(logged_out_user))
# ✅ Expected output:
# Welcome to your dashboard, Alice!
# Please log in first!Understanding decorators = understanding real frameworks.
⚙️ 3. The Core Issue — Decorators Remove Metadata
⚠️ The Problem:
When you wrap a function, Python "forgets" the original function's name, docstring, and other info. This breaks documentation tools, debugging, and frameworks like FastAPI!
| Without @wraps | With @wraps ✅ |
|---|---|
| func.__name__ → "wrapper" | func.__name__ → original name |
| func.__doc__ → None | func.__doc__ → original docstring |
| Debugger shows "wrapper" | Debugger shows real function name |
from functools import wraps
# ❌ BAD: Without wraps - loses metadata
def bad_decorator(fn):
def wrapper(*a, **k):
return fn(*a, **k)
return wrapper # Returns wrapper, not fn!
@bad_decorator
def greet():
"""Says hello"""
return "Hello!"
print("Without @wraps:")
print(f" Name: {greet.__name__}") # 'wrapper' - WRONG!
print(f" Doc: {greet.__doc__}") # None - WRONG!
# ✅ GOOD: With wraps - preserves metadata
def good_decorator(fn):
@wraps(fn) # ← This line fixes everything!
def wrapper(*a, **k):
return fn(*a, **k)
return wrapper
@good_decorator
def greet2():
"""Says hello"""
return "Hello!"
print("\nWith @wraps:")
print(f" Name: {greet2.__name__}") # 'greet2' - CORRECT!
print(f" Doc: {greet2.__doc__}") # 'Says hello' - CORRECT!
# ✅ Expected output:
# Without @wraps:
# Name: wrapper
# Doc: None
#
# With @wraps:
# Name: greet2
# Doc: Says hello🎯 4. Decorators That Accept Arguments (Decorator Factories)
🤔 The Challenge:
What if you want @check_role("admin")? You need to pass an argument to the decorator! This requires an extra layer of nesting called a decorator factory.
| Layer | Purpose | Returns |
|---|---|---|
| Outer function | Accepts decorator arguments | Returns the actual decorator |
| Middle function | The actual decorator (takes fn) | Returns the wrapper |
| Inner function | The wrapper that runs | Calls the original fn |
from functools import wraps
# Layer 1: Outer function accepts the argument
def check_role(required_role):
# Layer 2: This is the actual decorator
def decorator(fn):
@wraps(fn)
# Layer 3: This is the wrapper that runs
def wrapper(user, *args, **kwargs):
if user.get("role") != required_role:
raise PermissionError(f"Access denied. Required: {required_role}")
return fn(user, *args, **kwargs)
return wrapper
return decorator
# Now we can use @check_role("admin")!
@check_role("admin") # ← Passes "admin" to outer function
def delete_user(user, target_id):
return f"Deleted user {target_id}"
# Test with admin - works!
admin = {"name": "Alice", "role": "admin"}
print(delete_user(admin, 123))
# Test with regular user - blocked!
try:
regular = {"name": "Bob", "role": "user"}
print(delete_user(regular, 123))
except PermissionError as e:
print(f"Error: {e}")
# ✅ Expected output:
# Deleted user 123
# Error: Access denied. Required: admin🔄 5. Stacking Multiple Decorators (Order Matters!)
🏠 Analogy: Layers of Wrapping Paper
Imagine wrapping a gift with multiple layers. The closest decorator to the function wraps first, then the next one wraps around that, and so on. When you call the function, you "unwrap" from outside in.
| Code Order | Execution Order |
|---|---|
| @A (top) | A runs FIRST (outermost layer) |
| @B (bottom) | B runs SECOND (inner layer) |
| def func: | Function runs LAST (the core) |
from functools import wraps
def decorator_A(fn):
@wraps(fn)
def wrapper(*a, **k):
print("A: before") # Runs 1st
result = fn(*a, **k) # Calls B's wrapper
print("A: after") # Runs 6th
return result
return wrapper
def decorator_B(fn):
@wraps(fn)
def wrapper(*a, **k):
print("B: before") # Runs 2nd
result = fn(*a, **k) # Calls the real function
print("B: after") # Runs 4th
return result
return wrapper
@decorator_A # Wraps second (outer layer)
@decorator_B # Wraps first (inner layer)
def run():
print("Running function") # Runs 3rd
run()
print("\n=> Order: A before → B before → function → B after → A after")
# ✅ Expected output:
# A: before
# B: before
# Running function
# B: after
# A: after
#
# => Order: A before → B before → function → B after → A after⚡ 6. Example: Timing + Logging + Caching Stack
import time
from functools import wraps
def timer(fn):
@wraps(fn)
def wrapper(*a, **k):
start = time.time()
result = fn(*a, **k)
print(f"⏱ {fn.__name__} took {time.time() - start:.4f}s")
return result
return wrapper
def logger(fn):
@wraps(fn)
def wrapper(*a, **k):
print(f"📘 Calling {fn.__name__}")
return fn(*a, **k)
return wrapper
def cache(fn):
saved = {}
@wraps(fn)
def wrapper(x):
if x in saved:
print("📦 Cached result")
return saved[x]
result = fn(x)
saved[x] = result
return result
return wrapper
@logger
@timer
@cache
def compute(n):
time.sleep(0.1) # Simulate work
return n * n
print("First call:")
compute(5)
print("\nSecond call (cached):")
compute(5)🧩 7. Real Project Example — Retry Decorator
Used in API calls, database queries, and cloud services.
from functools import wraps
import time
import random
def retry(times):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
for i in range(times):
try:
return fn(*args, **kwargs)
except Exception as e:
print(f"Retry {i+1}/{times}: {e}")
time.sleep(0.1)
raise RuntimeError("All retries failed")
return wrapper
return decorator
@retry(3)
def unstable_api():
if random.random() < 0.7: # 70% failure rate
raise ValueError("Network error")
return "Success!"
try:
result = unstable_api()
print(f"Result: {result}")
except RuntimeError as e:
print(f"Final error: {e}")🔒 8. Real Project Example — Input Validation Decorator
from functools import wraps
def validate_types(*types):
def decorator(fn):
@wraps(fn)
def wrapper(*args):
for arg, expected in zip(args, types):
if not isinstance(arg, expected):
raise TypeError(f"Expected {expected.__name__}, got {type(arg).__name__}")
return fn(*args)
return wrapper
return decorator
@validate_types(int, int)
def add(a, b):
return a + b
print(add(5, 3))
try:
print(add("5", 3)) # Will raise TypeError
except TypeError as e:
print(f"Error: {e}")
# ✅ Expected output:
# 8
# Error: Expected int, got str🧠 9. Passing Multiple Arguments to Decorators
from functools import wraps
def require_roles(*allowed):
def decorator(fn):
@wraps(fn)
def wrapper(user, *a, **k):
if user.get("role") not in allowed:
return f"Access denied. Allowed roles: {allowed}"
return fn(user, *a, **k)
return wrapper
return decorator
@require_roles("admin", "moderator")
def edit_post(user, post_id):
return f"{user['name']} edited post {post_id}"
admin = {"name": "Alice", "role": "admin"}
mod = {"name": "Bob", "role": "moderator"}
user = {"name": "Charlie", "role": "user"}
print(edit_post(admin, 1))
print(edit_post(mod, 2))
print(edit_post(user, 3))
# ✅ Expected output:
# Alice edited post 1
# Bob edited post 2
# Access denied. Allowed roles: ('admin', 'moderator')🧵 10. Decorators With Keyword Arguments
from functools import wraps
def log(prefix="INFO", suffix=""):
def decorator(fn):
@wraps(fn)
def wrapper(*a, **k):
print(f"[{prefix}] Calling {fn.__name__} {suffix}")
return fn(*a, **k)
return wrapper
return decorator
@log(prefix="DEBUG", suffix="- testing")
def load_config():
return {"debug": True}
@log(prefix="WARN")
def risky_operation():
return "done"
load_config()
risky_operation()
# ✅ Expected output:
# [DEBUG] Calling load_config - testing
# [WARN] Calling risky_operation🎓 11. Advanced Pattern — Class-Based Decorators
🤔 When to Use Classes?
Use class-based decorators when you need to track state across multiple calls (like counting calls, caching results, or enforcing rate limits).
| Function Decorator | Class Decorator |
|---|---|
| Simple, one-off behavior | Needs state between calls |
| Uses closures for state | Uses self attributes |
| 3 nested functions max | Cleaner for complex logic |
from functools import wraps
class RateLimiter:
def __init__(self, limit):
self.limit = limit # Max allowed calls
self.calls = 0 # State: track call count
def __call__(self, fn): # Makes the class callable as decorator
@wraps(fn)
def wrapper(*a, **k):
if self.calls >= self.limit:
return f"Rate limit exceeded ({self.limit} calls max)"
self.calls += 1 # Update state
return fn(*a, **k)
return wrapper
# Use the class as a decorator!
@RateLimiter(3) # Creates instance, limit=3
def process():
return "Processing..."
# Try calling 5 times - only first 3 work!
for i in range(5):
print(f"Call {i+1}: {process()}")
# ✅ Expected output:
# Call 1: Processing...
# Call 2: Processing...
# Call 3: Processing...
# Call 4: Rate limit exceeded (3 calls max)
# Call 5: Rate limit exceeded (3 calls max)🧨 12. Debugging Decorators (Common Problems)
These are the most common mistakes when writing decorators:
| ❌ Mistake | What Happens | ✅ Fix |
|---|---|---|
| Forgetting @wraps | Function name/docs disappear | Always add @wraps(fn) |
| Wrong decorator order | Unexpected behavior | Remember: bottom wraps first |
| Missing *args, **kwargs | Arguments don't pass through | Always use wrapper(*a, **k) |
| Forgetting to return result | Function returns None | Add return fn(*a, **k) |
| Calling instead of returning wrapper | Decorator runs immediately | Use return wrapper not return wrapper() |
→ Breaks documentation, tooling, introspection, FastAPI routes, pytest
❌ Using wrong order in stacks
→ Decorators run in unexpected order; auth might run after logging
→ Modifying outer variable without nonlocal causes errors
🧪 13. Mini Project — Build a Full Decorator Suite
Build decorators for timing, logging, validation, caching, retries, and permissions:
from functools import wraps
import time
def logger(fn):
@wraps(fn)
def wrapper(*a, **k):
print(f"📘 {fn.__name__} called")
return fn(*a, **k)
return wrapper
def timer(fn):
@wraps(fn)
def wrapper(*a, **k):
start = time.time()
result = fn(*a, **k)
print(f"⏱ {time.time() - start:.4f}s")
return result
return wrapper
def retry(times):
def decorator(fn):
@wraps(fn)
def wrapper(*a, **k):
for i in range(times):
try:
return fn(*a, **k)
except:
if i == times - 1:
raise
return wrapper
return decorator
@logger
@timer
@retry(3)
def api_request():
time.sleep(0.05)
return "Success!"
print(api_request())# 🎯 YOUR TURN — replace each ___ using the hint beside it.
import functools
def announce(func):
# 1) Without this, the wrapper's name and docstring replace the
# original's — which breaks help(), debuggers and other decorators.
@functools.___(func) # 👉 replace ___ with wraps
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
# 2) Hand the real answer back, or every wrapped call returns None.
___ result # 👉 replace ___ with return
# 3) A decorator returns the replacement function — it does not call it.
return ___ # 👉 replace ___ with wrapper
# 4) The @ line is just add = announce(add) written above the function.
@___ # 👉 replace ___ with announce
def add(a, b):
"""Add two numbers."""
return a + b
print(add(2, 3))
print("Name kept:", add.__name__)
print("Docstring kept:", add.__doc__)
# A decorator that takes an argument needs one more layer: a function that
# RETURNS a decorator.
def repeat(times):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for _ in range(times):
func(*args, **kwargs)
return wrapper
return decorator
# 5) Called with an argument, so it runs first and its result decorates greet.
@repeat(___) # 👉 replace ___ with 2
def greet(name):
print("Hello,", name)
greet("Ada")
# ✅ Expected output:
# calling add
# add returned 5
# 5
# Name kept: add
# Docstring kept: Add two numbers.
# Hello, Ada
# Hello, AdaYou now have a framework-level decorator system.
🎉 Conclusion
✔ How decorators truly work
✔ How to create decorators that accept arguments
✔ How to stack and combine decorators
✔ How to preserve metadata
✔ How closures power all decorators
✔ How major Python frameworks implement them
✔ How to design your own production-ready decorator system
📋 Quick Reference — Decorators
| Syntax | What it does |
|---|---|
| @my_decorator | Apply decorator to a function |
| functools.wraps(fn) | Preserve original function metadata |
| @staticmethod | Method that needs no self |
| @classmethod | Method that receives the class |
| @lru_cache | Cache results for repeated calls |
🏆 Lesson Complete!
You can now write, stack, and configure decorators for any use case — the same pattern used by Flask, Django, and FastAPI.
Practice quiz
What does the @my_decorator syntax above a function actually do?
- Calls the function immediately
- Imports a module
- Is shorthand for func = my_decorator(func)
- Defines a class
Answer: Is shorthand for func = my_decorator(func). @my_decorator is shorthand for func = my_decorator(func) — it replaces func with the wrapper.
In a basic decorator, what should you do with the inner wrapper function?
- Return it (not call it): return wrapper
- Call it: return wrapper()
- Delete it
- Print it
Answer: Return it (not call it): return wrapper. You return the wrapper itself (return wrapper); calling it would run it immediately.
What does functools.wraps(fn) preserve?
- The function's speed
- The return value
- The arguments
- The original function's __name__ and __doc__ (its metadata)
Answer: The original function's __name__ and __doc__ (its metadata). @wraps copies metadata like __name__ and __doc__ from the original onto the wrapper.
Without @wraps, what does the decorated function's __name__ become?
- The original name
- 'wrapper'
- None
- 'decorator'
Answer: 'wrapper'. Without @wraps, the metadata is lost and __name__ shows 'wrapper'.
How many def statements does a decorator that accepts arguments (a decorator factory) typically have?
- 3
- 1
- 2
- 4
Answer: 3. A decorator with arguments has 3 levels: outer (takes args), middle (takes fn), inner wrapper.
With @decorator_A on top of @decorator_B, which wraps the function first?
- A (the top one)
- They wrap simultaneously
- B (the bottom one, closest to the function)
- Neither
Answer: B (the bottom one, closest to the function). The bottom decorator (closest to the function) wraps first; the top one runs first when called.
Why use *args, **kwargs in the wrapper signature?
- To make it faster
- So arguments pass through to the wrapped function correctly
- To preserve metadata
- It is required syntax for all functions
Answer: So arguments pass through to the wrapped function correctly. wrapper(*args, **kwargs) lets the wrapper accept and forward any arguments to fn.
For a class-based decorator, which method makes the instance usable as a decorator?
- __init__
- __main__
- __wrap__
- __call__
Answer: __call__. __call__ makes the instance callable, so it acts as the decorator; __init__ stores its arguments.
When is a class-based decorator most useful?
- For simple one-off behavior
- When you need to track state across multiple calls
- When you want fewer lines
- When the function has no arguments
Answer: When you need to track state across multiple calls. Class decorators use self attributes to track state (like call counts or rate limits) between calls.
In the validate_types decorator, what does add('5', 3) raise?
- ValueError
- Nothing, it returns '53'
- TypeError
- KeyError
Answer: TypeError. '5' is a str, not an int, so the isinstance check fails and raises TypeError.
Continue this course
- Previous: Inheritance and Polymorphism
- Next: Advanced Functions & Parameters Masterclass — args, kwargs, keyword-only args, and function signatures in depth
- Quick reference: Python cheat sheet
- From the blog: Python Decorators: A Practical Guide