Skip to content
Python Decorators — Explained with Examples

Python Decorators — Explained with Examples

DodaTech Updated Jun 15, 2026 6 min read

A Python decorator is a function that takes another function and extends its behavior without modifying the original code. Decorators use the @ syntax and are a powerful form of metaprogramming.

What You’ll Learn

  • How functions are first-class objects in Python
  • The @ syntax and how decorators work under the hood
  • functools.wraps to preserve metadata
  • Decorators with arguments, class decorators, and built-in decorators
  • Real-world example: building a timing decorator

Why Decorators Matter

Durga Antivirus Pro uses decorators to log every scan operation, measure scan duration, and cache results. DodaZIP decorates compression functions to add progress bars and error logging. Without decorators, you’d repeat boilerplate code in every function — logging, timing, access control, caching. Decorators let you write that logic once and apply it anywhere.

    flowchart LR
    A["OOP"] --> B["Decorators"]
    B --> C["Generators"]
    C --> D["Context Managers"]
    D --> E["Async"]
    A:::done --> B:::current
    style A fill:#2563eb,stroke:#2563eb,color:#fff
    style B fill:#2563eb,stroke:#2563eb,color:#fff
    style C fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style E fill:#f1f5f9,stroke:#94a3b8,color:#64748b
  
Prerequisite: Understand Python functions (first-class, nested, closures) and basic OOP. Review https://tutorials.dodatech.com/programming-languages/python/py-functions/ if needed.

Functions Are First-Class Objects

In Python, functions are objects — you can assign them to variables, pass them as arguments, and return them:

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

say_hello = greet          # Assign to variable
print(say_hello("Alice"))  # Hello, Alice!

def call_twice(func, arg):
    return func(arg), func(arg)

print(call_twice(greet, "Bob"))  # ('Hello, Bob!', 'Hello, Bob!')

This first-class nature is what makes decorators possible.

How a Decorator Works

A decorator is just a function that wraps another function:

# Manual decoration (without @)
def make_uppercase(func):
    def wrapper(text: str) -> str:
        result = func(text)
        return result.upper()
    return wrapper

def greet(name: str) -> str:
    return f"Hello, {name}"

greet = make_uppercase(greet)
print(greet("Alice"))  # HELLO, ALICE!

The @ syntax is sugar for greet = make_uppercase(greet):

def make_uppercase(func):
    def wrapper(text: str) -> str:
        result = func(text)
        return result.upper()
    return wrapper

@make_uppercase
def greet(name: str) -> str:
    return f"Hello, {name}"

print(greet("Alice"))  # HELLO, ALICE!

Preserving Metadata with functools.wraps

Without wraps, the decorated function loses its name and docstring:

def decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@decorator
def say_hello():
    """Says hello."""
    pass

print(say_hello.__name__)  # wrapper (not say_hello!)
print(say_hello.__doc__)   # None

Fix with @functools.wraps:

import functools

def decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@decorator
def say_hello():
    """Says hello."""
    pass

print(say_hello.__name__)  # say_hello
print(say_hello.__doc__)   # Says hello.

Always use @functools.wraps in your decorators.

Real-World Example: Timing Decorator

import functools
import time

def timer(func):
    """Print how long a function takes to run."""
    @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 slow_function():
    time.sleep(0.5)
    return "Done"

print(slow_function())
# slow_function took 0.5002s
# Done

Decorators with Arguments

Sometimes you want to pass arguments to your decorator. Add an extra layer:

import functools

def repeat(times: int = 2):
    """Run the decorated function `times` 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 greet(name: str) -> str:
    print(f"Hello, {name}!")
    return f"Hello, {name}!"

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

Without ()@repeat without arguments — the decorator is applied with default times=2.

Built-in Decorators

@staticmethod and @classmethod

class Calculator:
    @staticmethod
    def add(a: int, b: int) -> int:
        return a + b

    @classmethod
    def from_string(cls, expr: str):
        a, op, b = expr.split()
        if op == "+":
            return cls.add(int(a), int(b))
        raise ValueError(f"Unknown operator: {op}")

print(Calculator.add(3, 4))          # 7
print(Calculator.from_string("3 + 4"))  # 7

@property

Already covered in the Python https://tutorials.dodatech.com/programming-languages/python/py-oop/ tutorial — turns a method into a read-only attribute with optional setter.

Class Decorators

You can decorate entire classes too:

def add_repr(cls):
    """Add a __repr__ method to a class."""
    def __repr__(self) -> str:
        items = ", ".join(f"{k}={v}" for k, v in self.__dict__.items())
        return f"{cls.__name__}({items})"
    cls.__repr__ = __repr__
    return cls

@add_repr
class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

p = Point(3, 4)
print(p)  # Point(x=3, y=4)

Common Mistakes

1. Forgetting to Call functools.wraps

Without it, debugging becomes painful — all decorated functions appear as wrapper.

2. Forgetting the Inner Function in Parametrized Decorators

def decorator(arg):   # This runs at decoration time
    # Missing inner decorator function!
    def wrapper(*args, **kwargs):
        pass
    return wrapper

Fix: Always have three levels: outer(arg) -> decorator(func) -> wrapper(*args, **kwargs).

3. Applying a Non-Callable as a Decorator

@42  # TypeError: 'int' object is not callable
def f():
    pass

4. Modifying Mutable Arguments Across Calls

If your decorator stores state in a mutable default (like []), all decorated functions share the same state.

5. Wrapping a Class Method Without @functools.wraps

This breaks self binding. Always wrap and preserve the signature.

Practice Questions

1. What does @timer above do when applied to a function?
It prints how long the function took and returns the original result.

2. Why does wrapper.__name__ become "wrapper" without @functools.wraps?
Because wrapper is the actual function object returned by the decorator. wraps copies __name__, __doc__, etc. from the original.

3. Fix this decorator:

def log(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper  # OK, but missing @functools.wraps

4. Write a decorator that returns "Access Denied" if the first argument is not "admin".

def require_admin(func):
    @functools.wraps(func)
    def wrapper(user, *args, **kwargs):
        if user != "admin":
            return "Access Denied"
        return func(user, *args, **kwargs)
    return wrapper

Challenge: Build a retry(max_attempts=3) decorator that retries a function if it raises an exception.

Solution
import functools
import time

def retry(max_attempts: int = 3, delay: float = 0.5):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    print(f"Attempt {attempt} failed, retrying...")
                    time.sleep(delay)
            return None
        return wrapper
    return decorator

Mini Project: Logging and Timing Decorators

import functools
import time
import logging

logging.basicConfig(level=logging.INFO)

def log_and_time(func):
    """Log function calls and measure execution time."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        args_repr = ", ".join(repr(a) for a in args)
        kwargs_repr = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
        all_args = ", ".join(filter(None, [args_repr, kwargs_repr]))
        logging.info(f"Calling {func.__name__}({all_args})")

        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start

        logging.info(f"{func.__name__} returned {result!r} in {elapsed:.4f}s")
        return result
    return wrapper

@log_and_time
def compute_square(n: int) -> int:
    return n * n

print(compute_square(42))
# INFO:root:Calling compute_square(42)
# INFO:root:compute_square returned 1764 in 0.0000s
# 1764

Expected output: Log lines followed by the result.

What’s Next

Decorators open the door to advanced Python patterns. Next, learn generators for lazy evaluation and context managers for resource cleanup.

TopicDescriptionLink
Python GeneratorsYield and lazy iterationhttps://tutorials.dodatech.com/programming-languages/python/py-generators/
Python Context ManagersWith-statement patternshttps://tutorials.dodatech.com/programming-languages/python/py-context-managers/
OOP glossaryOOP terminology referenceOOP

Practice tip: Combine decorators with your BankAccount class from the OOP tutorial — add @log_and_time to every method. Notice how decorators let you add cross-cutting concerns without touching existing code.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro