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
| Timer | Includes sleep/IO | Monotonic | Resolution | Use case |
|---|---|---|---|---|
perf_counter() | ✅ | ✅ | Highest | Benchmarking |
monotonic() | ✅ | ✅ | Medium | Timeouts |
process_time() | ❌ | ✅ | Medium | CPU profiling |
time() | ✅ | ❌ | Medium | Wall 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.
See Also
- Python Algorithmic Complexity Understand Algorithmic Complexity through a practical analogy so your Python decisions become faster and clearer.
- Python Async Performance Tuning Making your async Python faster is like organizing a busy restaurant kitchen — it's all about flow.
- Python Benchmark Methodology Why timing Python code once means nothing, and how fair testing works like a science experiment.
- Python C Extension Performance How Python borrows C's speed for the hard parts — like hiring a specialist for the toughest job on the worksite.
- Python Caching Strategies Understand Python caching strategies with a shortcut-road analogy so your app gets faster without taking wrong turns.