Profiling
Profiling measures where a program actually spends its time and memory, replacing guesswork about performance with concrete, measured data before you optimize anything.
Profiling answers a concrete question: where does this program actually spend its time (or memory)? The standard library provides cProfile and profile for time-based function-level profiling, and pstats for analyzing the results; third-party tools extend this to line-level profiling, memory allocation tracking, and low-overhead sampling profilers suitable for production use.
Profile Before You Optimize
The most important rule of performance work is to measure before changing code. Intuition about which part of a program is 'slow' is frequently wrong — real bottlenecks are often in unexpected places (a function called far more often than expected, an accidentally quadratic loop, unnecessary I/O). Optimizing without profiling risks spending effort on code that was never the actual bottleneck, while leaving the real one untouched.
This is sometimes phrased as 'premature optimization is the root of all evil' — the point isn't that optimization is bad, but that it should be guided by measurement, not guesswork.
cProfile: The Standard Library's Deterministic Profiler
cProfile is part of the standard library and instruments every function call to record how many times it was called and how much time was spent in it (a deterministic, or 'tracing', profiler — it observes every call, unlike a sampling profiler). It's the standard first tool for finding which functions dominate a program's runtime.
python -m cProfile -s cumulative app.pyimport cProfile
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
cProfile.run("fib(28)")832039 function calls (4 primitive calls) in 0.198 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
832040 0.198 0.000 0.198 0.000 app.py:3(fib)profile: The Pure-Python Alternative
The standard library also provides profile, a pure-Python implementation with the same interface as cProfile. cProfile is implemented in C and has much lower overhead, so it's almost always preferred; profile exists mainly for cases where you need to subclass and customize the profiler's behavior in Python, which cProfile's C implementation makes harder to do.
pstats: Analyzing Profile Output
Raw profiler output is more useful when saved to a file and analyzed with pstats, which can sort by different metrics and filter down to the functions that matter.
tottime is usually the more actionable column for finding a genuine hotspot: high cumtime but low tottime often just means a function calls something else that's slow, not that the function itself is inefficient.
import cProfile
import pstats
cProfile.run("fib(28)", "profile_output.prof")
stats = pstats.Stats("profile_output.prof")
stats.sort_stats(pstats.SortKey.CUMULATIVE)
stats.print_stats(10) # top 10 functions by cumulative timencalls -> number of calls
tottime -> total time spent in the function itself, excluding sub-calls
percall -> tottime / ncalls
cumtime -> total time spent in the function including sub-calls
percall -> cumtime / ncalls (second percall column)Profiling a Specific Section of Code
cProfile.Profile can be used directly (as an object, or as a context manager since Python 3.8) to profile only part of a program rather than the whole run, which keeps output focused on the code actually under investigation.
import cProfile
import pstats
with cProfile.Profile() as profiler:
result = expensive_computation()
stats = pstats.Stats(profiler)
stats.sort_stats(pstats.SortKey.TIME)
stats.print_stats(10)Reading Profiler Output to Find Hotspots
A useful workflow: sort by cumulative time to see which top-level call paths dominate overall runtime, then sort by total (self) time to find the specific functions actually consuming CPU rather than just delegating to slow children. A function called an enormous number of times (high ncalls) with a small percall time can still dominate tottime in aggregate — this is a common source of surprising bottlenecks.
Line-Level Profiling
cProfile reports at function granularity; it can't tell you which specific line inside a function is slow. The third-party line_profiler package fills that gap, reporting per-line timing for functions you explicitly decorate.
line_profiler is third-party and adds significant overhead, so it's typically used only on a function already identified as a hotspot by cProfile, not run across a whole program.
pip install line_profiler
kernprof -l -v app.py # profile functions decorated with @profileSampling Profilers
Unlike cProfile's deterministic tracing of every call (which adds meaningful overhead), a sampling profiler periodically interrupts the running program and records the current call stack, building up a statistical picture with much lower overhead. This makes sampling profilers suitable for profiling production workloads. py-spy is a widely used third-party sampling profiler for Python that can even attach to an already-running process without any code changes.
pip install py-spy
py-spy top --pid 12345 # live view of a running process
py-spy record -o profile.svg --pid 12345 # produce a flame graphAllocation and Memory Profiling Concepts
Time profiling answers 'where does the program spend CPU time'; memory profiling answers a different question: 'where does the program allocate memory, and where might it be leaking'. The standard library's tracemalloc module tracks memory allocations and can report which lines of code are responsible for the most currently-allocated memory.
For deeper memory profiling (line-by-line memory usage over time, not just a snapshot), the third-party memory_profiler package is a common complement to tracemalloc.
import tracemalloc
tracemalloc.start()
data = [str(i) for i in range(100_000)]
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:3]:
print(stat)Profiling Overhead and Realistic Measurement
Any profiler changes the program's timing to some degree; a deterministic profiler like cProfile can slow a program down significantly (sometimes several times slower), especially for code with many small, frequent function calls. This is usually fine for identifying relative hotspots (which function is slowest relative to others) but means the absolute times reported under a profiler should not be taken as the program's real-world speed — use timeit or direct wall-clock measurement (see the Benchmarking topic) for that.
Practical Usage and Best Practices
Start with cProfile at the whole-program or whole-function level to find which function dominates; only reach for line_profiler once a specific function is identified, since its overhead makes it impractical to run broadly. Profile with realistic input sizes and data — a profile run on a tiny toy input can point at entirely the wrong hotspot compared to production-scale data. Re-profile after each significant change rather than assuming an optimization worked; profiling is an iterative loop, not a one-time step.
Complete Example
This profile would show is_prime dominating tottime (it's called once per candidate number and does the actual division work), confirming it — not count_primes_below's loop overhead — as the place to optimize, for example by skipping even numbers.
import cProfile
import pstats
def is_prime(n: int) -> bool:
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
def count_primes_below(limit: int) -> int:
return sum(1 for n in range(2, limit) if is_prime(n))
with cProfile.Profile() as profiler:
total = count_primes_below(200_000)
print("primes found:", total)
stats = pstats.Stats(profiler)
stats.sort_stats(pstats.SortKey.TIME)
stats.print_stats(5)Mental Model and Quick Reference
Think of cProfile as a stopwatch that clicks on every function call and click, giving exact (but overhead-affected) numbers; a sampling profiler is more like glancing at the program every few milliseconds and noting what it's doing, giving a statistical (but low-overhead, production-safe) picture.
cProfile.run("expr") -> deterministic, function-level, stdlib
pstats.Stats(...).sort_stats(...) -> analyze/sort profiler output
tottime -> time in the function itself
cumtime -> time including everything it calls
line_profiler (third-party) -> per-line timing for a targeted function
py-spy (third-party) -> low-overhead sampling, works on running processes
tracemalloc (stdlib) -> where memory is allocatedKey Takeaways
Always profile before optimizing; intuition about bottlenecks is frequently wrong.
cProfile (standard library) gives deterministic, function-level timing; pstats analyzes and sorts its output by tottime, cumtime, or ncalls.
tottime (time in the function itself) is usually the more actionable metric for finding a real hotspot than cumtime (time including everything it calls).
line_profiler and py-spy are third-party tools for per-line timing and low-overhead sampling profiling, respectively.
tracemalloc (standard library) tracks where memory is allocated, complementing time-based profiling.
Profiling overhead affects absolute timing; use it to find relative hotspots, and use timeit/benchmarking for real-world speed measurements.
Optimizing based on intuition risks spending effort on code that isn't actually the bottleneck; profiling data should drive the decision of what to optimize.
stats.sort_stats(pstats.SortKey.TIME) # tottime: time spent in the function itself stats.print_stats(10)
A high cumtime often just reflects a function calling something else that's slow (e.g. main() has high cumtime because everything runs under it); tottime isolates which function is actually doing the slow work.
Different code paths dominate at different scales (e.g. O(n) setup cost vs O(n^2) inner loop); a profile on unrealistically small input can point at the wrong hotspot entirely.
line_profiler's per-line instrumentation carries significant overhead; it's meant for zooming into an already-identified hotspot, not for broad first-pass profiling.
Why is 'profile before you optimize' considered essential rather than optional advice?
Human intuition about where a program spends its time is frequently wrong; without measurement, effort is often spent optimizing code that was never the actual bottleneck, while the real hotspot remains untouched.
What is the difference between tottime and cumtime in cProfile/pstats output?
tottime is the time spent inside the function itself, excluding time spent in functions it calls; cumtime includes that function's own time plus all the time spent in everything it calls (its subtree of calls).
Why is cProfile generally preferred over the pure-Python profile module?
cProfile is implemented in C and has much lower overhead, giving more accurate and faster profiling; profile is pure Python and mainly useful when you need to subclass and customize profiler behavior in ways cProfile's C implementation doesn't easily allow.
What is the fundamental difference between a deterministic profiler like cProfile and a sampling profiler like py-spy?
A deterministic profiler instruments and records every function call, giving exact counts and timings but with meaningful overhead; a sampling profiler periodically snapshots the call stack at intervals, giving a statistical picture with much lower overhead, which makes it suitable for profiling live production processes.
What does tracemalloc measure that cProfile does not?
tracemalloc tracks memory allocations — which lines of code are responsible for currently allocated memory — whereas cProfile measures CPU time spent in function calls; they answer different questions (memory vs time) and are often used together.
Why might a function with a very small per-call time still be the biggest contributor to a program's total runtime?
If it's called an extremely large number of times, its small per-call cost multiplied by a huge call count can dominate total (tottime) time even though no single call looks slow in isolation — this shows up clearly in the ncalls and tottime columns together.
Why does profiling with a tiny, unrealistic input size risk misleading conclusions?
Different code paths or algorithmic complexities can dominate at different input scales; a hotspot found at production-scale data may not even appear as significant when profiled on a small toy input, leading you to optimize the wrong thing.
Write code that profiles a function slow_sum(n) (which sums 0..n-1 in a Python loop) using cProfile as a context manager, then prints the top 3 functions sorted by total (self) time.
import cProfile import pstats def slow_sum(n: int) -> int: total = 0 for i in range(n): total += i return total with cProfile.Profile() as profiler: slow_sum(5_000_000) stats = pstats.Stats(profiler) stats.sort_stats(pstats.SortKey.TIME) stats.print_stats(3) cProfile.Profile() as a context manager (3.8+) profiles only the code inside the with-block; sorting by SortKey.TIME orders results by tottime, showing which functions consumed the most CPU time in themselves rather than in what they called.
Between outer() and inner(), which would you expect to show the higher tottime, and which the higher cumtime?
inner() would show the higher tottime, since it does the actual summing work 1000 times. outer() would show a cumtime at least as large as (and here, dominated by) the total time spent across all 1000 calls to inner(), since cumtime includes everything a function calls; outer()'s own tottime (just the generator/sum overhead) would be relatively small.
Write code using tracemalloc that takes a snapshot before and after building a large list, and prints the top memory-allocating line.
import tracemalloc tracemalloc.start() before = tracemalloc.take_snapshot() big_list = [i * i for i in range(500_000)] after = tracemalloc.take_snapshot() top_stats = after.compare_to(before, "lineno") print(top_stats[0]) compare_to() diffs two snapshots by line number, isolating the allocations that happened between them — here, the line building big_list — rather than reporting all memory allocated since the program started.
What does the final print statement output, and what does this pattern (enable/disable plus a StringIO stream) demonstrate?
It prints True, since stats.print_stats() writes formatted profiler output into the StringIO stream, producing non-empty text. This demonstrates the lower-level Profile API (explicit enable()/disable() instead of run() or a context manager) combined with directing pstats output somewhere other than stdout — useful for capturing or logging profiling results programmatically.
import cProfile
def inner():
return sum(range(1000))
def outer():
return sum(inner() for _ in range(1000))
cProfile.run("outer()")import cProfile
import pstats
import io
def work():
return [x ** 2 for x in range(100_000)]
profiler = cProfile.Profile()
profiler.enable()
work()
profiler.disable()
stream = io.StringIO()
stats = pstats.Stats(profiler, stream=stream)
stats.sort_stats(pstats.SortKey.CUMULATIVE)
stats.print_stats()
print(len(stream.getvalue()) > 0)