Python Intermediate

Python Decorators: From Basics to Advanced

Understand Python decorators from scratch. Learn to write your own decorators for logging, authentication, caching, and more.

DjangoZen Team Mar 29, 2026 4 min read 25 views

Decorators are one of Python's most elegant features. They let you modify or extend functions and classes without changing their source code. If you've ever used @login_required in Django or @property in Python, you've already used decorators.

Understanding Functions as Objects

In Python, functions are first-class objects. You can assign them to variables, pass them as arguments, and return them from other functions:

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

# Assign function to a variable
say_hello = greet
print(say_hello("Django"))  # "Hello, Django!"

# Pass function as argument
def call_twice(func, arg):
    func(arg)
    func(arg)

call_twice(greet, "World")

Your First Decorator

A decorator is a function that takes a function as input and returns a modified version of it:

def uppercase_decorator(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

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

print(greet("Django"))  # "HELLO, DJANGO"
How it works: The @uppercase_decorator syntax is equivalent to writing greet = uppercase_decorator(greet). It replaces the original function with the wrapper.

Practical Decorator: Timing

Measure how long a function takes to execute:

import time
import functools

def timer(func):
    @functools.wraps(func)
    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 process_data(n):
    return sum(range(n))

process_data(1_000_000)  # "process_data took 0.0312s"
Always use @functools.wraps in your decorators! Without it, the decorated function loses its original name, docstring, and other metadata. This is especially important in Django, where debugging tools rely on function names.

Decorator with Arguments

To create a decorator that accepts arguments, you need an extra layer of nesting:

import functools

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

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

say_hello()
# Hello!
# Hello!
# Hello!

Practical Examples: Logging, Caching, Auth

Logging Decorator

import functools
import logging

logger = logging.getLogger(__name__)

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        logger.info(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        logger.info(f"{func.__name__} returned {result}")
        return result
    return wrapper

Simple Cache Decorator

def cache(func):
    "Simple memoization decorator"
    _cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in _cache:
            _cache[args] = func(*args)
        return _cache[args]
    return wrapper

@cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))  # Instant! Without cache this would take forever
Python built-in: Python 3.9+ has @functools.cache and @functools.lru_cache built in. Use those instead of writing your own for production code.

Decorators in Django

Django uses decorators extensively. Here are the most common ones:

from django.contrib.auth.decorators import login_required, permission_required
from django.views.decorators.http import require_http_methods
from django.views.decorators.cache import cache_page

@login_required
def dashboard(request):
    # Only authenticated users can access
    return render(request, 'dashboard.html')

@permission_required('app.can_edit')
def edit_item(request, pk):
    # Only users with 'can_edit' permission
    ...

@require_http_methods(["GET", "POST"])
def contact(request):
    # Only GET and POST allowed
    ...

@cache_page(60 * 15)  # Cache for 15 minutes
def product_list(request):
    # Response cached, no DB hit for 15 minutes
    ...

Stacking Decorators

You can apply multiple decorators to a single function. They execute from bottom to top:

@login_required            # 2. Then check authentication
@require_http_methods(["POST"])  # 1. First check HTTP method
def delete_item(request, pk):
    ...

Class Decorators

Decorators can also modify classes:

import functools

def singleton(cls):
    "Ensure only one instance of a class exists"
    instances = {}
    @functools.wraps(cls)
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class DatabaseConnection:
    def __init__(self):
        print("Connecting to database...")

db1 = DatabaseConnection()  # "Connecting to database..."
db2 = DatabaseConnection()  # No output - returns same instance
print(db1 is db2)          # True

Summary

Decorators are a powerful pattern for adding reusable behavior to functions and classes:

  • Use @functools.wraps to preserve function metadata
  • Use *args, **kwargs to make decorators work with any function signature
  • Add arguments with an extra wrapper layer
  • Django uses decorators extensively for auth, caching, and HTTP methods
  • Stack decorators when you need multiple behaviors