Python Lesson 24: Your Decorator Is Silently Deleting Your Function's Identity
š Full written solution: https://interview-kit-fe.vercel.app/python-lesson-24-decorators-and-closures š» Code on GitHub: https://github.com/timepasshub2539-hue/leetcode-python/tree/main/python-lesson-24-decorators-and-closures Chapters: 0:00 Why Does This Function Still Remember Something That's Gone? 0:31 The Trick Behind sort(), Callbacks, and Every Framework You Use 0:52 A Function Inside a Function 1:04 Why It's Not Garbage Collected 1:23 A Closure You Can Feel 1:37 The Problem Decorators Solve 1:58 Anatomy of a Decorator 2:13 The At Symbol, Explained 2:27 Timing a Real Function 2:44 The Wrapped Function Loses Itself 2:59 functools.wraps Fixes It 3:15 What to Remember Closures aren't magic ā they're just a function carrying its birth environment with it, and that's exactly why sort(), callbacks, and every decorator in Python work. This lesson builds a closure from scratch, shows why it isn't garbage collected, then writes a real decorator that times a function ā and breaks it, so you can see functools.wraps fix the damage. You'll learn: first-class functions, closures and variable capture, the @ syntax explained, why wrapped functions lose their name/docstring, and how functools.wraps restores it. #python #decorators #closures #pythontutorial #functoolswraps Watch next: - Python Lesson 22: @dataclass Cuts 15 Lines to 2 ā Here's the Trick: https://youtu.be/aVMY_xACXL0 - This Bug Waits Until Your 5th Class to Bite You #shorts: https://youtu.be/WfGv0CMCHd8 - Lesson 20: This Bug Waits Until Your 5th Class to Bite You: https://youtu.be/i0cB10femTI
Introduction
If you've written Python for more than a few weeks, you've probably used a decorator without fully knowing what it does. @app.route, @staticmethod, @pytest.fixture, @lru_cache ā they're everywhere, and most tutorials treat them like syntax you memorize rather than a mechanism you understand.
That gap matters more than it looks like it should. Interviewers ask about decorators not because you'll write one every day, but because explaining one forces you to demonstrate that you understand functions as values, scope, and closures ā three ideas that show up constantly in real Python code, from callbacks to sorted() to Flask routes. And there's a genuinely nasty bug hiding inside almost every decorator you'll ever write: it silently deletes the identity of the function it wraps. Miss that, and you'll spend an afternoon debugging something that looks like a completely unrelated problem ā a broken introspection tool, a missing docstring, a test framework that can't find your test.
This lesson builds the whole picture from the ground up: functions as first-class values, closures, the decorator pattern itself, and the fix for the identity bug most people don't know exists until it bites them.
Problem Overview
There's no LeetCode-style input/output here ā this is a conceptual problem, and it's this: how do you add behavior to a function ā timing, logging, caching, access control ā without modifying the function's own code, and without losing information about what that function actually is?
Rewritten as a sequence of sub-problems:
- Can you treat a function as data ā store it, pass it around, return it from another function?
- If an inner function uses a variable from an outer function, and the outer function has already finished running, how does the inner function still have access to that variable?
- Given (1) and (2), how do you build a wrapper that adds behavior around any function, generically?
- Once you build that wrapper, what breaks about the original function, and how do you fix it?
Each one builds on the last. That's exactly how decorators are usually taught badly ā as syntax ā and exactly why they should be taught as a sequence of small, provable facts.
Example
Start with the simplest possible proof that functions are values:
def greet():
return "hello"
say_hi = greet
print(say_hi()) # "hello"
Nothing about greet changed. say_hi isn't a copy of the result of greet ā it's a reference to the same function object. Both names point at the same thing in memory.
Next, the closure example:
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
make_counter() runs, returns increment, and finishes. By every normal rule of scope, count should be gone. It isn't ā calling counter() again shows it's still there, still climbing. That's the whole mystery this lesson is built around.
Intuition
Here's how an engineer arrives at this without being told the answer in advance.
Start from something you already accept: sorted(names, key=len). You're passing the function len ā not calling it, not stringifying it ā as a plain argument. If a function can be an argument, it can be a return value too. Nothing in the language distinguishes functions from any other object; that symmetry is what makes the rest of this work.
Now ask: if make_counter returns increment, and increment references count, what does Python do with count when make_counter exits? The naive answer ā "it's deleted, like any local variable" ā would make increment crash the moment you called it. But it doesn't crash. So Python must be doing something smarter: it looks at increment's body, notices it references a variable from the enclosing scope, and keeps that variable alive specifically because increment still needs it. That bundle ā the function plus the variables it captured ā is a closure.
Once closures click, decorators aren't a new concept, they're an application of one. If you can write a function that takes another function and returns a new function that wraps it, you've built a decorator by hand. The @ syntax is just sugar for calling that wrapping function and reassigning the result to the original name:
@my_decorator
def do_work():
...
# is exactly equivalent to:
def do_work():
...
do_work = my_decorator(do_work)
That's the entire trick. No new execution model, no magic ā just a function call and a reassignment.
Brute Force Solution
"Brute force" here means: add the behavior (say, timing) by hand, inside every function you want timed.
import time
def slow_task():
start = time.perf_counter()
# actual work
total = sum(i * i for i in range(1_000_000))
elapsed = time.perf_counter() - start
print(f"slow_task took {elapsed:.4f}s")
return total
Advantages: obvious, no indirection, easy to step through in a debugger.
Disadvantages: you repeat the timing boilerplate in every function that needs it. Change how you log timing ā say, switch from print to a logger ā and you have to edit every single function. This is the exact repetition that should make you reach for a decorator.
Complexity: O(1) extra time and space overhead per call beyond the function's own cost ā the inefficiency here isn't computational, it's maintenance cost.
Optimal Solution
Write the timing logic once, as a decorator that wraps any function:
do_work()
ā
ā¼
wrapper() ā this is what actually gets called now
āā start timer
āā call original do_work()
āā stop timer, print elapsed
āā return do_work()'s result
The decorator function takes func as a parameter, defines wrapper as a closure around it (exactly like increment closed over count), and returns wrapper. When you call the decorated function, you're actually calling wrapper, which calls the original function internally.
Python Code
import functools
import time
def timer(func):
"""Decorator that prints how long `func` took to run."""
@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_task(n: int) -> int:
"""Sum of squares from 0 to n-1."""
return sum(i * i for i in range(n))
if __name__ == "__main__":
slow_task(1_000_000)
print(slow_task.__name__) # "slow_task", not "wrapper"
print(slow_task.__doc__) # original docstring preserved
Code Walkthrough
def timer(func):ā the decorator itself; it accepts the function being decorated.def wrapper(*args, **kwargs):ā the closure.*args, **kwargslet it wrap any function signature, not just no-argument ones.func(*args, **kwargs)ā calls the original function with whatever arguments were passed towrapper, and captures the result.func.__name__inside the print statement ā note this reads the original function's name, not the wrapper's, becausefunctools.wrapshasn't affectedfunc, onlywrapper's metadata.return wrapperā the decorator hands back the new function; this is whatslow_taskgets reassigned to.@functools.wraps(func)ā copies__name__,__doc__,__module__, and a few other attributes fromfuncontowrapper, so that after decoration,wrapperlooks likefuncfrom the outside.
Dry Run
slow_task = timer(slow_task)runs at import time (triggered by@timer).timerreceives the originalslow_taskasfunc, defineswrapper, appliesfunctools.wraps(func)towrapper, and returnswrapper.- The name
slow_taskin the module now points towrapperā butwrapper.__name__was copied from the original, so it reports"slow_task". - Calling
slow_task(1_000_000)actually callswrapper(1_000_000). wrapperrecordsstart, callsfunc(1_000_000)(the real computation), recordselapsed, prints the timing line, and returns the sum.slow_task.__name__prints"slow_task"andslow_task.__doc__prints the original docstring ā because offunctools.wraps. Remove that line and both would read"wrapper"/None.
Complexity Analysis
- Time: O(1) overhead added by the wrapper itself (two timer reads, one print), on top of whatever
funccosts. The wrapping doesn't change the asymptotic complexity of the wrapped function. - Space: O(1) extra ā the closure holds one reference (
func) plus whateverfunctools.wrapscopies over (references to strings/dicts, not deep copies). - These are correct because the decorator never touches the algorithm inside
func; it only adds bookkeeping around the call.
Alternative Solutions
Class-based decorators, using __call__, are a reasonable alternative when the decorator needs to hold more complex state (e.g., counting calls across many functions):
class Timer:
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
def __call__(self, *args, **kwargs):
start = time.perf_counter()
result = self.func(*args, **kwargs)
print(f"{self.func.__name__} took {time.perf_counter() - start:.4f}s")
return result
Functionally equivalent for this case, but heavier. Stick with the closure-based version unless you need per-decorator state that doesn't cleanly live in a closure variable.
Edge Cases
- Function with no arguments ā handled by
*args, **kwargsdefaulting to empty. - Function that raises an exception ā as written,
wrapperdoesn't catch it, so the exception propagates normally and the timing print never happens. Wrapfunc(*args, **kwargs)intry/finallyif you need timing even on failure. - Decorating a method ā
selfarrives as the first positional argument in*args, so this works unchanged. - Decorating an already-decorated function ā stacking decorators works because each one just wraps the previous wrapper; order matters (
@aabove@bmeansa(b(func))). - Missing
functools.wrapsā not a crash, but a silent correctness bug:__name__,__doc__, and__module__all report the wrapper's values instead of the original's.
Common Mistakes
- Forgetting
functools.wraps, which breaks introspection, documentation generators, and any code that keys off__name__(some test runners and CLI frameworks do). - Not accepting
*args, **kwargsin the wrapper, which breaks the decorator for any function that isn't zero-argument. - Forgetting to
return resultfrom the wrapper, silently turning every decorated function into one that returnsNone. - Confusing
@decoratorwith@decorator()ā a decorator factory (one that takes its own arguments, like@retry(times=3)) needs an extra layer of nesting; forgetting it causes aTypeErrorat decoration time. - Mutating shared state in a closure without
nonlocal, which raisesUnboundLocalErroron assignment inside the inner function.
Interview Questions
- What's the difference between a closure and a regular nested function?
- Why does
functools.wrapsmatter, and what specifically does it copy? - How would you write a decorator that also accepts arguments (like
@retry(times=3))? - Can you write a decorator that works on both regular functions and methods?
- What happens if you stack multiple decorators on one function ā what order do they execute in?
Similar Problems
- LeetCode-style "Design a Rate Limiter" ā often solved with a decorator-and-closure pattern that tracks call timestamps.
functools.lru_cacheimplementation ā a real-world decorator that uses closures to store a cache dict per decorated function.- Memoization exercises ā writing your own cache decorator is a natural next step after this lesson.
- Logging decorator exercises ā same shape as the timer here, swapping the timing logic for structured logging.
Key Takeaways
- Functions in Python are ordinary values ā they can be assigned, passed, and returned like anything else.
- A closure is a function bundled with the variables it captured from its enclosing scope, kept alive specifically because the inner function still needs them.
- A decorator is just a function that takes a function, defines a closure around it, and returns that closure ā the
@syntax is sugar for a call-and-reassign. - Wrapping a function this way silently replaces its
__name__and__doc__with the wrapper's own āfunctools.wrapsis the one-line fix, and it should be in every decorator you write.
Watch the Video
This lesson is easier to absorb watching it run live ā see the closure survive scope exit, watch the wrapped function lose its identity, and watch functools.wraps restore it in real time: {LINK}
About the Series
This is part of Fun with Learning Technology, a series built around one idea: don't just run the code, figure out why it works. Instead of memorizing syntax, each lesson takes one Python concept apart until the "magic" disappears and what's left is a small, provable mechanism you can reason about on your own.
Call To Action
If this helped the closure-and-decorator picture finally click, do three things: like the video on YouTube so more developers find it, subscribe to the channel for the rest of the Python series, and subscribe to the Substack for deeper written breakdowns like this one. Drop a comment with your guess from the video, or with a decorator pattern you'd like explained next ā every lesson in this series comes from a question someone actually asked.
The solution
import time
def timer(func):
def wrapper(*args):
start = time.time()
result = func(*args)
print(func.__name__, "took", time.time() - start)
return result
return wrapper
@timer
def slow():
time.sleep(1)
slow()Ready to try it yourself? Solve Python problems with instant feedback in the practice sandbox.
Practice now ā


