Python Decorators — A Quick Guide

What is a decorator?

A decorator is a function that wraps another function to add extra behavior without changing the original function's code. Think of it like gift wrapping: the gift (your function) stays the same, but the wrapping (the decorator) adds something extra around it — logging, timing, access checks, caching, etc.

Under the hood, a decorator just takes a function as input and returns a new function. The @decorator_name syntax above a function definition is "syntactic sugar" for:

say_hello = my_decorator(say_hello)

That's it. Once you see decorators as "a function that takes a function and returns a function," the rest is just patterns.


A simple, fully-commented example

import time
from functools import wraps


def timer(func):
    """
    This is our decorator. It takes a function (func) and returns
    a new function (wrapper) that adds timing behavior around it.
    """

    @wraps(func)  # Preserves func's name/docstring (best practice, see below)
    def wrapper(*args, **kwargs):
        # --- Code here runs BEFORE the original function ---
        start = time.perf_counter()

        # Call the original function and store its result
        result = func(*args, **kwargs)

        # --- Code here runs AFTER the original function ---
        end = time.perf_counter()
        print(f"{func.__name__} took {end - start:.4f} seconds")

        # Always return the original result so behavior isn't broken
        return result

    return wrapper


@timer
def slow_add(a, b):
    """Adds two numbers, slowly (for demonstration)."""
    time.sleep(1)
    return a + b


# Because of @timer, calling slow_add() actually calls wrapper(),
# which calls the real slow_add() inside it.
result = slow_add(3, 4)
print(result)

# Output:
# slow_add took 1.0002 seconds
# 7

Why *args, **kwargs?

The wrapper function needs to accept any arguments, because it might be wrapping a function with a completely different signature. *args catches positional arguments, **kwargs catches keyword arguments, and both get passed straight through to the original function.

Why @wraps(func)?

Without it, slow_add.__name__ would become "wrapper" and its docstring would be lost — because wrapper is technically what got returned. @wraps(func) (from functools) copies over the original function's name, docstring, and other metadata, which matters a lot for debugging, tooling, and documentation.


Decorators with their own arguments

Sometimes you want the decorator itself to be configurable, e.g. @repeat(3). That needs one more layer of nesting:

def repeat(times):
    """Outer function: takes the decorator's own arguments."""

    def decorator(func):
        """Middle function: takes the function being decorated."""

        @wraps(func)
        def wrapper(*args, **kwargs):
            """Inner function: actually runs each time the function is called."""
            for _ in range(times):
                result = func(*args, **kwargs)
            return result

        return wrapper

    return decorator


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


greet("Sam")
# Output:
# Hello, Sam!
# Hello, Sam!
# Hello, Sam!

Three layers, three jobs:

  1. repeat(times) — captures the decorator's own argument
  2. decorator(func) — captures the function being wrapped
  3. wrapper(*args, **kwargs) — runs on every actual call

Common built-in decorators you'll see immediately

  • @staticmethod — method that doesn't take self
  • @classmethod — method that takes cls instead of self
  • @property — lets a method be accessed like an attribute
  • @functools.lru_cache — caches return values for given inputs
  • @functools.wraps — used inside your own decorators (see above)

Mental model / rules of thumb

  1. A decorator is just a function that returns a function.
  2. @my_decorator above a function is shorthand for func = my_decorator(func).
  3. Always use *args, **kwargs in your wrapper unless you know exactly what arguments you're intercepting.
  4. Always use @functools.wraps(func) inside your decorator so introspection (__name__, __doc__, help(), debuggers) still works correctly.
  5. Decorators run once, at definition/import time — that's when func = my_decorator(func) happens. The wrapper code inside runs every time the decorated function is actually called. This trips people up constantly — keep it straight.
  6. You can stack multiple decorators — they apply bottom-up:
    @decorator_a
    @decorator_b
    def f(): ...
    # equivalent to: f = decorator_a(decorator_b(f))
    

Further reading

Good next exercises for practice

  • Write a @debug decorator that prints the arguments and return value of every call.
  • Write a @retry(n) decorator that retries a function up to n times if it raises an exception.
  • Look at how Flask/FastAPI use decorators (@app.route(...)) to register functions — a great real-world example of decorators with arguments.

Python Dunder Methods Cheat Sheet

Object Lifecycle

| Method | Purpose | |---|---| | __new__(cls, ...) | Creates and returns the bare instance. Runs before __init__. Rarely overridden. | | __init__(self, ...) | Initializes the instance after creation. The one everyone uses. | | __del__(self) | Called when the object is garbage collected. Rarely needed. |

Representation

| Method | Purpose | Triggered by | |---|---|---| | __repr__(self) | Unambiguous, developer-facing string | repr(obj), console echo, debugger | | __str__(self) | Readable, user-facing string | str(obj), print(obj) | | __format__(self, spec) | Custom string formatting | f"{obj:spec}", format(obj) | | __bytes__(self) | Byte-string representation | bytes(obj) |

Callable & Container Behavior

| Method | Purpose | Triggered by | |---|---|---| | __call__(self, ...) | Makes the instance callable like a function | obj() | | __len__(self) | Length | len(obj) | | __getitem__(self, key) | Index/key access | obj[key] | | __setitem__(self, key, val) | Index/key assignment | obj[key] = val | | __delitem__(self, key) | Index/key deletion | del obj[key] | | __contains__(self, item) | Membership test | item in obj |

Iteration

| Method | Purpose | Triggered by | |---|---|---| | __iter__(self) | Returns an iterator | for x in obj, [x for x in obj], iter(obj) | | __next__(self) | Returns next value, raises StopIteration when done | next(obj) | | __reversed__(self) | Reverse iteration | reversed(obj) |

Comparison

| Method | Operator | |---|---| | __eq__(self, other) | == | | __ne__(self, other) | != | | __lt__(self, other) | < | | __le__(self, other) | <= | | __gt__(self, other) | > | | __ge__(self, other) | >= | | __hash__(self) | hash(obj) — needed if the object should be usable as a dict key / in a set | | __bool__(self) | bool(obj), truthiness in if obj: |

Arithmetic

| Method | Operator | |---|---| | __add__(self, other) | + | | __sub__(self, other) | - | | __mul__(self, other) | * | | __truediv__(self, other) | / | | __floordiv__(self, other) | // | | __mod__(self, other) | % | | __pow__(self, other) | ** | | __neg__(self) | unary -obj | | __abs__(self) | abs(obj) | | __iadd__(self, other) | += (in-place; falls back to __add__ if absent) | | __radd__(self, other) | right-side + (e.g. 5 + obj when obj is on the right) |

Attribute Access

| Method | Purpose | Triggered by | |---|---|---| | __getattr__(self, name) | Called only when normal lookup fails | obj.missing_attr | | __getattribute__(self, name) | Called for every attribute access (rarely overridden — easy to break things) | obj.any_attr | | __setattr__(self, name, val) | Intercepts attribute assignment | obj.attr = val | | __delattr__(self, name) | Intercepts attribute deletion | del obj.attr |

Context Managers

| Method | Purpose | Triggered by | |---|---|---| | __enter__(self) | Setup, returns value bound by as | with obj as x: | | __exit__(self, exc_type, exc_val, tb) | Cleanup, can suppress exceptions | end of with block |

Class-Level / Metaprogramming

| Method | Purpose | |---|---| | __class__ | The instance's class (attribute, not a method) | | __subclasshook__(cls, subclass) | Customize issubclass() checks | | __init_subclass__(cls) | Hook that runs when a subclass is defined | | __set_name__(self, owner, name) | Runs when a descriptor is assigned a name in a class body |

Quick Reference: Your Bacon / DataRange Examples

class Bacon:
    def __init__(self, name):   # init on creation
        self.name = name
    def __call__(self):          # make instance callable
        print(self.name)

class DataRange:
    def __iter__(self):          # enable `for` and list comprehensions
        return iter(self.data)
    def __len__(self):            # enable len()
        return len(self.data)
    def __repr__(self):           # enable clean printing
        return f"DataRange({self.data})"

Rule of thumb: you rarely need more than a handful of these in any one class. __init__, __repr__, __eq__, and __iter__/__len__ cover the vast majority of real-world use.