Decorators
The first time you see @app.get("/") or @property sitting on top of a function, it looks like a
spell. Something happens - the function gets powers it didn't ask for - and the mechanism is hidden
behind a symbol you've never been formally introduced to. People copy these incantations from tutorials
for years without ever knowing what the @ actually does.
Here's the relief up front: there is no magic. A decorator is an ordinary function. The @ is a
two-character shortcut for one line of plain Python you could write yourself. By the end of this phase
you'll be able to read any @something and know exactly what it's doing - and write your own.
To get there we need one idea first, and it's the idea most languages never make you think about.
Functions are first-class objects
What it actually is. In Python, a function is a value, just like 3 or "hello" or a list. The
def statement doesn't summon something special - it creates an object and binds a name to it. And
because a function is a value, you can do everything to it you'd do to any value: store it in a variable,
put it in a list, pass it to another function, and return it from one.
Why this matters here. Decorators are built out of this single fact. If a function can be passed around and returned like data, then a function can take another function as input and hand back a new one. That's the whole game. Let's prove each piece in code.
return +
# 1. A function is a value - bind it to another name
=
# 2. Pass a function as an argument
return
$ python firstclass.py
HELLO!
HIHI!
What just happened: yell = shout didn't call shout - note there are no parentheses. It bound a
second name to the same function object, so yell and shout are now two names for one thing.
apply_twice received shout as plain data in its func parameter and called it. A function went
into another function as an argument and got used there. (apply_twice(shout, "hi") runs
shout(shout("hi")) → shout("HI!") → "HI!!".)
📝 First-class - a value the language lets you pass, return, and store with no special ceremony. In Python, functions are first-class. So are classes and modules. This is why decorators are possible.
Functions can be defined inside, and returned out
The last piece: a function can be defined inside another function, and the outer function can return that inner function as its result.
# defined inside make_greeter
return f
return # hand the inner function back
= # this returns a function
=
$ python factory.py
Hello, Ana!
Hi, Bo!
What just happened: make_greeter("Hello") ran, defined a fresh greet inside, and returned it
without calling it. So say_hello is now a function. The clever part: the returned greet still
remembers the greeting it was born with ("Hello"), even though make_greeter already finished. That
remembered value is called a closure - the inner function closes over the variables it used from the
outer one.
📝 Closure - an inner function plus the outer-scope variables it captured. say_hello carries
greeting="Hello" around with it forever; say_hi carries "Hi". They're the same code with different
captured data.
Hold those two abilities - pass a function in, return a function out - together. Combine them, and you have a decorator.
A decorator is a function that takes a function and returns a function
What it actually is. A decorator is a function whose input is a function and whose output is a
(usually wrapped) function. You give it f; it gives you back a new function that does something extra
around f - runs code before, after, or instead of it - then hands control to f and returns its
result.
💡 Key point. That one sentence is the entire concept: takes a function, returns a function. Everything else - the
@, the arguments,functools.wraps- is detail layered on top of it.
A real example. Here's a decorator that logs every call. Read it bottom-up: log_calls takes a
function func, defines a wrapper that prints around calling func, and returns that wrapper.
=
return
return
return +
= # wrap it by hand - no @ yet
$ python logcalls.py
-> calling add
<- add returned 5
5
What just happened: log_calls(add) returned wrapper - a brand-new function that closes over the
original add (as func). We rebound the name add to point at that wrapper. So when we call
add(2, 3), we're really calling wrapper(2, 3): it prints, calls the real add through func,
prints again, and returns the result. The original function never changed - it got wrapped.
📝 *args, **kwargs - "accept any positional arguments and any keyword arguments." The wrapper uses
this so it works for any function regardless of its signature, then forwards everything to func
unchanged. You met functions back in Phase 4; this is where treating them as values pays off.
@decorator is sugar for f = decorator(f)
Now the reveal. That line add = log_calls(add) is exactly what @ does - it's just written above the
function instead of below it.
These two snippets are identical in behavior:
# The longhand you already understand:
return +
=
# The @ shorthand - same thing, said once:
return +
The @log_calls line means: "after defining add, immediately run add = log_calls(add)." That's the
whole definition of the @ syntax. It's not a keyword with hidden rules - it's a rewrite.
flowchart LR
A["@d above def f"] --> B["f = d(f)"]
B --> C["name f now points at<br/>d's returned wrapper"]
One idea: @d is a textual shortcut. The interpreter sees it, defines your function, then passes it
through d and rebinds the name to whatever d returns. Knowing this, you can read any decorator stack.
Here it is doing real work - a @timer that reports how long a function took:
=
=
= -
return
return
return
$ python timer.py
slow_sum took 0.0143s
9999990000
What just happened: @timer rewrote slow_sum into timer(slow_sum) - the wrapper that times the
call. We never touched the body of slow_sum; the timing logic lives entirely in the decorator and can
be slapped onto any function the same way. (Your exact seconds will differ - that's a live measurement,
not a fixed number.)
functools.wraps - don't lose the function's identity
There's a quiet bug in every decorator we've written so far. When you wrap a function, the name now
points at wrapper - so the original function's __name__ and docstring vanish, replaced by the
wrapper's. Tools that introspect functions (debuggers, docs generators, test frameworks) suddenly see
wrapper everywhere.
return
return
return +
# we'd hope for "add"
# we'd hope for "Add two numbers."
$ python noidentity.py
wrapper
None
What just happened: add is now the wrapper function, so add.__name__ honestly reports "wrapper"
and the docstring is gone - wrapper never had one. Every function decorated with log_calls would
claim to be named wrapper. That's confusing in tracebacks and breaks tools that rely on __name__.
⚠️ Gotcha - always wrap your wrapper with functools.wraps. functools.wraps(func) is a decorator
you put on the inner wrapper. It copies the original function's __name__, __doc__, and other
metadata onto the wrapper, so the wrapped function still looks like itself. Leaving it out is the single
most common decorator mistake.
# copy func's identity onto wrapper
return
return
return +
$ python wraps.py
add
Add two numbers.
What just happened: @functools.wraps(func) ran on wrapper and copied add's name and docstring
onto it. Now the decorated add still introspects as add - same name, same docs - even though it's
secretly the wrapper. Make this a reflex: every decorator you write gets a @functools.wraps(func) on
its inner function.
Decorators that take arguments
So far @timer and @log_calls take no configuration. But you've surely seen @app.get("/users") or
@retry(times=3) - decorators with parentheses and arguments. How does that work, when a decorator is
supposed to take a function?
The trick: one more layer. @repeat(3) is two steps. First Python evaluates repeat(3) - that's a
normal function call, and it must return a decorator. Then that returned decorator gets applied to your
function. So a decorator-with-arguments is a function that returns a decorator that returns a wrapper -
three layers deep.
📝 Decorator factory - a function you call (repeat(3)) that builds and returns a decorator. The
arguments you pass configure the decorator it produces.
# the factory: takes the config
# the actual decorator: takes the function
# the wrapper: takes the call's args
=
return
return
return
$ python repeat.py
Hi, Ana!
Hi, Ana!
Hi, Ana!
What just happened: @repeat(3) ran in two beats. repeat(3) was called first and returned
decorator (carrying times=3 in a closure). Then decorator was applied to greet, exactly like a
normal decorator, producing wrapper. When you call greet("Ana"), the wrapper loops three times. The
extra layer exists purely to capture the 3 before the function ever shows up.
The way to read @repeat(3): mentally rewrite it as greet = repeat(3)(greet). The first call eats the
argument; the second call eats the function. Once you see those two calls, decorators with arguments stop
being mysterious.
Where you've already met decorators
You don't usually write decorators day to day - you use ones other people wrote. Now that you know the mechanism, these all read as plain code:
- Web framework routes -
@app.get("/users")in FastAPI,@app.route("/")in Flask. The decorator takes your view function and registers it in the framework's routing table, then returns it (often unchanged). The "magic" is justyour_view = app.get("/users")(your_view), and the side effect is the registration. @property- a built-in decorator that turns a method into a computed attribute, so you can writeobj.area(no parentheses) and have it run a method behind the scenes - a decorator you'll reach for often once you're writing classes.@functools.lru_cache- wraps a function so its results are cached: call it again with the same arguments and you get the stored answer instead of recomputing. It's a decorator factory (@lru_cache(maxsize=128)) doing exactly the layered trick you just learned.@staticmethod/@classmethod- built-in decorators that change how a method receives its first argument, from Phase 6's class machinery.
Here's lru_cache turning a painfully slow recursive Fibonacci into an instant one, with a single line:
return
return +
$ python cache.py
102334155
CacheInfo(hits=38, misses=41, maxsize=None, currsize=41)
What just happened: @lru_cache wrapped fib so each (n,) it's ever called with is computed once
and remembered. Without it, fib(40) would recompute the same sub-results billions of times; with it,
each of the 41 distinct calls runs once (misses=41) and the rest are served from cache (hits=38). One
decorator line bought all of that - and cache_info() is a bonus method the decorator attached to your
function.
Why this saves you later. Decorators are how Python lets a library add behavior to your code
without you editing your code - logging, timing, caching, routing, access control, retries. Once you read
@ as "pass this function through that function and rebind the name," every framework you pick up gets
less mysterious. You'll spend most of your time applying decorators; understanding the mechanism is what
lets you debug them when one misbehaves.
Recap
- Functions are first-class - you can store them, pass them as arguments, and return them. Decorators are built entirely on this.
- A closure is an inner function plus the outer variables it captured; returning an inner function keeps those values alive.
- A decorator is a function that takes a function and returns a (usually wrapped) function.
@decoratorabove adefis pure sugar forf = decorator(f)- a rewrite, not magic.functools.wraps(func)on the inner wrapper copies the original's name and docstring across - omit it and your function forgets who it is. Make it a reflex.- Decorators with arguments add one layer:
@repeat(3)meansrepeat(3)(func)- a factory returns the decorator, which returns the wrapper. - You've already used decorators everywhere -
@app.get(...),@property,@lru_cache. Now you know exactly what each one does.
You can now read and write the @ with confidence. Next we look at another piece of "magic" that turns
out to be a plain protocol - the with statement and the context managers behind it, the reliable way to
guarantee setup and cleanup.
See an LRU cache fill and evict, live:
Quick check - make sure these stuck:
[
{
"q": "What is a decorator, fundamentally?",
"choices": [
"A keyword that adds hidden behavior to a function",
"A function that takes a function and returns a (usually wrapped) function",
"A special class that wraps methods",
"A comment the interpreter reads above a def"
],
"answer": 1,
"explain": "A decorator is just an ordinary function whose input is a function and whose output is a function. No magic - the @ is sugar on top of that one idea."
},
{
"q": "The line `@log_calls` written above `def add(...):` is equivalent to which plain statement?",
"choices": [
"log_calls(add())",
"add = log_calls",
"add = log_calls(add)",
"log_calls = add(log_calls)"
],
"answer": 2,
"explain": "`@d` above a def means: define the function, then run `f = d(f)`. So `@log_calls` over `add` is exactly `add = log_calls(add)` - a textual rewrite, not a keyword."
},
{
"q": "Why put `@functools.wraps(func)` on your inner wrapper?",
"choices": [
"It makes the wrapped function run faster",
"It copies the original's __name__, __doc__, and other metadata onto the wrapper so it still looks like itself",
"It is required syntax or the decorator won't apply",
"It caches the function's results between calls"
],
"answer": 1,
"explain": "Without it, the decorated name points at `wrapper`, so `__name__` reports \"wrapper\" and the docstring is lost. `functools.wraps` copies the original's identity (name, docstring, etc.) onto the wrapper."
}
]
← Phase 11: Iterators & Generators · Guide overview · Phase 13: Context Managers →
Check your understanding 3 questions
1. What is a decorator, fundamentally?
2. The line `@log_calls` written above `def add(...):` is equivalent to which plain statement?
3. Why put `@functools.wraps(func)` on your inner wrapper?