Understanding Python Decorators: A Practical Guide

Shaik SameerShaik Sameer
8 min read

Decorators in Python might look a bit strange at first (especially that @ symbol), but they’re actually a powerful and elegant way to modify the behavior of functions — without changing their actual code.

This guide will walk you through decorators step by step, using simple, real-world examples and clean code you can understand and use right away.


📌 What is a Decorator?

In Python, a decorator is a function that takes another function as input and returns a modified version of that function.

In short:

  • You wrap a function with extra functionality.

  • You don’t modify the function’s actual body.

  • You use the @ syntax to apply it.

Let’s break that down with an example.


A Simple Example – Without Using @

def decorate_me(func):
    def wrapper():
        print("Before the function runs")
        func()
        print("After the function runs")
    return wrapper

def greet():
    print("Hello!")

decorated_greet = decorate_me(greet)
decorated_greet()

Output:

Before the function runs  
Hello!  
✅ After the function runs

What’s happening:

  • decorate_me() is a decorator.

  • It returns wrapper(), which runs some extra code before and after calling the original function greet().

  • greet() remains unchanged, but you can run it with extra logic around it.


Using the @decorator Syntax (Syntactic Sugar)

Python offers a cleaner way to apply decorators using the @ symbol:

You can rewrite the given code using the @ decorator syntax like this:

def decorate_me(func):
    def wrapper():
        print("Before the function runs")
        func()
        print("After the function runs")
    return wrapper

@decorate_me
def greet():
    print("Hello!")

greet()

Explanation:

  • @decorate_me is syntactic sugar for greet = decorate_me(greet)

  • Now, when you call greet(), it will automatically go through the wrapper function defined in the decorator.

Code:

def decorate_me(func):
    def wrapper():
        print("Before the function runs")
        func()
        print("After the function runs")
    return wrapper

🔹 What is this?

This is a decorator function.

  • decorate_me takes a function func as an argument.

  • Inside it, another function wrapper() is defined. This:

    • Prints a message before calling func()

    • Calls the original function func()

    • Prints a message after the function runs

  • Then wrapper is returned — not called yet, just returned as a function.


Now the decorated function:

@decorate_me
def greet():
    print("Hello!")

This:

@greet = decorate_me(greet)

So now greet() is actually pointing to the wrapper() function returned by decorate_me.


Final call:

greet()

This now calls the wrapper() function, which behaves like:

print("Before the function runs")
greet()  # original one: prints "Hello!"
print("After the function runs")

Final Output:

Before the function runs
Hello!
After the function runs

🎯 Summary:

  • The @decorate_me applies extra logic before and after running the original function.

  • Useful for logging, validation, authentication, timing, etc., without changing the main function code.

  • “Take greet, pass it to decorate_me(), and replace greet with the returned wrapper function.”

Key Points:

  • The @decorator must be placed directly above the function definition.

  • You can stack multiple decorators on a single function by using multiple @ lines.

  • Decorators help in adding reusable features like logging, timing, validation, or authentication without modifying the core function.

When to Use:

  • To apply the same piece of logic (like logging or checking permissions) to many functions.

  • To make your code cleaner, more modular, and easier to manage.

Common Use Cases:

  • Logging function calls

  • Measuring execution time

  • Access control and authentication

  • Input validation

  • Caching results


Why Are Functions "First-Class" in Python?

In Python, the term first-class refers to first-class objects (or citizens) — and functions in Python are first-class objects.


✅ What Does “First-Class” Mean?

It means that a value (like a function) can be:

  1. Assigned to a variable

  2. Passed as an argument to another function

  3. Returned from another function

  4. Stored in data structures (like lists, dicts)


🔹 Example: Functions Are First-Class in Python

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

# 1. Assigned to a variable
say_hello = greet
print(say_hello("Alice"))  # Output: Hello, Alice!

# 2. Passed as argument
def call_function(func, value):
    return func(value)

print(call_function(greet, "Bob"))  # Output: Hello, Bob!

# 3. Returned from another function
def get_greeter():
    return greet

new_func = get_greeter()
print(new_func("Charlie"))  # Output: Hello, Charlie!

Explanation:

Part 1: Define a simple function

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

This defines a function greet that takes a name and returns a greeting string.

Example:

greet("Alice") ➝ "Hello, Alice!"

✅ Step 1: Assigned to a variable

say_hello = greet

Here, you're not calling the function with () — you're assigning the function itself to a new variable say_hello.

So now:

say_hello("Alice")  ➝ "Hello, Alice!"  # behaves exactly like greet()

Print Output:

Hello, Alice!

This shows that the function can be assigned to a variable — a feature of first-class objects.


✅ Step 2: Passed as an argument to another function

def call_function(func, value):
    return func(value)

This defines a new function call_function, which accepts:

  • func: a function

  • value: a value to pass into that function

Now:

print(call_function(greet, "Bob"))

greet is passed as an argument
call_function calls it: greet("Bob")
➡ Output: "Hello, Bob!"

Print Output:

Hello, Bob!

✅ Step 3: Returned from another function

def get_greeter():
    return greet

This function returns the greet function itself, not a value.
When you call:

new_func = get_greeter()

Now new_func is equal to the original greet function.

So:

new_func("Charlie") ➝ "Hello, Charlie!"

Print Output:

Hello, Charlie!

Summary Table:

OperationResult
Assign function to variablesay_hello = greet
Pass function as an argumentcall_function(greet, val)
Return function from another oneget_greeter() ➝ greet

Conclusion:

This code shows functions are first-class citizens in Python — they can be used like data:

  • assigned

  • passed

  • returned

Decorators with Parameters Using *args and **kwargs

Great! Let's dive into decorators with parameters using *args and **kwargs.


🧠 Why Use *args and **kwargs?

When writing decorators, you might want to decorate functions that:

  • Have any number of arguments (like func(a, b), func(name="Sameer"), etc.)

  • To handle this flexibly, decorators use *args (positional) and **kwargs (keyword arguments).


✅ Basic Example

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before the function runs")
        result = func(*args, **kwargs)
        print("After the function runs")
        return result
    return wrapper

@my_decorator
def greet(name):
    print(f"Hello, {name}!")

greet("Sameer")

🔍 Explanation:

  • *args collects positional arguments (like "Sameer")

  • **kwargs collects keyword arguments (like name="Sameer")

  • The decorator passes those along to the actual function.


🧪 Output:

Before the function runs
Hello, Sameer!
After the function runs

Another Example: Function with multiple args

def debug_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        return func(*args, **kwargs)
    return wrapper

@debug_decorator
def add(x, y):
    return x + y

@debug_decorator
def greet(name="Guest"):
    print(f"Hi, {name}!")

print(add(10, 5))
greet(name="Sameer")

Output:

Calling add with args=(10, 5), kwargs={}
15
Calling greet with args=(), kwargs={'name': 'Sameer'}
Hi, Sameer!

🎯 Summary:

  • Use *args and **kwargs to make your decorator work with any kind of function.

  • This makes your decorators generic and reusable.


Real-World Decorator Examples

Let’s explore some real, useful use-cases for decorators in actual applications.


Logging Function Calls

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}...")
        return func(*args, **kwargs)
    return wrapper

@log_call
def download_file(filename):
    print(f"Downloading {filename}...")

download_file("report.pdf")

Use Case: Useful for debugging or tracking what’s being called.


Timing How Long a Function Takes

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} ran in {end - start:.2f} seconds.")
        return result
    return wrapper

@timer
def slow_operation():
    time.sleep(1)
    print("Done.")

slow_operation()

Use Case: Benchmark or optimize functions.


Check User Authentication

def require_auth(func):
    def wrapper(*args, **kwargs):
        if kwargs.get("is_authenticated"):
            return func(*args, **kwargs)
        else:
            print("🚫 Access Denied. Please log in.")
    return wrapper

@require_auth
def view_dashboard(user, is_authenticated=False):
    print(f"Welcome, {user}!")

view_dashboard("Sameer", is_authenticated=True)
view_dashboard("Guest", is_authenticated=False)

Use Case: Security checks in web apps or APIs.


Why Use functools.wraps?

When you decorate a function, you lose its name and docstring, because you're replacing it with the wrapper.

📌 functools.wraps in Python

In Python, functools.wraps is a decorator used inside custom decorators to preserve the original function’s metadata — such as its name, docstring, and annotations.

What It Does:

When you create a decorator, it wraps another function inside a new one. Without functools.wraps, the wrapped function loses its identity.

This can affect:

  • Debugging

  • Introspection (__name__, __doc__, etc.)

  • Tools that rely on metadata (like documentation generators or test frameworks)

To preserve the original function’s identity, use functools.wraps:

from functools import wraps

def log(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Running {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log
def greet():
    """Greets the user"""
    print("Hi!")

print(greet.__name__)  # greet
print(greet.__doc__)   # Greets the user

Without @wraps, greet.__name__ would return 'wrapper', which is confusing.

Key Points:

  • Use @wraps(func) inside your decorator's wrapper function.

  • Comes from the functools module: from functools import wraps

  • Helps keep the original function’s __name__, __doc__, and other attributes intact.


When to Use:

  • Always use @wraps when creating custom decorators.

  • Especially important in production code, debugging, and tools that depend on function metadata


When Should You Use Decorators?

Use decorators when you want to:

  • Avoid repeating common logic (DRY principle)

  • Add logging, validation, security, or performance tracking

  • Wrap third-party functions cleanly


When Not to Use Decorators?

Avoid decorators when:

  • You’re working on very simple logic

  • You need custom logic that can't be easily abstracted

  • It adds confusion rather than clarity

Summary Table

FeatureDescription
@decoratorSyntactic sugar for applying a decorator function
*args, **kwargsAllow decorators to accept any function signature
functools.wrapsPreserves original function name and docstring
Use CasesLogging, timing, access control, validation, caching

0
Subscribe to my newsletter

Read articles from Shaik Sameer directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shaik Sameer
Shaik Sameer