TechJanuary 26, 20264 min read

Python Decorators Explained with Real Examples

Understand Python decorators finally — functions as objects, the @ syntax, arguments, functools.wraps, and the decorators you'll actually write.

Galvan

Galvan

Founder & Creator

Introduction

Every Python developer meets decorators early — @st.cache_data, @app.route, @property — and most tutorials respond with syntax before concepts, which is why decorators stay mysterious. The truth: a decorator is just a function that takes a function and returns a new function. Everything else follows from that one sentence and one prerequisite idea.

This guide builds decorators from first principles, then shows the four you'll actually write in real projects.

The Prerequisite: Functions Are Objects

In Python, a function is a value like any other — you can assign it, pass it, return it:

code
def shout(text):
    return text.upper() + "!"

speak = shout          # no parentheses — we pass the function itself
print(speak("hello"))  # HELLO!


def run_twice(fn):
    fn()
    fn()

run_twice(lambda: print("hi"))   # hi\nhi

Functions accepting functions and returning functions isn't exotic — it's how the Streamlit timer callbacks and every event-driven app on this site work.

Building a Decorator by Hand

Say you want to time any function. Without decorators:

code
import time

def slow_add(a, b):
    time.sleep(1)
    return a + b

start = time.time()
slow_add(1, 2)
print(f"took {time.time() - start:.1f}s")   # repeated for every function... ugh

With a decorator — a function that wraps functions:

code
import time
import functools

def timed(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = fn(*args, **kwargs)
        print(f"{fn.__name__} took {time.time() - start:.2f}s")
        return result
    return wrapper

@timed
def slow_add(a, b):
    time.sleep(1)
    return a + b

slow_add(1, 2)   # slow_add took 1.00s -> 3

@timed is pure sugar for slow_add = timed(slow_add). The name slow_add now points at wrapper, which adds timing around calls to the original. That's the entire trick.

The Three Non-Negotiable Details

  • `*args, kwargs** — the wrapper accepts anything and forwards it, so timed` decorates functions with any signature.
  • `return result` — forget this and every decorated function silently returns None. The #1 decorator bug.
  • `@functools.wraps(fn)` — copies the original's name and docstring onto the wrapper. Without it, help(slow_add) shows wrapper's metadata and debuggers get confused.
  • Decorators with Arguments

    @cache_data(ttl=3600) has *arguments* — which means it's a function returning a decorator returning a wrapper. Three layers:

    code
    def retry(times):
        def decorator(fn):
            @functools.wraps(fn)
            def wrapper(*args, **kwargs):
                for attempt in range(1, times + 1):
                    try:
                        return fn(*args, **kwargs)
                    except Exception as e:
                        print(f"Attempt {attempt} failed: {e}")
                raise RuntimeError(f"All {times} attempts failed")
            return wrapper
        return decorator
    
    @retry(times=3)
    def flaky_api_call():
        ...   # runs up to 3 times before giving up

    Read it inside-out: retry(times=3) returns decorator; @decorator wraps flaky_api_call. The arguments live in the outermost layer's closure — that's how times reaches the wrapper.

    The Four Decorators You'll Actually Write

    1. Caching — skip recomputation:

    code
    @functools.lru_cache(maxsize=None)
    def fibonacci(n):
        return n if n < 2 else fibonacci(n - 1) + fibonacci(n - 2)

    This turns exponential recursion into linear — the same idea as Streamlit's @st.cache_data from the currency converter, which caches API responses across reruns.

    2. Timing/logging — the timed example above, pointed at any function.

    3. Retry — the retry example, essential for anything touching a network — the web scraper and email sender both want this.

    4. Validation/gating — check conditions before running:

    code
    def require_api_key(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            import os
            if not os.environ.get("API_KEY"):
                raise RuntimeError("Set API_KEY first")
            return fn(*args, **kwargs)
        return wrapper

    Common Errors & Fixes

  • Decorated function returns None — the wrapper forgot return result.
  • `TypeError: wrapper() takes 0 positional arguments` — the wrapper declared fixed parameters instead of *args, **kwargs.
  • "My function's name is wrapper" in logs/help — missing @functools.wraps(fn).
  • Decorator runs at import, not at call — correct and often surprising: the wrapping happens once when the @ line executes; the wrapper body runs per call.
  • Cached function never refresheslru_cache keys on arguments; mutable arguments (lists) break it — accept tuples, or use hash=True-safe inputs.
  • Key Concepts

  • First-class functions — the prerequisite that makes decorators possible.
  • The wrapper pattern — accept anything, delegate, return the result.
  • Three-layer argument decorators — function → decorator → wrapper.
  • `functools.wraps` — metadata hygiene that costs one line.
  • What to Try Next

  • Add @timed to three functions in one of your projects and find the slow one.
  • Write a @count_calls decorator that prints how many times a function has run.
  • Read the source of st.cache_data's behavior by decorating a function and inspecting functools.wraps metadata.
  • Combine decorators — stack @retry(times=3) over @timed and predict the order before running.
  • FAQ

    Why do decorators run bottom-up when stacked?

    Because @a\n@b\ndef f() means f = a(b(f)) — the decorator closest to the function wraps first. Timing inside retry measures each attempt; retry outside timing wraps the whole sequence.

    Are decorators just for frameworks?

    No — they're for any cross-cutting concern you'd otherwise copy-paste into every function: timing, caching, retries, validation, logging. If you're pasting the same three lines into ten functions, that's a decorator.

    What's the difference between a decorator and a class-based one?

    Same job, implemented with a class whose __call__ replaces wrapper — useful when the decorator needs state (like counting calls). Start with functions; reach for classes when you need attributes.