Optimizing on a hunch tends to fix the wrong thing. The loop that looks expensive is rarely the one the clock blames, and a profiler exists to settle that argument before anyone rewrites code.

Python ships one profiler in its standard library, cProfile, and a third-party sampler called py-spy covers cases the built-in one cannot. This Q&A answers the questions people search for when a script or service runs slower than it should. Details come from the Python documentation and the py-spy project page; opinions are labeled as such.

How do I run cProfile on a script?

The command-line form takes an optional output file, an optional sort order and either a script or a module.

python -m cProfile -s cumulative myscript.py
python -m cProfile -o out.prof myscript.py
python -m cProfile -o out.prof -m mypackage.module

Per the docs, -s picks one of the sort_stats() values and applies only when -o is not supplied. The -o file is binary, meant for the pstats module. In Python 3.15 the docs list the same options under python -m profiling.tracing, with cProfile kept as a backward-compatible alias, so existing commands keep working.

To explore a saved file, load it into pstats:

import pstats
from pstats import SortKey

p = pstats.Stats("out.prof")
p.strip_dirs().sort_stats(SortKey.CUMULATIVE).print_stats(10)
p.print_callers("build")

strip_dirs() removes leading path information so lines fit within about 80 columns. print_stats(10) limits output to ten rows. print_callers() lists the functions that called each function, and its restriction argument accepts a regular expression matched against names.

What do the columns mean?

The report has six columns. ncalls is the number of calls. tottime is the total time spent in the function itself, excluding calls to sub-functions. The first percall is tottime divided by ncalls. cumtime is the cumulative time spent in this function and everything it calls, from invocation until exit, and the docs say that figure is accurate even for recursive functions. The second percall is cumtime divided by primitive calls. The last column gives filename:lineno(function).

A first column showing two numbers, such as 3/1, means the function recursed: the first value is total calls and the second is primitive calls.

Should I sort by cumulative or total time?

Both, in turn, because they answer different questions. Sorting by cumulative surfaces the entry points that account for the most wall time overall, which is where the story of the program starts: a request handler, a parsing step, a main loop. Sorting by time (internal time, tottime) surfaces the functions that burn time in their own bodies, which is where the actual work is.

A function with high cumtime and low tottime is mostly waiting on things it calls, so look down the call tree. A function with high tottime is the suspect. Opinion: read the cumulative list top-down to find the branch, then switch to tottime to find the leaf.

Consider this small script, where the list is rebuilt on every pass.

def build(n):
    out = []
    for i in range(n):
        out = out + [i * i]
    return out

def total(n):
    return sum(build(n))

if __name__ == "__main__":
    total(20000)

Profiled, total and build would both show large cumulative times. Reasoning from the docs, which describe monitoring of function call, return and exception events, the + operator is not a function call, so its cost lands in build‘s own tottime. That is the signal to look at the line itself and not at its callees.

How is deterministic profiling different from sampling?

cProfile is deterministic: the docs say all function call, return and exception events are monitored, with precise timings between them. A statistical profiler instead periodically samples the instruction pointer and deduces where time is spent. The same page states the trade: sampling traditionally involves less overhead because code does not need to be instrumented, but gives only relative indications of where time goes.

PropertyDeterministic (cProfile)Sampling (py-spy, profiling.sampling)
Call countsExactNot provided
Very short functionsAlways recordedMay be missed between samples
OverheadInstrumentation on every callLow, code is not instrumented
Attach to a running processStart under the profiler, or enable it in codeYes

The 3.15 sampling documentation says directly that very short-lived functions can be missed if they finish between samples, and that exact call counts require the tracing profiler.

How much does cProfile slow things down?

The docs call cProfile a C extension with reasonable overhead, which is why it is recommended for most users and suits long-running programs. The older pure-Python profile module is the contrast: the current documentation describes its implementation as significantly slower and unsuitable for performance-sensitive code. The docs give no overhead percentage, and none is invented here. Timings inside a profiled run are best read as proportions, not as the speed the program has in production.

How do I profile a process that is already running?

This is where py-spy earns its place. Install it with pip install py-spy. The project’s README describes three main commands.

py-spy record -o profile.svg --pid 12345
py-spy record -o profile.svg -- python myprogram.py
py-spy top --pid 12345
py-spy dump --pid 12345

record writes a flame graph, top shows a live view of which functions use the most time, and dump prints the current call stack of each Python thread. The README calls its overhead extremely low, since it is written in Rust and runs outside the profiled process.

Permissions are the common stumbling block. On Linux, attaching to a process you did not start usually requires root, as in sudo py-spy record --pid 123456. Without root, launch the program through py-spy. Inside Docker or Kubernetes the container needs the SYS_PTRACE capability. Flags worth knowing: --native includes C, C++ and Cython frames, --idle includes threads py-spy considers idle, and --subprocesses follows child processes such as multiprocessing workers.

How do I read a flame graph?

Brendan Gregg, who created the format, gives the rules. The y-axis shows stack depth, counting from zero at the bottom. The wider a frame is, the more often it was present in the stacks. The x-axis shows the stack profile population, sorted alphabetically, and it is not the passage of time. Colors are random in the original design, only to tell adjacent frames apart.

Practical reading, in this guide’s words and not Gregg’s: scan for wide boxes near the top edge. Those are frames that were on the stack often and had nothing further to call. A wide frame lower down only says that a lot of time passed beneath it.

Does Python 3.15 make py-spy unnecessary?

Python 3.15 adds profiling.sampling, named Tachyon, a statistical profiler in the standard library. Its docs show python -m profiling.sampling run script.py, attach 12345 for a running PID, and --flamegraph -o profile.html for a self-contained HTML flame graph. The default rate is 1 kHz, adjustable with -r, and -d sets a duration in seconds. The profiler and its target must run the same Python minor version.

Whether that retires py-spy is a judgment call. Teams on older interpreters still need an external tool, and the built-in one is new. If you are already checking your code against the 3.15 candidate, the x9m8 guide to testing on the Python 3.15 release candidate is a good place to try it.

Which tool should I start with?

Opinion, not a documented rule: begin with cProfile when you can run the code from the start and want exact call counts, and reach for a sampler when the process is long-lived, already running, or too slow to run under instrumentation. If the profile shows time vanishing into memory pressure rather than Python code, that points elsewhere, and a look at how compressed RAM works on Linux may help.

Sources

  1. Python documentation, The Python Profilers: https://docs.python.org/3.12/library/profile.html
  2. Python documentation, profiling.tracing: https://docs.python.org/3/library/profiling.tracing.html
  3. Python documentation, profiling.sampling: https://docs.python.org/3/library/profiling.sampling.html
  4. Python documentation, profile module (current): https://docs.python.org/3/library/profile.html
  5. Python documentation, pstats: https://docs.python.org/3/library/pstats.html
  6. py-spy README: https://github.com/benfred/py-spy
  7. Brendan Gregg, Flame Graphs: https://www.brendangregg.com/flamegraphs.html