Benchmarks You Can Keep

The Course
•
September 19, 2026
•
8 min read

Yesterday’s measurements were one-offs: run the benchmark, read the number, change the code. A library that will be maintained needs those measurements to survive — and to complain when somebody undoes them.

A benchmark is part of the source

benchmark.py ships in the repository, next to the tests. That matters for three reasons: anyone can reproduce your claims, the numbers get re-measured on hardware you don’t own, and a change that regresses performance can be demonstrated rather than argued about.

The library’s benchmark is thirty lines and prints this:

2000 rows, cache_size=128

in-memory SQLite (no latency):
  one by one     2000 selects       3.7 ms
  get_many          1 selects       3.5 ms
  ratio: 1.0x

with 0.2 ms of latency per query:
  one by one     2000 selects     636.2 ms
  get_many          1 selects       8.2 ms
  ratio: 77x

Four properties make that output useful rather than decorative.

It states the workload. Rows and cache size are printed, because a number without its inputs is meaningless — and because the next person will change them.

It reports counts as well as times. The selects column is exact and machine-independent; the milliseconds are neither. When someone reports different timings, the counts tell you whether they ran the same thing.

It compares, rather than reporting an absolute. “77x” survives being run on a slower laptop; “8.2 ms” doesn’t.

It models the condition that matters. Without simulated latency this benchmark says the optimisation is pointless. That’s the honest result for in-process SQLite, and the wrong conclusion for a library people will point at a server.

Benchmarks rot faster than tests

Nothing fails when a benchmark stops being meaningful. The workload gets unrepresentative, the comparison drifts, the numbers in the README are from two years ago. Put the date and the machine next to any number you publish, and re-run it as part of a release rather than trusting it.

Warm-up, repetition and what to report

First run, steady state Python
import time


def work(size=50_000):
    return sum(index * index for index in range(size))


timings = []
for run in range(7):
    start = time.perf_counter()
    work()
    timings.append((time.perf_counter() - start) * 1000)

print("each run (ms):", " ".join(f"{value:6.2f}" for value in timings))
print(f"\nfirst:   {timings[0]:6.2f} ms")
print(f"best:    {min(timings):6.2f} ms")
print(f"mean:    {sum(timings) / len(timings):6.2f} ms")
print(f"worst:   {max(timings):6.2f} ms")

Here the seven runs agree closely, and the first is not the slowest — a tight loop over integers has little to warm up. Add imports, a connection, a cold file cache or a JIT and the first run can be many times the others, which is often exactly what a user experiences. Which number to report depends on the question:

  • Best of N for “how fast can this code be”, the comparison you want when optimising. Interruptions only add time, so the minimum is the least noisy estimate.
  • Mean or median of N for “what will a user see”, once the system is warm.
  • First run when startup cost is the thing you’re measuring.

Report which one you chose. A number without that is not reproducible.

Turn the important ones into tests

A benchmark tells you the number today. A test tells you when it changed.

The rule from Day 6 applies: assert on counts, not milliseconds. The query counts in this library are its performance contract, and they’re exact:

    def test_get_many_costs_one_query(self) -> None:
        Post.get_many(self.connection, list(range(1, 11)))
        self.assertEqual(self.connection.selects, 1)

    def test_a_partially_cached_request_only_fetches_the_rest(self) -> None:
        Post.get_many(self.connection, [1, 2])
        self.connection.reset()
        Post.get_many(self.connection, [1, 2, 3, 4])
        self.assertEqual(self.connection.selects, 1)
        self.assertEqual(self.connection.queries[-1].count("?"), 2, "only the missing ids")

Those two fail on any change that reintroduces N+1, on any machine, in milliseconds. No wall clock anywhere.

The same trick works for more than queries: count allocations of a class, calls to a mocked dependency, bytes written, HTTP requests made. Whatever the expensive thing is, count it.

If you must assert on time

Sometimes the resource really is time — an animation budget, a timeout. Then: use a generous threshold (2-3x the observed value), assert a ratio between two operations in the same run rather than an absolute, and mark the test so it can be skipped in CI. A flaky performance test gets disabled, and then you have neither the test nor the signal.

Exercise

A regression test for the cache

The library’s cache exists to avoid queries. Write the tests that would fail if it stopped working — without timing anything. Three are described; the fourth is yours to invent.

The starter bodies are ..., so Run passes before you’ve written anything — an empty test is a passing test. That’s the trap this whole lesson is about: green means nothing until you’ve seen the test go red. After each one, break the Store on purpose — delete the if id in self.cache short-circuit, say — and confirm your test fails. A regression test you’ve never watched fail is a regression test you don’t yet trust.

import unittest


class Store:
    """Rows, a cache, and a counter of how often it really queried."""

    def __init__(self):
        self.rows = {id: f"row {id}" for id in range(1, 101)}
        self.cache = {}
        self.queries = 0
        self.fetched = []          # the ids each real query asked the database for

    def get(self, id):
        if id in self.cache:
            return self.cache[id]
        self.queries += 1
        self.fetched.append([id])
        self.cache[id] = self.rows[id]
        return self.cache[id]

    def get_many(self, ids):
        missing = [id for id in dict.fromkeys(ids) if id not in self.cache]
        if missing:
            self.queries += 1
            self.fetched.append(missing)
            for id in missing:
                self.cache[id] = self.rows[id]
        return {id: self.cache[id] for id in dict.fromkeys(ids)}

    def invalidate(self, id):
        self.cache.pop(id, None)


class CacheContract(unittest.TestCase):
    def setUp(self):
        self.store = Store()

    def test_a_second_get_is_free(self):
        # TODO: two gets of the same id cost one query
        ...

    def test_get_many_is_one_query(self):
        # TODO: ten uncached ids cost one query
        ...

    def test_a_partially_cached_batch_only_fetches_the_rest(self):
        # TODO: after caching two, a batch of four costs one more query
        ...

    def test_invalidating_forces_a_reread(self):
        # TODO: your own — what does invalidate() owe the caller?
        ...


unittest.TextTestRunner(verbosity=2).run(
    unittest.defaultTestLoader.loadTestsFromTestCase(CacheContract)
)
Hint 1: the idea

Every test here is a claim about counts, never times: queries and fetched record what really reached the database. Each test sets up a situation, does something, and asserts on those counters — and must go red when you break the Store on purpose.

Hint 2: a first step

The first test is two calls and an assertion: get(1) twice, then self.assertEqual(self.store.queries, 1). Now delete the cache short-circuit in get and run: it should fail. Put it back. That break-and-check is the step to repeat for every test you write.

Show one solution
    def test_a_second_get_is_free(self):
        self.store.get(1)
        self.store.get(1)
        self.assertEqual(self.store.queries, 1)

    def test_get_many_is_one_query(self):
        self.store.get_many(list(range(1, 11)))
        self.assertEqual(self.store.queries, 1)

    def test_a_partially_cached_batch_only_fetches_the_rest(self):
        self.store.get_many([1, 2])
        self.store.get_many([1, 2, 3, 4])
        self.assertEqual(self.store.queries, 2)
        self.assertEqual(self.store.fetched[-1], [3, 4])   # only the missing ids

    def test_invalidating_forces_a_reread(self):
        self.store.get(1)
        self.store.invalidate(1)
        self.store.get(1)
        self.assertEqual(self.store.queries, 2)

Each is a few lines, and together they pin the whole performance contract: a cache hit costs nothing, a batch is one query, a partial batch fetches only what’s missing, and invalidation actually invalidates.

The partial-batch test needed two assertions, and that’s the lesson. queries == 2 alone is too weak: an implementation that re-fetches all four ids on the second call also issues exactly two queries and passes. Only checking which ids were asked for — fetched[-1] == [3, 4] — distinguishes “fetch the missing ones” from “fetch everything again”. The library’s own Day 6 test does the same thing when it counts the ? placeholders in the last query.

The fourth test is worth dwelling on differently. invalidate changes nothing about a returned value, so the counter is the cheapest way to see its effect. It isn’t the only way — you could change the backing data, invalidate, and assert the next read returns the new value — but that’s a slower, less direct test of the same thing. Instrument the property you care about, and a behaviour with no other visible effect becomes a one-line assertion.

Notice that none of these break if Store gets faster, switches to a different cache, or changes its SQL. They constrain the contract, not the implementation, which is why they’ll still be passing in three years.

What you learned

  • Ship the benchmark with the library, print its workload, and report counts beside times.
  • Compare ratios rather than absolutes, so numbers survive different hardware.
  • Say whether you report best, mean or first — they answer different questions.
  • Turn the performance properties that matter into counting tests; keep wall-clock assertions out of the suite.
Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.