AlgoMaster Logo

Profiling

Low Priority19 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Profiling answers a single question: where is the program spending its time? Without that answer, optimizing Python code is guesswork, and most guesses are wrong. A function that seemed like the bottleneck may take five percent of the runtime, while a forgotten loop in a helper takes eighty. This lesson covers cProfile (the built-in deterministic profiler), pstats for reading its output, line-level profiling with line_profiler, statistical profilers like py-spy and scalene, and the workflow that ties them together.

Why Profile Before Optimizing

A familiar pattern with performance work: a function feels slow, the inner list comprehension looks like the problem, it gets rewritten with map or NumPy, and the program is still slow. The hour spent rewriting did nothing because the bottleneck was somewhere else entirely.

Donald Knuth's old line, "premature optimization is the root of all evil," is half the story. The other half is that even non-premature optimization fails when it targets the wrong code. Profiling is the cheap way to find out which code matters before changing it.

Consider this order-loading script:

If report() is slow, the suspect functions are load_orders (file I/O and JSON parsing), normalize (string operations on every record), and total_revenue (a generator over the list). Without profiling, picking one is guesswork. On a 500MB JSON file, load_orders dominates and the rest is rounding error. On a small file with millions of normalized records, normalize could be the hot spot. The right fix depends on the answer, and the answer takes 10 seconds with cProfile to find.

The workflow: profile first, identify the hot spot, change one thing, profile again, confirm the change helped. Anything else is hope, not engineering.

cProfile, the Built-In Deterministic Profiler

cProfile is the profiler that ships with Python. It instruments every function call: when a call starts, cProfile records the time; when it returns, it records the time again. The output is a precise table of how often each function ran and how much time it took, in total and per call.

The simplest way to run it is from the command line:

That runs the script normally and prints a profile table at the end. Output looks like this (trimmed to the relevant rows):

Six columns matter:

ColumnWhat it tells you
ncallsHow many times the function was called. Two numbers (e.g., 5/1) means recursion.
tottimeTotal time spent inside this function, excluding time in callees.
percalltottime / ncalls. Average time per call, not counting callees.
cumtimeTotal time spent inside this function, including everything it called.
percallcumtime / ncalls. Average wall time per call.
filename:lineno(function)Which function the row is about.

The distinction that newcomers miss is tottime versus cumtime. cumtime answers "how long did this whole subtree take?" tottime answers "how much time was spent in this function's own code, not its callees?" A function with 5 seconds of cumtime but only 0.001 of tottime is not the bottleneck. It is calling something else that is. Walk down its callees to find the real hot spot.

In the trace above, load_orders has tottime 1.789 and cumtime 1.789, so essentially all the time is inside its own body (json.load is the dominant cost, hidden inside it). normalize has 0.184 own time and 0.302 cumulative, so it spends real time on its own work plus some inside its lower() call. report has nearly zero tottime because it mostly delegates to the others.

cProfile can also be called programmatically, which is useful for profiling a single section of a long program:

cProfile.run takes a string, evaluates it, and prints the profile. For structured data rather than printed output, use cProfile.Profile directly:

That pattern (enable, run the code, disable, build a Stats) applies whenever a slice of a larger program needs profiling.

cProfile adds 5 to 30 percent overhead, sometimes more on call-heavy code, because every function call goes through the C-level profiler hook. The recorded times reflect code running under the profiler, not in normal operation. Relative ranking of hot spots is still accurate, but absolute numbers shift.

Reading and Filtering with pstats

A profile from a real program can have thousands of rows. pstats is the standard library module for sorting, filtering, and slicing that data to focus on the relevant parts.

Save a profile to a file first, then explore it:

The -o flag writes the profile to a binary file instead of printing. Now open it in Python:

sort_stats accepts the field names shown earlier: "cumulative", "tottime", "ncalls", "filename", and a few more. print_stats(20) prints the top 20 rows in that order. print_stats(0.1) prints the top 10 percent of rows. print_stats("orders") filters rows whose qualified name contains "orders", which is useful for focusing on application code and hiding standard-library noise.

Two methods are worth learning beyond print_stats:

print_callers shows the call sites that reach a function. print_callees shows what a function called. Together they support walking up and down the call graph, which is the point of profiling: find the hot spot, then find what is pushing work into it.

A pattern that pays off on every real program: sort by cumulative, find the function with the highest cumtime that is not a framework or library, then sort by tottime to see which of its descendants is doing the actual work.

The first call shows which high-level operations cost the most. The second shows the bottom-of-the-stack functions where time is actually accumulating. The honest bottleneck is usually visible by comparing the two lists.

Visualizing Profiles

Reading a pstats table by hand works, but for any non-trivial program a visual representation is faster. Two free tools are popular.

SnakeViz renders a profile as an interactive icicle or sunburst chart in a browser. The visual structure shows the call tree, with each box's width proportional to the time spent in that subtree. Clicking a box zooms into it, and a wide rectangle near the bottom of the tree marks the bottleneck in seconds.

The browser opens automatically with the profile loaded. The default view is an icicle chart with the root at the top and callees stacked below; the sunburst alternative shows the same data as concentric rings.

flameprof generates a static flame graph (SVG) from a cProfile file. Flame graphs are the standard format in the broader performance community (popularized by Brendan Gregg for systems profiling), and the visual conventions are the same: each box is a function, width is proportional to time, vertical stacks show call depth.

Open the SVG in a browser to see the chart. Both tools read the same .prof file cProfile produces, so switching between them does not require re-running.

Visual tools are useful when the profile has hundreds of functions. The table view is fine for ten rows; for a thousand, a view of the program's shape beats scrolling through text.

Line-Level Profiling

cProfile measures per-function time. That identifies which function is slow. It does not identify which line inside the function. For most bugs the function is enough, but sometimes a single function has multiple suspects and a line-by-line breakdown is needed.

line_profiler is the third-party tool for that. Install it, decorate the function to inspect, and run the script with the kernprof wrapper:

Run it under kernprof:

The -l flag enables line-by-line mode and -v prints the report at the end. Output:

The % Time column shows which line owns the runtime. Here, the float conversion takes a bit more than the string cleanup, and the return statement is essentially free. The float conversion is where any optimization should focus.

Two notes catch people the first time. The @profile decorator is not imported from anywhere; kernprof injects it at runtime. Running the file directly with python normalize.py raises NameError: name 'profile' is not defined. Wrap the decorator in a try/except or use the LINE_PROFILE=1 environment variable approach with the newer line_profiler versions.

Second, line-level profiling is roughly an order of magnitude slower than cProfile. Use it on a single suspect function after cProfile has narrowed the search, not on the whole program.

line_profiler triggers on every executed line in the decorated function, which adds substantial overhead. On a function called 100,000 times in a tight loop, expect a 10x to 50x slowdown during profiling. The relative line ranking remains accurate.

Statistical Profilers: py-spy and scalene

cProfile is a deterministic profiler: it records every call. That gives perfect accuracy at the cost of substantial overhead, and it requires starting the program under the profiler. Neither matters on a 30-second script. Both matter on a long-running server that cannot easily be restarted.

Statistical profilers solve the other half of the problem. Instead of recording every call, they sample the program's stack at a fixed interval (say, 100 times per second) and aggregate the samples. The result is approximate but close enough for finding hot spots, and the overhead is small enough to leave running in production.

py-spy is the most popular Python statistical profiler. It runs as a separate process and inspects the target's memory directly, allowing attachment to a running program without modifying its code or restarting it.

py-spy top provides a top-like live view of the hottest functions, updated every second. py-spy record writes a flame graph SVG of the full session. The lack of any code changes is the main advantage: a misbehaving production server can be attached by PID, profiled, and detached.

scalene is a more recent profiler that measures CPU time, GPU time, and memory together. It runs sampled like py-spy, attributes time per line (not just per function), and distinguishes between time spent in Python versus time spent in native code (NumPy, C extensions). The split matters because the optimization advice is different for each.

Output is HTML by default and opens in a browser. The line-level view shows which lines take CPU time, which allocate memory, and which call into native code. For data-science workloads where NumPy and Pandas operations dominate, the Python-vs-native split is the most useful column on the screen.

A comparison table:

ProfilerTypeOverheadAttaches to running process?Best for
cProfileDeterministic5 to 30%NoLocal development, function-level totals
line_profilerDeterministic10x or more on a functionNoLine-by-line on one suspect function
py-spyStatistical1 to 5%YesProduction attach, flame graphs
scaleneStatistical1 to 10%Yes (with --pid)CPU + memory + native code attribution

The rule of thumb: cProfile for local development when the script can be rerun, py-spy for production processes that cannot be restarted, line_profiler after cProfile has narrowed the search, scalene when memory and native-code attribution are both needed.

A Practical Workflow

Profiling is most useful as a habit, not an emergency tool. The pattern that works in practice has four steps.

Measure the baseline. Record how long the program takes today. Without that number, there is no way to tell whether a change helped. A simple time python script.py is enough for end-to-end measurements; cProfile gives the breakdown.

Profile to find the hot spot. Run cProfile, sort by cumulative time, and walk down the tree until a function in application code takes real time. Then sort by tottime to see where the time is actually being spent at the leaves. Intuition about which function is slow is unreliable. The profile often disagrees.

Change one thing. Apply a targeted optimization to the function the profile identified. Common candidates: replace a list with a set for membership checks, hoist a constant computation out of a loop, batch a per-record database call into a single query, swap a Python loop for a NumPy operation. Change one thing so the source of any improvement is clear.

Profile again and compare. Run the same profile and compare the new cumtime against the old. A successful optimization shows up as a smaller number on the changed line. If the number did not move, the change did not help (or the optimization targeted something that was not actually the bottleneck). Revert and try again.

Two anti-patterns break the workflow.

Do not optimize without measuring first. Rewriting code in NumPy because it feels slow often produces a slower program. A two-minute profile would have shown that the suspect function takes 3 percent of the runtime.

Do not change five things at once. Replacing the algorithm, refactoring the data structures, and adding caching all in one pass makes it impossible to tell which change helped and which made things worse. Make changes one at a time, profile after each, keep what works.

When Profiling Is Not the Right Tool

A few cases where profiling will not give a useful answer.

Microbenchmarks. For "is list comprehension faster than map for this 10-element operation?", cProfile's overhead dwarfs the operation itself. Use timeit instead, which is purpose-built for measuring short pieces of code.

I/O-bound programs. When the program spends most of its wall time waiting for the network or disk, cProfile shows long cumtime on the waiting functions but reveals nothing useful about how to speed them up. The fix is async or concurrency, not function-level optimization. Tools like py-spy --idle (which counts waiting time) or scalene are more useful here.

Memory bottlenecks. A program that is slow because it is swapping or running the garbage collector hard does not show up as obvious hot spots in a CPU profile. tracemalloc, memory_profiler, and scalene's memory mode are the relevant tools for memory profiling.

Issues across processes or machines. A profile shows what one Python process is doing. For a distributed system where requests bounce between services, tracing (OpenTelemetry, Jaeger) is needed rather than profiling. Profiling is per-process; tracing is per-request across processes.

In all four cases, profiling is not useless, just not the most informative tool. Pick the tool that matches the shape of the problem.

Quiz

Profiling Quiz

10 quizzes