Python Decorators Explained: What They Are and When to Write One

How Python decorators actually work under the hood, with the built-in decorators you already use and a guide to writing your own

Decorators are one of those Python features developers use constantly (@property, @app.get("/"), @pytest.fixture) long before understanding what they actually do. The mechanism underneath is simpler than it looks.

What a decorator actually is

A decorator is a function that takes another function as input and returns a new function — usually one that wraps the original with extra behavior before or after it runs. @decorator above a function definition is exactly equivalent to writing function = decorator(function) right after it.

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

@shout
def greet(name):
    return f"hello, {name}"

greet("world")  # "HELLO, WORLD"

@shout isn’t special syntax with unique behavior — it’s shorthand for greet = shout(greet). Once you see that equivalence, decorators stop being magic.

Decorators you already use

  • @property turns a method into something accessed like an attribute (obj.value instead of obj.value()) — useful for computed values that should look like plain data from the caller’s perspective.
  • @staticmethod and @classmethod change how a method receives its first argument — no implicit self, or the class itself instead.
  • @app.get("/path") in FastAPI registers a function as the handler for that route — a decorator that takes arguments, one level more complex than the basic example above.
  • @pytest.fixture in pytest marks a function as reusable test setup that other tests can request by name.

Writing a decorator that takes arguments

The @app.get("/path") pattern requires an extra layer — a function that returns a decorator, rather than being one directly:

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                func(*args, **kwargs)
        return wrapper
    return decorator

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

repeat(times=3) runs first and returns decorator, which is then applied to say_hi exactly like the simple case above — it’s the same mechanism with one more function call in front of it.

When to write your own

Reach for a decorator when the same wrapping logic — logging, timing, caching, access checks, retries — needs to apply to multiple functions without repeating the wrapping code in each one. For a single one-off case, a decorator usually adds more indirection than it saves; the pattern earns its complexity at three or more call sites, not one.

TopicsLanguage FeaturesExplainerIntermediate