Python Decorators Tutorial: Everything You Need to Know

Python decorators are one of the most powerful features in the language. They allow you to modify or extend the behavior of functions and methods without modifying their source code. In this Python decorators tutorial, we’ll explore how decorators work, how to use them effectively, and how they can enhance your code with minimal effort.

 

πŸ“Œ What Are Python Decorators?

Decorators in Python are a design pattern that lets you add new functionality to an existing object or function. The beauty of decorators lies in their ability to modify the behavior of functions or methods dynamically, keeping your code clean, reusable, and flexible.

Decorators are usually applied to functions or methods using the @ syntax. They wrap functions, adding extra functionality before or after the decorated function is executed.

 

πŸ“Œ Functions as First-Class Objects

In Python, functions are first-class citizens, meaning they can be passed around, returned from other functions, and assigned to variables. This makes Python highly flexible, and decorators are a great example of this flexibility.

🧩 Example: Assigning Functions to Variables

def plus_one(number):
    return number + 1

add_one = plus_one
add_one(5)  # Output: 6

πŸ’‘ Here, add_one is assigned the function plus_one, demonstrating that functions can be stored in variables and called dynamically.

πŸ“Œ Inner Functions and Closures

Closures are a fundamental concept for decorators. A closure is a nested function that remembers the environment in which it was created, even after the outer function has finished execution. This is how decorators are able to remember the function they decorate.

🧩 Example of a Closure:

def outer_function(message):
    def inner_function():
        print(f"Message from closure: {message}")
    return inner_function

closure_function = outer_function("Hello, closures!")
closure_function()  # Output: Message from closure: Hello, closures!

πŸ’‘ The inner_function accesses the variable message from its enclosing scope, demonstrating a closure.

πŸ“ Note: Nested Functions in PythonDefinition:
A nested function is a function defined inside another function.βœ… Example:

def outer():
    def inner():
        print("Hello from inner!")
    return inner

πŸ” How It Works:

  • outer() returns the inner function without calling it.
  • You can call the inner function later using outer()().

▢️ Calling Sequence:

outer()()  # First call returns inner, second call executes it

πŸ” Multi-Layer Nesting:

You can nest multiple layers:

def first():
    def second():
        def third():
            print("Deep inside!")
        return third
    return second

first()()()  # Output: Deep inside!

πŸ“Œ Use Cases:

  • Closures (functions remembering outer variables)
  • Decorators
  • Function factories
  • Currying

 

πŸ“Œ Creating Your First Python Decorator

A decorator in Python is a function that takes another function as an argument and modifies its behavior. Here’s how you can create a simple decorator that converts a string to uppercase:

🧩 Simple Decorator Example:

def uppercase_decorator(function):
    def wrapper():
        result = function()
        return result.upper()
    return wrapper

@uppercase_decorator
def say_hi():
    return 'hello there'

print(say_hi())  # Output: HELLO THERE

πŸ’‘ In this example, uppercase_decorator is applied to the say_hi function, which modifies the returned string to uppercase.

πŸ“ Note: Python .upper() Method

Definition:
.upper() is a built-in string method in Python that returns a copy of the string with all characters converted to uppercase.

βœ… Example:

text = "hello"
print(text.upper())  # Output: HELLO

 

πŸ“Œ Stacking Multiple Decorators

python multiple decorators

Python allows you to stack multiple decorators on a single function. The order of decorators is important, as they are applied from bottom to top.

🧩 Example of Stacking Decorators:

import functools

def uppercase_decorator(function):
    def wrapper():
        result = function()
        return result.upper()
    return wrapper

def split_string(function):
    @functools.wraps(function)
    def wrapper():
        result = function()
        return result.split()
    return wrapper

@split_string
@uppercase_decorator
def say_hi():
    return 'hello there'

print(say_hi())  # Output: ['HELLO', 'THERE']

πŸ’‘ Here, the string is first converted to uppercase, then split into a list. The decorators are stacked from bottom to top.

πŸ“ Note: import functools and .split() in Python

πŸ”Ή import functools
functools is a standard Python module that provides higher-order functions β€” functions that act on or return other functions.

Higher-Order Functions β€” Python. A programming language is said to… | by Tharun Kumar Sekar | Analytics Vidhya | Medium

In this code, we use:

@functools.wraps(function)

This preserves the original function’s metadata (like its name and docstring) when it’s wrapped by a decorator.

⚠️ Without @wraps, say_hi.__name__ would return 'wrapper' instead of 'say_hi'.

πŸ”Ή .split() String Method
.split() is a built-in Python method used on strings.

It splits a string into a list of words, using whitespace as the default separator.

βœ… Example:

text = "hello there"
print(text.split())  # Output: ['hello', 'there']

🧠 Application in the Code:

@split_string
@uppercase_decorator
def say_hi():
    return 'hello there'

say_hi() returns 'hello there'
uppercase_decorator turns it into 'HELLO THERE'
split_string applies .split() β†’ gives ['HELLO', 'THERE']

πŸ“Œ Summary:

Feature Purpose
functools.wraps Keeps the original function name and docstring
.split() Converts a string into a list of words

 

πŸ“ Note: What Does @functools.wraps(function) Mean?
When you use a decorator, it wraps your original function with a new function (usually called wrapper), and that causes it to lose metadata like:

  • __name__ (function’s name)
  • __doc__ (docstring)
  • __annotations__ (type hints)

πŸ” Without @functools.wraps:

def decorator(func):
    def wrapper():
        return func()
    return wrapper

@decorator
def say_hi():
    """Says hi"""
    return "hi"

print(say_hi.__name__)  # Output: wrapper
print(say_hi.__doc__)   # Output: None

βœ… With @functools.wraps:

import functools

def decorator(func):
    @functools.wraps(func)
    def wrapper():
        return func()
    return wrapper

@decorator
def say_hi():
    """Says hi"""
    return "hi"

print(say_hi.__name__)  # Output: say_hi
print(say_hi.__doc__)   # Output: Says hi

πŸ“ In Simple Words:
@functools.wraps(original_function) means:
“Make this wrapper look like the original function.”

 

πŸ“ Note: What is Metadata?
Metadata is “data about data”. In Python, it refers to information about a function itself β€” not what it does, but what it is.

🧾 Common Function Metadata:

Metadata Attribute What It Represents Example Output
__name__ The function’s name ‘say_hi’
__doc__ The function’s description ‘Returns a greeting’
__module__ The module where it’s defined ‘__main__’
__annotations__ Type hints (if any) {‘x’: int, ‘return’: str}

βœ… Example:

def greet():
    """This function says hello."""
    return "Hello!"

print(greet.__name__)   # Output: greet
print(greet.__doc__)    # Output: This function says hello.

⚠️ Why It Matters with Decorators:
Decorators can overwrite this metadata unless you use @functools.wraps to preserve it.

πŸ“Œ Summary:
Metadata = Information about the function itself (name, docstring, etc).
@functools.wraps keeps that information intact.

 

πŸ“Œ Accepting Arguments in Decorators

Sometimes, you may want to pass arguments into your decorators. To achieve this, you can define a decorator that accepts arguments by creating a decorator factory.

🧩 Example of a Decorator with Arguments:

def decorator_with_arguments(function):
    def wrapper_accepting_arguments(arg1, arg2):
        print(f"My arguments are: {arg1}, {arg2}")
        return function(arg1, arg2)
    return wrapper_accepting_arguments

@decorator_with_arguments
def cities(city_one, city_two):
    print(f"Cities I love are {city_one} and {city_two}")

cities("Nairobi", "Accra")

Output:

My arguments are: Nairobi, Accra
Cities I love are Nairobi and Accra

πŸ’‘ This allows the decorator to work with arguments dynamically passed to the decorated function.

 

πŸ“Œ **General-Purpose Decorators with *args and kwargs

To create a general-purpose decorator, you can use *args and **kwargs to accept any number of arguments and keyword arguments, making your decorator flexible and reusable.

🧩 Example of a General-Purpose Decorator:

def general_decorator(function):
    def wrapper(*args, **kwargs):
        print(f"Positional arguments: {args}")
        print(f"Keyword arguments: {kwargs}")
        return function(*args, **kwargs)
    return wrapper

@general_decorator
def greet(name, age):
    return f"Hello {name}, you are {age} years old."

print(greet("John", age=30))

πŸ’‘ Output:

Positional arguments: ('John',)
Keyword arguments: {'age': 30}
Hello John, you are 30 years old.

πŸ’‘ This decorator accepts any number of arguments and keywords, making it highly reusable.

 

πŸ“Œ Debugging Decorators

One challenge with decorators is that they can obscure the original function’s metadata (like its name or docstring). To solve this, Python provides functools.wraps() to preserve the original function’s metadata when decorating it.

🧩 Example of Debugging with functools.wraps():

import functools

def uppercase_decorator(func):
    @functools.wraps(func)
    def wrapper():
        return func().upper()
    return wrapper

@uppercase_decorator
def greet():
    "This will say hi"
    return 'hello there'

print(greet())  # Output: HELLO THERE
print(greet.__name__)  # Output: greet
print(greet.__doc__)  # Output: This will say hi

πŸ’‘This ensures the decorated function retains its original metadata.

 

πŸ“Œ Class-Based Decorators

Class-based decorators offer more flexibility and maintainability, especially for more complex use cases. In a class-based decorator, you define a class with aΒ  __call__Β  method, which allows the class to behave like a function.

🧩 Example of a Class-Based Decorator:

class UppercaseDecorator:
    def __init__(self, function):
        self.function = function

    def __call__(self, *args, **kwargs):
        result = self.function(*args, **kwargs)
        return result.upper()

@UppercaseDecorator
def greet():
    return "hello there"

print(greet())  # Output: HELLO THERE

πŸ’‘ Class-based decorators can also maintain state, making them more powerful than function-based decorators in some scenarios.

 

πŸ“Œ Real-World Decorator Use Case: Caching

@lru_cache is a decorator from the functools module that allows Python to cache (remember) the results of function calls. It improves performance by storing the output of expensive or frequently repeated function calls. This is especially useful for functions with repetitive calls, like in the Fibonacci sequence calculation.

🧩 Example: Caching with lru_cache:

from functools import lru_cache

@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(50))  # Subsequent calls with the same argument are faster #output: 12586269025

πŸ’‘ This significantly improves performance by caching results of repeated function calls.

πŸ“ Note: @lru_cache and Memoization in Python

πŸ”Ή Use Case:
It’s especially useful in recursive functions like calculating Fibonacci numbers, where the same input is repeatedly calculated.

πŸ” How It Works:

  • The first time fibonacci(50) runs, it computes all values recursively.
  • Each computed value is cached.
  • Future calls with the same arguments return results instantly from the cache.

πŸ”§ Parameter:

  • maxsize=128: stores up to 128 recent results. Older ones are discarded if the cache gets full.

 

πŸ“Œ Summary Table:

Feature What It Does
@lru_cache Remembers (caches) function results to avoid recalculating
maxsize=128 Stores up to 128 recent calls
Best for Recursive or computationally expensive functions

 

πŸ“Œ Python Decorators Tutorial Summary

Python decorators are a powerful feature that allows you to extend the behavior of functions or methods without changing their core logic. They provide a way to write clean, reusable, and maintainable code. Decorators have a wide range of applications, such as logging, input validation, authorization, performance measurement, and more.

By mastering Python decorators, you can write more elegant and efficient Python code while following the DRY (Don’t Repeat Yourself) principle.

πŸ“Source: Datacamp