Your web app checks a user's role on every request. Is role in ALLOWED
faster when ALLOWED is a tuple or a set of five names? One check takes
less time than reading the clock twice, so the perf_counter timing from
the last article would measure only the clock. The timeit module runs
the code many times in a loop and times the whole loop:
import timeit
ALLOWED_TUPLE = ("viewer", "editor", "admin", "owner", "billing")
ALLOWED_SET = set(ALLOWED_TUPLE)
for label, check in [
("tuple", lambda: "billing" in ALLOWED_TUPLE),
("set", lambda: "billing" in ALLOWED_SET),
]:
timer = timeit.Timer(check)
number, _ = timer.autorange()
totals = timer.repeat(repeat=5, number=number)
best = min(totals) / number
print(f"{label:>5}: {best * 1e9:.0f} ns per check ({number:,} loops)")
On a laptop with Python 3.15, it printed:
tuple: 27 ns per check (10,000,000 loops)
set: 14 ns per check (20,000,000 loops)
Read the numbers per run
timeit.Timer(check) wraps a function that takes no arguments. Its methods
return totals, not times per run:
timer.timeit(number=n)runs the functionntimes and returns the total seconds.timer.repeat(repeat=r, number=n)does thatrtimes and returns a list ofrtotals.timer.autorange()picksnfor you. It tries 1, 2, 5, 10, 20, 50 and so on, until one total takes at least 0.2 seconds, and returns(n, seconds).
Divide each total by n before you compare anything. Two totals with
different loop counts mean nothing side by side.
Use the fastest total for small code
Other programs, a busy disk or a hot processor can only add time to a run. For a few lines of code, the fastest total is the closest to what the code itself costs, and the timeit documentation recommends it. Keep all the totals anyway, so you can see how much they spread. For a whole request that users wait for, look at the median and the slow runs instead, as in the last article.
timeit turns the garbage collector off
While it times, timeit turns off the garbage collector, so a collection
cannot land in one total and not another. Reference counting still frees
most objects as usual. Code that builds cycles is different. In real use the
collector has to find and free them, and under timeit it never runs. To
include that cost, pass setup=gc.enable. timeit runs the setup before
each timing loop, outside the timed part:
import gc
import timeit
def make_cycles() -> list[list[object]]:
nodes: list[list[object]] = []
for _ in range(1_000):
a: list[object] = []
b: list[object] = [a]
a.append(b)
nodes.append(a)
return nodes
was_enabled = gc.isenabled()
try:
timer = timeit.Timer(make_cycles, setup=gc.enable)
number, _ = timer.autorange()
best = min(timer.repeat(repeat=5, number=number)) / number
finally:
if was_enabled:
gc.enable()
else:
gc.disable()
print(f"collector on: {best * 1e6:.0f} µs per call")
This printed 102 µs per call. With the collector off, as timeit sets it by
default, the same function took 42 µs.
The try and finally matter. timeit turns the collector back on
afterwards only if it was on when timing started. If it was off and your
setup turned it on, it stays on. Saving the setting and restoring it covers
both cases.
Pass a function rather than a string
timeit also accepts code as a string, which is how you use it from the
command line:
python -m timeit -s 'allowed = {"viewer", "editor", "admin", "owner", "billing"}' '"billing" in allowed'
It printed 6.1 nsec per loop, against 14 ns for the lambda. Calling the
lambda adds about 8 nanoseconds per run, which matters only for code this
small. In return, a type checker reads a function like the rest of your
code. It cannot see inside a string, so a typo there shows up only when the
string runs.
Check your understanding A change makes building a dictionary 1.4 times faster in timeit, but request times barely move. What would you check? Show answer Hide answer
First, the dictionary's share of a request. If it took 2% of the time,
making it 1.4 times faster saves about 0.57%. Then compare the conditions.
timeit reuses one small input, runs with warm caches and turns off the
garbage collector, while real requests do none of that. Profile a real
request, as the next article shows, to see where its time goes.
Going further
Python 3.15 adds a target_time argument to autorange() and a matching
-t option on the command line, to change the 0.2-second goal.