TRENDING
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 28, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Two earthworms lying together on wet brown leaf litter, photographed close up
October 28, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A mountain gorilla looks at the camera from green forest undergrowth
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 269 Posts
News 272 Posts
Learning Hub 242 Posts
Home/Learning Hub/How to Write Python Decorators From Scratch: A Step-by-Step Guide
Learning Hub

How to Write Python Decorators From Scratch: A Step-by-Step Guide

A hands-on guide to writing Python decorators from scratch, including the functools.wraps gotcha, decorators with arguments, and the bugs that appear when you stack them.

September 2, 2026 17 Min Read
104

If you have ever read Python code and seen a line starting with @ sitting right above a function definition, like @app.route in Flask, @pytest.fixture in a test file, or @staticmethod in a class, you have already used a decorator. You just have not built one yet. By the end of this tutorial you will understand exactly what that @ line is doing, and you will have written several decorators of your own, including one that manages a small registry of named commands the way real frameworks do it under the hood.

Table Of Content

  • What You Will Build
  • Prerequisites
  • Step 1: Functions Are Just Objects
  • Step 2: Closures, Functions That Remember
  • Step 3: Write Your First Decorator
  • Step 4: The functools.wraps Gotcha
  • Step 5: Handling Any Function Signature
  • Step 6: A Real-World Example, Timing a Function
  • Step 7: Decorators With Arguments, and a Very Confusing Bug
  • Step 8: Stacking Decorators, Where Order Actually Matters
  • Step 9: Class-Based Decorators That Track State
  • Step 10: Put It All Together, a Mini Command-Line Task Runner
  • Common Mistakes and How to Catch Them
  • How to Verify Everything Works
  • Next Steps

A decorator is a function that takes another function as input and returns a new function that wraps it, usually adding some behavior before or after the original code runs, without you having to edit the original function’s body. That matters because things like logging, timing, permission checks, retries, and caching tend to be needed in many places across a codebase. Copy-pasting that logic into every function is error-prone and hard to keep consistent. A decorator lets you write that logic once and apply it anywhere with a single line.

Two smaller ideas make decorators possible, and this tutorial teaches both before writing a single @ symbol: a higher-order function is simply a function that takes another function as an argument or returns one, and a closure is an inner function that remembers variables from the scope it was created in, even after that outer scope has finished running. Decorators are just higher-order functions that happen to use closures.

What You Will Build

Rather than a grab-bag of disconnected examples, this tutorial builds one thing incrementally: a small command-line task runner called taskcli, similar in spirit to how Flask registers routes or Click registers commands, except every piece of it is built from scratch so you can see exactly how the machinery works. Along the way you will hit three real bugs on purpose, the same ones that trip up most people the first time they write decorators, and fix each one before moving on.

Prerequisites

  • Python 3.10 or newer installed and runnable from a terminal (this tutorial was written and tested against Python 3.13.14, but anything from 3.10 up behaves identically for everything shown here).
  • Comfort with defining functions, calling them, and reading a traceback. You do not need any prior exposure to decorators, closures, or the functools module; all of that is explained from first principles below.
  • pytest installed if you want to run the verification suite at the end (pip install pytest). It is optional for following the tutorial itself.
  • A plain text editor and a way to run python somefile.py. No external services, accounts, or packages beyond the Python standard library and pytest are required.

Step 1: Functions Are Just Objects

Before decorators make sense, you need to internalize one fact about Python: a function is a normal object, exactly like a string or a number. You can assign it to a variable, store it in a list, and pass it into another function as an argument.

def greet(name):
    return f"Hello, {name}!"


# Functions are ordinary objects: you can assign them to a new name
say_hello = greet
print(say_hello("Amir"))

# You can store them in a list alongside other callables
funcs = [greet, str.upper, len]
for f in funcs:
    print(f.__name__)

# You can pass a function into another function as an argument
def call_it(func, value):
    return func(value)


print(call_it(greet, "Sam"))

Run that file and you get:

Hello, Amir!
greet
upper
len
Hello, Sam!

say_hello is not a copy of greet, it is the same function object under a second name. Both names point at the exact same thing in memory. This is what makes the next step possible: if a function is just a value, then another function can accept it as an argument and return a modified version of it.

Step 2: Closures, Functions That Remember

The second building block is the closure. When you define a function inside another function, the inner function can read variables from the outer function’s local scope, and it keeps access to them even after the outer function has already returned.

def make_multiplier(factor):
    def multiply(number):
        return number * factor
    return multiply


double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))
print(triple(5))

# The inner function remembers "factor" even after make_multiplier() has
# already returned. That remembered value lives in the function's closure.
print(double.__closure__[0].cell_contents)
print(triple.__closure__[0].cell_contents)

Output:

10
15
2
3

make_multiplier(2) runs, creates a local variable factor set to 2, defines multiply, and returns it. Normally factor would be thrown away the moment make_multiplier finishes, since it is a local variable. But multiply still needs it, so Python keeps it alive in what is called a closure, a small bundle of variables the inner function carries around with it. You can even inspect that bundle directly through double.__closure__[0].cell_contents, which is exactly what the last two lines print. This “remembering” behavior is the mechanism a decorator relies on to hang on to the original function it is wrapping.

Step 3: Write Your First Decorator

A decorator is a function that takes a function, defines a new inner function (the closure from Step 2), and returns that inner function instead of the original. The @ syntax you see in real code is nothing more than shorthand for calling the decorator manually and reassigning the result back to the original name.

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


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


# Manual decoration: this is ALL "@shout" ever does under the hood
loud_greet = shout(greet)
print(loud_greet("amir"))

# The @ syntax below is exactly equivalent to: greet2 = shout(greet2)
@shout
def greet2(name):
    return f"hi, {name}"


print(greet2("sam"))

Output:

HELLO, AMIR
HI, SAM

Read shout carefully: it takes func, defines wrapper, and wrapper calls func and modifies its result. shout then returns wrapper, not the original function. The line loud_greet = shout(greet) spells this out explicitly. The line @shout directly above def greet2 does the identical thing automatically: Python defines greet2 as normal, then immediately runs greet2 = shout(greet2) for you. That single substitution, “define the function, then replace it with what the decorator returns,” is the entire decorator mechanism. Everything else in this tutorial is refinements on that one idea.

Step 4: The functools.wraps Gotcha

The shout decorator above has a hidden defect that will not show up until you rely on introspection, meaning code that inspects a function’s name, docstring, or signature instead of just calling it. Here is the bug, reproduced on purpose:

import functools


def log_calls_broken(func):
    def wrapper(*args, **kwargs):
        print(f"CALL {func.__name__}{args}")
        return func(*args, **kwargs)
    return wrapper


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


print("--- broken: no functools.wraps ---")
print("add.__name__ =", repr(add.__name__))
print("add.__doc__  =", repr(add.__doc__))
help(add)


def log_calls_fixed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"CALL {func.__name__}{args}")
        return func(*args, **kwargs)
    return wrapper


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


print("--- fixed: with functools.wraps ---")
print("add2.__name__ =", repr(add2.__name__))
print("add2.__doc__  =", repr(add2.__doc__))
help(add2)

print("--- calling through the wrapper vs. calling __wrapped__ directly ---")
print("add2(2, 3):", add2(2, 3))
print("add2.__wrapped__(2, 3):", add2.__wrapped__(2, 3))

Output:

--- broken: no functools.wraps ---
add.__name__ = 'wrapper'
add.__doc__  = None
Help on function wrapper in module __main__:

wrapper(*args, **kwargs)

--- fixed: with functools.wraps ---
add2.__name__ = 'add2'
add2.__doc__  = 'Add two numbers together.'
Help on function add2 in module __main__:

add2(a, b)
    Add two numbers together.

--- calling through the wrapper vs. calling __wrapped__ directly ---
CALL add2(2, 3)
add2(2, 3): 5
add2.__wrapped__(2, 3): 5

Look at the broken section: add.__name__ reports 'wrapper', not 'add', and add.__doc__ is None even though the real add function has a docstring. That happens because after decoration, add literally is the wrapper function defined inside log_calls_broken. Python has no way to know it is supposed to look like a different function; it just sees whatever object wrapper actually is. Anything that relies on a function’s identity, like help(), API documentation generators, debuggers, or (as you will see in Step 8) a command registry that reads a function’s name automatically, silently gets the wrong information.

The fix is the standard library’s functools.wraps, and once it is applied, add2 reports its own correct name and docstring, and gains one more thing: an __wrapped__ attribute pointing at the original, undecorated function. Calling add2(2, 3) goes through the wrapper and prints the [log] line; calling add2.__wrapped__(2, 3) skips the wrapper entirely and reaches the raw add2 function directly, which is why the second call produces the same result with no log line at all. Per the official functools documentation, wraps copies over __module__, __name__, __qualname__, __annotations__, and __doc__ by default, merges in __dict__, and always sets __wrapped__ to point back at the function you decorated.

Rule of thumb: every decorator you write should use functools.wraps on its inner wrapper function, with no exceptions. It costs one line and it prevents a whole category of confusing bugs, including the one you will see in Step 8.

Step 5: Handling Any Function Signature

There is a second defect hiding in the simple decorators above: they only work correctly if the wrapper’s own parameter list happens to match the decorated function’s parameter list. Watch what happens when it does not:

def bad_decorator(func):
    def wrapper(x):
        print("before")
        result = func(x)
        print("after")
        return result
    return wrapper


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


print("--- calling a 2-argument function through a 1-argument wrapper ---")
try:
    add(2, 3)
except TypeError as e:
    print(f"TypeError: {e}")


def flexible_decorator(func):
    def wrapper(*args, **kwargs):
        print("before")
        result = func(*args, **kwargs)
        print("after")
        return result
    return wrapper


@flexible_decorator
def add2(a, b):
    return a + b


print("--- same function, wrapper now forwards *args and **kwargs ---")
print(add2(2, 3))

Output:

--- calling a 2-argument function through a 1-argument wrapper ---
TypeError: bad_decorator.<locals>.wrapper() takes 1 positional argument but 2 were given
--- same function, wrapper now forwards *args and **kwargs ---
before
after
5

The inner wrapper defined inside bad_decorator only accepts one argument, x, so when it is used to decorate add(a, b), which needs two, calling the decorated function raises a TypeError before add ever runs. The fix is to make the wrapper accept and forward absolutely anything: def wrapper(*args, **kwargs): ... return func(*args, **kwargs). Now the wrapper does not need to know or care how many arguments the wrapped function takes; it just passes everything through unchanged. From this point on, every decorator in this tutorial uses *args, **kwargs for exactly this reason.

Step 6: A Real-World Example, Timing a Function

With the two gotchas above understood, here is a genuinely useful decorator that combines both fixes: it uses functools.wraps so the decorated function keeps its identity, and *args, **kwargs so it works on any function.

import functools
import time


def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper


@timer
def slow_add(a, b):
    time.sleep(0.2)
    return a + b


print(slow_add(3, 4))

Output:

slow_add took 0.2010s
7

time.perf_counter() is called immediately before and after the real function runs, and the difference is printed. Because the wrapper still returns whatever func returned, calling code cannot tell the difference between slow_add(3, 4) and a plain, undecorated add(3, 4), except that it also gets a timing line for free. This is the shape of almost every practical decorator you will encounter: do something, call the real function, do something else, return its result.

Step 7: Decorators With Arguments, and a Very Confusing Bug

So far every decorator has taken exactly one thing: the function being decorated. Real code often needs a decorator that also takes its own configuration, for example @app.route("/users") in Flask or @pytest.mark.parametrize("x", [1, 2, 3]). That requires an extra layer of nesting, and it is easy to get wrong in a way that does not fail loudly.

Start with a command registry: a decorator that records a function in a dictionary under a given name, so it can be looked up and called later by that name.

COMMANDS = {}


def register_v1(name):
    def decorator(func):
        COMMANDS[name] = func
        return func
    return decorator


@register_v1("greet")
def greet(who):
    return f"hello {who}"


print("registered so far:", list(COMMANDS.keys()))
print(COMMANDS["greet"]("Amir"))

print("--- now forget the parentheses ---")


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


print("registered after the mistake:", list(COMMANDS.keys()))
print("type(add) is now:", type(add))

try:
    add(2, 3)
except TypeError as e:
    print(f"TypeError: {e}")

Output:

registered so far: ['greet']
hello Amir
--- now forget the parentheses ---
registered after the mistake: ['greet']
type(add) is now: <class 'function'>
TypeError: register_v1.<locals>.decorator() takes 1 positional argument but 2 were given

register_v1("greet") works fine: it is called first with the string "greet", which returns the real decorator function, and that is what actually gets applied to greet. But the second part deliberately forgets the parentheses: @register_v1 with no call at all. Python then does add = register_v1(add), meaning the add function itself gets passed in as the name parameter. register_v1 dutifully defines decorator and returns it, completely unaware that it received a function instead of a string, and add now refers to that inner decorator function rather than to anything resembling addition. Nothing crashes at decoration time. The registry silently stays at ['greet'], no error, no warning. The failure only shows up later, when something finally tries to call add(2, 3) and gets a TypeError that mentions a function called decorator, a name that appears nowhere in the code you actually wrote. This is exactly the kind of bug that costs twenty minutes of confused staring the first time you hit it, because the error message points at the wrong problem.

The fix is a pattern you will see across many real libraries: give the decorator an optional first parameter that captures the function directly if it is called bare, and falls back to returning a normal decorator otherwise.

COMMANDS = {}


def register(_func=None, *, name=None):
    def decorator(func):
        key = name or func.__name__
        COMMANDS[key] = func
        return func

    if _func is not None:
        # Used as @register with no parentheses: _func IS the function
        # being decorated, so register it immediately.
        return decorator(_func)
    # Used as @register(...) or @register(name="..."): return the
    # decorator so Python can call it with the function next.
    return decorator


@register
def greet(who):
    return f"hello {who}"


@register(name="addition")
def add(a, b):
    return a + b


print("registered commands:", list(COMMANDS.keys()))
print(greet("Amir"))
print(add(2, 3))

Output:

registered commands: ['greet', 'addition']
hello Amir
5

The trick is the _func=None parameter combined with the * that forces name to be keyword-only. When you write @register with no parentheses, Python calls register(greet), so _func receives the function directly and the code takes the if _func is not None branch, registering it immediately. When you write @register(name="addition"), Python first calls register(name="addition"), which leaves _func as None, so the function returns the inner decorator closure for Python to apply next. Both call styles end up registering the function correctly, and neither one produces the silent, confusing failure from register_v1.

Step 8: Stacking Decorators, Where Order Actually Matters

You can apply more than one decorator to the same function by stacking @ lines. Decorators apply bottom to top: the one closest to def wraps the original function first, and each decorator above it wraps the result of the one below. This is not just a formatting detail, it changes behavior, and it interacts directly with the functools.wraps lesson from Step 4.

import functools

COMMANDS = {}


def register(_func=None, *, name=None):
    def decorator(func):
        key = name or func.__name__
        COMMANDS[key] = func
        return func

    if _func is not None:
        return decorator(_func)
    return decorator


def log_calls_broken(func):
    def wrapper(*args, **kwargs):
        print(f"CALL {func.__name__}{args}")
        return func(*args, **kwargs)
    return wrapper


def log_calls_fixed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"CALL {func.__name__}{args}")
        return func(*args, **kwargs)
    return wrapper


print("=== register on top of log_calls_broken ===")


@register
@log_calls_broken
def greet(who):
    """Say hello."""
    return f"hello {who}"


print("COMMANDS keys:", list(COMMANDS.keys()))
print("calling COMMANDS['wrapper']('Amir') ->", COMMANDS["wrapper"]("Amir"))

COMMANDS.clear()

print()
print("=== register on top of log_calls_fixed ===")


@register
@log_calls_fixed
def greet2(who):
    """Say hello."""
    return f"hello {who}"


print("COMMANDS keys:", list(COMMANDS.keys()))
print("calling COMMANDS['greet2']('Sam') ->", COMMANDS["greet2"]("Sam"))

Output:

=== register on top of log_calls_broken ===
COMMANDS keys: ['wrapper']
CALL greet('Amir',)
calling COMMANDS['wrapper']('Amir') -> hello Amir

=== register on top of log_calls_fixed ===
COMMANDS keys: ['greet2']
CALL greet2('Sam',)
calling COMMANDS['greet2']('Sam') -> hello Sam

This is the payoff of Step 4. register reads func.__name__ to decide what key to use in the registry. When it sits on top of log_calls_broken, the func it receives is the wrapper function defined inside log_calls_broken, whose name is literally "wrapper" because log_calls_broken never called functools.wraps. So the command gets registered under the key "wrapper", not "greet", and a real CLI user typing greet would get an unknown-command error. The function still runs correctly once you find the right key, which makes this bug even sneakier: nothing is actually broken except the one thing you needed to work, looking the command up by its real name. Swap in log_calls_fixed, which does call functools.wraps, and func.__name__ correctly reports "greet2" all the way up the stack, so register uses the right key. This is why the rule from Step 4 is not optional once you start stacking decorators: a single missing functools.wraps anywhere in a stack can quietly corrupt what every decorator above it sees.

Step 9: Class-Based Decorators That Track State

Decorators do not have to be functions. Any object that implements __call__ can be used with @, and a class is often a cleaner way to write a decorator that needs to remember information across calls, such as how many times a function has been invoked.

import functools


class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.num_calls = 0

    def __call__(self, *args, **kwargs):
        self.num_calls += 1
        print(f"Call {self.num_calls} of {self.func.__name__!r}")
        return self.func(*args, **kwargs)


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


print(add(1, 2))
print(add(3, 4))
print(add(5, 6))
print("total calls:", add.num_calls)
print("add.__name__ =", repr(add.__name__))
print("add.__doc__  =", repr(add.__doc__))
print("type(add) is now a class instance:", type(add))

Output:

Call 1 of 'add'
3
Call 2 of 'add'
7
Call 3 of 'add'
11
total calls: 3
add.__name__ = 'add'
add.__doc__  = 'Add two numbers.'
type(add) is now a class instance: <class '__main__.CountCalls'>

CountCalls replaces add with an instance of itself. __init__ runs once, at decoration time, and stores the original function plus a counter starting at zero. __call__ runs every time the decorated function is invoked: it increments the counter, prints it, and forwards the call to the real function. Because instances are not functions, there is no functools.wraps decorator you can stick on __call__; instead you call functools.update_wrapper(self, func) once inside __init__, which is the same underlying machinery wraps uses, just applied directly to self. That is why add.__name__ and add.__doc__ still report correctly even though type(add) is now CountCalls, not function. The call count itself is a genuinely useful thing a plain function-based decorator cannot do nearly as cleanly, since a plain closure would need a nonlocal counter variable with no easy way for outside code to read it; here it is just a public attribute, add.num_calls.

Step 10: Put It All Together, a Mini Command-Line Task Runner

Now combine everything: the fixed register decorator that supports both call styles, a log_calls decorator, and a timer decorator, all stacked on real commands, plus a dispatch function that looks a command up by name and runs it. This is the same overall shape used by real frameworks like Click and Flask, just small enough to read in one sitting.

import functools
import time

COMMANDS = {}


def register(_func=None, *, name=None):
    """Register a function as a named CLI command."""
    def decorator(func):
        key = name or func.__name__
        COMMANDS[key] = func
        return func

    if _func is not None:
        return decorator(_func)
    return decorator


def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[log] calling {func.__name__}{args}")
        return func(*args, **kwargs)
    return wrapper


def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"[timer] {func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper


@register
@timer
@log_calls
def greet(who):
    """Say hello to someone."""
    return f"hello, {who}"


@register(name="add")
@log_calls
def add_numbers(a, b):
    """Add two numbers."""
    return a + b


@register
@timer
def slow_report():
    """Simulate a slow report that takes a moment to generate."""
    time.sleep(0.15)
    return "report ready"


def list_commands():
    for key, func in sorted(COMMANDS.items()):
        first_line = (func.__doc__ or "(no description)").strip().splitlines()[0]
        print(f"  {key:12s} {first_line}")


def dispatch(name, *args):
    if name not in COMMANDS:
        raise SystemExit(
            f"Unknown command: {name!r}. Available: {', '.join(sorted(COMMANDS))}"
        )
    return COMMANDS[name](*args)


if __name__ == "__main__":
    print("Available commands:")
    list_commands()
    print()
    print("dispatch('greet', 'Amir') ->", dispatch("greet", "Amir"))
    print()
    print("dispatch('add', 2, 3) ->", dispatch("add", 2, 3))
    print()
    print("dispatch('slow_report') ->", dispatch("slow_report"))

Running it directly:

Available commands:
  add          Add two numbers.
  greet        Say hello to someone.
  slow_report  Simulate a slow report that takes a moment to generate.

[log] calling greet('Amir',)
[timer] greet took 0.0000s
dispatch('greet', 'Amir') -> hello, Amir

[log] calling add_numbers(2, 3)
dispatch('add', 2, 3) -> 5

[timer] slow_report took 0.1509s
dispatch('slow_report') -> report ready

A few details worth pointing out. First, greet is stacked three deep: register on top of timer on top of log_calls. Because both log_calls and timer use functools.wraps, register still sees the real name "greet" at the top of the stack, exactly as Step 8 demonstrated. Second, notice that add_numbers is registered under the key "add" because of the explicit name="add" argument, but the function’s own __name__ is still "add_numbers", since wraps preserves the function’s real identity, not the registry key you chose for it; those are two independent things on purpose, and it is worth keeping straight when you write your own registries. Third, list_commands() only works correctly because every registered function’s docstring survived the decorator stack intact; without functools.wraps somewhere in that chain, every command would show up as “(no description)” instead of its real one-line summary.

If you dispatch a command that was never registered, you get a clear error instead of a raw KeyError:

>>> taskcli.dispatch('delete-everything')
SystemExit: Unknown command: 'delete-everything'. Available: add, greet, slow_report

Common Mistakes and How to Catch Them

  • Forgetting to return the wrapper. If a decorator’s outer function does not return wrapper at the end, the decorated name becomes None, and calling it raises TypeError: 'NoneType' object is not callable. Check that every code path through your decorator ends in a return of something callable.
  • Forgetting *args, **kwargs on the wrapper. As Step 5 showed, a wrapper with a fixed parameter list breaks the moment you apply it to a function with a different signature. Default to *args, **kwargs unless you have a specific reason not to.
  • Forgetting functools.wraps. This one is the most dangerous because it does not raise an exception. It silently corrupts __name__ and __doc__, breaks help(), and, as Step 8 showed, can corrupt anything downstream that introspects the function, including your own decorators.
  • Forgetting the parentheses on a decorator that takes arguments. As Step 7 showed, this does not always fail immediately either; it can silently replace your function with an unrelated closure and only fail later, with a misleading error message. The _func=None pattern from Step 7 lets you support both @register and @register(name=...) safely, which removes the mistake entirely instead of just documenting it.
  • Assuming decorator order does not matter. It does. Read a stack of decorators from the bottom up to understand what actually happens to a call, and remember that a broken (non-wraps) decorator anywhere in the stack can corrupt what every decorator above it sees.

How to Verify Everything Works

Rather than trusting the manual runs above, write a small automated check. This suite verifies the registry contents, confirms functools.wraps really did preserve names and docstrings through three stacked decorators, and exercises dispatch for both the success and failure paths.

import pytest

import taskcli as cli


def test_registered_command_names():
    assert set(cli.COMMANDS) == {"greet", "add", "slow_report"}


def test_wraps_preserved_real_function_names():
    # Registry keys and __name__ are independent: "add" was registered
    # with an explicit name=, but the function itself is still called
    # add_numbers, and functools.wraps kept that real name intact.
    assert cli.COMMANDS["greet"].__name__ == "greet"
    assert cli.COMMANDS["add"].__name__ == "add_numbers"
    assert cli.COMMANDS["slow_report"].__name__ == "slow_report"


def test_docstrings_survived_three_stacked_decorators():
    assert cli.COMMANDS["greet"].__doc__ == "Say hello to someone."


def test_dispatch_greet():
    assert cli.dispatch("greet", "Amir") == "hello, Amir"


def test_dispatch_add():
    assert cli.dispatch("add", 2, 3) == 5


def test_dispatch_unknown_command_raises():
    with pytest.raises(SystemExit):
        cli.dispatch("does-not-exist")

Running pytest -v against it:

============================= test session starts =============================
platform win32 -- Python 3.13.14, pytest-9.1.1, pluggy-1.6.0
collected 6 items

test_taskcli.py::test_registered_command_names PASSED                    [ 16%]
test_taskcli.py::test_wraps_preserved_real_function_names PASSED         [ 33%]
test_taskcli.py::test_docstrings_survived_three_stacked_decorators PASSED [ 50%]
test_taskcli.py::test_dispatch_greet PASSED                              [ 66%]
test_taskcli.py::test_dispatch_add PASSED                                [ 83%]
test_taskcli.py::test_dispatch_unknown_command_raises PASSED             [100%]

============================== 6 passed in 0.04s ==============================

If you want to confirm the Step 8 bug for yourself instead of taking the output above on faith, run step8_stacking_order.py and check the printed COMMANDS keys line: with the broken logger it will be ['wrapper'], and with the fixed one it will be ['greet2']. Seeing your own registry end up with the wrong key is a much more durable lesson than reading about it.

Next Steps

Once the mechanics above feel natural, a few standard-library tools build directly on the same ideas and are worth exploring next:

  • functools.lru_cache and the newer functools.cache are ready-made memoizing decorators; now that you have built CountCalls by hand, their internals will look familiar rather than magical.
  • contextlib.contextmanager is a decorator that turns a generator function into a with-statement context manager, another example of a decorator that fundamentally changes how a function can be used, not just what happens around it.
  • Class decorators, meaning a decorator applied to an entire class instead of a single function (@dataclass is the most common example), follow the exact same “receive an object, return a modified or replacement object” pattern taught here, just operating one level up.
  • The official functools documentation is worth reading end to end once decorators click; nearly every function in that module exists to solve a problem you will eventually hit while writing your own.

Tags:

ClosuresDecoratorsfunctoolsPython

Share

The Italian tall ship Amerigo Vespucci under full sail, sails filled with wind
Previous Post

Google’s Fairwind Program Turns Cyber AI Access Into a Vetted-Partner Gate

A Microsoft campus building in Redmond, Washington, showing its glass-walled exterior surrounded by evergreen trees
Next Post

Microsoft Will Disclose Azure’s Quarterly Revenue for the First Time After Years of Investor Pressure

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
28 Oct
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Trending
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026