Suppose you are writing the backend for a small IRCTC-style ticket booking practice project in Python. You have a function book_ticket() that reserves a seat, a function cancel_ticket() that cancels one, and a function check_status() that looks up a PNR. Halfway through building this, your teacher adds a requirement: every single one of these functions must now print a timestamped log line before it runs, and every one of them must refuse to run at all if the user object passed in is not logged in. You could go into each function and paste in the same four or five lines of logging and login-checking code. But now imagine ten more functions arrive next week, and the login rule changes slightly the week after that. Copy-pasting the same block into every function is exactly the kind of repetition that turns a clean program into a maintenance nightmare — one bug fix has to be applied in fifteen places, and it is only a matter of time before you forget one of them.
A decorator is Python's answer to this exact problem: a way to attach extra behaviour — logging, timing, access checks, retry logic, caching — to a function from the outside, without rewriting a single line inside the function itself. By the end of this chapter you will be able to write your own decorators from scratch, read the @something syntax fluently, and know exactly what order things run in when several decorators are stacked on the same function.
Step one: functions are values, not just verbs
Before decorators make any sense, you need to fully absorb one fact about Python: a function is an object, just like a number or a string is an object. It can be stored in a variable, passed as an argument to another function, and returned as the result of another function. This might feel strange at first, because in everyday speech we think of a function as an action ("shout this text"), not as a thing you can hold. But in Python code, the name shout and the call shout("hi") are two different things — the first refers to the function object itself, the second actually runs it.
def shout(text):
return text.upper() + "!"
say = shout # no parentheses -> say now points to the SAME function object
print(say("hello")) # calling say(...) runs shout's code
Trace this line by line: def shout(...) creates a function object and binds the name shout to it. The line say = shout does not call the function — there are no parentheses — it simply makes a second name, say, point at that same function object. say("hello") now calls it with the argument "hello": inside, text.upper() turns "hello" into "HELLO", and + "!" appends an exclamation mark, giving "HELLO!". The program prints HELLO!.
The next idea you need is that a function can build and return another function. This is called a closure — the inner function "remembers" the variables from the outer function's scope even after the outer function has finished running.
def make_multiplier(n):
def multiply(x):
return x * n
return multiply # returning a function, not calling it
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
When make_multiplier(2) runs, it defines multiply with n fixed at 2 and returns that function object; double now refers to it. Crucially, multiply still "remembers" that n was 2, even though make_multiplier has already returned — that memory is the closure. So double(5) computes 5 * 2 = 10. Separately, triple holds its own version of multiply with n fixed at 3, so triple(5) gives 15. Two calls to make_multiplier produced two independent functions, each with its own private memory of n. This "function that returns a function which remembers something" pattern is precisely the machinery decorators are built from.
Step two: building a decorator by hand, no special syntax yet
A decorator is simply a function that takes a function as input and returns a new function as output — usually a wrapped version that does something extra before and/or after calling the original. Let's build one that logs every call to a function: what it was called with, and what it returned.
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
def add(a, b):
return a + b
add = log_calls(add) # replace add with its wrapped version
print(add(3, 4))
Read this carefully, because every piece is doing real work. log_calls takes one argument, func — this will be the original add function object. Inside log_calls, we define wrapper, a new function that accepts any arguments at all: *args collects positional arguments into a tuple, and **kwargs collects keyword arguments into a dictionary. We need this flexibility because log_calls should work on functions with completely different signatures — one argument, five arguments, keyword arguments, it shouldn't matter. Inside wrapper, we print a message, then call the original function with func(*args, **kwargs) — the * and ** here do the reverse job, unpacking the tuple and dictionary back into individual arguments — store its result, print a second message, and return that result so the caller doesn't lose it.
log_calls itself returns wrapper, not the result of calling anything. So the line add = log_calls(add) passes the original add function into log_calls, gets back the new wrapper function, and reassigns the name add to point at wrapper instead. The original function object still exists — it's held alive inside wrapper's closure as func — but the name add in your program now means "the wrapped version."
Now trace print(add(3, 4)) exactly. Since add is now wrapper, this calls wrapper(3, 4). Inside, args becomes the tuple (3, 4) and kwargs becomes the empty dictionary {}. The first print statement outputs Calling add with args=(3, 4), kwargs={} — note it says "add", not "wrapper", because func.__name__ refers to the original function's stored name. Then result = func(3, 4) calls the real, original add, computing 3 + 4 = 7. The second print outputs add returned 7. Finally wrapper returns 7, which the outer print(...) displays. The full output, in order, is:
Calling add with args=(3, 4), kwargs={}
add returned 7
7
Step three: the @ syntax is just a shortcut
Writing add = log_calls(add) right after every function you want to decorate is exactly what Python's @ syntax does for you automatically. These two pieces of code are 100% identical in behaviour:
def add(a, b):
return a + b
add = log_calls(add)
@log_calls
def add(a, b):
return a + b
Whenever Python's interpreter reaches a def statement with @log_calls written directly above it, it defines the function as normal and then immediately does add = log_calls(add) for you, using whatever name follows def. There is no new mechanism to learn here beyond what you already traced above — @ is purely a readability shortcut for "wrap this function with that decorator and rebind the name." The diagram below shows the full call flow when the decorated add(3, 4) is executed.
Notice what the diagram makes visible: the caller never talks to the original add directly at all. Every call to add(3, 4) actually goes to wrapper first, and wrapper chooses when — and whether — to call the real, original function hidden inside its closure. That "chooses whether" part is exactly what makes decorators useful for access control, which we'll see shortly.
Common misconception: "the decorator runs every time the function is called"
This is the single most common mistake students make with decorators, so let's correct it directly with a trace. There are actually two different pieces of code involved, and they run at two different times: the body of the decorator function (everything in log_calls outside of wrapper) runs exactly once, at the moment the @decorator line is processed — which is when the module is loaded, i.e. definition time. The body of wrapper runs once per call — that's call time. Watch what happens when we add a print statement outside wrapper:
def noisy(func):
print(f"Decorating {func.__name__}...") # runs ONCE, at definition time
def wrapper(*args, **kwargs):
print("Wrapper running...") # runs on EVERY call
return func(*args, **kwargs)
return wrapper
@noisy
def greet():
print("Hello!")
greet()
greet()
Trace this from the top. As soon as Python reaches @noisy / def greet(): ..., it defines the original greet function, then immediately calls noisy(greet) — and that call executes print(f"Decorating {func.__name__}...") right there, before any explicit call to greet() has even been written. This is why the very first line of output is the decoration message, not "Hello!" or "Wrapper running." Only after noisy returns wrapper, and the name greet is rebound to it, do the two explicit calls greet() and greet() run — and each one prints "Wrapper running..." followed by "Hello!", because each call re-enters wrapper's body from scratch. The full output is:
Decorating greet...
Wrapper running...
Hello!
Wrapper running...
Hello!
"Decorating greet..." appears only once, even though greet() was called twice — because wrapping happens once, at definition time; calling the wrapper happens every time you actually use the function.
A second gotcha: decorators change what your function "looks like"
Here is a subtle bug that trips up even experienced programmers. Try this after applying the plain log_calls decorator from earlier:
@log_calls
def add(a, b):
return a + b
print(add.__name__) # prints "wrapper" — not "add"!
This happens because add now refers to the wrapper function object, and wrapper's own built-in __name__ attribute is literally the string "wrapper" — Python has no way of knowing you intended it to "stand in" for add. This matters in real programs: debugging tools, error messages, and documentation generators often rely on __name__, and having every decorated function report itself as "wrapper" makes bugs much harder to track down. The fix is the standard-library helper functools.wraps, which copies the original function's name, docstring, and a few other details onto the wrapper:
from functools import wraps
def log_calls(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
print(add.__name__) # now correctly prints "add"
@wraps(func) is itself a decorator — one supplied by Python's standard library — applied to wrapper inside your own decorator. It doesn't change what wrapper does when called; it only patches its metadata afterwards so that, from the outside, the wrapped function still identifies itself correctly. Good practice: whenever you write a decorator meant for real use (not just a classroom example), add @wraps(func) to it.
Where decorators earn their keep: gatekeeping, not just logging
Logging is a useful first example because the trace is simple, but the real power of decorators is that wrapper gets to decide whether to call the original function at all. That makes decorators perfect for access control. Imagine an IRCTC-style booking function that should refuse to run for a user who isn't logged in:
def require_login(func):
def wrapper(user, *args, **kwargs):
if not user.get("logged_in"):
print("Access denied: please log in to IRCTC first.")
return None
return func(user, *args, **kwargs)
return wrapper
@require_login
def book_ticket(user, train_no, seat):
print(f"Ticket booked on train {train_no}, seat {seat} for {user['name']}")
guest = {"name": "Aisha", "logged_in": False}
member = {"name": "Rohan", "logged_in": True}
book_ticket(guest, "12951", "23")
book_ticket(member, "12951", "23")
Trace both calls. book_ticket(guest, "12951", "23") really calls wrapper(guest, "12951", "23"): here user is bound to guest, and args becomes ("12951", "23"). Since guest.get("logged_in") is False, the condition not user.get("logged_in") is True, so wrapper prints the denial message and returns None — the real book_ticket code never runs, and no ticket is booked. For book_ticket(member, ...), member.get("logged_in") is True, so the condition is False, the if block is skipped, and wrapper calls the original func(member, "12951", "23"), which prints the booking confirmation. Output:
Access denied: please log in to IRCTC first.
Ticket booked on train 12951, seat 23 for Rohan
Notice that book_ticket's own code was never touched to add this rule, and the exact same require_login decorator could be dropped onto cancel_ticket, check_status, or any future function that needs the same login check — one honest sentence of logic, reused everywhere, instead of copy-pasted fifteen times.
Stacking decorators: order matters
You can apply more than one decorator to the same function by stacking @ lines. The rule to memorise: they apply from the bottom upward — the decorator closest to def wraps the function first, and each decorator above it wraps the result of the one below.
def bold(func):
def wrapper(*args, **kwargs):
return "<b>" + func(*args, **kwargs) + "</b>"
return wrapper
def italic(func):
def wrapper(*args, **kwargs):
return "<i>" + func(*args, **kwargs) + "</i>"
return wrapper
@bold
@italic
def greet(name):
return f"Hello, {name}"
print(greet("Meera"))
Written without the shortcut, this stack is greet = bold(italic(greet)). So italic wraps the original greet first, producing an inner wrapper; then bold wraps that inner wrapper, producing the final, outer wrapper that the name greet now refers to. Trace greet("Meera"): it calls the outer, bold-built wrapper, which needs the result of calling the inner, italic-built wrapper first. That inner wrapper needs the result of the truly original greet("Meera"), which is "Hello, Meera". The italic wrapper wraps this as "<i>Hello, Meera</i>" and returns it outward. The bold wrapper then wraps that as "<b><i>Hello, Meera</i></b>". That final string is what gets printed:
<b><i>Hello, Meera</i></b>
The decorator nearest the function (italic) ends up as the innermost layer of the result; the one furthest from the function (bold) ends up as the outermost layer. If you swapped the order to @italic above @bold, the output would instead be <i><b>Hello, Meera</b></i> — same two decorators, different nesting, different result. This is why, in real projects, the order you stack decorators is a deliberate design choice, not an arbitrary one — a @require_login decorator, for instance, is normally placed so that it runs before a @log_calls decorator that logs successful bookings, so failed access attempts are never logged as successful calls.
Decorators you'll meet already built into Python
Once you can read the pattern, you'll start noticing it everywhere in real Python code. Inside classes, @staticmethod marks a method that doesn't need access to the object it's called on; @property lets you call a method without parentheses, as if it were a plain attribute. Python's standard library also ships @functools.lru_cache, a decorator that automatically remembers the results of expensive function calls so repeated calls with the same arguments return instantly instead of recomputing — genuinely useful, for example, when a function performs a slow calculation (like checking whether a large number is prime) that a program might call with the same input many times. Every one of these follows exactly the mechanism you've now traced by hand: a function wrapping another function, deciding what extra behaviour happens around the original call.
Check your understanding
- Given
def double(f): def wrapper(x): return f(x) * 2 return wrapperand@double def square(n): return n * n, what doessquare(3)return, and why? - In the
noisyexample, if you calledgreet()three times instead of twice, how many times would "Decorating greet..." print, and how many times would "Wrapper running..." print? - Why does a decorator's inner function almost always use
*args, **kwargsinstead of naming specific parameters like(a, b)? - If
@boldand@italicfrom this chapter were stacked in the opposite order —@italicabove@bold— on the samegreetfunction, what exact string wouldgreet("Meera")produce? - Without using
functools.wraps, what wouldbook_ticket.__name__print after applying@require_loginto it, and why is that surprising to a beginner?
Answers
square(3)returns18.square = double(square)replacessquarewithwrapper; callingwrapper(3)computesf(3) * 2, wherefis the originalsquare, sof(3) = 9, and9 * 2 = 18.- "Decorating greet..." still prints only once — decoration happens a single time, when the
@noisyline is processed, regardless of how many times you later callgreet(). "Wrapper running..." prints once per call, so three calls print it three times. - Because a decorator is usually written once and then applied to many different functions with different numbers and kinds of parameters. Naming specific parameters like
(a, b)would only work for functions with exactly that signature;*args, **kwargslets the same wrapper accept and forward any combination of positional and keyword arguments to whatever function it happens to be wrapping. - With
@italicon top, the stack becomesgreet = italic(bold(greet)), soboldwraps the original text first anditalicwraps the result of that. The output would be<i><b>Hello, Meera</b></i>— the tags are nested in the reverse order compared to the original stacking. - It would print
"wrapper", becausebook_ticketnow refers to thewrapperfunction defined insiderequire_login, and that function's own__name__attribute really is the literal string"wrapper"— Python has no automatic way to know it's meant to representbook_ticket. This is exactly why real decorators should apply@wraps(func)fromfunctoolsto the inner wrapper.
Summary
A decorator is a function that takes another function as input and returns a replacement function — almost always a wrapper — as output. The @decorator_name line above a def is pure shorthand for name = decorator_name(name); nothing about it is magic once you can trace that assignment by hand. Because functions are ordinary objects in Python, they can be passed around, wrapped in closures, and swapped out for enhanced versions without ever touching the original function's source code — which is exactly what makes decorators the right tool for cross-cutting concerns like logging, timing, caching, and access control that would otherwise have to be pasted into dozens of unrelated functions. The wrapper typically accepts *args, **kwargs so it can stand in for a function with any signature, calls the original function through a variable captured in its closure, and can choose to run before the call, after the call, or — as with require_login — skip the call entirely. When several decorators are stacked, they apply bottom-up: the one nearest the function wraps first and ends up innermost in the resulting behaviour, so order is a real design decision, not a stylistic choice. Finally, remember that a hand-written decorator silently overwrites the wrapped function's __name__ and other metadata unless you apply functools.wraps to the inner wrapper — a one-line habit that separates a decorator that merely works from one that's safe to use in a real codebase.