How to find what's slow in your Python service using cProfile

Your Python service is slow. You don't know why.

That's the actual problem. Not the slowness itself — that's fixable once you know what's happening — but the not knowing. Manual timing with time.time() tells you a function takes 800ms. It doesn't tell you which of the twelve things that function does is responsible.

For a backend service with database queries, serialization, and HTTP calls all mixed together, "add timing around everything" is a maintenance problem, not a solution.

What cProfile actually gives you

Python ships with cProfile in the standard library. No install. It captures every function call during a run and reports per-function timing:

import cProfile
import pstats
import io

pr = cProfile.Profile()
pr.enable()

your_function() # whatever you want to profile

pr.disable()

s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats('cumulative')
ps.print_stats(20)
print(s.getvalue())

The output has five columns. The ones that matter:

ncalls — how many times the function was called. An entry like 2692537/1 means 2.6 million total calls, one direct invocation — the rest was recursion.

cumtime — cumulative time including all subcalls. Sort by this first. If something is slow, it will be near the top.

tottime — time in the function itself, excluding subcalls. Useful for identifying functions doing expensive work directly rather than delegating it.

Sort by cumtime descending and read the top ten lines. That's usually enough to identify the problem category.

A decorator that doesn't stay in your way

For a service with multiple request handlers or worker entrypoints, wrapping each one inline clutters everything. A decorator keeps profiling contained and removable:

import cProfile
import pstats
import io
from functools import wraps

def profile(func):
@wraps(func)
def wrapper(*args, **kwargs):
pr = cProfile.Profile()
pr.enable()
result = func(*args, **kwargs)
pr.disable()

s = io.StringIO()
ps = pstats.Stats(pr, stream=s).sort_stats('cumulative')
ps.print_stats(20)
print(s.getvalue())
return result
return wrapper

Apply @profile to the outermost function you suspect — a request handler, a task function, a scheduled job. Don't apply it to a recursive function directly; it'll profile every nested call and the numbers won't mean anything useful.

Saving profiles when you can't read stdout

In staging or a remote container, you can't always read stdout live. Save the profile data to disk instead:

pr.dump_stats('/tmp/profile.out')

Pull the file locally and open it with snakeviz:

pip install snakeviz
snakeviz /tmp/profile.out

SnakeViz renders the call tree in a browser. The icicle view shows depth; the sunburst view shows proportional time. For a recursive function with redundant calls, the sunburst makes it obvious: rings that should be thin are wide. For a Django view with an N+1 query, you'll see hundreds of nearly-identical database calls radiating outward.

What the output tells you to do

The profiler points you at the function. The fix usually falls into one of four categories:

N+1 queries. Your ORM issues one SQL query per iteration through a result set. cumtime will show a query function called dozens or hundreds of times. Use select_related/prefetch_related in Django, or batch-load in SQLAlchemy.

Redundant computation. The same expensive operation — a regex compile, a deserialization, an API call — running on every request when it only needs to run once. Cache the result or hoist it out of the loop.

Blocking I/O in a sync context. An HTTP call to a third-party API, a slow subprocess, a file read inline with request handling. tottime on a socket or subprocess function is the signal. Move it to a background task or make the handler async.

Algorithmic cost. This is the dramatic case. The Better Stack profiling guide walks through a Fibonacci example where a naive recursive implementation calls itself 2,692,537 times to compute the 30th number. The iterative version makes 7 total function calls. Same output, completely different execution profile. Real-world examples are less dramatic but the signal is the same: ncalls that's orders of magnitude higher than it should be points to an algorithm that's doing work it doesn't need to do.

Connecting the dots in production

Profiling is a point-in-time measurement. You run it, find the bottleneck, fix it, re-profile to confirm the fix, and ship.

What profiling doesn't give you is ongoing visibility. A background worker that handled 50 jobs per minute last week and handles 5 per minute this week won't trip an uptime check — it's still running. It shows up if you're shipping timing data alongside your application logs. If you're using Log Blazer, you can POST timing events as structured log entries; they land in the same dashboard as your uptime and heartbeat data without standing up a separate metrics pipeline. A heartbeat check tells you the worker stopped; a timing log tells you it started getting slow three deploys ago.

The practical sequence

Add @profile to the slow function's caller.

Run it with realistic input — not toy data, actual representative payloads.

Sort output by cumtime. Read the top five entries.

Check ncalls. Anything in the thousands that shouldn't be raises a flag.

Fix the worst offender. Re-profile.

Don't touch what the profiler doesn't show. Tuning a function that contributes 2% of execution time is time you'll never get back.

The standard library has what you need. Most slow Python services stay slow because nobody looked.