Write Python Decorators that Preserve Behavior
Wrap a callable to add behavior while forwarding its arguments and result, keeping metadata, and tracking state per decorated function.
Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons
Video lesson: Write Python Decorators that Preserve Behavior
The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 00:00
Suppose we want to count how often several functions are called while we investigate a slow program. We could add a counter inside every function, but that mixes measurement with the function's main job and is easy to forget. A decorator can wrap each function with reusable behavior. Here the wrapper will increment a call count and then run the original function. That sounds simple, but two details matter: the wrapper must return the original function's result, and it should preserve the original function's name and documentation for tools and readers. We will first run a broken decorator that silently loses a result.
- 00:40
Then we will inspect what decoration actually does, repair the wrapper with `functools.wraps`, and test independent counters, keyword arguments, and an exception. The companion PythonLessonLab lesson provides a browser console, solved examples, a checked task, a quiz, and common mistakes. Before the first run, remember the plain function's contract: add two numbers and return their sum. Counting calls is an extra observation. It must not change the arithmetic result. When learning decorators, separate those two concerns in your mind. The decorator receives a function object and returns a new callable; the `@` notation is a compact way to perform that assignment when the function is defined.
- 01:25
The wrapper did call the original add function, and the counter did increase. But it threw away the returned five. A Python function that reaches the end without a return statement returns None, so the decorated add now appears to have a different result. That is a dangerous failure because it may travel far before another part of the program tries to use the missing number. The third line reveals a second change: the decorated function's name is now wrapper. If a debugger, help page, or test report inspects it, that lost metadata can obscure what function was actually called. The `*args` and `**kwargs` parameters were appropriate here; they let the wrapper forward positional and keyword arguments without hard-coding add's signature.
- 02:14
But forwarding arguments is only half of delegation. A transparent wrapper must also forward the return value. We should fix that with `return func(*args, **kwargs)`. For metadata, use `@wraps(func)` above the inner wrapper. It copies useful attributes and sets a reference to the wrapped function. The call count remains an attribute on each new wrapper object. If we create a fresh wrapper for each decorated function, the counters are naturally independent. The original add still returns five. `double_result(add)` returns a new wrapper function, which calls add and doubles its result.
- 02:56
Assigning that wrapper to a name gives us decorated_add. Writing `@double_result` above a function definition is equivalent to defining the function and then replacing its name with the decorator's returned callable. This example deliberately changes the result to ten, so it is not the kind of transparent measurement wrapper we want for counting calls. It exposes the mechanism. A decorator can change behavior intentionally, but the change should be part of its documented contract. Our count_calls decorator is different: its only intended outward addition is a `call_count` attribute. The returned value should remain whatever the original function returns.
- 03:39
The wrapper's name shows again why metadata needs attention. `functools.wraps` is itself a decorator applied to the inner wrapper at definition time. It does not magically make a broken return statement correct; we need both changes. When debugging a decorated function, ask three questions: which original callable did the decorator receive, what callable did it return, and which values or exceptions does that returned callable pass through? Those questions prevent a lot of confusion about seemingly mysterious `@` behavior. The decorated function now returns five and nine for the two calls, exactly as add normally would.
- 04:20
The wrapper's counter reaches two because it increments once for each invocation. The name and documentation string still describe add, thanks to wraps. Under the hood, the wrapper remains a different function object, but its public metadata is much more useful. We increment before invoking the original function because the lesson's contract counts every attempted call, even one that later raises an exception. If we incremented only after a successful return, the count would mean successful calls instead. Either definition can be valid, but we must choose and test one. The practical task on PythonLessonLab asks you to implement count_calls with this signature and an independent `wrapper.call_count` for each decorated function.
- 05:07
It checks that positional and keyword arguments pass through and that results are unchanged. Make sure you attach the attribute to the wrapper you return, initialize it before any call, and use `return` before the delegated call. Adding `@wraps(func)` does not alter the call count logic; it protects metadata. Keep this decorator intentionally small. More elaborate timing or caching decorators bring their own choices about state, exceptions, and concurrency. Each decorated function has its own wrapper and therefore its own call_count. Calling divide twice does not change add's count. The second divide call raises ZeroDivisionError inside the original function, but the wrapper already recorded the attempt, so divide's count becomes two.
- 05:56
We catch the exception only in the verification code; the decorator itself does not swallow it. That means callers still see the same error they would see from the undecorated divide. The result and exception path together define what transparency means for this wrapper. In a larger system with multiple threads, incrementing a plain attribute is not a complete thread-safe metrics design. For this educational task, the state is deliberately local and simple. On the website, run the broken snippet first and notice how easy it is to overlook None when the counter appears correct. Complete the corrected task, pass the checker, and answer the quiz.
- 06:38
Then decorate two small functions of your own and inspect their names and counts. If you later use a decorator from a library, inspect its documented behavior in the same way: argument forwarding, return values, exceptions, state, and metadata. A decorator is a function transformation, and clear tests tell you whether that transformation respects the contract you intended.
Understand the concept
A decorator receives a function and returns a callable that will stand in for it. Writing @count_calls above a function definition is equivalent to defining the function and then assigning count_calls(function) to its name. Decoration happens when that definition executes; the wrapped function is called later. Functions can be passed around like other Python values, so a decorator is a practical use of functions as arguments and return values.
The inner wrapper uses *args and **kwargs to accept positional and keyword arguments without guessing the decorated function's signature. It calls the original with those same arguments and returns the original result. Forgetting that return changes a useful result into None. This general wrapper supports a range of functions, though it does not validate their arguments for them.
functools.wraps copies useful metadata such as __name__ and __doc__ to the wrapper and sets __wrapped__ for introspection. Without it, help and debugging tools usually show the generic name wrapper. wraps does not make a decorator thread-safe, nor does it magically preserve a statically inspectable signature for every tool. Use it for ordinary wrappers and be explicit about any behavior your decorator changes.
The checked exercise stores call_count on each returned wrapper. Initialize it when the decorator runs, increment it each time that wrapper is invoked, and call the original exactly once. Two independently decorated functions must have separate counters. Try both positional and keyword calls, a function returning None, and a function that raises: count the attempted call even when the original raises.
- A decorator transforms a callable
- @decorator is assignment after definition
- *args and **kwargs forward a call
- functools.wraps preserves useful metadata
See it step by step
Read the code, predict the output, then compare it with the result.
01. Apply a decorator manually
def add_marker(func):
def wrapper():
return '[' + func() + ']'
return wrapper
def greet():
return 'hello'
decorated = add_marker(greet)
print(greet())
print(decorated())hello
[hello]The decorator receives greet and returns a new wrapper; the original greet still works before reassignment.
02. Forward arguments and preserve the return value
from functools import wraps
def announce(func):
@wraps(func)
def wrapper(*args, **kwargs):
print('calling', func.__name__)
return func(*args, **kwargs)
return wrapper
@announce
def multiply(a, b=2):
return a * b
print(multiply(3, b=4))
print(multiply.__name__)calling multiply
12
multiplyThe wrapper accepts both argument forms, returns 12 from multiply, and keeps the decorated name.
03. Keep state on one decorated function
from functools import wraps
def count_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
wrapper.call_count += 1
return func(*args, **kwargs)
wrapper.call_count = 0
return wrapper
@count_calls
def double(number):
return number * 2
print(double(4))
print(double(5))
print(double.call_count)8
10
2The closure retains func, while the returned wrapper stores and updates its own count.
A closer look
Follow the reasoning, inspect each result, then try the suggested changes in the console below.
See when decoration happens
A decorator is a callable that receives another callable and returns its replacement. The @ syntax is compact, but it does not create a new kind of function: assigning decorated = mark(greet) has the same transformation effect as placing @mark over a definition and then using the name greet. The original function object can still be kept in a separate variable. This distinction helps when debugging whether a message belongs to decoration time or to invocation time.
The decorator below prints preparing when it is applied, before either decorated call. Each later call enters the wrapper, which prints calling and returns the original result inside brackets. Run the snippet and compare the three stages. A decorator that unexpectedly opens a network connection during preparation would do so as the module loads; keep setup light unless that behavior is intentional.
def mark(func):
print('preparing', func.__name__)
def wrapper():
print('calling', func.__name__)
return '[' + func() + ']'
return wrapper
def greet():
return 'hello'
decorated = mark(greet)
print(decorated())
print(decorated())preparing greet
calling greet
[hello]
calling greet
[hello]- mark runs once when it receives greet and returns wrapper.
- Each decorated call runs wrapper and then greet.
- Returning the value from wrapper preserves the useful result.
Forward both kinds of arguments and keep metadata
A wrapper that accepts no parameters only works around functions that take no parameters. A general wrapper collects positional arguments in *args and keyword arguments in **kwargs, then forwards both to the wrapped callable. It should return the wrapped callable's result. If it merely calls func without return, any non-None result disappears and callers may fail far from the decorator itself.
functools.wraps(func) also matters. It copies the original name and other useful metadata onto wrapper, and exposes the original callable through __wrapped__. That helps inspection tools and readers discover what was decorated. It does not change the mathematical result of a call. In the example, repeat receives a positional word and a keyword times value; the decorator records the call while preserving the returned string and the visible function name.
from functools import wraps
def announce(func):
@wraps(func)
def wrapper(*args, **kwargs):
print('calling', func.__name__)
return func(*args, **kwargs)
return wrapper
@announce
def repeat(word, times=2):
return word * times
print(repeat('ha', times=3))
print(repeat.__name__)
print(repeat.__wrapped__.__name__)calling repeat
hahaha
repeat
repeat- *args and **kwargs preserve the caller's argument forms.
- The wrapper returns the exact result from repeat.
- wraps keeps the public name and a reference to the original function.
Attach independent state to each wrapper
A closure keeps access to func after count_calls has returned. We can also put a small counter attribute on the returned wrapper. Each time the wrapper is called, it increments its own call_count and calls func exactly once. Since every decoration creates a new wrapper object, two decorated functions get independent counters. The count is useful for a single-process demonstration; a production counter shared across threads would need synchronization or a different monitoring design.
The count increases before calling the original. That means an attempted call is counted even when the original raises. The prepared exercise asks for that behavior and checks return values, keyword forwarding, metadata, and independent counters. Do not store the count in one global variable, because then unrelated functions would influence one another. Try decorating another arithmetic function and inspect both counts after alternating calls.
from functools import wraps
def count_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
wrapper.call_count += 1
return func(*args, **kwargs)
wrapper.call_count = 0
return wrapper
@count_calls
def add(a, b):
return a + b
@count_calls
def square(number):
return number * number
print(add(2, b=3), square(4))
print(add.call_count, square.call_count)
print(add(10, 1), add.call_count)5 16
1 1
11 2- Decoration creates a distinct wrapper for add and square.
- Both calls return their original arithmetic results.
- The second add call updates only add.call_count.
Try it in Python
Edit the example and run it. Python starts in your browser the first time you click Run.
Python console
Ready to runNeed input()? Add one value per line
Your output appears here.
Need a hint?
Inside wrapper, place wrapper.call_count += 1 before return func(*args, **kwargs). The counter belongs to the particular wrapper returned by this decoration.
Complete the task and select Check task to verify your code.
Quick quiz
Three questions. You can change your answers and try again.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
Forgetting to return the wrapper
def announce(func):
def wrapper():
return func()
# no return wrapperdef announce(func):
def wrapper():
return func()
return wrapperWhat happens: The decorated name becomes None, so calling it raises TypeError.
The decorator itself must return the callable that replaces the original name.
Discarding the original result
def wrapper(*args, **kwargs):
func(*args, **kwargs)def wrapper(*args, **kwargs):
return func(*args, **kwargs)What happens: A function that returned a value now appears to return None.
Return the original result unless changing it is the deliberate purpose of the decorator.
Losing the decorated function's name
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapperWhat happens: The decorated function reports __name__ as wrapper.
functools.wraps keeps useful metadata and exposes __wrapped__.