A decorator is just a callable that takes a function and returns a new one, often a wrapper, letting us extend behavior while keeping functions clean and following the Open–Closed Principle.

What Is the @decorator Syntax?

The @decorator syntax is syntactic sugar for applying a decorator to a function or class.

These two snippets are exactly equivalent:

1
2
3
@decorator
def f():
...

and

1
2
3
def f():
...
f = decorator(f)

In other words, the @decorator syntax:

  • Passes the function f to decorator

  • Reassigns f to the result of that call

So when Python executes the @decorator line, it effectively runs:

1
f = decorator(f)

How Decorators Work: The Core Mechanism

At its core, a decorator is a callable (usually a function) that:

  • Takes another function as input

  • Returns a new function (often a modified or enhanced version of the original)

Basic Pattern

1
2
3
4
5
6
7
8
9
10
11
12
def my_decorator(func):      # The decorator
def wrapper(): # The wrapper that replaces the original function
print("Do something before the function")
func() # Call the original function
print("Do something after the function")
return wrapper

@my_decorator
def say_hello():
print("Hello!")

say_hello()

Execution Flow

  1. Decoration phase
    When Python sees @my_decorator, it calls:

    1
    say_hello = my_decorator(say_hello)

    my_decorator returns wrapper, so now say_hello actually refers to wrapper.

  2. Replacement
    The original say_hello function object is replaced by wrapper.

  3. Execution
    When we call say_hello(), we are really calling wrapper(), which:

    • Does “before” work

    • Calls the original say_hello

    • Does “after” work

Why Use Decorators Instead of Direct Modification?

A natural question is: why use this extra indirection instead of writing the logic directly inside our functions?

We can compare:

Aspect Direct Modification Using Decorators
Core principle Invasive: we modify function internals Non-invasive: we wrap behavior from outside
Code reusability Poor: logic is duplicated in many functions High: one decorator can be reused everywhere
Maintainability Low: many places to change High: change decorator once, all decorated functions update
Responsibility separation Mixed: core logic + “extra stuff” together Clear: core logic vs cross-cutting concerns
Flexibility Rigid: hard to add/remove behavior cleanly Flexible: easy to add/remove a decorator

Decorators give us a clean way to add cross-cutting concerns (logging, authentication, timing, etc.) without polluting each function’s core business logic.

Practical Example: Authentication

Consider adding authentication to a function.

Direct Modification

1
2
3
4
5
6
7
def delete_item(user_id, item_id):
# Authentication code mixed with business logic
if not check_user_logged_in():
raise Exception("Authentication failed")

# Core business logic
# ... delete item code ...

Here, the authentication logic is tightly coupled with the business logic.

Decorator Approach

1
2
3
4
5
6
7
8
9
10
11
12
def login_required(func):
def wrapper(*args, **kwargs):
if check_user_logged_in():
return func(*args, **kwargs)
else:
raise Exception("Authentication failed")
return wrapper

@login_required
def delete_item(user_id, item_id):
# Pure business logic
# ... delete item code ...

Advantages of the decorator approach:

  • The function delete_item focuses only on its core responsibility.

  • We can reuse @login_required on many other functions.

  • If our authentication logic changes, we update login_required in one place.

Advanced Decorator Patterns

Decorating Functions with Parameters

Real-world functions almost always take arguments. To support this, we typically use *args and **kwargs in the wrapper:

1
2
3
4
5
6
7
8
9
10
11
def log_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args: {args}, kwargs: {kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} finished execution")
return result
return wrapper

@log_decorator
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")

Now @log_decorator can be applied to any function, regardless of its signature.

Decorators with Their Own Arguments

Sometimes we want the decorator itself to accept parameters (e.g., repeat count, log level, etc.). In this case, we use a decorator factory: a function that returns a decorator.

1
2
3
4
5
6
7
8
9
10
11
12
13
def repeat(num_times):           # Outer function: takes decorator arguments
def decorator_repeat(func): # Actual decorator
def wrapper(*args, **kwargs):
result = None
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator_repeat

@repeat(num_times=3)
def say_hello():
print("Hello!")

Conceptually, @repeat(num_times=3) is equivalent to:

1
say_hello = repeat(3)(say_hello)

So the call chain is:

  1. repeat(3) returns decorator_repeat

  2. decorator_repeat(say_hello) returns wrapper

  3. say_hello is replaced by wrapper

Preserving Function Metadata with functools.wraps

A common side effect of decorators is that the wrapper function hides the original function’s metadata such as __name__, __doc__, etc. To preserve these, we use functools.wraps.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from functools import wraps

def my_decorator(func):
@wraps(func) # Preserves name, docstring, etc.
def wrapper(*args, **kwargs):
"""Wrapper docstring (usually not used)"""
return func(*args, **kwargs)
return wrapper

@my_decorator
def example():
"""Original docstring"""
pass

print(example.__name__) # 'example'
print(example.__doc__) # 'Original docstring'

Without @wraps, example.__name__ would be 'wrapper', and its docstring would be the wrapper’s docstring.

Design Philosophy Behind Decorators

Decorators are a Pythonic way to implement the Open–Closed Principle:

Software entities should be open for extension but closed for modification.

Instead of modifying existing function bodies (which can be risky and break encapsulation), we extend their behavior externally by wrapping them with decorators.

A useful analogy is a camera:

  • The camera body is the core function.

  • Decorators are like lenses and filters: we attach them externally to change behavior without opening up the camera and modifying its internals.

This design leads to code that is more:

  • Modular

  • Reusable

  • Easier to test and reason about

Common Real-World Use Cases

In practice, decorators appear everywhere in modern Python code. Typical use cases include:

  • Logging
    Automatically logging function calls, arguments, and return values.

  • Timing / Profiling
    Measuring how long a function takes to run.

  • Authentication / Authorization
    Checking user permissions, especially in web frameworks (@login_required, @permission_required, etc.).

  • Caching / Memoization
    Storing results of expensive computations, for example @functools.lru_cache.

  • Validation / Pre-conditions
    Validating function arguments before running the function body.

  • Retry Logic
    Automatically retrying operations that may fail transiently (e.g., network calls).

Summary

  • The @decorator syntax is just syntactic sugar for func = decorator(func).

  • A decorator is a callable that takes a function and returns a new function, often wrapping extra behavior around it.

  • Decorators shine when we deal with cross-cutting concerns such as logging, authentication, timing, caching, and validation.

  • Using decorators, we keep our core business logic clean and focused, while centralized decorators handle reusable concerns.

  • This approach aligns closely with good software engineering principles, especially the Open–Closed Principle.

As our Python projects grow in complexity, decorators evolve from “extra syntax” into an indispensable tool for writing clean, maintainable, and extensible code.