Performance Tools

timeit for precise micro-benchmarks, cProfile for function-level CPU profiling, and tracemalloc for memory profiling — the standard-library instruments for measuring before optimizing.

Measuring is the prerequisite to optimizing; guessing which part of a program is slow (or memory-heavy) is unreliable even for experienced developers. This topic covers the standard library's three core measurement tools — timeit for precise micro-benchmarks of small snippets, cProfile for function-level CPU profiling of a whole program, and tracemalloc for memory allocation tracing — plus general profiling concepts and where third-party ecosystem tools fit in.

Profiling Concepts

Profiling means observing a running program to measure where time or memory actually goes, rather than reasoning about it in the abstract. A benchmark measures the wall-clock or CPU time of a specific, isolated piece of code (best for comparing two implementations of the same small operation). A profiler instruments a whole program's execution and reports a breakdown by function or line (best for finding which part of a larger program is the actual bottleneck). Choosing the wrong tool for the question — benchmarking a whole program, or profiling to compare two one-liners — produces noisy, less useful results than picking the tool suited to the question being asked.

timeit: Micro-Benchmarking

The standard-library timeit module runs a small snippet of code repeatedly (many times, in a loop, with GC and other noise sources minimized as far as practical) and reports how long it took, which is far more reliable than timing a single run with time.perf_counter() — a single run is vulnerable to system noise (other processes, cache effects, GC pauses). It's designed specifically for comparing small, isolated pieces of code, not for profiling an entire application.

timeit.timeit() runs stmt number times and returns total elapsed time; running many iterations averages out one-off system noise, giving a far more stable measurement than a single timed run.

From the command line, python -m timeit -s "setup code" "statement to time" runs the same benchmarking logic without writing a script, and automatically picks a repeat count. Inside a Jupyter-style interactive session (not covered here), the %timeit magic wraps the same module.

import timeit

# time a small snippet, executed 100,000 times by default with timeit.timeit
concat_time = timeit.timeit(
    stmt='"".join(str(i) for i in range(100))',
    number=10_000,
)
print(f"join approach: {concat_time:.4f}s for 10,000 runs")

# compare two approaches directly
setup = "words = ['word'] * 100"
join_time = timeit.timeit('"".join(words)', setup=setup, number=100_000)
concat_loop_time = timeit.timeit(
    'r = ""\nfor w in words:\n    r += w',
    setup=setup,
    number=100_000,
)
print(f"join: {join_time:.4f}s, loop concat: {concat_loop_time:.4f}s")

cProfile: Function-Level Profiling

The standard-library cProfile module profiles an entire callable (or script) and reports, per function, how many times it was called, total time spent in it (excluding calls to other profiled functions), and cumulative time (including those calls). This is the tool for the common question 'which function in my program is actually eating the time?' — where timeit answers a narrower, pre-selected micro-benchmark question.

pstats.Stats formats the raw profiler output; sorting by "cumulative" surfaces the functions that, including everything they call, account for the most total time — usually the most useful sort order for finding a bottleneck.

cProfile adds real overhead to every function call while active (it's a C-accelerated profiler, faster than the pure-Python profile module, but still not free) — profile a representative workload, not necessarily full production traffic, and expect absolute times under the profiler to be somewhat inflated versus unprofiled execution. Relative comparisons between functions remain meaningful.

import cProfile
import pstats

def slow_function():
    total = 0
    for i in range(1_000_000):
        total += i ** 2
    return total

def fast_function():
    return sum(i ** 2 for i in range(1_000_000))

def main():
    slow_function()
    fast_function()

profiler = cProfile.Profile()
profiler.enable()
main()
profiler.disable()

stats = pstats.Stats(profiler).sort_stats("cumulative")
stats.print_stats(5)  # top 5 functions by cumulative time

Reading cProfile Output

cProfile's output columns: ncalls (how many times the function was called), tottime (time spent in the function itself, excluding sub-calls), cumtime (time including everything the function calls), and percall (per-call averages for each of those). A function with high tottime is doing expensive work itself; a function with high cumtime but low tottime is mostly a thin wrapper around expensive calls further down the call stack — that distinction usually tells you where to actually focus.

2000003 function calls in 0.312 seconds

   Ordered by: cumulative time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.187    0.187    0.187    0.187 script.py:4(slow_function)
        1    0.089    0.089    0.125    0.125 script.py:10(fast_function)
  1000000    0.036    0.000    0.036    0.000 {built-in method builtins.sum}

tracemalloc: Memory Profiling

The standard-library tracemalloc module traces Python memory allocations, letting you take a 'snapshot' of what's allocated at a point in time and compare snapshots to see what grew. This answers questions sys.getsizeof() can't: not just how big one object is, but where in the code the currently-allocated memory actually originated.

compare_to() diffs two snapshots and reports which source lines are responsible for the memory growth between them — far more actionable than a single total-memory number, since it points at the exact allocation site.

tracemalloc itself adds some memory and CPU overhead while active (it has to record where each allocation happened) — typical usage is to start() it, take snapshots around the code under investigation, then stop() it rather than leaving it running for an entire long-lived production process.

import tracemalloc

tracemalloc.start()

snapshot1 = tracemalloc.take_snapshot()

data = [str(i) * 100 for i in range(100_000)]  # allocate a chunk of memory

snapshot2 = tracemalloc.take_snapshot()
diff = snapshot2.compare_to(snapshot1, "lineno")

print("Top memory-growth lines:")
for stat in diff[:3]:
    print(stat)

tracemalloc.stop()

Choosing the Right Tool

A practical decision guide: comparing two small implementations of the same operation — timeit. Finding which function in a larger program consumes the most CPU time — cProfile. Finding where memory is actually being allocated, or diagnosing a suspected memory leak — tracemalloc. These three cover the vast majority of standard-library-only profiling needs; reach for one of them before reasoning abstractly about where a program's time or memory goes.

Ecosystem Tools (Overview)

Beyond the standard library, several third-party tools extend profiling capability and are worth knowing by name, though using them in depth is outside this topic's scope: py-spy is a sampling profiler that can attach to a running Python process from outside it (no code changes needed, low overhead, useful in production); line_profiler gives line-by-line (not just function-level) CPU timing; memory_profiler gives line-by-line memory usage; Scalene combines CPU, GPU, and memory profiling with a focus on distinguishing Python-level from native-code time. All of these are ecosystem packages, installed separately — none are part of the standard library.

Practical Usage

A typical workflow: suspect a program is slow, run it under cProfile to find the hot function(s); once you have a candidate hot function or a specific line-level question ('is list.append or += faster for this pattern?'), isolate that snippet and measure it precisely with timeit; if the concern is memory rather than time, use tracemalloc snapshots around the suspect code path. Only after measurement points at a specific, confirmed bottleneck should you reach for an optimization technique (see the related Optimization Strategies topic).

Edge Cases

Profiler overhead can distort relative timings for very fast, frequently-called functions (the overhead of the profiler's own bookkeeping per call can rival the function's actual work), so treat absolute numbers under cProfile cautiously for micro-level comparisons — that's exactly the case timeit is better suited to. timeit's default of disabling garbage collection during timing (to reduce noise) means it doesn't capture GC-pause costs that would occur in normal running conditions — appropriate for comparing pure algorithmic cost, less appropriate if GC behavior itself is part of what you're investigating.

Complete Example

import cProfile
import pstats
import timeit
import tracemalloc

def build_with_list(n: int) -> list[int]:
    return [i * i for i in range(n)]

def build_with_generator_sum(n: int) -> int:
    return sum(i * i for i in range(n))

# 1. cProfile: which of these two dominates a combined workload?
def workload():
    build_with_list(500_000)
    build_with_generator_sum(500_000)

profiler = cProfile.Profile()
profiler.enable()
workload()
profiler.disable()
pstats.Stats(profiler).sort_stats("cumulative").print_stats(3)

# 2. timeit: precisely compare the two approaches in isolation
list_time = timeit.timeit(lambda: build_with_list(10_000), number=100)
gen_time = timeit.timeit(lambda: build_with_generator_sum(10_000), number=100)
print(f"list: {list_time:.4f}s, generator+sum: {gen_time:.4f}s")

# 3. tracemalloc: confirm the list version allocates far more memory
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
build_with_list(100_000)
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:2]:
    print(stat)
tracemalloc.stop()

Mental Model

Think of timeit as a stopwatch for one specific move, cProfile as a full-body motion-capture session showing where all the time went across an entire routine, and tracemalloc as a set of before/after photographs showing exactly what got heavier and where. Each answers a different kind of question — pick the instrument that matches the question.

Quick Reference

timeit.timeit(stmt, setup=..., number=N)  - precise micro-benchmark, small snippets
python -m timeit "expr"                    - CLI micro-benchmark, no script needed
cProfile.Profile() / pstats.Stats(...)     - function-level CPU profiling, whole program
  ncalls / tottime / cumtime               - calls / own time / own+sub-calls time
tracemalloc.start() / take_snapshot()      - memory allocation tracing
  snapshot.compare_to(other, "lineno")     - diff memory growth by source line
py-spy, line_profiler, memory_profiler, Scalene  - third-party, deeper/production profiling

Key Takeaways

Use timeit for precise, noise-resistant micro-benchmarks comparing two small snippets — not for profiling a whole program.

Use cProfile (with pstats) to find which function(s) in a larger program actually consume the CPU time; sort by cumtime to find the real bottleneck.

Use tracemalloc to trace where memory is actually being allocated, via before/after snapshot comparison, rather than guessing.

Every profiling tool adds some overhead of its own — treat absolute numbers cautiously for very fast operations, and prefer relative comparisons.

Third-party tools (py-spy, line_profiler, memory_profiler, Scalene) extend profiling to line-level detail or production, running-process attachment, but are ecosystem packages, not standard library.

import time start = time.perf_counter() result = some_function() print(time.perf_counter() - start) # single run: noisy, unreliable for small operations import timeit duration = timeit.timeit(some_function, number=10_000) print(f"{duration / 10_000:.9f}s per call (averaged over 10,000 runs)")

A single timed run is highly sensitive to system noise (background processes, cache state, one-off GC pauses); timeit runs the operation many times and reports an average, which is far more representative for small, fast operations.

stats = pstats.Stats(profiler).sort_stats("tottime") stats.print_stats(10) # sort by tottime to find functions doing real work themselves

A function can have high cumulative time simply because it calls other expensive functions, while doing little work itself (low tottime). Sorting by tottime (not just cumtime) surfaces the functions actually responsible for the time, avoiding wasted effort optimizing a thin wrapper.

Why is timeit generally more reliable than a single time.perf_counter()-wrapped measurement for comparing two small code snippets?

A single measurement is vulnerable to one-off system noise — background processes, CPU cache effects, a GC pause happening to occur during that one run. timeit runs the snippet many times and reports the total/average, averaging out that noise and giving a far more stable, representative comparison.

What's the difference between tottime and cumtime in cProfile's output, and why does that distinction matter?

tottime is time spent in the function's own code, excluding any functions it calls; cumtime includes everything it calls (cumulative). A function can have a high cumtime while doing almost no work itself (low tottime) if it's mostly a wrapper around other expensive calls — sorting by the right one points you at the function actually responsible for the time, versus one that merely happens to be on the call path.

What question does tracemalloc answer that sys.getsizeof() cannot?

sys.getsizeof() reports the size of a single given object; tracemalloc traces where in the program's source code memory was allocated over time, letting you compare snapshots to find which lines are responsible for memory growth across a whole run — a much broader, source-location-aware picture than a single object's size.

Why might profiling with cProfile give misleading absolute timings for a very fast, frequently-called function?

cProfile adds bookkeeping overhead on every function call it profiles; for a function whose actual work is extremely fast, that per-call overhead can rival or exceed the function's real execution time, inflating its apparent cost. Relative comparisons between functions under the same profiling run remain meaningful even though absolute numbers are somewhat inflated.

When would you reach for timeit versus cProfile for a given performance question?

Use timeit when you already know which small, isolated snippet or operation you want to compare precisely (e.g. two ways of writing the same one-liner). Use cProfile when you don't yet know which part of a larger program is slow and need a function-level breakdown to find out.

Name two third-party (non-standard-library) profiling tools mentioned as extending beyond timeit/cProfile/tracemalloc, and what each specializes in.

py-spy is a sampling profiler that can attach to an already-running Python process from outside it, useful for production diagnosis without code changes; line_profiler gives line-by-line (rather than just function-level) CPU timing. (memory_profiler for line-by-line memory, and Scalene combining CPU/GPU/memory profiling, are also acceptable answers.)

Why does tracemalloc's documentation-recommended usage pattern involve calling start() and stop() around a specific code section rather than leaving it running for an entire long-lived process?

Tracing every allocation adds ongoing CPU and memory overhead of its own (it must record the source location of each allocation); scoping it to start()/take_snapshot()/stop() around the specific code under investigation keeps that overhead limited to the measurement window rather than paying it continuously in production.

Write code using timeit to compare the performance of building a list with a list comprehension versus with repeated .append() calls in a loop, for 1,000 elements, run 1,000 times each.

import timeit comprehension_time = timeit.timeit( "[i * i for i in range(1000)]", number=1000, ) def build_with_append(): result = [] for i in range(1000): result.append(i * i) return result append_time = timeit.timeit(build_with_append, number=1000) print(f"comprehension: {comprehension_time:.4f}s, append loop: {append_time:.4f}s")

Both approaches are timed via timeit.timeit, either with a string statement (stmt=, parsed and compiled internally) or a callable — timeit accepts either form. Running each 1,000 times and comparing the totals gives a stable, noise-resistant comparison; list comprehensions typically edge out an equivalent append loop due to lower per-iteration overhead, though the exact margin depends on the Python version and workload.

Write code that uses cProfile to profile a function process_data() and prints the top 5 functions sorted by total time spent in each function's own code (excluding sub-calls).

import cProfile import pstats def process_data(): data = [i * i for i in range(500_000)] return sum(x for x in data if x % 2 == 0) profiler = cProfile.Profile() profiler.enable() process_data() profiler.disable() stats = pstats.Stats(profiler) stats.sort_stats("tottime").print_stats(5)

cProfile.Profile() is enabled around the call to process_data(), capturing per-function call counts and timings. pstats.Stats(profiler).sort_stats("tottime") sorts the results by time spent in each function's own code (excluding time in functions it calls), and print_stats(5) prints the top 5 rows by that sort order — the functions actually doing the most work themselves, not just the ones on the busiest call path.

Write code that uses tracemalloc to measure how much memory is allocated by building a list of 200,000 strings, and print the single line responsible for the largest allocation.

import tracemalloc tracemalloc.start() snapshot_before = tracemalloc.take_snapshot() big_list = [f"item-{i}" for i in range(200_000)] snapshot_after = tracemalloc.take_snapshot() diff = snapshot_after.compare_to(snapshot_before, "lineno") print("Largest allocation:") print(diff[0]) tracemalloc.stop()

tracemalloc.start() begins tracking allocations; two snapshots taken before and after building big_list are compared with compare_to(..., "lineno"), which returns statistics grouped and sorted by the source line each allocation occurred at, largest first. diff[0] is therefore the single line responsible for the biggest memory increase between the two snapshots — here, the list-comprehension line itself.