Master Python Decorators: Syntax, Examples & Best Practices

Programming
Date:October 8, 2026
Topic:
Master Python Decorators: Syntax, Examples & Best Practices
⏱ 3 min read

Python decorators look intimidating until you realize they are just functions that take a function and return a function. That single insight unlocks cleaner logging, memoization, authentication, and cross-cutting concerns without cluttering business logic. If you have ever copy-pasted timing code into ten different methods, you already understand the problem decorators solve.

The Mental Model

A decorator is syntactic sugar. When you write @decorator above a function definition, Python rewrites it as func = decorator(func). The original function object is passed in, the wrapper does whatever it needs, and a new callable comes out. Nothing magical, just first-class functions doing what they do best.

python
def timer(func):
    import time
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def heavy_computation(n):
    return sum(i * i for i in range(n))

heavy_computation(1_000_000)
💡
TipAlways use *args and **kwargs in the wrapper signature so the decorator works with any callable signature.

Preserving Metadata with functools.wraps

The wrapper function replaces the original, so __name__, __doc__, and __annotations__ point to the wrapper instead of your function. That breaks introspection, debugging tools, and frameworks like FastAPI or pytest. functools.wraps copies the metadata over.

python
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        import time
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.perf_counter() - start:.4f}s")
        return result
    return wrapper

Decorators with Arguments

Sometimes you need to configure the decorator itself, like @retry(max_attempts=3). This requires a factory: a function that returns a decorator. Three nesting levels appear, but the pattern stays consistent.

python
def retry(max_attempts=3, delay=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            import time
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if attempt == max_attempts:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=5, delay=0.5)
def flaky_network_call():
    ...

Class-Based Decorators

Classes implement __call__ to act as decorators. This is handy when you need internal state, like counting calls or caching results per instance.

python
class CallCounter:
    def __init__(self, func):
        self.func = func
        self.count = 0
        wraps(func)(self)
    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Call {self.count} to {self.func.__name__}")
        return self.func(*args, **kwargs)

@CallCounter
def greet(name):
    return f"Hello, {name}"

greet("Ada")
greet("Grace")
print(greet.count)
⚠️
WarningClass decorators without @wraps lose metadata. Call wraps(func)(self) in __init__ to fix it.

Stacking Multiple Decorators

Decorators apply bottom-up. The decorator closest to the function executes first. Order matters: @timer @retry(3) times each retry attempt, while @retry(3) @timer retries the entire timed block.

OrderBehavior
@timer @retry(3)Logs duration of every single attempt
@retry(3) @timerRetries the whole timed operation

Real-World Patterns

Use decorators for cross-cutting concerns: authentication guards, rate limiting, structured logging, metrics emission, and transaction boundaries. Keep business logic pure; let decorators handle the plumbing.

python
def require_role(role):
    def decorator(func):
        @wraps(func)
        def wrapper(user, *args, **kwargs):
            if user.role != role:
                raise PermissionError(f"Requires {role}")
            return func(user, *args, **kwargs)
        return wrapper
    return decorator

@require_role("admin")
def delete_user(user, target_id):
    db.delete(target_id)
ℹ️
NotePrefer composition over deep decorator stacks. A single well-named decorator beats five cryptic ones.

Testing Decorated Functions

Decorators make unit testing harder because the original function is wrapped. Access the raw function via __wrapped__ (added by @wraps) to test logic in isolation without mocking the decorator behavior.

python
def test_heavy_computation():
    assert heavy_computation.__wrapped__(5) == 30

Performance Considerations

Decorators add a function call overhead. For hot paths, consider functools.lru_cache (built-in decorator) or write a C-extension. Profile before optimizing; readability usually wins.


✦

Decorators are not magic. They are functions returning functions, powered by Python's first-class callables. Start small: wrap a print statement, add @wraps, then parameterize. Next time you spot duplicated boilerplate, reach for a decorator. Your future self will thank you for the clean separation of concerns.

Share𝕏 Twitterin LinkedInin Whatsapp