Python perf_counter Timing — Core Concepts

Why perf_counter matters

When you need to know how long something takes in Python, reaching for the right timer matters. Using time.time() — the most common first instinct — can give misleading results because the system clock can jump backward (NTP adjustments, daylight saving). time.perf_counter() uses a monotonic, high-resolution clock designed specifically for measuring elapsed time.

The three main timers

perf_counter — wall-clock precision

Measures real elapsed time with the highest resolution available on your system. Includes time spent sleeping, waiting for I/O, and other processes using the CPU.

import time

start = time.perf_counter()
result = expensive_function()
elapsed = time.perf_counter() - start
print(f"Took {elapsed:.4f} seconds")

Use when: Measuring end-to-end latency, benchmarking user-visible performance.

monotonic — wall-clock, lower resolution

Similar to perf_counter but potentially lower resolution. Guaranteed never to go backward.

Use when: Implementing timeouts, scheduling, or when nanosecond precision doesn’t matter.

process_time — CPU time only

Measures only the time your process spent executing on the CPU. Excludes sleep, I/O waits, and time given to other processes.

start = time.process_time()
result = cpu_intensive_function()
cpu_elapsed = time.process_time() - start
print(f"CPU time: {cpu_elapsed:.4f} seconds")

Use when: Measuring computational efficiency, ignoring I/O and sleep.

Comparison table

TimerIncludes sleep/IOMonotonicResolutionUse case
perf_counter()HighestBenchmarking
monotonic()MediumTimeouts
process_time()MediumCPU profiling
time()MediumWall clock (not timing)

Nanosecond variants

Python 3.7+ added _ns variants that return integer nanoseconds instead of float seconds:

start = time.perf_counter_ns()
do_something()
elapsed_ns = time.perf_counter_ns() - start
print(f"Took {elapsed_ns} nanoseconds")

This avoids floating-point precision loss for very short durations. When measuring microsecond-level operations, the nanosecond variant is more accurate.

The timing context manager pattern

A reusable pattern for timing blocks of code:

from contextlib import contextmanager
import time

@contextmanager
def timer(label: str = "Block"):
    start = time.perf_counter()
    yield
    elapsed = time.perf_counter() - start
    print(f"{label}: {elapsed:.4f}s")

with timer("Data loading"):
    data = load_data()

with timer("Processing"):
    result = process(data)

The timing decorator pattern

For timing entire functions:

import functools
import time

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__}: {elapsed:.4f}s")
        return result
    return wrapper

@timed
def fetch_data():
    ...

Common misconception

“time.time() is fine for benchmarking.”

time.time() returns the system clock, which can jump forward or backward when the OS syncs with NTP servers. On most systems, this happens rarely and the jump is small — but it means your timing measurement could occasionally be negative or wildly off. perf_counter() uses a monotonic counter that never goes backward, making it strictly better for timing.

Multiple runs and statistics

A single timing measurement is noisy — OS scheduling, garbage collection, and cache effects all add variation. For reliable benchmarks, measure multiple runs:

import time
import statistics

times = []
for _ in range(100):
    start = time.perf_counter()
    result = function_to_measure()
    times.append(time.perf_counter() - start)

print(f"Median: {statistics.median(times):.6f}s")
print(f"Mean:   {statistics.mean(times):.6f}s")
print(f"Stdev:  {statistics.stdev(times):.6f}s")
print(f"Min:    {min(times):.6f}s")

Use median rather than mean — it’s less affected by outliers from garbage collection pauses.

When to use timeit instead

For micro-benchmarks (measuring operations that take microseconds), use the timeit module, which handles warmup, repetition, and garbage collection disabling:

import timeit

result = timeit.timeit("sorted(data)", globals={"data": list(range(1000))}, number=10000)
print(f"10000 iterations: {result:.4f}s")

Use perf_counter for real-world timing of your actual code. Use timeit for isolated micro-benchmarks of small expressions.

The one thing to remember: time.perf_counter() is the right tool for measuring how long real code takes — it’s precise, monotonic, and purpose-built for performance measurement.

pythonperformancestdlib

See Also