Skip to content
← Back to explainers

Python explainer

Time Small Pieces of Code with timeit

About 4 min to read. Allow extra time to work through examples.

Let timeit run a tiny piece of code millions of times, and read its results per run. It turns the garbage collector off while it times.

This article uses Python. You can make it your practice mode to browse related content across the site.

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 function n times and returns the total seconds.
  • timer.repeat(repeat=r, number=n) does that r times and returns a list of r totals.
  • timer.autorange() picks n for 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

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.

step 27 of 38

Memory, Copies and Slow Code

Up next Compare Best Timings and Put the Garbage Collector Back