code execution time
performance measurement
benchmarking
software development
programming techniques

Measuring code execution time in this code

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Measuring execution time is easy to do badly and surprisingly hard to do well. A useful measurement depends on timing the right section of code, using an appropriate clock, and understanding whether you want a rough check, a comparison benchmark, or a full profile.

Start with a Simple Wall-Clock Measurement

For a quick answer, use a high-resolution monotonic timer. In Python, time.perf_counter() is the standard choice for this kind of timing.

python
1import time
2
3start = time.perf_counter()
4
5total = 0
6for i in range(1_000_000):
7    total += i
8
9elapsed = time.perf_counter() - start
10print(f"elapsed={elapsed:.6f}s total={total}")

This gives you an elapsed time in seconds for the code between the two calls. It is useful for quick local checks, but a single number should be treated as a rough sample rather than a final benchmark.

Time Only the Work You Care About

A very common mistake is measuring setup plus the real workload and then blaming the wrong part for the runtime. If you are benchmarking sorting, for example, generate the data before the timer starts.

python
1import random
2import time
3
4numbers = [random.randint(1, 1000) for _ in range(200_000)]
5
6start = time.perf_counter()
7sorted_numbers = sorted(numbers)
8elapsed = time.perf_counter() - start
9
10print(f"sorting took {elapsed:.6f}s")

If list creation stayed inside the timed block, the number would be less useful because it would mix data generation with sorting cost.

Wrap Code in Functions for Repeatable Tests

As soon as timing becomes more than a quick sanity check, put the code under test inside a function. That makes the benchmark easier to read and easier to compare against alternatives.

python
1import time
2
3def use_list_comprehension(values):
4    return [value * value for value in values]
5
6def use_loop(values):
7    result = []
8    for value in values:
9        result.append(value * value)
10    return result
11
12data = list(range(100_000))
13
14for fn in (use_list_comprehension, use_loop):
15    start = time.perf_counter()
16    fn(data)
17    elapsed = time.perf_counter() - start
18    print(fn.__name__, elapsed)

This keeps the comparison explicit and reduces the chance that inline benchmark code becomes confusing.

Use timeit for Comparisons

If the goal is to compare two implementations fairly, timeit is usually better than timing a single run by hand. It repeats the code many times and reduces some of the noise from background activity.

python
1import timeit
2
3results = timeit.repeat(
4    stmt="sum(range(10000))",
5    repeat=5,
6    number=1000
7)
8
9print(results)
10print("best:", min(results))

Repeated timings are more informative than one sample. The fastest run is often a better representation of the real cost because it is the least affected by unrelated system activity.

Measuring Async Code Correctly

Async code has one extra trap: creating a coroutine is not the same thing as running it. You must time the awaited execution.

python
1import asyncio
2import time
3
4async def simulated_io():
5    await asyncio.sleep(0.05)
6
7async def main():
8    start = time.perf_counter()
9    await simulated_io()
10    elapsed = time.perf_counter() - start
11    print(f"async elapsed={elapsed:.6f}s")
12
13asyncio.run(main())

If you stop the timer before await, the result measures almost nothing and tells you nothing useful about the real workload.

Profiling Helps When You Do Not Know the Bottleneck

Sometimes the question is not "how long does this one snippet take" but "why is the whole program slow". In that case, profiling is usually more useful than hand timing guesses.

python
1import cProfile
2import pstats
3
4def workload():
5    data = [i * i for i in range(200_000)]
6    return sum(data)
7
8with cProfile.Profile() as profile:
9    workload()
10
11stats = pstats.Stats(profile)
12stats.sort_stats("cumtime").print_stats(10)

Profilers help identify where the cumulative time actually goes. That can prevent wasted effort on code that only looked suspicious.

Keep Context with the Number

A timing value without context is not very informative. Good measurements usually include at least the input size, the environment, and the number of repetitions. If a benchmark changed after a refactor, you want enough context to explain whether the code, the data, or the machine changed.

That is also why tiny performance differences should be treated carefully. If two versions differ by a few microseconds in a noisy environment, the result may not matter or even be real.

Common Pitfalls

The most common mistake is trusting one run. Timings fluctuate, and a single sample can be misleading.

Another issue is including setup work inside the measured block. Imports, random-data generation, and file reading can dominate the number and hide the behavior you actually wanted to inspect.

Developers also sometimes optimize a microbenchmark that does not affect real application performance. Faster isolated code is only valuable if it matters in production.

Finally, avoid using time.time() for benchmarking when time.perf_counter() is available. The latter is designed for high-resolution elapsed-time measurements.

Summary

  • Use time.perf_counter() for quick elapsed-time measurements.
  • Time only the code you actually want to evaluate.
  • Wrap benchmark targets in functions for readable comparisons.
  • Use timeit when comparing alternative implementations.
  • Profile the full program when the true bottleneck is unknown.

Course illustration
Course illustration

All Rights Reserved.