Python decorators explained from the ground up: how the @ symbol wraps and replaces your function, how to write decorators that take arguments, why you always reach for functools.wraps, and the real-world patterns you will actually use, like timing, retry, and caching.
“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”
Martin Fowler, Refactoring
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 17 minutes
You have seen @staticmethod. You have seen @property. Maybe you copy-pasted a @login_required off a Flask tutorial and it just worked. But what does that little @ symbol actually DO? Here is the one-sentence answer that unlocks everything: it quietly swaps your function out for a different one. That is it. Every other thing decorators do is just a knock-on effect of that single swap.
Here is a picture that makes it click. Think of a phone screen protector. The phone underneath still does everything it always did, you have not changed the phone at all. But now every tap passes through the protector first. A decorator is exactly that protector for a function: your original function stays the same, and the decorator wraps a thin layer around it that runs a bit of extra code before and after each call. That layer is what people actually call when they call your function now.
Python decorators are everywhere once you start looking. Flask uses @app.route, pytest uses @pytest.fixture, and dataclasses use @dataclass. This post walks you from that one-line idea all the way to writing solid, production-ready decorators: ones that take arguments, that keep your function’s identity intact with functools.wraps, and that solve real problems like timing slow code, retrying flaky calls, and caching expensive results.
Table of Contents
The Mystery: Sugar Syntax That Changes Everything
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The diagram traces the whole decorator wrapping mechanism. The @decorator line takes your original function, hands it to the decorator, and the decorator hands back a wrapper function that quietly takes its place. From then on, when you call your function, you are really calling the wrapper, which gets to run code before and after the real thing. This one pattern is how @login_required, @cache, and every other Python decorator works under the hood. Once you see it here, you see it everywhere.
📄 the_mystery.py: these two blocks do the exact same thing
# Version 1: with @ syntax
@log_calls
def greet(name):
return f"Hello, {name}"
# Version 2: without @ syntax (exactly what Python does)
def greet(name):
return f"Hello, {name}"
greet = log_calls(greet) # greet is REPLACED
# They're identical. The @ is just sugar.
# greet no longer points to your original function.
# It points to whatever log_calls() returned.
What happened here: @log_calls is pure syntactic sugar, a shorthand and nothing more. Python compiles your greet function, then immediately hands it to log_calls(). Whatever log_calls gives back becomes the new greet. Your original function object has not vanished. It is still alive in memory, tucked away inside the closure that log_calls returned. If closures from the closures tutorial made sense to you, this should click right away. A decorator is just a closure with a friendly bit of syntax on top. One note before you paste this anywhere: log_calls stands in for any decorator function and is defined fully in the next section, so this block is here to show the equivalence, not to run as-is.
Step-by-Step Execution Trace
Let us slow the whole thing down and watch it happen one step at a time. The trick to understanding decorators is spotting which code runs once, at decoration time, and which code runs again on every single call. It is like installing an app versus opening it: installation happens once, opening it happens every day. In the trace below we greet two users named Rahul and Viraj, so you can see exactly which lines fire once and which fire on every call. Read the comments in order, they are numbered.
📄 execution_trace.py: a simple decorator, step by step
def log_calls(func):
"""Step 2: This function receives greet as 'func'"""
print(f"Decorating {func.__name__}") # Runs at DECORATION time, not call time
def wrapper(*args, **kwargs):
"""Step 4: This runs every time greet() is called"""
print(f"Calling {func.__name__} with {args}")
result = func(*args, **kwargs) # Call the original function
print(f"{func.__name__} returned {result}")
return result
return wrapper # Step 3: wrapper replaces greet
# Step 1: Python compiles greet, then calls log_calls(greet)
@log_calls
def greet(name):
return f"Hello, {name}"
# Step 5: Calling greet() actually calls wrapper()
print(greet("Rahul"))
print(greet("Viraj"))
▶ Output
Decorating greet
Calling greet with ('Rahul',)
greet returned Hello, Rahul
Hello, Rahul
Calling greet with ('Viraj',)
greet returned Hello, Viraj
Hello, Viraj
What happened here: Look closely at the output. “Decorating greet” prints exactly once, at decoration time, the moment Python runs the @log_calls line. But “Calling greet” prints on every single call. That split is the whole idea. The body of log_calls runs one time to set things up, and the wrapper it returns is what runs over and over, because greet now points at wrapper, a closure that has captured the original func inside it. Decorator code: once. Wrapper code: every call. Burn that distinction into your memory and decorators stop being mysterious.
The functools.wraps Problem
There is a sneaky cost to all this wrapping, and almost every beginner gets bitten by it. When you replace greet with wrapper, the name, the docstring, and the rest of the original function’s identity get replaced too. Your function quietly forgets who it is. Watch what happens to a small tax calculator we wrote for an employee named Niranjan.
📄 wraps_problem.py: your decorated function forgets who it is
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def calculate_tax(income):
"""Calculate income tax for Niranjan's salary."""
return income * 0.3
# The function's identity is GONE
print(calculate_tax.__name__) # wrapper <-- NOT calculate_tax!
print(calculate_tax.__doc__) # None <-- docstring lost!
▶ Output
wrapper None
That broken identity quietly poisons everything downstream: help() shows nothing useful, debuggers and stack traces say wrapper instead of your real function name, and tools like Sphinx generate the wrong docs. The good news is the fix is one line. Picture a courier repacking a parcel into a plain brown box and forgetting to copy the address label across. functools.wraps is the rule that says “copy the label over before you ship it.”
📄 wraps_fix.py: functools.wraps copies the label across
from functools import wraps
def my_decorator(func):
@wraps(func) # Copy __name__, __doc__, __module__, etc.
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def calculate_tax(income):
"""Calculate income tax for Niranjan's salary."""
return income * 0.3
print(calculate_tax.__name__) # calculate_tax <-- correct!
print(calculate_tax.__doc__) # Calculate income tax...
▶ Output
calculate_tax Calculate income tax for Niranjan's salary.
Rule of thumb: every decorator you write should use @wraps(func) on its wrapper. No exceptions. It costs you one import and one line, and it saves a future teammate (or future you) from a baffling debugging session.
Decorators That Accept Arguments
What if you want to write @retry(max_attempts=3) instead of a plain @retry? That extra pair of parentheses changes the game. You now need a decorator factory: a function whose only job is to take your settings and hand back a decorator. Think of it like a coffee machine with buttons. retry(max_attempts=3, delay=0.1) is you pressing the buttons to configure the machine, and what comes out is a ready-to-use decorator brewed to your taste.
📄 decorator_with_args.py: three layers of nesting
from functools import wraps
import time
def retry(max_attempts=3, delay=1):
"""Decorator factory: returns a decorator configured with max_attempts and delay."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
return wrapper
return decorator
@retry(max_attempts=3, delay=0.1)
def fetch_user_data(user_id):
"""Simulate an unreliable API call."""
import random
if random.random() < 0.7:
raise ConnectionError("Server timeout")
return {"id": user_id, "name": "Pravin", "age": 28}
# Each call retries up to 3 times
result = fetch_user_data(42)
print(result)
▶ Output (random: one real run, retries vary each time)
Attempt 1 failed: Server timeout. Retrying in 0.1s...
Attempt 2 failed: Server timeout. Retrying in 0.1s...
{'id': 42, 'name': 'Pravin', 'age': 28}
What happened here: follow the calls. retry(max_attempts=3, delay=0.1) runs first and returns decorator. Then Python applies that to your function, so decorator(fetch_user_data) runs and returns wrapper. So @retry(max_attempts=3) is actually two function calls stacked back to back, not one. The outer call sets the options, the inner call does the wrapping. That is the three-layer shape you keep seeing with parametrized decorators: factory, then decorator, then wrapper. Since the fake API (Application Programming Interface) fails about 70% of the time, the output is genuinely random.
The run shown here failed twice, then succeeded on the third try and returned the profile of a user named Pravin. Run it yourself and you might see zero retries, or all three attempts fail and a ConnectionError bubble up, which is exactly what the real code does when it runs out of attempts.
Real-World Decorator Patterns
Timing Decorator
Timing is where Python decorators first earn their keep for most people. A timing decorator is a stopwatch you can clip onto any function: click start, let the function run, click stop, read the time. Say an HR manager named Anvay complains that the payroll job feels slow. Instead of guessing, you clip @timer on and let the numbers talk. Here it crunches 20,000 salary records for employees like Vinay and Prathamesh.
📄 timer.py: measure how long any function takes
from functools import wraps
import time
def timer(func):
@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 process_records(records):
"""Process Anvay's batch of employee records."""
total = sum(r["salary"] for r in records)
return total
data = [{"name": "Vinay", "salary": 75000}, {"name": "Prathamesh", "salary": 82000}] * 10000
result = process_records(data)
print(f"Total payroll: {result:,}")
▶ Output (the exact time varies per machine)
process_records took 0.0015s Total payroll: 1,570,000,000
What happened here: the beauty of timer is that it does not care what process_records does. It starts a clock, calls the real function, stops the clock, and prints the gap. Because wrapper takes *args, **kwargs and passes them straight through, this exact decorator works on any function in your codebase. Slap @timer on top, and you instantly know how slow it is. That is the everyday strength of decorators: write the behavior once, reuse it everywhere.
Caching Decorator (Memoization)
Here is a classic. The naive fibonacci function recomputes the same values thousands of times over, so fibonacci(35) crawls. A caching decorator fixes that by remembering answers it has already worked out, like jotting a hard sum on a sticky note so you never have to redo it. The fancy name for this is memoization, but it is really just “look it up before you compute it.”
📄 cache.py: build your own before reaching for functools.lru_cache
from functools import wraps
def memoize(func):
cache = {}
@wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@memoize
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Without memoize: fibonacci(35) takes ~4 seconds
# With memoize: instant
print(fibonacci(35)) # 9227465
print(fibonacci(100)) # 354224848179261915075
▶ Output
9227465 354224848179261915075
Production note: in real code, do not write your own memoize. Python already ships functools.lru_cache and functools.cache (the simpler cache arrived in Python 3.9) for exactly this job, and they handle thread safety and cache size for you. The reason we built one by hand is to see that a cache is nothing magical: it is just a closure holding onto a dictionary. Once that clicks, @lru_cache stops looking like a black box.
Stacking Multiple Decorators
📄 stacking.py: order matters, bottom-up wrapping, top-down execution
from functools import wraps
def bold(func):
@wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
@bold # Applied second (outer wrapper)
@italic # Applied first (inner wrapper)
def greet(name):
return f"Hello, {name}"
# Equivalent to: greet = bold(italic(greet))
print(greet("Viraj"))
▶ Output
<b><i>Hello, Viraj</i></b>
What happened here: stacked decorators apply from the bottom up. italic sits closest to the function, so it wraps first, then bold wraps that result. But when you actually call greet, execution flows from the top down: bold‘s wrapper runs first, calls italic‘s wrapper, which finally calls the real greet. Picture a set of Russian nesting dolls. The last decorator you write is the outermost doll, the one you open first. That is why the bold tags end up on the outside and the italic tags on the inside.
Class-Based Decorators
Every decorator so far has been a function that returns a function. But there is another way that comes in handy when your decorator needs to remember things between calls, like a running total. You can use a class instead. Picture a security guard with a hand clicker at a mall gate: every visitor passes through the same gate, and the guard clicks once per entry, so the count survives from one visitor to the next. The trick that makes this work in Python is the __call__ method, which lets an object be called like a function. Here we count how many times a payment function gets invoked as two users, Rahul and Niranjan, get charged.
📄 class_decorator.py: using __call__ instead of a closure
class CountCalls:
"""Track how many times a function is called."""
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"{self.func.__name__} called {self.count} time(s)")
return self.func(*args, **kwargs)
@CountCalls
def process_payment(amount, user):
return f"Charged {user} ₹{amount}"
print(process_payment(500, "Rahul"))
print(process_payment(1200, "Niranjan"))
print(f"Total calls: {process_payment.count}")
▶ Output
process_payment called 1 time(s) Charged Rahul ₹500 process_payment called 2 time(s) Charged Niranjan ₹1200 Total calls: 2
What happened here: not every decorator has to be a function. When Python sees @CountCalls, it calls CountCalls(process_payment), which runs __init__ and stores your function on the object. That object then becomes the new process_payment. Every time you call it, Python runs the object’s __call__ method, which is what makes an instance behave like a function. The win here is state. The count attribute lives on the object, so you can read process_payment.count from the outside. With a closure-based decorator, that running count would be hidden away and much harder to reach.
Production Reality
Here is the payoff for all this. Once the wrapping pattern clicks, the Python decorators you meet in real frameworks stop being magic spells you copy without understanding. Flask’s @app.route("/") is just a decorator with arguments. Pytest’s @pytest.fixture is a decorator. Django’s @login_required is a decorator. @dataclass is a decorator that reshapes a whole class. Even the @property you met in the property decorators tutorial is a decorator under the hood. You now know what every one of them is really doing: taking a function or class in, and handing back a smarter version.
Common Misconceptions
Two ideas trip people up again and again. These are not typos or syntax slips, they are wrong mental models, and a wrong mental model will quietly mislead you for months. Get these two straight and you are ahead of most developers who use Python decorators every day.
❌ Misconception: “Decorators modify the original function”
# Decorators do NOT modify the original function. # They REPLACE the name binding with a new function (the wrapper). # The original function object still exists, captured inside the closure. # @wraps(func) even stores it on wrapper.__wrapped__ so you can still reach it.
❌ Misconception: “Forgetting @wraps doesn’t matter”
# Without @wraps: # - help(your_function) shows the wrapper's docstring (or None) # - Debuggers and tracebacks show "wrapper" instead of your function name # - Sphinx and other doc tools generate the wrong documentation # - your_function.__wrapped__ is missing, so you cannot reach the original # Always use @wraps(func). Always.
Conclusion
So that is the whole story. Python decorators wrap a function to add behavior without touching the original code. The @decorator line is just sugar for func = decorator(func). Reach for functools.wraps to keep your function’s identity, add one more layer of nesting when your decorator needs arguments, and switch to a class with __call__ when you need to hold state between calls. From here on, every Flask route, pytest fixture, and dataclass you see is just this pattern wearing a different hat.
Decorators change how functions behave. Context managers change how resources are handled. In the context managers tutorial up next, you will meet the with statement, the __enter__ and __exit__ protocol, and @contextmanager, the pattern that guarantees your cleanup code runs even when an exception blows up halfway through.
Want to revisit closures first, or jump ahead to a different topic? The full index of every post lives at the Python + AI/ML tutorial series home.
Frequently Asked Questions
What is a decorator in Python?
Python decorators are functions that take another function as input and return a modified version (usually a wrapper function). The @decorator syntax is shorthand for func = decorator(func). Decorators use closures to capture the original function and add behavior before or after it.
What does functools.wraps do?
@wraps(func) copies the original function’s metadata (__name__, __doc__, __module__, __qualname__, __dict__, __wrapped__) onto the wrapper function. Without it, the decorated function loses its identity.
How do I write a decorator that accepts arguments?
Use a decorator factory, a function that takes the arguments and returns the actual decorator. This gives you three nested functions: factory(args) returns decorator(func), which returns wrapper(*args, **kwargs). Example: @retry(max_attempts=3) calls retry(3), which returns the decorator that then wraps your function.
What order do stacked decorators execute?
Decorators are applied bottom-up (closest to the function first) but execute top-down when called. @A @B def f means f = A(B(f)). When calling f(), A’s wrapper runs first, then B’s wrapper, then the original f.
Can I decorate a class instead of a function?
Yes. A class decorator receives a class and returns a modified class (or a completely different one). @dataclass is the most famous example: it takes a class with annotations and quietly adds __init__, __repr__, __eq__, and more.
Try It Yourself
Try building a @validate_types decorator factory that checks argument types at runtime. Used as @validate_types(str, int) on a function like register(name, age), it should raise a TypeError if name is not a string or age is not an int. So signing up a new user named Aditi with register('Aditi', 25) should pass, while register(25, 'Aditi') should raise. Hint: use *args to grab the incoming arguments and zip() to pair each one with its expected type. Remember to put @wraps(func) on your wrapper.
Interview Questions on Python Decorators
How interviewers actually probe this topic: real scenarios, with answers you can say out loud.
Q: You deploy a new @audit_log decorator on an API view. Production tracebacks now say “wrapper” everywhere and help() on the view prints nothing useful. What happened, and what is the one-line fix?
The decorator’s wrapper replaced the view without copying its metadata, so __name__ became “wrapper” and __doc__ became None. The fix is to put @functools.wraps(func) on the wrapper. It copies __name__, __doc__, __module__, and friends onto the wrapper, and also stores the original function on wrapper.__wrapped__ so debugging tools can unwrap it.
Q: Your team put @retry(max_attempts=3) on a charge_card() function. A customer reports being charged twice for a single order. What went wrong?
Retry is only safe on idempotent operations, and charging a card is not one. The charge can succeed on the server while the response times out on the way back, so the wrapper sees an exception and fires the charge again. The fix is to remove blanket retries from non-idempotent calls, or send an idempotency key with each charge so the server rejects duplicates, and only retry on errors that guarantee nothing happened, like a connection refused before the request was sent.
Q: Why does @retry(max_attempts=3) need three nested functions when @timer only needs two?
@timer receives the function directly, so a decorator plus a wrapper is enough. @retry(max_attempts=3) has parentheses, which means Python first calls retry(max_attempts=3) and uses whatever it returns as the decorator. That forces a factory layer that captures the settings and returns the real decorator, which in turn returns the wrapper. Factory, decorator, wrapper: three layers.
Q: You add @functools.lru_cache to a method, memory keeps climbing until the service restarts, and one call with a list argument raised TypeError: unhashable type. What do you check first?
Two things. First, check maxsize: lru_cache(maxsize=None) and functools.cache grow without bound, and on a method every cache key includes self, so each cached entry also keeps an instance alive and blocks garbage collection. Second, cache keys are built by hashing the arguments, and a list is unhashable, which explains the TypeError; pass a tuple instead. For methods, prefer caching a module-level helper or set a sensible maxsize.
Q: How would you write a decorator that counts calls and lets outside code read that count?
Three options. A closure with a nonlocal counter works but hides the value from callers. A class-based decorator with __call__ stores the count as an instance attribute, so anyone can read func.count. Or you can attach an attribute directly to the wrapper function, like wrapper.count = 0 at decoration time and wrapper.count += 1 inside. The class and the wrapper attribute both make the state publicly readable.
Q: When does the code in the decorator body (outside the wrapper) actually run, and why can that slow down application startup?
It runs the moment the def statement of the decorated function executes, which is at module import time, not at first call. So anything heavy in the decorator body, like reading config files or opening connections, runs once per decorated function while your app is still importing modules. Keep decoration-time work light and push expensive work into the wrapper so it happens lazily on the first real call.
Further reading: the official Python documentation is the authoritative source on this.
Related Posts
Previous: Python: Closures, Lexical Scoping & Practical Uses
Next: Python: Context Managers, with Statement & Custom Managers
Series Home: Python + AI/ML Tutorial Series

No comment