Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+200 XP

Decorators — Writing and Using Them

Functions that wrap functions. How decorators actually work, and writing your own from scratch.

45 min August 2026
// Part 01 — Functions Are Objects

The Foundation Decorators Are Built On

You already know functions can be passed as arguments and returned from other functions — this was covered when Functions were introduced, and used again with map/filter in the functional-tools module. A decorator is nothing more exotic than a function that takes a function as input and returns a new function as output.

A decorator is just a function returning a function
def loud(func):
    def wrapper():
        print("About to call the function...")
        func()
        print("...function finished.")
    return wrapper

def greet():
    print("Hello!")

greet = loud(greet)   # manually "decorating" greet by reassigning it
greet()
# About to call the function...
# Hello!
# ...function finished.

Every ingredient of a decorator is already visible here: loud takes a function, defines a new inner function (wrapper) that calls the original and adds behaviour around it, and returns that wrapper. The @ syntax you are about to see is purely syntax sugar for exactly the reassignment on the line above.

// Part 02 — The @ Syntax

@decorator Is Sugar for func = decorator(func)

The @ syntax, unwrapped
def loud(func):
    def wrapper():
        print("About to call the function...")
        func()
        print("...function finished.")
    return wrapper

@loud
def greet():
    print("Hello!")

# The line above is EXACTLY equivalent to:
#     def greet():
#         print("Hello!")
#     greet = loud(greet)

greet()   # identical output to Part 01
🎯 Pro Tip
Read @decorator as "replace the function immediately below with decorator(that function)." Once you can mentally unwrap the @ syntax into the plain reassignment it really is, decorators stop looking like magic — they are ordinary function calls, just applied at definition time instead of call time.
// Part 03 — Handling Arguments and Return Values

A Real Decorator Must Forward Everything

The loud example above only works on functions that take no arguments and return nothing — useless for real code. A proper decorator uses *args and **kwargs (covered in the previous module) so it can wrap any function signature, and it must explicitly return whatever the wrapped function returns.

A decorator that works on any function
def loud(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result       # critical — forget this and every decorated function silently returns None
    return wrapper

@loud
def add(a, b):
    return a + b

total = add(3, 4)
# Calling add with args=(3, 4), kwargs={}
# add returned 7
print(total)   # 7 — the real return value made it through
⚠️ Important
Forgetting return result inside the wrapper is the single most common decorator bug. The decorated function still "runs" and appears to work — but every call site that relies on its return value silently receives None instead, often not noticed until much later.
// Part 04 — functools.wraps

The Metadata a Naive Decorator Silently Destroys

Once a function is decorated, it is literally replaced by the wrapper — which means things like func.__name__ and func.__doc__ now report the wrapper's identity, not the original function's, unless you fix it.

The metadata-loss problem
def loud(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@loud
def add(a, b):
    """Add two numbers together."""
    return a + b

print(add.__name__)   # "wrapper" — WRONG! Should be "add"
print(add.__doc__)    # None — WRONG! The real docstring is lost
functools.wraps — the fix
from functools import wraps

def loud(func):
    @wraps(func)              # copies __name__, __doc__, and more from func onto wrapper
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@loud
def add(a, b):
    """Add two numbers together."""
    return a + b

print(add.__name__)   # "add" — correct
print(add.__doc__)    # "Add two numbers together." — correct
🎯 Pro Tip
@wraps(func) should be considered mandatory on every decorator you write — without it, debugging tools, documentation generators, and anything that introspects a function's identity (including some testing frameworks) see the wrapper's generic identity instead of the real function's, which becomes a genuinely confusing debugging experience once a codebase has several decorators stacked or applied broadly.
// Part 05 — Decorators That Take Arguments

Decorator Factories — One More Layer of Nesting

Sometimes you want to configure a decorator itself — @retry(times=3) instead of just @retry. This requires a decorator factory: a function that takes the configuration arguments and returns the actual decorator, which then returns the wrapper. Three levels of nested functions in total.

A decorator factory — three nested layers
from functools import wraps
import time

def retry(times):                          # layer 1: takes the CONFIGURATION
    def decorator(func):                    # layer 2: takes the FUNCTION
        @wraps(func)
        def wrapper(*args, **kwargs):       # layer 3: runs on each CALL
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except ConnectionError as e:
                    last_error = e
                    print(f"Attempt {attempt} failed, retrying...")
                    time.sleep(1)
            raise last_error
        return wrapper
    return decorator

@retry(times=3)
def fetch_data():
    ...

Unwrapped, @retry(times=3) above def fetch_data(): means: fetch_data = retry(3)(fetch_data) — first retry(3) runs and returns the actual decorator function, and that is what gets applied to fetch_data. This is the same nesting pattern used by closures (the very next module), and understanding closures makes this pattern click far faster.

// Part 06 — Stacking Decorators

Multiple Decorators — Order Matters, Both Ways

Stacked decorators
@decorator_a
@decorator_b
def my_function():
    ...

# Equivalent to:
# my_function = decorator_a(decorator_b(my_function))

Two directions of "order" both matter, and they are opposites of each other. Application order (which decorator wraps which) works bottom-up: decorator_b wraps the original function first, then decorator_a wraps the already-wrapped result. Execution order (what actually runs when you call the function) works top-down: decorator_a's wrapper code runs first (since it is the outermost layer), which then calls into decorator_b's wrapper, which finally calls the real function.

Seeing both orders in one example
from functools import wraps

def announce(label):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(f"[{label}] entering")
            result = func(*args, **kwargs)
            print(f"[{label}] exiting")
            return result
        return wrapper
    return decorator

@announce("OUTER")
@announce("INNER")
def task():
    print("  doing the actual work")

task()
# [OUTER] entering
# [INNER] entering
#   doing the actual work
# [INNER] exiting
# [OUTER] exiting

Notice OUTER starts first and finishes last — exactly like nested parentheses, the outermost call is the first thing you "enter" and the last thing you "exit."

// Part 07 — Real World
💼 What This Looks Like at Work

Adding Caching to a Slow Endpoint at a Miami Travel-Tech Company

Scenario — Travel-tech company, Miami · Performance incident

A function that looks up flight-route metadata from a slow internal database is called repeatedly with the same handful of popular routes, hundreds of times a minute, each call taking 200ms. An engineer reaches for Python's built-in functools.lru_cache decorator rather than hand-rolling a caching layer.

Before — every call hits the slow database
def get_route_info(origin, destination):
    return slow_database_lookup(origin, destination)   # ~200ms every time
After — one decorator, dramatic effect
from functools import lru_cache

@lru_cache(maxsize=256)
def get_route_info(origin, destination):
    return slow_database_lookup(origin, destination)

get_route_info("JFK", "LAX")   # ~200ms — cache miss, runs the real lookup
get_route_info("JFK", "LAX")   # ~0ms  — cache hit, returns the stored result instantly

Why this is exactly the kind of thing decorators are for

The caching logic — checking whether this exact combination of arguments was seen before, storing results, evicting the oldest entries once maxsize is reached — is completely generic and has nothing to do with flight routes specifically. lru_cache is itself just a decorator, written using exactly the pattern covered in this module, and applying it required changing precisely one line, with zero changes to get_route_info's own logic. This is the real payoff of decorators: cross-cutting behaviour (caching, logging, timing, retries, access control) added without touching the function's actual implementation at all.

// Part 08 — Misconceptions

Four Misconceptions About Decorators

✕ ""Decorators are an advanced, rarely-used Python feature""
You almost certainly use them constantly without thinking of them as advanced — @property, @staticmethod, @classmethod (already covered), and functools.lru_cache are all decorators. They are a core, everyday part of idiomatic Python, not an obscure corner.
✕ ""@wraps(func) is just a nice-to-have, not something that matters much""
Skipping it silently corrupts the decorated function's __name__, __doc__, and other metadata to the wrapper's generic identity — which breaks debugging tools, documentation generation, and anything that introspects the function, in ways that can be genuinely confusing to track down later.
✕ ""The order you stack decorators in doesn't really matter""
It matters a great deal — application is bottom-up, execution is top-down (outermost enters first, exits last), and reordering decorators that interact (e.g. a caching decorator above vs below a logging decorator) can change what actually gets cached or logged.
✕ ""A decorator can only wrap a function that takes no arguments, or a fixed signature""
A properly written decorator using *args and **kwargs in its wrapper works on ANY function signature — the whole point of *args/**kwargs (covered in the previous module) is enabling exactly this kind of fully generic forwarding.
// Part 09 — Interview Prep

5 Interview Questions — With Complete Answers

What is a decorator, in the simplest possible terms?
A function that takes a function as input and returns a new function (usually a wrapper that adds behaviour before/after/around calling the original) as output. @decorator above a function definition is syntax sugar for func = decorator(func).
Why is functools.wraps important, and what does it actually do?
Without it, a decorated function's __name__, __doc__, and related metadata report the wrapper's generic identity instead of the original function's, breaking debugging tools and introspection. @wraps(func), applied to the inner wrapper function, copies that metadata from the original function onto the wrapper.
How do you write a decorator that accepts its own arguments, like @retry(times=3)?
You need three levels of nested functions: an outer function that takes the configuration argument(s) and returns the actual decorator; the decorator, which takes the function and returns the wrapper; and the wrapper, which runs on each call. @retry(times=3) unwraps to fetch_data = retry(3)(fetch_data).
If two decorators are stacked on the same function, what determines execution order?
Application is bottom-up (the closest decorator to the function wraps it first), but execution at call time is top-down — the outermost decorator's wrapper code runs first, then calls into the next one down, and so on until the real function runs, then unwinds back out in reverse.
What is the most common bug when writing a decorator from scratch, and how do you avoid it?
Forgetting to return the wrapped function's result inside the wrapper — the decorated function still appears to run correctly, but every caller relying on its return value silently gets None instead. Always end the wrapper with "return func(*args, **kwargs)" or capture the result and return it explicitly after any extra logic.
// Common Mistakes

Decorator Mistakes Beginners Make Constantly

Forgetting to return the wrapper function from the decorator
def decorator(func): def wrapper(): ... — without "return wrapper" at the end of decorator, the decorated name becomes None, since a function with no explicit return implicitly returns None.
Forgetting *args, **kwargs on the wrapper
def wrapper(): only works on zero-argument functions — decorating any function that takes arguments raises a TypeError the moment it is called with them. Always write def wrapper(*args, **kwargs): unless you specifically need a fixed signature.
Calling the decorator factory but forgetting the parentheses
@retry (without parentheses) passes the function itself as the "times" argument to retry, which is not what was intended — decorator FACTORIES always need to be called, even with no real arguments: @retry() at minimum.
Putting logic in the outer decorator function instead of the inner wrapper
Code written directly inside "def decorator(func):" (not inside "def wrapper(...):") runs exactly ONCE, at decoration time — not on every call. Logic that should run on every call to the decorated function must live inside the innermost wrapper.
// Error Library

Errors You Will Hit With Decorators — And Exactly Why

TypeError: 'NoneType' object is not callable
Cause: A decorator function did not return its inner wrapper — the decorated name became None, and later code tried to call it like a function.
Fix: Add "return wrapper" at the end of the decorator function.
TypeError: wrapper() takes 0 positional arguments but 2 were given
Cause: The wrapper function was defined without *args, **kwargs, so it cannot accept the arguments the decorated function is actually being called with.
Fix: Change the wrapper's signature to def wrapper(*args, **kwargs): and forward them with func(*args, **kwargs).
TypeError: retry() missing 1 required positional argument: 'func'
Cause: A decorator factory was applied without calling it — @retry instead of @retry(times=3) — so the function being decorated was passed directly as the factory's first (configuration) argument instead of being decorated properly.
Fix: Always call a decorator factory, even with default/no arguments: @retry() at minimum.
AttributeError: 'function' object has no attribute '__wrapped__'
Cause: Code (often a testing or introspection tool) expected functools.wraps to have been used, exposing the original function via __wrapped__, but the decorator was written without @wraps.
Fix: Add @wraps(func) to the wrapper definition inside every decorator you write.

🎯 Key Takeaways

  • A decorator is a function that takes a function and returns a new (usually wrapping) function. @decorator is sugar for func = decorator(func).
  • A wrapper should accept *args, **kwargs to support any function signature, and must explicitly return the original function's result — the most common decorator bug is forgetting that return.
  • functools.wraps(func) on the inner wrapper preserves __name__, __doc__, and other metadata — treat it as mandatory on every decorator you write.
  • A decorator that takes its own arguments (@retry(times=3)) needs three levels of nesting: a factory, the decorator, and the wrapper.
  • Stacked decorators apply bottom-up but execute top-down at call time — the outermost decorator enters first and exits last.
  • functools.lru_cache, @property, @staticmethod, and @classmethod are all decorators you likely already use — decorators are an everyday Python tool, not an obscure advanced feature.

What comes next

Module 30 covers context managers and the with statement — what with is actually doing under the hood, and building your own for resource management.

Module 30 → Context Managers and the with Statement
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...