Python Decorators Explained: Practical Guide

Updated on: May 21, 2026
Reading time: 8 minutes

Python decorators are one of those features that make the language feel elegant once they finally click. At first, the @decorator syntax can look like magic. In reality, decorators are just a clean way to wrap one function with another function so you can add behavior before, after, or around the original call without changing the original function body.

This guide is an English-first adaptation for developers who want a practical explanation of decorators, not a literal translation. You will learn what decorators are, how wrapper functions work, why functools.wraps matters, how to pass arguments through decorators, when class-based decorators make sense, and which mistakes to avoid. If you are still building the foundation, start with this guide to functions in Python before going deeper.

What Is a Python Decorator?

A decorator is a callable that takes another function and returns a new function, usually with extra behavior. In simple terms, it lets you say: “run this function, but add something around it.” That extra behavior might be logging, timing, authentication, caching, validation, retries, permissions, or formatting. The official Python glossary describes a decorator as a function returning another function, usually applied with the @ syntax.

The key idea is that functions in Python are first-class objects. You can store them in variables, pass them as arguments, return them from other functions, and define functions inside functions. Decorators combine all of these ideas. If that sounds advanced, review variable scope in Python and Python args and kwargs; both concepts appear frequently when writing decorators.

A Decorator Without the @ Syntax

Before using the familiar @ syntax, it helps to see what decorators do manually. Suppose you have a simple function that says hello. You can create another function that receives it, defines a wrapper, and returns that wrapper. The wrapper can run code before and after the original function.

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


def add_border(function):
    def wrapper():
        print("=" * 20)
        function()
        print("=" * 20)
    return wrapper


say_hello = add_border(say_hello)
say_hello()

After the assignment, say_hello no longer points directly to the original function. It points to the wrapper returned by add_border. When you call say_hello(), the wrapper runs, prints a border, calls the original function, and prints another border. That is the core mechanic behind decorators.

The Same Example with @decorator

The @ syntax is just a cleaner way to apply the same transformation. Instead of assigning the function manually, you place the decorator above the function definition. Python applies the decorator when the function is defined.

def add_border(function):
    def wrapper():
        print("=" * 20)
        function()
        print("=" * 20)
    return wrapper


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


say_hello()

This is equivalent to writing say_hello = add_border(say_hello). The decorator syntax does not introduce a new kind of function. It simply makes the intention clearer. You are declaring that say_hello should be wrapped by add_border.

Why Decorators Are Useful

Decorators are useful because they separate cross-cutting behavior from business logic. Imagine several functions that need logging. You could write logging code inside each function, but that creates repetition. A decorator lets you define logging once and apply it wherever you need it. The original functions stay focused on what they are supposed to do.

This is especially useful in web frameworks, testing tools, command-line apps, data pipelines, and APIs. Frameworks like Flask use decorators to map functions to URLs. Testing frameworks use decorators to mark tests. Caching utilities use decorators to store previous results. If you have read about Flask in Python or FastAPI, you have already seen decorator-style APIs in practice.

Passing Arguments Through a Decorator

The first decorator examples often fail when the decorated function needs arguments. The fix is to write the wrapper with *args and **kwargs. That allows the wrapper to accept any positional and keyword arguments, then forward them to the original function.

def trace(function):
    def wrapper(*args, **kwargs):
        print(f"Calling {function.__name__}")
        result = function(*args, **kwargs)
        print("Finished")
        return result
    return wrapper


@trace
def multiply(a, b):
    return a * b


print(multiply(4, 5))

This pattern is everywhere in real Python code. Without *args and **kwargs, your decorator would only work for functions with one specific signature. With argument forwarding, the decorator becomes reusable. This is one reason decorators are often taught after functions, scope, and flexible arguments.

Why functools.wraps Matters

A basic wrapper changes the metadata of the decorated function. For example, the function name may appear as wrapper instead of the original name. That can confuse debugging tools, documentation generators, logs, and introspection. Python’s standard library provides functools.wraps to preserve important metadata from the original function.

from functools import wraps


def trace(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        print(f"Calling {function.__name__}")
        return function(*args, **kwargs)
    return wrapper


@trace
def greet(name):
    """Return a greeting message."""
    return f"Hello, {name}!"


print(greet.__name__)
print(greet.__doc__)

Using @wraps(function) is a professional habit. It keeps the decorated function easier to inspect and debug. The official functools.wraps documentation explains how it updates wrapper metadata based on the original callable.

A Practical Timing Decorator

One of the most common examples is a timing decorator. It measures how long a function takes to run. This is useful for learning and quick diagnostics, although serious performance work should use profilers. If you want a deeper workflow for performance investigation, read this guide on finding Python bottlenecks with cProfile.

from functools import wraps
from time import perf_counter


def measure_time(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        start = perf_counter()
        result = function(*args, **kwargs)
        end = perf_counter()
        print(f"{function.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper


@measure_time
def slow_sum(limit):
    total = 0
    for number in range(limit):
        total += number
    return total


slow_sum(1_000_000)

This decorator does not change what slow_sum returns. It only adds measurement around the call. That is the ideal style for many decorators: add behavior without making the original function harder to understand.

Decorators with Arguments

Sometimes the decorator itself needs configuration. For example, you may want a retry decorator that accepts the number of attempts. This requires one extra layer: a function that receives the decorator arguments, returns the actual decorator, and then returns the wrapper.

from functools import wraps


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


@repeat(times=3)
def say_hi():
    print("Hi!")


say_hi()

This nested structure can look intimidating, but each layer has a job. The outer function receives configuration. The middle function receives the original function. The inner wrapper runs when the decorated function is called. Once you see those layers separately, decorators with arguments become much easier to read.

Caching with Decorators

Decorators are not only for custom code. Python’s standard library includes decorator-based utilities. One of the most useful is @lru_cache, which memoizes function results. If a function receives the same arguments again, Python can return the cached result instead of recomputing it.

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(40))
print(fibonacci.cache_info())

Caching is powerful when the same expensive calculation happens repeatedly. It is not appropriate for functions with changing external state or side effects. For a detailed explanation, see this guide on how to speed up Python with lru_cache.

Class-Based Decorators

Most decorators are functions, but classes can also act as decorators if they implement __call__. A class-based decorator can be useful when you need to store state across calls or organize more complex behavior. This is less common for beginners, but it is useful to recognize when reading advanced Python code.

class CountCalls:
    def __init__(self, function):
        self.function = function
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Call number {self.count}")
        return self.function(*args, **kwargs)


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


greet("Ana")
greet("Bruno")

This works because the decorated name now points to an instance of CountCalls, and that instance can be called like a function. If classes and special methods are still unfamiliar, review object-oriented Python before using class-based decorators in production.

Common Mistakes with Decorators

The first common mistake is forgetting to return the result of the original function. If the wrapper calls the original function but does not return its value, the decorated function may unexpectedly return None. This is especially dangerous when decorating functions that calculate values, query databases, or return API responses.

The second mistake is forgetting *args and **kwargs, which makes the decorator fail for functions with parameters. The third mistake is forgetting functools.wraps, which damages metadata and makes debugging harder. The fourth mistake is putting too much hidden behavior inside a decorator. A decorator should make repeated behavior cleaner, not hide business logic that future developers need to understand.

Another mistake is using decorators too early. If the repeated behavior appears only once, a decorator may be unnecessary. If the behavior appears across many functions, a decorator can make the codebase more consistent. The decision is a design tradeoff. Readability should matter as much as cleverness. This connects to broader Python best practices.

When Should You Use Decorators?

Use decorators when the same behavior needs to wrap several functions. Good examples include logging, timing, authentication checks, permission validation, caching, retries, deprecation warnings, input validation, transaction handling, and route registration. Decorators work best when the extra behavior is orthogonal to the function’s main purpose.

Do not use decorators just to look advanced. They add indirection, and indirection has a cost. Someone reading the function must also understand what the decorator does. If a decorator changes arguments, catches exceptions silently, changes return types, or talks to external systems, document that behavior clearly. Type hints can also help make decorated functions easier to reason about; this Python type hints guide is a useful complement.

Final Checklist

Remember the core idea: a decorator receives a function and returns a new callable. Use @decorator as shorthand for assigning the decorated function back to the same name. Use *args and **kwargs to forward arguments. Use functools.wraps to preserve metadata. Return the original function’s result unless you intentionally want to change it. Use decorators for repeated cross-cutting behavior, not as a way to hide complicated logic.

Once you understand decorators, a lot of Python code becomes easier to read. Flask routes, caching utilities, testing markers, permission checks, and timing helpers all start to make more sense. The syntax may look compact, but the mechanism is straightforward: wrap a function, add behavior, and keep the original code clean.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Uso do super em Python para resolver problemas de herança
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Use super() in Python and Fix Inheritance Errors

    Learn how to use super() in Python for parent methods, multiple inheritance, MRO, *args, **kwargs, and cleaner object-oriented code.

    Ler mais

    Tempo de leitura: 8 minutos
    19/05/2026
    Criando instalador EXE com ícone personalizado em Python
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    How to Create a exe Installer with a Custom Icon in Python

    Learn how to package any Python script into a standalone .exe file with a custom icon using PyInstaller. Step-by-step guide

    Ler mais

    Tempo de leitura: 11 minutos
    09/05/2026