Skip to content
Hello Python
2/7

Decorators

Topic 2 of 7, with 3 concept checks. Wrapping functions to add behavior without changing their code

Wrap behavior without rewriting the function

Callable wrappers

Trace how a decorator receives a callable, returns a replacement, and preserves the wrapped contract so logging, validation, or caching does not obscure the original API.

Core lesson 01

@decorator above a function is syntactic sugar for func = decorator(func) — the decorator receives the original function and returns a replacement bound to the same name.

A decorator is any callable that takes a function and returns a callable. @my_decorator on top of def f(): ... runs immediately at definition time, replacing f with my_decorator(f). This is how you transparently add logging, timing, caching, or access control around a function without touching its internal code.

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

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

print(greet("ada"))   # HELLO, ADA
# equivalent to: greet = shout(greet)

What to remember

What is a decorator, and what does @decorator actually do?

Common footguns

  • Forgetting the wrapper needs *args, **kwargs to forward arbitrary arguments — otherwise the decorated function loses its original signature's flexibility.
  • Decorating a function and being confused why help(greet) shows wrapper's info instead of greet's — see functools.wraps below.

Core lesson 02

functools.wraps(func) copies the original function's __name__, __doc__, and metadata onto the wrapper, so introspection tools still see the original function's identity.

Without @functools.wraps(func) on the inner wrapper, the decorated function's __name__ becomes 'wrapper', its docstring disappears, and tools relying on function metadata (logging, doc generators, some test frameworks) get confused about what's being called. functools.wraps is itself a decorator that fixes this by copying over the important metadata attributes.

Python example
import functools

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

@shout
def greet(name):
    """Return a greeting."""
    return f"hello, {name}"

print(greet.__name__)   # 'greet', not 'wrapper'
print(greet.__doc__)    # 'Return a greeting.'

What to remember

Why do decorators typically need functools.wraps?

Common footguns

  • Skipping functools.wraps 'because it still works' — it works functionally but breaks introspection, stacking multiple decorators, and anything relying on __name__ (like some caching or routing decorators).

Core lesson 03

Add an outer function that takes the decorator's own arguments and returns the real decorator — three nested levels total: factory(args) -> decorator(func) -> wrapper(*a, **kw).

A plain decorator takes exactly one argument: the function being decorated. To support @retry(times=3), you need a factory function that accepts times and returns a decorator, which itself returns a wrapper. It looks like three levels of nested functions because it is — each level closes over the one before it.

Python example
import functools

def retry(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"attempt {attempt} failed: {e}")
            raise
        return wrapper
    return decorator

@retry(times=3)
def flaky():
    raise ValueError("nope")

try:
    flaky()
except ValueError:
    print("gave up after 3 tries")

What to remember

How do you write a decorator that accepts its own arguments (a decorator factory)?

Common footguns

  • Forgetting the extra layer and writing @retry without () — that passes the function itself as `times`, causing confusing errors deep inside the decorator.
retry(times=3)          -- factory call, returns `decorator`
  -> decorator(flaky)    -- decorator call, returns `wrapper`
    -> wrapper(*a,**kw)  -- what actually runs when you call flaky()

Python lab

Browser Python lab

Runtime · idle

Python loads on your first run. Your code stays in this browser.

Best practices

  • Always apply functools.wraps to preserve the original function's metadata.
  • Keep decorators narrowly focused — one decorator, one responsibility.
  • Order matters when stacking multiple decorators — they apply bottom-up, closest to the function first.
  • Prefer functools.lru_cache over writing your own memoization decorator for simple cases.

Apply the concept in Interview practice

LRU CachemediumLeetCode #146 · O(1) get/put

Combine a dict with a doubly linked list (or OrderedDict) so both get and put are O(1); move accessed items to the 'most recent' end and evict from the 'least recent' end when over capacity.

Open problem
Time Based Key-Value StoremediumLeetCode #981 · O(log n) get

Store a list of (timestamp, value) pairs per key and binary search for the largest timestamp <= the query timestamp.

Open problem
Number of Recent CallseasyLeetCode #933 · O(1) amortized per call

Keep a queue of call timestamps; on each ping, pop timestamps older than 3000ms from the front, then return the queue's length — the same rate-limiting pattern often built as a decorator.

Open problem

Concept checks

Q01

What is a decorator, and what does @decorator actually do?

Q02

Why do decorators typically need functools.wraps?

Q03

How do you write a decorator that accepts its own arguments (a decorator factory)?