Session 31 - Decorators

Session 31 of the Python Fundamentals series, covered in depth. A decorator is a function that wraps another function to add behavior around it, and the session builds up to that from the ground: functions are ordinary objects you can pass around, a function can return another function to form a closure, and the @decorator line is just sugar for f = deco(f). You write a logging and timing wrapper that forwards *args and **kwargs, use functools.wraps to keep the original __name__ and docstring, and stack two decorators. The traps are forgetting to return the wrapper, which makes the decorated function None, losing __name__ by omitting functools.wraps, and a wrapper that does not forward its arguments. Every snippet and error message was executed and copied verbatim from CPython 3.12.

Subject: Python Fundamentals · 100 slides · code lesson

Open the interactive version of this deck · Homework for this lesson

What this lesson covers

The lesson, slide by slide

1. Decorators

Title

Python Fundamentals - Session 31

Wrap a function to add behavior around it

2. What you will be able to do

Objectives

A decorator adds behavior around a function without editing the function's body. By the end you can:

  1. Treat a function as an object: store it, pass it, return it.
  2. Write a function that returns an inner function (a closure).
  3. Read @deco as shorthand for f = deco(f).
  1. Write a wrapper that forwards *args and **kwargs and returns the result.
  2. Use functools.wraps to keep the original __name__ and docstring.
  3. Avoid the three classic traps: no return, lost name, dropped arguments.

3. What survived from Session 30 - Iterators & Generators?

Warm-up

Discussion prompt

Before we open Session 31 - Decorators: without looking back, what was the main idea of Session 30 - Iterators & Generators, and what could you do by the end of it that you could not do before?

Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.

Answer:

Session 30 of the Python Fundamentals series, in depth. What actually happens when you write a for loop: iter() builds an iterator, next() pulls one value at a time, and a StopIteration signal ends the loop.

4. Functions Are Objects

Section

Part 1

5. A function is a value

Concept

A def creates an object and binds it to a name - just like x = 5 binds a number. The name and the function are two separate things.

function object — The thing a def creates. You can store it in a variable, put it in a list, pass it to another function, or return it - all without calling it.

6. Break it if you can: A function is a value

Counterexample

Discussion prompt

A def creates an object and binds it to a name - just like x = 5 binds a number. The name and the function are two separate things.

That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.

Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.

7. What has to happen first: Store a function in another name

Ranking

Put in order

Put the moves of Store a function in another name into the order they have to happen.

  1. Line 4 copies the function, not its result
  2. Line 5 calls it through the new name
  3. Read the output

Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. No parentheses, so f now points at the same function object as greet.

8. Store a function in another name

Worked example

def greet():
    return "hello"

f = greet
print(f())
print(greet.__name__)

Line 4 copies the function, not its result

Why: No parentheses, so f now points at the same function object as greet.

Line 5 calls it through the new name

Why: f() runs the same body greet() would, so it returns hello.

Read the output

Why: Verified by execution: hello, then greet (the __name__ attribute holds the original name).

expressionvalue
fthe greet function object
f()hello
greet.__name__greet

9. Fill in: value for Store a function in another name

Comparison

Comparison matrix

From Store a function in another name: refill the value column from what you know. The rest of the table is as it appeared.

expressionvalue
fthe greet function object
f()hello
greet.__name__greet

10. The name is a label on a box

Intuition

Picture the function as a box that does work. def greet puts a label reading greet on it. f = greet sticks a second label on the same box.

Either label opens the box. Calling is the parentheses - f() and greet() open the same box the same way.

11. By analogy: The name is a label on a box

Analogy

Discussion prompt

Explain The name is a label on a box by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.

Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.

Answer:

Picture the function as a box that does work. def greet puts a label reading greet on it. f = greet sticks a second label on the same box.

12. Functions fit anywhere a value fits

Concept

Since a function is a value, you can put functions in a list or a dict and loop over them - handy for choosing behavior by name.

13. Teach it back: Functions fit anywhere a value fits

Explain it

Discussion prompt

Explain Functions fit anywhere a value fits to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.

Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.

Answer:

Since a function is a value, you can put functions in a list or a dict and loop over them - handy for choosing behavior by name.

14. Plan first: A list of functions

Step zero

Discussion prompt

A list of functions — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.

Hint: It starts with: The list holds two function objects

Answer:

  1. The list holds two function objects
  2. The loop calls each in turn
  3. Read the output

15. A list of functions

Worked example

def shout(s):
    return s.upper()

def whisper(s):
    return s.lower()

funcs = [shout, whisper]
for f in funcs:
    print(f("Hello"))

The list holds two function objects

Why: shout and whisper are stored without calling them - no parentheses in the list.

The loop calls each in turn

Why: f is each function; f("Hello") runs it.

Read the output

Why: Verified by execution: HELLO, then hello.

ff("Hello")
shoutHELLO
whisperhello

16. What each one costs: A list of functions

Trade off

Comparison matrix

From A list of functions: every row here is a choice with a cost. Fill the f("Hello") column, then say which row you would actually pick and what you give up for it.

ff("Hello")
shoutHELLO
whisperhello

17. Functions can be passed as arguments

Concept

Because a function is just a value, another function can accept one as a parameter and call it. A function that takes or returns a function is called higher-order.

18. Predict the next row: Pass a function in

Pattern

Predict first

The table runs: x | 5 · func(x) | 6

In Pass a function in, given the rows so far: what is the next one — the row where step is func(func(x))?

Correct: func(func(x)) | 7

stepvalue
x5
func(x)6
func(func(x))7

Why: The relationship between the columns, not the individual numbers, is what generates the next row. apply_twice receives the function object as func, then calls it itself.

19. Pass a function in

Worked example

def apply_twice(func, x):
    return func(func(x))

def add_one(n):
    return n + 1

print(apply_twice(add_one, 5))

add_one is passed without parentheses

Why: apply_twice receives the function object as func, then calls it itself.

func(func(x)) applies it twice

Why: Inner call: add_one(5) is 6. Outer call: add_one(6) is 7.

Read the output

Why: Verified by execution: 7.

stepvalue
x5
func(x)6
func(func(x))7

20. Inspect it line by line: Pass a function in

Error analysis

Annotate

Walk the callouts on Pass a function in. Each one is a place this is easy to get subtly wrong.

  • apply_twice receives the function object as func, then calls it itself.
  • Inner call: add_one(5) is 6. Outer call: add_one(6) is 7.

21. A Function Returning a Function

Section

Part 2

22. A function can build and return a function

Concept

Inside a def, you can write another def, then return that inner function. The caller gets back a brand-new function to call later.

The outer function is a factory; the inner function is the product.

23. Restore the missing line: outer returns inner

Fill the middle

Fill in the blanks

From outer returns inner — one line has had its right-hand side removed. Put it back.

def outer():
def inner():
return "inner result"
return inner

fn = outer()
print(fn)
print(fn())

Why: fn is what everything below it consumes, so the wrong expression here fails later and somewhere else. return inner hands back the function object itself - it is not called yet.

24. outer returns inner

Worked example

def outer():
    def inner():
        return "inner result"
    return inner

fn = outer()
print(fn)
print(fn())

outer() runs and returns the inner function

Why: return inner hands back the function object itself - it is not called yet.

fn now holds inner; fn() calls it

Why: print(fn) shows a function object; print(fn()) runs it and shows its return value.

Read the output

Why: Verified by execution: line 7 prints a function object, line 8 prints inner result.

expressionvalue
outer()the inner function object
fn<function outer.<locals>.inner at 0x...>
fn()inner result

25. Draw the shape of it: outer returns inner

Blank canvas

Draw it

Draw what outer returns inner just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.

26. The inner function remembers its birthplace

Concept

The inner function can use variables from the outer function, even after outer has finished. That captured-variable bundle is a closure.

closure — An inner function together with the outer-function variables it still remembers. This is what lets a decorator's wrapper keep a reference to the original function.

27. Plan first: A closure captures n

Step zero

Discussion prompt

A closure captures n — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.

Hint: It starts with: make_multiplier(3) bakes n = 3 into multiply

Answer:

  1. make_multiplier(3) bakes n = 3 into multiply
  2. triple is that remembering function
  3. Read the output

28. A closure captures n

Worked example

def make_multiplier(n):
    def multiply(x):
        return x * n
    return multiply

triple = make_multiplier(3)
print(triple(5))
print(triple(10))

make_multiplier(3) bakes n = 3 into multiply

Why: The returned function still remembers n = 3 after make_multiplier finished.

triple is that remembering function

Why: Each call multiplies its argument by the captured n.

Read the output

Why: Verified by execution: 15, then 30.

calln (captured)xreturns
triple(5)3515
triple(10)31030

29. A closure is a function with a backpack

Intuition

When the inner function leaves the factory, it carries a little backpack of the outer variables it used. Here the backpack holds n = 3.

A decorator works exactly this way: the wrapper carries a backpack holding the original function, so it can call it whenever it likes.

30. The Wrapper Idea

Section

Part 3

31. Wrap: run code before and after

Concept

Combine the two ideas: write a factory that takes a function, builds a wrapper that runs extra code around it, and returns the wrapper.

decorator — A function that takes a function and returns a new function (usually a wrapper) adding behavior before and/or after the original call.

32. Term to definition: Session 31 - Decorators

Matching

Match the pairs

Match each term to the definition this lesson gave it — not the one you would guess from the word.

  • t1. function object
  • t2. closure
  • t3. decorator
  • d1. The thing a def creates. You can store it in a variable, put it in a list, pass it to another function, or return it - all without calling it.
  • d2. An inner function together with the outer-function variables it still remembers. This is what lets a decorator's wrapper keep a reference to the original function.
  • d3. A function that takes a function and returns a new function (usually a wrapper) adding behavior before and/or after the original call.

Why: These are the working definitions of function object, closure, decorator as Session 31 - Decorators uses them. Pairing them correctly is the test of whether you could state each one with the slide switched off.

33. Restore the missing line: Wrap by hand: announce

Fill the middle

Fill in the blanks

From Wrap by hand: announce — one line has had its right-hand side removed. Put it back.

def announce(func):
def wrapper():
print("before")
func()
print("after")
return wrapper

def say_hi():
print("hi")

say_hi = announce(say_hi)
say_hi()

Why: say_hi is what everything below it consumes, so the wrong expression here fails later and somewhere else. The wrapper's backpack holds the original say_hi as func.

34. Wrap by hand: announce

Worked example

def announce(func):
    def wrapper():
        print("before")
        func()
        print("after")
    return wrapper

def say_hi():
    print("hi")

say_hi = announce(say_hi)
say_hi()

announce(say_hi) builds a wrapper around say_hi

Why: The wrapper's backpack holds the original say_hi as func.

We reassign the name say_hi to the wrapper

Why: Now the name say_hi points at wrapper, not the original.

Calling say_hi() runs the wrapper

Why: Verified by execution: it prints before, then hi (the original), then after.

line runoutput
print("before")before
func()hi
print("after")after

35. The old name now points at the wrapper

Concept

say_hi = announce(say_hi) is the whole trick. The name you already use now runs the wrapper, and callers do not have to change anything.

The original function is not lost - the wrapper still holds it in its backpack and calls it as func().

36. Where does each piece belong: Session 31 - Decorators

Sorting

Sort into buckets

These are the pieces of Session 31 - Decorators, out of order. Put each one back under the part of the lesson it belongs to.

Functions Are Objects
A function is a value; Store a function in another name; The name is a label on a box
A Function Returning a Function
A function can build and return a function; outer returns inner; The inner function remembers its birthplace
The Wrapper Idea
Wrap: run code before and after; Wrap by hand: announce; The old name now points at the wrapper
s1
Functions Are Objects is where Session 31 - Decorators puts A function is a value, Store a function in another name, The name is a label on a box. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s2
A Function Returning a Function is where Session 31 - Decorators puts A function can build and return a function, outer returns inner, The inner function remembers its birthplace. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s3
The Wrapper Idea is where Session 31 - Decorators puts Wrap: run code before and after, Wrap by hand: announce, The old name now points at the wrapper. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.

37. The @ Syntax

Section

Part 4

38. @deco is shorthand

Concept

Writing @announce on the line above a def is exactly the same as defining the function and then writing say_hi = announce(say_hi).

The @ line is pure convenience - it makes the wrapping visible right at the definition.

39. What has to happen first: The same thing with @

Ranking

Put in order

Put the moves of The same thing with @ into the order they have to happen.

  1. @announce sits above the def
  2. say_hi is now the wrapper
  3. Read the output

Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. At definition time Python runs say_hi = announce(say_hi) for you.

40. The same thing with @

Worked example

def announce(func):
    def wrapper():
        print("before")
        func()
        print("after")
    return wrapper

@announce
def say_hi():
    print("hi")

say_hi()

@announce sits above the def

Why: At definition time Python runs say_hi = announce(say_hi) for you.

say_hi is now the wrapper

Why: Nothing else changed - the call site say_hi() is identical.

Read the output

Why: Verified by execution: before, hi, after - identical to the hand-wired version.

you writepython does
@announce above def say_hisay_hi = announce(say_hi)
say_hi()runs wrapper()

41. The wrapping happens once, at def time

Concept

The @ line runs the moment Python reads the def, not each time you call. After that, every call goes straight to the wrapper.

42. Gift-wrapping the function

Intuition

The decorator wraps your function like a gift. From the outside you still hand people the same box by the same name - but now it comes with wrapping paper that does something extra.

Callers never see the wrapping. They call say_hi() as before; the paper (the wrapper) quietly runs its before/after code.

43. Wrappers That Take Arguments

Section

Part 5

44. Forward *args and kwargs

Concept

A real wrapper must work for functions that take any arguments. Give the wrapper *args, **kwargs and pass them straight through to the original.

*args catches any positional arguments; **kwargs catches any keyword arguments. Forwarding them makes one wrapper fit every function.

45. State the rule before it runs: A logging decorator

Hypothesis

Predict first

A logging decorator is about to be worked. State your hypothesis first: which rule or definition decides this one, and what is the first move it forces? Then watch whether the example agrees with you.

Correct: wrapper accepts and forwards the arguments

Why: add(3, 4) reaches wrapper as args = (3, 4), which it passes on to func.

A hypothesis you wrote down is falsifiable; a vague sense of how it will go is not. If the example opens somewhere else, that gap is the thing worth chasing.

46. A logging decorator

Worked example

def logged(func):
    def wrapper(*args, **kwargs):
        print("calling", func.__name__)
        result = func(*args, **kwargs)
        print("got", result)
        return result
    return wrapper

@logged
def add(a, b):
    return a + b

print(add(3, 4))

wrapper accepts and forwards the arguments

Why: add(3, 4) reaches wrapper as args = (3, 4), which it passes on to func.

It logs before and after, and returns the result

Why: The wrapper stores func's return in result and hands it back so add(3, 4) is still 7.

Read the output

Why: Verified by execution: calling add, got 7, then 7 (the print of the returned value).

lineoutput
print("calling", func.__name__)calling add
print("got", result)got 7
print(add(3, 4))7

47. Return the result, or lose it

Concept

The wrapper replaces the function, so whatever the wrapper returns is what the caller gets. If the wrapper never returns func(...)'s result, the caller gets None.

48. Plan first: A call counter

Step zero

Discussion prompt

A call counter — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.

Hint: It starts with: wrapper.calls starts at 0 and climbs each call

Answer:

  1. wrapper.calls starts at 0 and climbs each call
  2. The third call also prints the returned greeting
  3. Read the output

49. A call counter

Worked example

def counted(func):
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        print("call number", wrapper.calls)
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper

@counted
def greet(name):
    return "hi " + name

greet("Ana")
greet("Bo")
print(greet("Cy"))

wrapper.calls starts at 0 and climbs each call

Why: An attribute on the wrapper survives between calls, like a closure's memory.

The third call also prints the returned greeting

Why: Only the last call is wrapped in print, so only its return value shows.

Read the output

Why: Verified by execution: call number 1, call number 2, call number 3, then hi Cy.

callwrapper.callsprinted
greet("Ana")1call number 1
greet("Bo")2call number 2
print(greet("Cy"))3call number 3 then hi Cy

50. Where the cost goes: A call counter

Cost model

Annotate

In A call counter, before reading the notes: mark where the time actually goes. Which line dominates?

  • An attribute on the wrapper survives between calls, like a closure's memory.
  • Only the last call is wrapped in print, so only its return value shows.
  • Verified by execution: call number 1, call number 2, call number 3, then hi Cy.

51. Restore the missing line: Decorate the return value

Fill the middle

Fill in the blanks

From Decorate the return value — one line has had its right-hand side removed. Put it back.

def loud(func):
def wrapper(*args, kwargs):
result =
func(*args, kwargs)
return str(result) + "!!!"
return wrapper

@loud
def greet(name):
return "hi " + name

print(greet("Ana"))

Why: result is what everything below it consumes, so the wrong expression here fails later and somewhere else. It calls func, then appends !!!

52. Decorate the return value

Worked example

def loud(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return str(result) + "!!!"
    return wrapper

@loud
def greet(name):
    return "hi " + name

print(greet("Ana"))

The wrapper changes the result after the call

Why: It calls func, then appends !!! before returning.

Read the output

Why: Verified by execution: hi Ana!!! - the wrapper transformed the return value.

stepvalue
func("Ana")hi Ana
str(result) + "!!!"hi Ana!!!
greet("Ana")hi Ana!!!

53. Fill in: value for Decorate the return value

Comparison

Comparison matrix

From Decorate the return value: refill the value column from what you know. The rest of the table is as it appeared.

stepvalue
func("Ana")hi Ana
str(result) + "!!!"hi Ana!!!
greet("Ana")hi Ana!!!

54. The wrapper is a checkpoint

Intuition

Think of the wrapper as a checkpoint on the road to the real function. Every call must pass through it, so it can log, time, count, or transform on the way in and on the way out.

The *args, **kwargs are the road: they let anything drive through unchanged, so the checkpoint fits any function.

55. Run the original twice

Worked example

def twice(func):
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)
        return func(*args, **kwargs)
    return wrapper

@twice
def shout(word):
    print(word + "!")
    return word

print(shout("go"))

The wrapper calls func two times

Why: First call runs for its side effect; the second call's result is returned.

shout prints each time and returns once

Why: Two prints of go!, and the returned word is printed by the outer print.

Read the output

Why: Verified by execution: go!, go!, then go.

stepoutput
first func callgo!
second func callgo!
print(shout("go"))go

56. Keeping the Name

Section

Part 6

57. Decorating hides the original identity

Concept

After decorating, the name points at wrapper. So add.__name__ reads wrapper, and the original docstring is gone. Debuggers and docs get confused.

58. The name is now wrapper

Worked example

def logged(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    "Add two numbers."
    return a + b

print(add.__name__)
print(add.__doc__)

add is really the wrapper now

Why: The name add points at wrapper, so its __name__ is wrapper.

The docstring is the wrapper's (none)

Why: wrapper has no docstring, so __doc__ is None - the original 'Add two numbers.' is hidden.

Read the output

Why: Verified by execution: wrapper, then None.

expressionvalue
add.__name__wrapper
add.__doc__None

59. functools.wraps copies the identity

Concept

Decorate the inner wrapper with @functools.wraps(func). It copies the original __name__, __doc__, and more onto the wrapper.

Make this a habit: every wrapper you write gets @functools.wraps(func).

60. Plan first: With functools.wraps

Step zero

Discussion prompt

With functools.wraps — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.

Hint: It starts with: @functools.wraps(func) sits on the wrapper

Answer:

  1. @functools.wraps(func) sits on the wrapper
  2. Now the name and docstring survive
  3. Read the output

61. With functools.wraps

Worked example

import functools

def logged(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    "Add two numbers."
    return a + b

print(add.__name__)
print(add.__doc__)

@functools.wraps(func) sits on the wrapper

Why: It copies func's identity onto wrapper as the wrapper is built.

Now the name and docstring survive

Why: add.__name__ is add and the docstring is back.

Read the output

Why: Verified by execution: add, then Add two numbers.

expressionwithout wrapswith wraps
add.__name__wrapperadd
add.__doc__NoneAdd two numbers.

62. Something is wrong here: forgetting functools.wraps

Anomaly

Predict first

A student writes this, and it looks reasonable:

No wraps - the wrapper's own identity leaks out.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: The name points at the inner wrapper, whose __name__ is wrapper - misleading in tracebacks and docs.

Add @functools.wraps(func) to the wrapper.

Why: The name points at the inner wrapper, whose __name__ is wrapper - misleading in tracebacks and docs.

63. Trap: forgetting functools.wraps

Trap

The trap

No wraps - the wrapper's own identity leaks out.

def logged(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    return a + b

print(add.__name__)

add.__name__ reports wrapper

Why: The name points at the inner wrapper, whose __name__ is wrapper - misleading in tracebacks and docs.

expressionprints
add.__name__wrapper

The fix

Add @functools.wraps(func) to the wrapper.

import functools

def logged(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    return a + b

print(add.__name__)

add.__name__ reports add

Why: wraps copied the original identity onto the wrapper. Real output: add. Make it reflexive on every decorator you write.

expressionprints
add.__name__add

64. Stacking Decorators

Section

Part 7

65. You can apply more than one

Concept

Stack decorators by writing several @ lines above one def. They apply bottom-up: the one nearest the def wraps first, then the one above wraps that.

66. What has to happen first: bold around italic

Ranking

Put in order

Put the moves of bold around italic into the order they have to happen.

  1. @italic (nearest the def) wraps first
  2. @bold wraps the result
  3. Read the output

Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. hello becomes italic(hello), turning hi into <i>hi</i>.

67. bold around italic

Worked example

def bold(func):
    def wrapper():
        return "<b>" + func() + "</b>"
    return wrapper

def italic(func):
    def wrapper():
        return "<i>" + func() + "</i>"
    return wrapper

@bold
@italic
def hello():
    return "hi"

print(hello())

@italic (nearest the def) wraps first

Why: hello becomes italic(hello), turning hi into <i>hi</i>.

@bold wraps the result

Why: Then bold wraps that, adding <b>...</b> on the outside.

Read the output

Why: Verified by execution: <b><i>hi</i></b>.

stagevalue
hello() rawhi
after @italic<i>hi</i>
after @bold<b><i>hi</i></b>

68. Order changes the nesting

Concept

Swap the two @ lines and the layers swap. The bottom decorator is always the innermost wrapping, closest to the original result.

69. Teach it back: Order changes the nesting

Explain it

Discussion prompt

Explain Order changes the nesting to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.

Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.

Answer:

Swap the two @ lines and the layers swap. The bottom decorator is always the innermost wrapping, closest to the original result.

70. Predict the next row: italic around bold

Pattern

Predict first

The table runs: hello() raw | hi · after @bold | <b>hi</b>

In italic around bold, given the rows so far: what is the next one — the row where stage is after @italic?

Correct: after @italic | <i><b>hi</b></i>

stagevalue
hello() rawhi
after @bold<b>hi</b>
after @italic<i><b>hi</b></i>

Why: The relationship between the columns, not the individual numbers, is what generates the next row. hi becomes <b>hi</b>, then italic wraps that.

71. italic around bold

Worked example

def bold(func):
    def wrapper():
        return "<b>" + func() + "</b>"
    return wrapper

def italic(func):
    def wrapper():
        return "<i>" + func() + "</i>"
    return wrapper

@italic
@bold
def hello():
    return "hi"

print(hello())

Now @bold is nearest the def, so it wraps first

Why: hi becomes <b>hi</b>, then italic wraps that.

Read the output

Why: Verified by execution: <i><b>hi</b></i> - the tags are nested in the opposite order.

stagevalue
hello() rawhi
after @bold<b>hi</b>
after @italic<i><b>hi</b></i>

72. Draw the shape of it: italic around bold

Blank canvas

Draw it

Draw what italic around bold just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.

73. Stacking still needs functools.wraps

Concept

When you stack decorators, give each wrapper its own @functools.wraps(func). Otherwise the outer wrapper's name overwrites the identity, and tracebacks point at wrapper again.

74. By analogy: Stacking still needs functools.wraps

Analogy

Discussion prompt

Explain Stacking still needs functools.wraps by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.

Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.

Answer:

When you stack decorators, give each wrapper its own @functools.wraps(func). Otherwise the outer wrapper's name overwrites the identity, and tracebacks point at wrapper again.

75. When to reach for a decorator

Concept

Use a decorator when the same extra behavior wraps many functions: logging, timing, caching, access checks. One decorator, applied with one line each.

If only one function needs the behavior, just write it inline - a decorator earns its keep through reuse.

76. Break it if you can: When to reach for a decorator

Counterexample

Discussion prompt

Use a decorator when the same extra behavior wraps many functions: logging, timing, caching, access checks. One decorator, applied with one line each.

That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.

Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.

Answer:

If only one function needs the behavior, just write it inline - a decorator earns its keep through reuse.

77. Pitfalls

Section

Part 8

78. Something is wrong here: forgetting to return the wrapper

Anomaly

Predict first

A student writes this, and it looks reasonable:

The decorator builds a wrapper but never returns it.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: With no return, announce(say_hi) is None; the name say_hi now holds None, and calling None crashes.

Return the wrapper from the decorator.

Why: With no return, announce(say_hi) is None; the name say_hi now holds None, and calling None crashes.

79. Trap: forgetting to return the wrapper

Trap

The trap

The decorator builds a wrapper but never returns it.

def announce(func):
    def wrapper():
        print("before")
        func()
        print("after")

@announce
def say_hi():
    print("hi")

say_hi()

announce returns None, so say_hi becomes None

Why: With no return, announce(say_hi) is None; the name say_hi now holds None, and calling None crashes.

stepresult
announce(say_hi)None
say_hiNone
say_hi()TypeError: 'NoneType' object is not callable

The fix

Return the wrapper from the decorator.

def announce(func):
    def wrapper():
        print("before")
        func()
        print("after")
    return wrapper

@announce
def say_hi():
    print("hi")

say_hi()

return wrapper makes say_hi the wrapper

Why: Now say_hi() runs the wrapper. Real output: before, hi, after. A decorator must always return the replacement function.

stepresult
announce(say_hi)the wrapper
say_hi()before / hi / after

80. Inspect it line by line: Trap: forgetting to return the wrapper

Error analysis

Annotate

Walk the callouts on Trap: forgetting to return the wrapper. Each one is a place this is easy to get subtly wrong.

  • With no return, announce(say_hi) is None; the name say_hi now holds None, and calling None crashes.
  • Now say_hi() runs the wrapper. Real output: before, hi, after. A decorator must always return the replacement function.

81. Something is wrong here: wrapper does not forward arguments

Anomaly

Predict first

A student writes this, and it looks reasonable:

The wrapper takes no arguments, so a call with arguments fails.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: wrapper accepts no parameters, so passing 3 and 4 raises a TypeError before add ever runs.

Give the wrapper *args, **kwargs and forward them.

Why: wrapper accepts no parameters, so passing 3 and 4 raises a TypeError before add ever runs.

82. Trap: wrapper does not forward arguments

Trap

The trap

The wrapper takes no arguments, so a call with arguments fails.

def logged(func):
    def wrapper():
        return func()
    return wrapper

@logged
def add(a, b):
    return a + b

print(add(3, 4))

add(3, 4) really calls wrapper(3, 4)

Why: wrapper accepts no parameters, so passing 3 and 4 raises a TypeError before add ever runs.

callresult
add(3, 4)wrapper(3, 4)
wrapper(3, 4)TypeError: logged.<locals>.wrapper() takes 0 positional arguments but 2 were given

The fix

Give the wrapper *args, **kwargs and forward them.

def logged(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    return a + b

print(add(3, 4))

*args, **kwargs accept and pass on any arguments

Why: wrapper(3, 4) now works and forwards to add. Real output: 7. Always forward with *args, **kwargs.

callresult
add(3, 4)wrapper(3, 4)
func(3, 4)7

83. What each one costs: Trap: wrapper does not forward arguments

Trade off

Comparison matrix

From Trap: wrapper does not forward arguments: every row here is a choice with a cost. Fill the result column, then say which row you would actually pick and what you give up for it.

callresult
add(3, 4)wrapper(3, 4)
func(3, 4)7

84. Something is wrong here: wrapper drops the result

Anomaly

Predict first

A student writes this, and it looks reasonable:

The wrapper calls func but never returns its value.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: The wrapper does not return it, so it hands back None; total becomes None.

Return what func returns.

Why: The wrapper does not return it, so it hands back None; total becomes None.

85. Trap: wrapper drops the result

Trap

The trap

The wrapper calls func but never returns its value.

def logged(func):
    def wrapper(*args, **kwargs):
        print("calling")
        func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    return a + b

total = add(3, 4)
print(total)

func's 7 is computed then thrown away

Why: The wrapper does not return it, so it hands back None; total becomes None.

stepvalue
func(3, 4)7 (discarded)
totalNone

The fix

Return what func returns.

def logged(func):
    def wrapper(*args, **kwargs):
        print("calling")
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    return a + b

total = add(3, 4)
print(total)

return func(...) passes the result through

Why: total is now 7. Real output: calling, then 7. A wrapper should almost always return func's result.

stepvalue
func(3, 4)7
total7

86. Which of these survive contact with Session 31 - Decorators?

Two truths and a lie

Sort into buckets

Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.

Holds up
A def creates an object and binds it to a name - just like x = 5 binds a number. The name and the function are two separate things.; Picture the function as a box that does work. def greet puts a label reading greet on it. f = greet sticks a second label on the same box.; Since a function is a value, you can put functions in a list or a dict and loop over them - handy for choosing behavior by name.
Breaks
No wraps - the wrapper's own identity leaks out.; The decorator builds a wrapper but never returns it.
sound
These are stated as this lesson states them — each one survives the edge cases Session 31 - Decorators puts it through.
flawed
Each of these is lifted from a trap in this deck: reasonable-sounding, and wrong in a way that only shows up once you rely on it.

87. Patterns & Checks

Section

Part 9

88. Writing a decorator

Pattern

1. def deco(func): - the decorator takes the function

Why: func is the original you are wrapping; it lives in the wrapper's backpack (closure).

2. Inside, def wrapper(*args, **kwargs): with @functools.wraps(func)

Why: args/*kwargs fit any function; wraps keeps the original name and docstring.

3. In the wrapper: extra code, result = func(*args, **kwargs), more code, return result

Why: Run behavior before and after, and pass the real result back to the caller.

4. return wrapper from the decorator

Why: Forget this and the decorated name becomes None. The decorator must return the replacement.

89. Reading the @ line

Pattern

@deco above def f means f = deco(f)

Why: The name f now points at whatever deco returned - normally a wrapper.

Stacked @a @b above def f means f = a(b(f))

Why: Bottom-up: the decorator nearest the def wraps first, the top one wraps last.

The wrapping runs once, at def time

Why: After that, every f(...) call goes through the wrapper - no re-wrapping per call.

90. Where this shows up: Session 31 - Decorators

Real world

Discussion prompt

Outside this lesson: where does Session 31 - Decorators actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of Reading the @ line is doing the work in it.

Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.

Answer:

Session 31 of the Python Fundamentals series, in depth. A decorator is a function that wraps another function to add behavior around it.

91. Check: name vs call

Check

What does line 5 print?

def greet():
    return "hello"

f = greet
print(f)
expressionvalue
f?

Check your understanding

What does print(f) show?

  • A. The function object, like <function greet at 0x...> (correct)
  • B. hello
  • C. greet
  • D. None

Answer: A

Why: f = greet copies the function object without calling it (no parentheses), so printing f shows the function itself, not its return value. Verified by execution.

Why B tempts people
hello would print only if you called it: print(f()). With no parentheses, f is the function, not its result.
Why C tempts people
greet is what f.__name__ would give; printing the object itself shows its full repr, not just the bare name.
Why D tempts people
f holds a real function object, not None - nothing here was left unassigned.

92. Rule out three: Check: @ desugaring

Elimination

Eliminate the wrong options

@deco above def f() is shorthand for which line?

3 of these 4 are wrong. Strike them one at a time, and say what rules each one out before you strike the next. The survivor is the answer.

  • A. f = deco(f)
  • B. f = deco()
  • C. deco = f(deco)
  • D. f = f(deco)

Survives elimination: A

Why: A decorator receives the function it sits above and its return value is rebound to the same name: f = deco(f). Verified by execution.

93. Check: @ desugaring

Check

Which plain line means the same as @deco?

@deco
def f():
    ...
# equivalent to ?
sugarplain form
@deco over def f?

Check your understanding

@deco above def f() is shorthand for which line?

  • A. f = deco(f) (correct)
  • B. f = deco()
  • C. deco = f(deco)
  • D. f = f(deco)

Answer: A

Why: A decorator receives the function it sits above and its return value is rebound to the same name: f = deco(f). Verified by execution.

Why B tempts people
deco() calls deco with no arguments; the decorator must receive f as its argument, so it is deco(f).
Why C tempts people
The decorator wraps f, not the other way around, and the result is bound back to f, not to deco.
Why D tempts people
f is the thing being decorated; you pass f into deco, you do not call f on deco.

94. Check: the None trap

Check

The decorator has no return statement.

def deco(func):
    def wrapper():
        return func()
    # no return here

@deco
def task():
    return 1

print(task())
deco returnstask is
None?

Check your understanding

What happens when task() runs?

  • A. TypeError: 'NoneType' object is not callable (correct)
  • B. It prints 1
  • C. It prints None
  • D. It prints the wrapper object

Answer: A

Why: deco never returns wrapper, so deco(task) is None and the name task becomes None. Calling None raises TypeError: 'NoneType' object is not callable. Verified by execution.

Why B tempts people
It would print 1 only if deco returned wrapper; without that return, task is not callable at all.
Why C tempts people
task is None, but the error happens at the call task() before anything could be printed - you never reach a None result.
Why D tempts people
The wrapper was built but thrown away because deco returned None instead of returning wrapper.

95. Check: forwarding arguments

Check

The wrapper takes no parameters.

def deco(func):
    def wrapper():
        return func()
    return wrapper

@deco
def add(a, b):
    return a + b

print(add(2, 5))
callresult
add(2, 5)?

Check your understanding

What does add(2, 5) do?

  • A. Raises TypeError: wrapper() takes 0 positional arguments but 2 were given (correct)
  • B. Returns 7
  • C. Returns None
  • D. Returns 25

Answer: A

Why: add now refers to wrapper, which is defined with no parameters, so passing 2 and 5 raises TypeError before add's body runs. The fix is def wrapper(*args, **kwargs). Verified by execution.

Why B tempts people
7 needs the arguments to reach add, but wrapper() cannot accept them, so the call fails first.
Why C tempts people
None would require the call to succeed and return nothing; here the call itself errors on the argument count.
Why D tempts people
25 has no basis - even if it ran, add returns a + b (7), not anything involving 25.

96. Check: stacking order

Check

Trace the two layers bottom-up.

def bold(f):
    def w():
        return "<b>" + f() + "</b>"
    return w

def italic(f):
    def w():
        return "<i>" + f() + "</i>"
    return w

@bold
@italic
def hello():
    return "hi"

print(hello())
stagevalue
rawhi
after inner?
after outer?

Check your understanding

What does hello() return?

  • A. <b><i>hi</i></b> (correct)
  • B. <i><b>hi</b></i>
  • C. <b><i>hi</b></i>
  • D. <i><b>hi</i></b>

Answer: A

Why: Decorators apply bottom-up: @italic (nearest the def) wraps first giving <i>hi</i>, then @bold wraps that giving <b><i>hi</i></b>. Verified by execution.

Why B tempts people
That is the result of the opposite stack order (@italic on top of @bold); here bold is on top, so <b> is outermost.
Why C tempts people
The tags always nest cleanly - each wrapper wraps the whole inner result, so an <i> opened inside must close inside, not after </b>.
Why D tempts people
Same nesting mistake: the outer wrapper (bold) fully surrounds the inner result, so <b> both opens outermost and closes outermost.

97. Fill in: value for Check: stacking order

Comparison

Comparison matrix

From Check: stacking order: refill the value column from what you know. The rest of the table is as it appeared.

stagevalue
rawhi
after inner?
after outer?

98. Check: functools.wraps

Check

What is preserved here?

import functools

def logged(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@logged
def multiply(a, b):
    return a * b

print(multiply.__name__)
expressionvalue
multiply.__name__?

Check your understanding

What does multiply.__name__ print?

  • A. multiply (correct)
  • B. wrapper
  • C. logged
  • D. None

Answer: A

Why: @functools.wraps(func) copies the original function's __name__ onto the wrapper, so multiply keeps its own name instead of showing wrapper. Verified by execution.

Why B tempts people
wrapper is what you would get WITHOUT functools.wraps; here wraps restores the original name multiply.
Why C tempts people
logged is the decorator's name, which never becomes the decorated function's __name__.
Why D tempts people
__name__ is always a string; wraps sets it to multiply, so it is never None here.

99. Connect it up: Session 31 - Decorators

Connect it up

Draw it

One page, no notation unless you need it: draw how these connect — Functions Are Objects · A Function Returning a Function · The Wrapper Idea · The @ Syntax · Wrappers That Take Arguments · Keeping the Name. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.

100. What you can do now

Recap

A decorator is a function that takes a function and returns a new one - usually a wrapper that runs code around the original. @deco is just f = deco(f).

You writeIt means
f = greetcopy the function object (no call)
return innerhand back a function to call later
@deco over def ff = deco(f)
def wrapper(*args, **kwargs)accept and forward any arguments
@functools.wraps(func)keep the original name and docstring
@a @b over def ff = a(b(f)), bottom-up

Always return the wrapper, forward *args/**kwargs, return func's result, and add functools.wraps. Next session we turn the wrapper pattern into decorators that take their own arguments.

Sources

  1. Python Glossary - decorator
  2. Python 3 Tutorial - Nested functions and scope
  3. functools.wraps
  4. All snippets and error messages executed and copied from CPython 3.12. — Author verification run, 2026-07-15 (Python Fundamentals series, Session 31).

Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108