Skip to content

I pasted the same timing code into twenty functions

  • Python · Core
  • 25 min read, about 1 h 20 min with practice
  • Concept
  • Asked often: high
  • Reviewed 2026-11

Your web app feels slow, and you want to know which functions take the time. So you add three lines to each one: note the start time, do the work, record how long it took.

def load_report(day):
start = time.perf_counter()
result = ... # the real work
TIMINGS.append(("load_report", time.perf_counter() - start))
return result

After twenty functions, the timing lines are bigger than the work. When you want to stop timing, you remove sixty lines by hand. There must be a way to add this once.

  • Input: any function, with any arguments.
  • Output: a function that does exactly what the original did, returns what it returned, and also records (name, seconds) in TIMINGS.
  • Rules: the original’s code doesn’t change. The new function keeps the original’s name and docstring, so logs, help() and web frameworks still see the right name.
  • The hard part: a wrapper that is invisible to everyone except you.

Example: after load_report = timed(load_report), calling load_report("mon") returns the report and adds ("load_report", 0.0001…) to TIMINGS.

Hints open one at a time:

Hint 1 of 3

Inside timed(func), define wrapper(*args, **kwargs) that times func(*args, **kwargs) and returns its result.

Hint 2 of 3

@timed above def load_report is short for load_report = timed(load_report).

Hint 3 of 3

wrapper has its own name, "wrapper". functools.wraps(func) copies func’s name and docstring onto it.

A decorator is a function that takes a function and returns another one. The @ line is a shortcut:

@timed
def load_report(day): ...
# is the same as:
def load_report(day): ...
load_report = timed(load_report)

The returned function is usually a closure, called wrapper. It keeps func, the original, and calls it in the middle of its own work.

functools.wraps(func) copies __name__, __doc__ and more from func onto wrapper, and stores the original as wrapper.__wrapped__.

A decorator with arguments, like @retry(times=3), is one more layer. retry(3) runs first and returns the real decorator.

Here is a tiny decorator with the @ written out. hi = shout(hi) points hi at the wrapper, and the wrapper’s box keeps the old hi as func. The player stops to ask what happens next. Then type your own lines under it.

The wrapper keeps the original as func

Built-inPython’s own names

  • print
  • len
  • max

Globalrunning now

no names yet

Step 1 of 12. Before the first line runs, the global box is empty. The built-in box holds names like print and len.

def shout(func):
    def wrapper(x):
        return func(x) + "!"
    return wrapper
def hi(x):
    return "hi " + x
hi = shout(hi)
print(hi("ana"))

Try your own input

Indent with spaces. It knows def, return, global, nonlocal, pass, =, +=, -=, calls, whole numbers, text, True, False, None, lists with [i], .append() and .clear(), and the built-ins print, len, max, min, abs, sum and str.

All three add O(1) work to each call. What changes is how many places hold the timing code, and what the wrapped function looks like.

Okay

Copy the timing lines

The idea: Add start, record and return lines to every function.

Each function records its time. You can see exactly what happens.

import time
TIMINGS: list[tuple[str, float]] = []
def load_report(day: str) -> str:
start = time.perf_counter()
result = f"report for {day}"
TIMINGS.append(("load_report", time.perf_counter() - start))
return result
def send_email(to: str, body: str = "") -> bool:
start = time.perf_counter()
result = bool(to)
TIMINGS.append(("send_email", time.perf_counter() - start))
return result
Time
O(1) extra
Space
O(1)
Why
A clock read and one append per call.

Where it breaks: three lines in every function, each with the name typed by hand. A copy with the wrong name, or one you forget to remove, gives wrong timings.

Better

A decorator

The idea: timed(func) returns a wrapper that times func(*args, **kwargs) and returns its result.

The waste in Okay: the same lines in twenty places.

@timed on each function is all you add. *args, **kwargs pass every argument through.

Highlighted lines are new in this rung.

import time
from collections.abc import Callable
TIMINGS: list[tuple[str, float]] = []
def timed(func: Callable) -> Callable:
def wrapper(*args: object, **kwargs: object) -> object:
start = time.perf_counter()
result = func(*args, **kwargs)
TIMINGS.append((func.__name__, time.perf_counter() - start))
return result
return wrapper
@timed
def load_report(day: str) -> str:
"""Build the report for one day."""
return f"report for {day}"
Time
O(1) extra
Space
O(1)
Why
The same work, written once.
What changed from the previous rung
import time
from collections.abc import Callable
TIMINGS: list[tuple[str, float]] = []
def load_report(day: str) -> str:
start = time.perf_counter()
result = f"report for {day}"
TIMINGS.append(("load_report", time.perf_counter() - start))
return result
def timed(func: Callable) -> Callable:
def wrapper(*args: object, **kwargs: object) -> object:
start = time.perf_counter()
result = func(*args, **kwargs)
TIMINGS.append((func.__name__, time.perf_counter() - start))
return result
return wrapper
def send_email(to: str, body: str = "") -> bool:
start = time.perf_counter()
result = bool(to)
TIMINGS.append(("send_email", time.perf_counter() - start))
return result
@timed
def load_report(day: str) -> str:
"""Build the report for one day."""
return f"report for {day}"

Where it breaks: load_report.__name__ is now "wrapper", and the docstring is gone. Flask names each page after its function, so two @timed pages both called wrapper clash. And if the function raises, no time is recorded.

Best

functools.wraps, and finally

The idea: Decorate wrapper with @functools.wraps(func), and record the time in a finally block.

The waste in Better: the wrapper hides who it wraps.

@functools.wraps(func) copies the name and docstring, so help(load_report) and logs look right. try: return … finally: records the time whether func returns or raises.

Highlighted lines are new in this rung.

import functools
import time
from collections.abc import Callable
TIMINGS: list[tuple[str, float]] = []
def timed(func: Callable) -> Callable:
@functools.wraps(func) # copy func's name, docstring and more onto wrapper
def wrapper(*args: object, **kwargs: object) -> object:
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally: # record the time even when func raises
TIMINGS.append((func.__name__, time.perf_counter() - start))
return wrapper
@timed
def load_report(day: str) -> str:
"""Build the report for one day."""
return f"report for {day}"
Time
O(1) extra
Space
O(1)
Why
The same work.
What changed from the previous rung
import functools
import time
from collections.abc import Callable
TIMINGS: list[tuple[str, float]] = []
def timed(func: Callable) -> Callable:
@functools.wraps(func) # copy func's name, docstring and more onto wrapper
def wrapper(*args: object, **kwargs: object) -> object:
start = time.perf_counter()
result = func(*args, **kwargs)
TIMINGS.append((func.__name__, time.perf_counter() - start))
return result
try:
return func(*args, **kwargs)
finally: # record the time even when func raises
TIMINGS.append((func.__name__, time.perf_counter() - start))
return wrapper
@timed
def load_report(day: str) -> str:
"""Build the report for one day."""
return f"report for {day}"

When Best isn’t best: for finding slow code, a profiler times everything without changing any code. The profiling lesson shows cProfile. Decorators suit things you keep, like metrics in production.

Write timed. The tests decorate a small function with it, call it, and check the result, TIMINGS and the name.

A timing decorator

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: timed(add)(2, b=3) → 5
  2. Not run yet: @timed add; add(1); add(2, 3); the names in TIMINGS → ['add', 'add']
  3. Not run yet: @timed add: [add.__name__, add.__doc__] → ['add', 'Add two numbers.']
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayLines per functionKeeps the nameTimes a call that raisesUse it when
Okay: copies3YesNoOne function, once
Better: a decorator1NoNoQuick experiments
Best: wraps and finally1YesYesAnything you keep
  • The order of decorators: the one nearest def wraps first. @a then @b above a function means a(b(f)).
  • @retry without brackets when retry takes arguments passes your function as times. Write @retry(3), or make the arguments optional on purpose.
  • Decorating a method: self arrives in *args, so *args, **kwargs wrappers work on methods too.
  • The decorator runs once, at def time: code in timed outside wrapper runs when the module loads, not on each call.
  • Built-in ones you’ll meet: @property, @classmethod, @staticmethod, @functools.cache, @dataclass, and Flask’s @app.route.

Questions to ask first

  • Should the wrapper change the arguments or the result, or only watch?
  • What should happen when the function raises?

A 60-second walkthrough

“A decorator is a function that takes a function and returns a new one. My timed defines a wrapper(*args, **kwargs) that reads the clock and calls the original. It records the elapsed time in a finally, so failures are timed too, and returns the original’s result. I decorate the wrapper with functools.wraps(func) so it keeps the name, docstring and __wrapped__. The @timed line is just f = timed(f). For a decorator with arguments, like @retry(3), there’s one more level: retry(3) returns the decorator.”

Likely follow-ups

  • Why functools.wraps? Without it, every decorated function is called wrapper, which breaks logs, debugging, help() and frameworks that use the name.
  • Can a class be a decorator? Yes: any callable works. A class with __call__ suits decorators that keep state.
  • Can you decorate a class? Yes: @dataclass takes a class and returns it with methods added.

Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.

Write a decorator shout that turns a function’s text result into capitals. It must work on functions with any arguments, and keep their name and docstring.

Hint 1 of 3

The wrapper must return something, or every call gives None.

Hint 2 of 3

Take *args and **kwargs, so any arguments pass through.

Hint 3 of 3

Put @functools.wraps(func) on the wrapper.

Shout the result

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: shout(hi)("ana") → 'HI ANA'
  2. Not run yet: shout(greet)("bo", greeting="hey") → 'HEY BO'
  3. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Call and capitalise

The idea: wrapper(text) calls func(text).upper().

It does the work.

from collections.abc import Callable
def shout(func: Callable) -> Callable:
def wrapper(text):
func(text).upper()
return wrapper
Time
O(n)
Space
O(n)
Why
upper() copies the n letters.

Where it breaks: it never returns, so every decorated function gives back None.

Better

Return it

The idea: Return func(text).upper().

The waste in Okay: the work is thrown away.

Highlighted lines are new in this rung.

from collections.abc import Callable
def shout(func: Callable) -> Callable:
def wrapper(text: str) -> str:
return func(text).upper()
return wrapper
Time
O(n)
Space
O(n)
Why
upper() copies the letters.
What changed from the previous rung
from collections.abc import Callable
def shout(func: Callable) -> Callable:
def wrapper(text):
func(text).upper()
def wrapper(text: str) -> str:
return func(text).upper()
return wrapper

Where it breaks: the wrapper takes exactly one argument, so greet("bo", greeting="hey") raises TypeError. And the name becomes wrapper.

Best

*args, **kwargs and wraps

The idea: Pass every argument through and copy the name with functools.wraps.

The waste in Better: it fits only functions shaped like the first one.

Highlighted lines are new in this rung.

import functools
from collections.abc import Callable
def shout(func: Callable[..., str]) -> Callable[..., str]:
@functools.wraps(func)
def wrapper(*args: object, **kwargs: object) -> str:
return func(*args, **kwargs).upper()
return wrapper
Time
O(n)
Space
O(n)
Why
upper() copies the letters.
What changed from the previous rung
import functools
from collections.abc import Callable
def shout(func: Callable) -> Callable:
def wrapper(text: str) -> str:
return func(text).upper()
def shout(func: Callable[..., str]) -> Callable[..., str]:
@functools.wraps(func)
def wrapper(*args: object, **kwargs: object) -> str:
return func(*args, **kwargs).upper()
return wrapper

When Best isn’t best: if only one caller wants capitals, call .upper() there. A decorator changes the function for everyone.

Write a decorator with an argument, route(path), that adds the function to the ROUTES dict under path, like a web framework does. The function must still work when called by its own name. Registering a second function for the same path raises ValueError("/ is already handled by home").

Hint 1 of 3

route("/") runs first and returns the real decorator, register(func).

Hint 2 of 3

register must return func, or the name home ends up pointing at None.

Hint 3 of 3

Check path in ROUTES before adding it.

Register pages with @route

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: @route("/about") about; ROUTES["/about"]() → 'About us'
  2. Not run yet: @route("/about") about; about() → 'About us'
  3. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Add each page by hand

The idea: Write ROUTES[“/”] = home after each function.

It works, and nothing is hidden.

from collections.abc import Callable
ROUTES: dict[str, Callable] = {}
def home() -> str:
return "Welcome"
def about() -> str:
return "About us"
ROUTES["/"] = home # easy to forget when you add a page
ROUTES["/about"] = about
Time
O(1) per page
Space
O(p)
Why
One entry per page.

Where it breaks: the path lives far from the function, and a new page that you forget to add is a 404.

Better

A decorator that registers

The idea: route(path) returns register(func), which adds func to ROUTES.

The waste in Okay: the path is written apart from its page.

Highlighted lines are new in this rung.

from collections.abc import Callable
ROUTES: dict[str, Callable] = {}
def route(path: str) -> Callable:
def register(func: Callable) -> None:
ROUTES[path] = func
return register
@route("/")
def home() -> str:
return "Welcome"
Time
O(1) per page
Space
O(p)
Why
One entry per page.
What changed from the previous rung
from collections.abc import Callable
ROUTES: dict[str, Callable] = {}
def home() -> str:
return "Welcome"
def route(path: str) -> Callable:
def register(func: Callable) -> None:
ROUTES[path] = func
def about() -> str:
return "About us"
return register
ROUTES["/"] = home # easy to forget when you add a page
ROUTES["/about"] = about
@route("/")
def home() -> str:
return "Welcome"

Where it breaks: register returns nothing, so @route("/") sets home to None. ROUTES["/"]() works, but home() raises TypeError: 'NoneType' object is not callable.

Best

Return the function

The idea: register checks for a clash, adds func, and returns func unchanged.

The waste in Better: it registers the function and then throws it away.

A decorator doesn’t have to wrap anything. This one only records the function, which is how Flask’s @app.route works.

Highlighted lines are new in this rung.

from collections.abc import Callable
ROUTES: dict[str, Callable] = {}
def route(path: str) -> Callable[[Callable], Callable]:
def register(func: Callable) -> Callable:
if path in ROUTES:
raise ValueError(f"{path} is already handled by {ROUTES[path].__name__}")
ROUTES[path] = func
return func # give the function back, unchanged
return register
@route("/")
def home() -> str:
return "Welcome"
Time
O(1) per page
Space
O(p)
Why
One entry per page.
What changed from the previous rung
from collections.abc import Callable
ROUTES: dict[str, Callable] = {}
def route(path: str) -> Callable:
def register(func: Callable) -> None:
def route(path: str) -> Callable[[Callable], Callable]:
def register(func: Callable) -> Callable:
if path in ROUTES:
raise ValueError(f"{path} is already handled by {ROUTES[path].__name__}")
ROUTES[path] = func
return func # give the function back, unchanged
return register
@route("/")
def home() -> str:
return "Welcome"

When Best isn’t best: registration happens at import time, so a module nobody imports registers nothing. Big apps list their routes in one place instead.

Write retry(times, on=(ConnectionError,)). A function decorated with @retry(3) is tried up to 3 times when it raises one of the errors in on. If the last try fails too, the error is raised. Any other error is raised at once, with no retry. Keep the function’s name.

Hint 1 of 3

Three layers: retry(times, on) returns decorate(func), which returns wrapper.

Hint 2 of 3

except on: catches only the errors in that tuple.

Hint 3 of 3

On the last attempt, raise the error again with a bare raise.

Retry, with arguments

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: fetch fails twice, then works: @retry(3) → ['ok', 3]
  2. Not run yet: fetch always fails: @retry(3) → raises ConnectionError
  3. Not run yet: fetch has a bug (KeyError): no retry → raises KeyError
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Three tries, any error

The idea: A plain decorator that tries 3 times and swallows every error.

A short network problem is survived.

from collections.abc import Callable
def retry(func: Callable) -> Callable:
def wrapper(*args: object, **kwargs: object) -> object:
for _ in range(3):
try:
return func(*args, **kwargs)
except Exception:
continue
return None
return wrapper
Time
Up to 3 calls
Space
O(1)
Why
At most three tries.

Where it breaks: the number of tries is fixed, @retry(3) doesn’t fit its shape, and when every try fails it returns None instead of the error.

Better

retry(times)

The idea: A decorator factory: retry(times) returns the decorator; the last failure is raised.

The waste in Okay: it hides the final error, and can’t be told how many tries to make.

Highlighted lines are new in this rung.

from collections.abc import Callable
def retry(times: int) -> Callable[[Callable], Callable]:
def decorate(func: Callable) -> Callable:
def wrapper(*args: object, **kwargs: object) -> object:
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception:
if attempt == times - 1:
raise
return None
return wrapper
return decorate
Time
Up to times calls
Space
O(1)
Why
At most times tries.
What changed from the previous rung
from collections.abc import Callable
def retry(func: Callable) -> Callable:
def wrapper(*args: object, **kwargs: object) -> object:
for _ in range(3):
try:
return func(*args, **kwargs)
except Exception:
continue
return None
def retry(times: int) -> Callable[[Callable], Callable]:
def decorate(func: Callable) -> Callable:
def wrapper(*args: object, **kwargs: object) -> object:
for attempt in range(times):
try:
return func(*args, **kwargs)
except Exception:
if attempt == times - 1:
raise
return None
return wrapper
return wrapper
return decorate

Where it breaks: it retries every error, so a KeyError from a bug is tried again as if the network had failed. And it has no on= argument at all.

Best

Only the errors worth retrying

The idea: except on: retries only the listed errors; others go straight up; wraps keeps the name.

The waste in Better: it retries errors that will never go away.

Highlighted lines are new in this rung.

import functools
from collections.abc import Callable
def retry(
times: int, on: tuple[type[Exception], ...] = (ConnectionError,)
) -> Callable[[Callable], Callable]:
def decorate(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args: object, **kwargs: object) -> object:
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except on: # only the errors worth trying again
if attempt == times:
raise
return wrapper
return decorate
Time
Up to times calls
Space
O(1)
Why
At most times tries.
What changed from the previous rung
import functools
from collections.abc import Callable
def retry(times: int) -> Callable[[Callable], Callable]:
def retry(
times: int, on: tuple[type[Exception], ...] = (ConnectionError,)
) -> Callable[[Callable], Callable]:
def decorate(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args: object, **kwargs: object) -> object:
for attempt in range(times):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except Exception:
if attempt == times - 1:
except on: # only the errors worth trying again
if attempt == times:
raise
return None
return wrapper
return decorate

When Best isn’t best: add a wait that grows between tries, with some randomness, so many clients don’t retry at the same moment. The exceptions lesson’s practice builds that; the tenacity package does it all.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Predict the outputWhat does this print?
    def loud(func):
        def wrapper(*args, **kwargs):
            func(*args, **kwargs)
        return wrapper
    
    @loud
    def add(a, b):
        return a + b
    
    print(add(2, 3))
  2. Multiple choiceWhat is @timed above def f the same as?
  3. Spot the bugAfter decorating, help(load_report) shows “wrapper” with no docstring. What is missing?
    def timed(func):
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        return wrapper
  4. Predict the outputWhat does this print?
    def tag(t):
        def deco(func):
            def wrapper():
                return t + func()
            return wrapper
        return deco
    
    @tag("<")
    @tag("[")
    def hi():
        return "hi"
    
    print(hi())

Add these to your review deck. Each card comes back a little later every time you get it right.

  • @deco above def f means f = deco(f).
  • The wrapper: def wrapper(*args, **kwargs): … return func(*args, **kwargs).
  • Always @functools.wraps(func) on the wrapper.
  • With arguments, @deco(x) means f = deco(x)(f): three layers.
  • Stacked decorators apply bottom up: the one nearest def wraps first.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started