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
@propertyturns a method into something accessed like an attribute (obj.valueinstead ofobj.value()) — useful for computed values that should look like plain data from the caller’s perspective.@staticmethodand@classmethodchange how a method receives its first argument — no implicitself, 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.fixturein 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.