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 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!

StepWhat Happens
1. Define decoratorCreate a function that takes another function as input
2. Create wrapperInside, define a wrapper function that adds behavior
3. Return wrapperReturn 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 @wrapsWith @wraps ✅
func.__name__ → "wrapper"func.__name__ → original name
func.__doc__ → Nonefunc.__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.

LayerPurposeReturns
Outer functionAccepts decorator argumentsReturns the actual decorator
Middle functionThe actual decorator (takes fn)Returns the wrapper
Inner functionThe wrapper that runsCalls 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 OrderExecution 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 DecoratorClass Decorator
Simple, one-off behaviorNeeds state between calls
Uses closures for stateUses self attributes
3 nested functions maxCleaner 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:

❌ MistakeWhat Happens✅ Fix
Forgetting @wrapsFunction name/docs disappearAlways add @wraps(fn)
Wrong decorator orderUnexpected behaviorRemember: bottom wraps first
Missing *args, **kwargsArguments don't pass throughAlways use wrapper(*a, **k)
Forgetting to return resultFunction returns NoneAdd return fn(*a, **k)
Calling instead of returning wrapperDecorator runs immediatelyUse 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, Ada

You 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

SyntaxWhat it does
@my_decoratorApply decorator to a function
functools.wraps(fn)Preserve original function metadata
@staticmethodMethod that needs no self
@classmethodMethod that receives the class
@lru_cacheCache 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